t47-device-effects.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  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. RING_LAYERS,
  15. deviceEffectOf,
  16. hasDeviceEffect,
  17. effectPhaseAt,
  18. pulseStrengthAt,
  19. blinkStrengthAt,
  20. spinAngleAt,
  21. ringScaleAt,
  22. ringAlphaAt,
  23. ringFrameAt,
  24. glowStrengthAt,
  25. glowScaleAt,
  26. planDeviceEffects,
  27. degradedAbnormalNote,
  28. } from '../../../src/modules/cockpit/domain/deviceEffects.js'
  29. import { DEVICE_STATUS_VISUALS, DEVICE_UNKNOWN_VISUAL } from '../../../src/modules/cockpit/domain/deviceStatus.js'
  30. import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
  31. const checks = []
  32. function check(name, pass, detail = '') {
  33. checks.push({ name, pass })
  34. const rest = detail === '' ? '' : ` — ${detail}`
  35. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${rest}`)
  36. }
  37. const device = (code, status) => ({ code, status, pointCode: 'MP-1' })
  38. // ================================================================ A 状态 → 动效映射
  39. {
  40. check('A1-three-attention-states-animate',
  41. deviceEffectOf('ALARM') === DEVICE_EFFECT.PULSE &&
  42. deviceEffectOf('WARNING') === DEVICE_EFFECT.BLINK &&
  43. deviceEffectOf('WORK_ORDER') === DEVICE_EFFECT.SPIN,
  44. `ALARM=${deviceEffectOf('ALARM')} WARNING=${deviceEffectOf('WARNING')} WORK_ORDER=${deviceEffectOf('WORK_ORDER')}`)
  45. check('A2-normal-offline-have-no-effect',
  46. deviceEffectOf('NORMAL') === '' && deviceEffectOf('OFFLINE') === '',
  47. `NORMAL='${deviceEffectOf('NORMAL')}' OFFLINE='${deviceEffectOf('OFFLINE')}'(决策票 07:五态全动会把优先级语义淹没)`)
  48. check('A3-unknown-does-not-guess-normal',
  49. deviceEffectOf('MAINTENANCE') === '' &&
  50. deviceEffectOf('') === '' &&
  51. deviceEffectOf(undefined) === '',
  52. '未知/空/undefined 都无动效(状态都不知道,更不能编一个"正在报警"的样子)')
  53. check('A4-effect-lives-in-the-device-table',
  54. DEVICE_STATUS_VISUALS.ALARM.effect === 'pulse' && DEVICE_UNKNOWN_VISUAL.effect === null,
  55. 'effect 维度挂在票 44 的设备独立词表上(不借点位表)')
  56. }
  57. // ================================================================ B 相位(跨时间采样)
  58. {
  59. // 纯逻辑也要能证明"值随时间变",否则只是映射表对。
  60. const samples = [0, 200, 400, 600, 800, 1000, 1200, 1400]
  61. const pulses = samples.map((t) => pulseStrengthAt(t))
  62. const blinks = samples.map((t) => blinkStrengthAt(t))
  63. const spins = samples.map((t) => spinAngleAt(t))
  64. check('B1-pulse-varies-over-time',
  65. new Set(pulses.map((v) => v.toFixed(4))).size >= 3 && Math.min(...pulses) >= 0 && Math.max(...pulses) <= 1,
  66. `脉冲强度采样 ${pulses.map((v) => v.toFixed(2)).join(',')}`)
  67. check('B2-blink-varies-over-time',
  68. new Set(blinks).size >= 2 && Math.min(...blinks) > 0 && Math.max(...blinks) <= 1,
  69. `闪烁强度采样 ${blinks.map((v) => v.toFixed(2)).join(',')}`)
  70. check('B3-spin-advances-monotonically-within-period',
  71. spins[0] < spins[1] && spins[1] < spins[2] && spins[7] < Math.PI * 2,
  72. `旋转角采样 ${spins.map((v) => v.toFixed(2)).join(',')}`)
  73. check('B4-phase-is-periodic',
  74. Math.abs(effectPhaseAt(DEVICE_EFFECT.PULSE, 0) - effectPhaseAt(DEVICE_EFFECT.PULSE, 1600)) < 1e-9,
  75. '相位按周期回绕(周期由 EFFECT_PERIOD_MS 定义)')
  76. check('B5-no-effect-has-zero-phase',
  77. effectPhaseAt('', 500) === 0 && effectPhaseAt(null, 500) === 0,
  78. '无动效的相位恒为 0(不会给正常/离线算出动画)')
  79. }
  80. // ================================================================ C 门控与降级
  81. {
  82. const rows = [device('EQ-A', 'ALARM'), device('EQ-W', 'WARNING'), device('EQ-O', 'WORK_ORDER'), device('EQ-N', 'NORMAL'), device('EQ-F', 'OFFLINE')]
  83. const off = planDeviceEffects({ devices: rows, enabled: false, abnormalLimit: 30 })
  84. check('C1-disabled-means-no-animation',
  85. off.enabled === false && off.animated.length === 0 && off.degraded === false,
  86. `特效关闭:动效台数 ${off.animated.length}(静态色/形/文字仍由反应量与实体属性表达)`)
  87. const on = planDeviceEffects({ devices: rows, enabled: true, abnormalLimit: 30 })
  88. check('C2-enabled-animates-only-attention-three',
  89. on.enabled === true && on.degraded === false &&
  90. on.animated.map((item) => item.code).sort().join(',') === 'EQ-A,EQ-O,EQ-W',
  91. `参与动效 ${on.animated.map((item) => `${item.code}:${item.effect}`).join(' ')}(正常/离线不在内)`)
  92. check('C3-abnormal-count-is-attention-only',
  93. on.abnormalCount === 2,
  94. `异常台数 ${on.abnormalCount}(ALARM/WARNING;工单处置不算异常,判据来自票 46 的单一住址)`)
  95. // 30 台降级:31 台异常 ⇒ 动画全停 + 静态异常标记仍在。
  96. const many = Array.from({ length: 31 }, (_, i) => device(`EQ-M${i}`, 'ALARM'))
  97. const degraded = planDeviceEffects({ devices: many, enabled: true, abnormalLimit: DEFAULT_ABNORMAL_LIMIT })
  98. check('C4-over-limit-degrades-animation',
  99. degraded.degraded === true && degraded.animated.length === 0 && degraded.abnormalCount === 31,
  100. `31 台异常 ⇒ 降级=${degraded.degraded}、动效台数=${degraded.animated.length}(动画全停,不是"只给前 30 台动")`)
  101. check('C5-degraded-keeps-abnormal-visible',
  102. degraded.staticAbnormal.length === 31,
  103. `降级后仍保留 ${degraded.staticAbnormal.length} 台静态异常标记(绝不静默删点/变成"什么都没画")`)
  104. check('C6-degraded-note-tells-the-count',
  105. degradedAbnormalNote(degraded) === '31 台异常' && degradedAbnormalNote(on) === '',
  106. `降级文案「${degradedAbnormalNote(degraded)}」;未降级不显示计数`)
  107. // 边界:恰好等于上限**不降级**("超过 30 台"才降)。
  108. const exactly = Array.from({ length: 30 }, (_, i) => device(`EQ-E${i}`, 'WARNING'))
  109. const atLimit = planDeviceEffects({ devices: exactly, enabled: true, abnormalLimit: 30 })
  110. check('C7-exactly-at-limit-does-not-degrade',
  111. atLimit.degraded === false && atLimit.animated.length === 30,
  112. `恰好 30 台异常:降级=${atLimit.degraded}、动效台数=${atLimit.animated.length}("超过"才降)`)
  113. // 可调阈值:上限调到 10,40 台异常(23 台异常的场景同构)⇒ 降级。
  114. const calibrated = planDeviceEffects({
  115. devices: [...Array.from({ length: 23 }, (_, i) => device(`EQ-X${i}`, i % 2 ? 'ALARM' : 'WARNING')),
  116. ...Array.from({ length: 17 }, (_, i) => device(`EQ-Y${i}`, 'NORMAL'))],
  117. enabled: true,
  118. abnormalLimit: 10,
  119. })
  120. check('C8-limit-is-configurable',
  121. calibrated.degraded === true && calibrated.abnormalCount === 23 && calibrated.animated.length === 0,
  122. `上限 10 + 23 台异常 ⇒ 降级(与票 08 现场校准同构:已降级、动效台数 0、异常台数 23 仍在)`)
  123. }
  124. // ================================================================ D 配置(map.effects)
  125. {
  126. const builtin = builtinConfig()
  127. check('D1-builtin-defaults',
  128. builtin.map.effects.enabled === null && builtin.map.effects.abnormalLimit === 30,
  129. `内置默认 enabled=${builtin.map.effects.enabled}(null=跟随构建形态)abnormalLimit=${builtin.map.effects.abnormalLimit}`)
  130. const explicit = normalizeConfig({ map: { effects: { enabled: true, abnormalLimit: 5 } } })
  131. check('D2-explicit-values-win',
  132. explicit.config.map.effects.enabled === true && explicit.config.map.effects.abnormalLimit === 5,
  133. `显式 true/5 被采纳:${JSON.stringify(explicit.config.map.effects)}`)
  134. const dirty = normalizeConfig({ map: { effects: { enabled: 'yes', abnormalLimit: 0 } } })
  135. check('D3-dirty-values-fall-back-and-report',
  136. dirty.config.map.effects.enabled === null &&
  137. dirty.config.map.effects.abnormalLimit === 30 &&
  138. dirty.issues.some((item) => item.includes('map.effects.enabled')) &&
  139. dirty.issues.some((item) => item.includes('map.effects.abnormalLimit')),
  140. `脏值退回默认并记 issue:${dirty.issues.filter((i) => i.includes('map.effects')).join(' | ')}`)
  141. }
  142. // ================================================================ E 科技感光环(决策票 07 新增方向)
  143. {
  144. const samples = [0, 150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500, 1650]
  145. // 多层同心环:层数 ≥3(参考图 3–4 层),每层都随时间扩张。
  146. check('E1-ring-layers-are-multi',
  147. RING_LAYERS >= 3,
  148. `光环层数 ${RING_LAYERS}(决策票 07 参考图:3–4 层同心扩散环)`)
  149. const layerScales = Array.from({ length: RING_LAYERS }, (_, layer) =>
  150. samples.map((t) => ringScaleAt(layer, t, DEVICE_EFFECT.PULSE)))
  151. check('E2-every-ring-expands-over-time',
  152. layerScales.every((list) => new Set(list.map((v) => v.toFixed(4))).size >= 5),
  153. `每层都在扩张(各层 scale 取值数:${layerScales.map((l) => new Set(l.map((v) => v.toFixed(4))).size).join('/')})`)
  154. check('E3-ring-scales-outward-and-fades',
  155. ringScaleAt(0, 0, DEVICE_EFFECT.PULSE) >= 1 &&
  156. ringScaleAt(0, 1700, DEVICE_EFFECT.PULSE) > 2.5 &&
  157. ringAlphaAt(0, 1700, DEVICE_EFFECT.PULSE) < ringAlphaAt(0, 100, DEVICE_EFFECT.PULSE),
  158. `环由内向外扩张(scale→${ringScaleAt(0, 1700, DEVICE_EFFECT.PULSE).toFixed(2)})且渐隐(alpha ${ringAlphaAt(0, 100, DEVICE_EFFECT.PULSE).toFixed(2)}→${ringAlphaAt(0, 1700, DEVICE_EFFECT.PULSE).toFixed(2)})`)
  159. // 层间相位错开 ⇒ 同一时刻各层 scale 不同("连续向外扩散"的观感来源)。
  160. const staggered = samples.some((t) => new Set(
  161. Array.from({ length: RING_LAYERS }, (_, layer) => ringScaleAt(layer, t, DEVICE_EFFECT.PULSE).toFixed(3))).size === RING_LAYERS)
  162. check('E4-layers-are-phase-staggered', staggered,
  163. '同一时刻各层 scale 互不相同(层间错开相位 ⇒ 连续扩散观感,而非同涨同落)')
  164. // 中心辉光:只给最高优先级(报警),且随时间呼吸。
  165. const glowSamples = samples.map((t) => glowStrengthAt(t))
  166. check('E5-glow-breathes-for-alarm-only',
  167. new Set(glowSamples.map((v) => v.toFixed(4))).size >= 5 &&
  168. ringFrameAt('ALARM', 300).glow !== null &&
  169. ringFrameAt('WARNING', 300).glow === null &&
  170. ringFrameAt('WORK_ORDER', 300).glow === null,
  171. `辉光只给报警(三个状态辉光:${['ALARM', 'WARNING', 'WORK_ORDER'].map((s) => (ringFrameAt(s, 300).glow ? '有' : '无')).join('/')}),强度取值数 ${new Set(glowSamples.map((v) => v.toFixed(4))).size}`)
  172. // 无动效状态不得有光环(正常/离线/未知)。
  173. check('E6-no-rings-for-static-states',
  174. ['NORMAL', 'OFFLINE', 'MAINTENANCE'].every((status) => {
  175. const frame = ringFrameAt(status, 500)
  176. return frame.rings.length === 0 && frame.glow === null
  177. }),
  178. '正常/离线/未知**没有任何光环**(决策票 07:静态状态不参与动效)')
  179. // 三种需处置状态都有环(层数一致 ⇒ 不靠层数表达优先级)。
  180. check('E7-all-attention-states-have-rings',
  181. ['ALARM', 'WARNING', 'WORK_ORDER'].every((status) => ringFrameAt(status, 500).rings.length === RING_LAYERS),
  182. `三种需处置状态都是 ${RING_LAYERS} 层环(层数一致,差异靠节奏与色相表达)`)
  183. }
  184. const failed = checks.filter((item) => !item.pass)
  185. console.log('')
  186. console.log(`T47 设备状态动效检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  187. if (failed.length) {
  188. console.log('未通过:')
  189. for (const item of failed) console.log(` · ${item.name}`)
  190. process.exit(1)
  191. }
  192. console.log('T47 设备状态动效全部通过。')