t47-device-effects.mjs 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. /**
  2. * T47 设备状态视觉特效与门控 纯逻辑检查(票 47;决策票 07 的 `## Answer`)。
  3. *
  4. * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t47-device-effects.mjs
  6. *
  7. * **这是三层证据里的第 1 层**(领域纯逻辑):映射唯一、未知不猜、门控与 30 台降级可断言。
  8. * 它**不能单独作为验收** —— 映射表对不等于画面在动;"真在跑"的证据在
  9. * `t47-device-effects-walkthrough.mjs` 的**跨帧快照**与真实帧差里(决策票 07 问题 7)。
  10. */
  11. import {
  12. DEVICE_EFFECT,
  13. DEFAULT_ABNORMAL_LIMIT,
  14. deviceEffectOf,
  15. hasDeviceEffect,
  16. effectPhaseAt,
  17. pulseStrengthAt,
  18. blinkStrengthAt,
  19. spinAngleAt,
  20. planDeviceEffects,
  21. degradedAbnormalNote,
  22. } from '../../../src/modules/cockpit/domain/deviceEffects.js'
  23. import { DEVICE_STATUS_VISUALS, DEVICE_UNKNOWN_VISUAL } from '../../../src/modules/cockpit/domain/deviceStatus.js'
  24. import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
  25. const checks = []
  26. function check(name, pass, detail = '') {
  27. checks.push({ name, pass })
  28. const rest = detail === '' ? '' : ` — ${detail}`
  29. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${rest}`)
  30. }
  31. const device = (code, status) => ({ code, status, pointCode: 'MP-1' })
  32. // ================================================================ A 状态 → 动效映射
  33. {
  34. check('A1-three-attention-states-animate',
  35. deviceEffectOf('ALARM') === DEVICE_EFFECT.PULSE &&
  36. deviceEffectOf('WARNING') === DEVICE_EFFECT.BLINK &&
  37. deviceEffectOf('WORK_ORDER') === DEVICE_EFFECT.SPIN,
  38. `ALARM=${deviceEffectOf('ALARM')} WARNING=${deviceEffectOf('WARNING')} WORK_ORDER=${deviceEffectOf('WORK_ORDER')}`)
  39. check('A2-normal-offline-have-no-effect',
  40. deviceEffectOf('NORMAL') === '' && deviceEffectOf('OFFLINE') === '',
  41. `NORMAL='${deviceEffectOf('NORMAL')}' OFFLINE='${deviceEffectOf('OFFLINE')}'(决策票 07:五态全动会把优先级语义淹没)`)
  42. check('A3-unknown-does-not-guess-normal',
  43. deviceEffectOf('MAINTENANCE') === '' &&
  44. deviceEffectOf('') === '' &&
  45. deviceEffectOf(undefined) === '',
  46. '未知/空/undefined 都无动效(状态都不知道,更不能编一个"正在报警"的样子)')
  47. check('A4-effect-lives-in-the-device-table',
  48. DEVICE_STATUS_VISUALS.ALARM.effect === 'pulse' && DEVICE_UNKNOWN_VISUAL.effect === null,
  49. 'effect 维度挂在票 44 的设备独立词表上(不借点位表)')
  50. }
  51. // ================================================================ B 相位(跨时间采样)
  52. {
  53. // 纯逻辑也要能证明"值随时间变",否则只是映射表对。
  54. const samples = [0, 200, 400, 600, 800, 1000, 1200, 1400]
  55. const pulses = samples.map((t) => pulseStrengthAt(t))
  56. const blinks = samples.map((t) => blinkStrengthAt(t))
  57. const spins = samples.map((t) => spinAngleAt(t))
  58. check('B1-pulse-varies-over-time',
  59. new Set(pulses.map((v) => v.toFixed(4))).size >= 3 && Math.min(...pulses) >= 0 && Math.max(...pulses) <= 1,
  60. `脉冲强度采样 ${pulses.map((v) => v.toFixed(2)).join(',')}`)
  61. check('B2-blink-varies-over-time',
  62. new Set(blinks).size >= 2 && Math.min(...blinks) > 0 && Math.max(...blinks) <= 1,
  63. `闪烁强度采样 ${blinks.map((v) => v.toFixed(2)).join(',')}`)
  64. check('B3-spin-advances-monotonically-within-period',
  65. spins[0] < spins[1] && spins[1] < spins[2] && spins[7] < Math.PI * 2,
  66. `旋转角采样 ${spins.map((v) => v.toFixed(2)).join(',')}`)
  67. check('B4-phase-is-periodic',
  68. Math.abs(effectPhaseAt(DEVICE_EFFECT.PULSE, 0) - effectPhaseAt(DEVICE_EFFECT.PULSE, 1600)) < 1e-9,
  69. '相位按周期回绕(周期由 EFFECT_PERIOD_MS 定义)')
  70. check('B5-no-effect-has-zero-phase',
  71. effectPhaseAt('', 500) === 0 && effectPhaseAt(null, 500) === 0,
  72. '无动效的相位恒为 0(不会给正常/离线算出动画)')
  73. }
  74. // ================================================================ C 门控与降级
  75. {
  76. const rows = [device('EQ-A', 'ALARM'), device('EQ-W', 'WARNING'), device('EQ-O', 'WORK_ORDER'), device('EQ-N', 'NORMAL'), device('EQ-F', 'OFFLINE')]
  77. const off = planDeviceEffects({ devices: rows, enabled: false, abnormalLimit: 30 })
  78. check('C1-disabled-means-no-animation',
  79. off.enabled === false && off.animated.length === 0 && off.degraded === false,
  80. `特效关闭:动效台数 ${off.animated.length}(静态色/形/文字仍由反应量与实体属性表达)`)
  81. const on = planDeviceEffects({ devices: rows, enabled: true, abnormalLimit: 30 })
  82. check('C2-enabled-animates-only-attention-three',
  83. on.enabled === true && on.degraded === false &&
  84. on.animated.map((item) => item.code).sort().join(',') === 'EQ-A,EQ-O,EQ-W',
  85. `参与动效 ${on.animated.map((item) => `${item.code}:${item.effect}`).join(' ')}(正常/离线不在内)`)
  86. check('C3-abnormal-count-is-attention-only',
  87. on.abnormalCount === 2,
  88. `异常台数 ${on.abnormalCount}(ALARM/WARNING;工单处置不算异常,判据来自票 46 的单一住址)`)
  89. // 30 台降级:31 台异常 ⇒ 动画全停 + 静态异常标记仍在。
  90. const many = Array.from({ length: 31 }, (_, i) => device(`EQ-M${i}`, 'ALARM'))
  91. const degraded = planDeviceEffects({ devices: many, enabled: true, abnormalLimit: DEFAULT_ABNORMAL_LIMIT })
  92. check('C4-over-limit-degrades-animation',
  93. degraded.degraded === true && degraded.animated.length === 0 && degraded.abnormalCount === 31,
  94. `31 台异常 ⇒ 降级=${degraded.degraded}、动效台数=${degraded.animated.length}(动画全停,不是"只给前 30 台动")`)
  95. check('C5-degraded-keeps-abnormal-visible',
  96. degraded.staticAbnormal.length === 31,
  97. `降级后仍保留 ${degraded.staticAbnormal.length} 台静态异常标记(绝不静默删点/变成"什么都没画")`)
  98. check('C6-degraded-note-tells-the-count',
  99. degradedAbnormalNote(degraded) === '31 台异常' && degradedAbnormalNote(on) === '',
  100. `降级文案「${degradedAbnormalNote(degraded)}」;未降级不显示计数`)
  101. // 边界:恰好等于上限**不降级**("超过 30 台"才降)。
  102. const exactly = Array.from({ length: 30 }, (_, i) => device(`EQ-E${i}`, 'WARNING'))
  103. const atLimit = planDeviceEffects({ devices: exactly, enabled: true, abnormalLimit: 30 })
  104. check('C7-exactly-at-limit-does-not-degrade',
  105. atLimit.degraded === false && atLimit.animated.length === 30,
  106. `恰好 30 台异常:降级=${atLimit.degraded}、动效台数=${atLimit.animated.length}("超过"才降)`)
  107. // 可调阈值:上限调到 10,40 台异常(23 台异常的场景同构)⇒ 降级。
  108. const calibrated = planDeviceEffects({
  109. devices: [...Array.from({ length: 23 }, (_, i) => device(`EQ-X${i}`, i % 2 ? 'ALARM' : 'WARNING')),
  110. ...Array.from({ length: 17 }, (_, i) => device(`EQ-Y${i}`, 'NORMAL'))],
  111. enabled: true,
  112. abnormalLimit: 10,
  113. })
  114. check('C8-limit-is-configurable',
  115. calibrated.degraded === true && calibrated.abnormalCount === 23 && calibrated.animated.length === 0,
  116. `上限 10 + 23 台异常 ⇒ 降级(与票 08 现场校准同构:已降级、动效台数 0、异常台数 23 仍在)`)
  117. }
  118. // ================================================================ D 配置(map.effects)
  119. {
  120. const builtin = builtinConfig()
  121. check('D1-builtin-defaults',
  122. builtin.map.effects.enabled === null && builtin.map.effects.abnormalLimit === 30,
  123. `内置默认 enabled=${builtin.map.effects.enabled}(null=跟随构建形态)abnormalLimit=${builtin.map.effects.abnormalLimit}`)
  124. const explicit = normalizeConfig({ map: { effects: { enabled: true, abnormalLimit: 5 } } })
  125. check('D2-explicit-values-win',
  126. explicit.config.map.effects.enabled === true && explicit.config.map.effects.abnormalLimit === 5,
  127. `显式 true/5 被采纳:${JSON.stringify(explicit.config.map.effects)}`)
  128. const dirty = normalizeConfig({ map: { effects: { enabled: 'yes', abnormalLimit: 0 } } })
  129. check('D3-dirty-values-fall-back-and-report',
  130. dirty.config.map.effects.enabled === null &&
  131. dirty.config.map.effects.abnormalLimit === 30 &&
  132. dirty.issues.some((item) => item.includes('map.effects.enabled')) &&
  133. dirty.issues.some((item) => item.includes('map.effects.abnormalLimit')),
  134. `脏值退回默认并记 issue:${dirty.issues.filter((i) => i.includes('map.effects')).join(' | ')}`)
  135. }
  136. const failed = checks.filter((item) => !item.pass)
  137. console.log('')
  138. console.log(`T47 设备状态动效检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  139. if (failed.length) {
  140. console.log('未通过:')
  141. for (const item of failed) console.log(` · ${item.name}`)
  142. process.exit(1)
  143. }
  144. console.log('T47 设备状态动效全部通过。')