/** * T47 设备状态视觉特效与门控 纯逻辑检查(票 47;决策票 07 的 `## Answer`)。 * * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t47-device-effects.mjs * * **这是三层证据里的第 1 层**(领域纯逻辑):映射唯一、未知不猜、门控与 30 台降级可断言。 * 它**不能单独作为验收** —— 映射表对不等于画面在动;"真在跑"的证据在 * `t47-device-effects-walkthrough.mjs` 的**跨帧快照**与真实帧差里(决策票 07 问题 7)。 */ import { DEVICE_EFFECT, DEFAULT_ABNORMAL_LIMIT, RING_LAYERS, deviceEffectOf, hasDeviceEffect, effectPhaseAt, pulseStrengthAt, blinkStrengthAt, spinAngleAt, ringScaleAt, ringAlphaAt, ringFrameAt, glowStrengthAt, glowScaleAt, planDeviceEffects, degradedAbnormalNote, } from '../../../src/modules/cockpit/domain/deviceEffects.js' import { DEVICE_STATUS_VISUALS, DEVICE_UNKNOWN_VISUAL } from '../../../src/modules/cockpit/domain/deviceStatus.js' import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js' const checks = [] function check(name, pass, detail = '') { checks.push({ name, pass }) const rest = detail === '' ? '' : ` — ${detail}` console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${rest}`) } const device = (code, status) => ({ code, status, pointCode: 'MP-1' }) // ================================================================ A 状态 → 动效映射 { check('A1-three-attention-states-animate', deviceEffectOf('ALARM') === DEVICE_EFFECT.PULSE && deviceEffectOf('WARNING') === DEVICE_EFFECT.BLINK && deviceEffectOf('WORK_ORDER') === DEVICE_EFFECT.SPIN, `ALARM=${deviceEffectOf('ALARM')} WARNING=${deviceEffectOf('WARNING')} WORK_ORDER=${deviceEffectOf('WORK_ORDER')}`) check('A2-normal-offline-have-no-effect', deviceEffectOf('NORMAL') === '' && deviceEffectOf('OFFLINE') === '', `NORMAL='${deviceEffectOf('NORMAL')}' OFFLINE='${deviceEffectOf('OFFLINE')}'(决策票 07:五态全动会把优先级语义淹没)`) check('A3-unknown-does-not-guess-normal', deviceEffectOf('MAINTENANCE') === '' && deviceEffectOf('') === '' && deviceEffectOf(undefined) === '', '未知/空/undefined 都无动效(状态都不知道,更不能编一个"正在报警"的样子)') check('A4-effect-lives-in-the-device-table', DEVICE_STATUS_VISUALS.ALARM.effect === 'pulse' && DEVICE_UNKNOWN_VISUAL.effect === null, 'effect 维度挂在票 44 的设备独立词表上(不借点位表)') } // ================================================================ B 相位(跨时间采样) { // 纯逻辑也要能证明"值随时间变",否则只是映射表对。 const samples = [0, 200, 400, 600, 800, 1000, 1200, 1400] const pulses = samples.map((t) => pulseStrengthAt(t)) const blinks = samples.map((t) => blinkStrengthAt(t)) const spins = samples.map((t) => spinAngleAt(t)) check('B1-pulse-varies-over-time', new Set(pulses.map((v) => v.toFixed(4))).size >= 3 && Math.min(...pulses) >= 0 && Math.max(...pulses) <= 1, `脉冲强度采样 ${pulses.map((v) => v.toFixed(2)).join(',')}`) check('B2-blink-varies-over-time', new Set(blinks).size >= 2 && Math.min(...blinks) > 0 && Math.max(...blinks) <= 1, `闪烁强度采样 ${blinks.map((v) => v.toFixed(2)).join(',')}`) check('B3-spin-advances-monotonically-within-period', spins[0] < spins[1] && spins[1] < spins[2] && spins[7] < Math.PI * 2, `旋转角采样 ${spins.map((v) => v.toFixed(2)).join(',')}`) check('B4-phase-is-periodic', Math.abs(effectPhaseAt(DEVICE_EFFECT.PULSE, 0) - effectPhaseAt(DEVICE_EFFECT.PULSE, 1600)) < 1e-9, '相位按周期回绕(周期由 EFFECT_PERIOD_MS 定义)') check('B5-no-effect-has-zero-phase', effectPhaseAt('', 500) === 0 && effectPhaseAt(null, 500) === 0, '无动效的相位恒为 0(不会给正常/离线算出动画)') } // ================================================================ C 门控与降级 { const rows = [device('EQ-A', 'ALARM'), device('EQ-W', 'WARNING'), device('EQ-O', 'WORK_ORDER'), device('EQ-N', 'NORMAL'), device('EQ-F', 'OFFLINE')] const off = planDeviceEffects({ devices: rows, enabled: false, abnormalLimit: 30 }) check('C1-disabled-means-no-animation', off.enabled === false && off.animated.length === 0 && off.degraded === false, `特效关闭:动效台数 ${off.animated.length}(静态色/形/文字仍由反应量与实体属性表达)`) const on = planDeviceEffects({ devices: rows, enabled: true, abnormalLimit: 30 }) check('C2-enabled-animates-only-attention-three', on.enabled === true && on.degraded === false && on.animated.map((item) => item.code).sort().join(',') === 'EQ-A,EQ-O,EQ-W', `参与动效 ${on.animated.map((item) => `${item.code}:${item.effect}`).join(' ')}(正常/离线不在内)`) check('C3-abnormal-count-is-attention-only', on.abnormalCount === 2, `异常台数 ${on.abnormalCount}(ALARM/WARNING;工单处置不算异常,判据来自票 46 的单一住址)`) // 30 台降级:31 台异常 ⇒ 动画全停 + 静态异常标记仍在。 const many = Array.from({ length: 31 }, (_, i) => device(`EQ-M${i}`, 'ALARM')) const degraded = planDeviceEffects({ devices: many, enabled: true, abnormalLimit: DEFAULT_ABNORMAL_LIMIT }) check('C4-over-limit-degrades-animation', degraded.degraded === true && degraded.animated.length === 0 && degraded.abnormalCount === 31, `31 台异常 ⇒ 降级=${degraded.degraded}、动效台数=${degraded.animated.length}(动画全停,不是"只给前 30 台动")`) check('C5-degraded-keeps-abnormal-visible', degraded.staticAbnormal.length === 31, `降级后仍保留 ${degraded.staticAbnormal.length} 台静态异常标记(绝不静默删点/变成"什么都没画")`) check('C6-degraded-note-tells-the-count', degradedAbnormalNote(degraded) === '31 台异常' && degradedAbnormalNote(on) === '', `降级文案「${degradedAbnormalNote(degraded)}」;未降级不显示计数`) // 边界:恰好等于上限**不降级**("超过 30 台"才降)。 const exactly = Array.from({ length: 30 }, (_, i) => device(`EQ-E${i}`, 'WARNING')) const atLimit = planDeviceEffects({ devices: exactly, enabled: true, abnormalLimit: 30 }) check('C7-exactly-at-limit-does-not-degrade', atLimit.degraded === false && atLimit.animated.length === 30, `恰好 30 台异常:降级=${atLimit.degraded}、动效台数=${atLimit.animated.length}("超过"才降)`) // 可调阈值:上限调到 10,40 台异常(23 台异常的场景同构)⇒ 降级。 const calibrated = planDeviceEffects({ devices: [...Array.from({ length: 23 }, (_, i) => device(`EQ-X${i}`, i % 2 ? 'ALARM' : 'WARNING')), ...Array.from({ length: 17 }, (_, i) => device(`EQ-Y${i}`, 'NORMAL'))], enabled: true, abnormalLimit: 10, }) check('C8-limit-is-configurable', calibrated.degraded === true && calibrated.abnormalCount === 23 && calibrated.animated.length === 0, `上限 10 + 23 台异常 ⇒ 降级(与票 08 现场校准同构:已降级、动效台数 0、异常台数 23 仍在)`) } // ================================================================ D 配置(map.effects) { const builtin = builtinConfig() check('D1-builtin-defaults', builtin.map.effects.enabled === null && builtin.map.effects.abnormalLimit === 30, `内置默认 enabled=${builtin.map.effects.enabled}(null=跟随构建形态)abnormalLimit=${builtin.map.effects.abnormalLimit}`) const explicit = normalizeConfig({ map: { effects: { enabled: true, abnormalLimit: 5 } } }) check('D2-explicit-values-win', explicit.config.map.effects.enabled === true && explicit.config.map.effects.abnormalLimit === 5, `显式 true/5 被采纳:${JSON.stringify(explicit.config.map.effects)}`) const dirty = normalizeConfig({ map: { effects: { enabled: 'yes', abnormalLimit: 0 } } }) check('D3-dirty-values-fall-back-and-report', dirty.config.map.effects.enabled === null && dirty.config.map.effects.abnormalLimit === 30 && dirty.issues.some((item) => item.includes('map.effects.enabled')) && dirty.issues.some((item) => item.includes('map.effects.abnormalLimit')), `脏值退回默认并记 issue:${dirty.issues.filter((i) => i.includes('map.effects')).join(' | ')}`) } // ================================================================ E 科技感光环(决策票 07 新增方向) { const samples = [0, 150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500, 1650] // 多层同心环:层数 ≥3(参考图 3–4 层),每层都随时间扩张。 check('E1-ring-layers-are-multi', RING_LAYERS >= 3, `光环层数 ${RING_LAYERS}(决策票 07 参考图:3–4 层同心扩散环)`) const layerScales = Array.from({ length: RING_LAYERS }, (_, layer) => samples.map((t) => ringScaleAt(layer, t, DEVICE_EFFECT.PULSE))) check('E2-every-ring-expands-over-time', layerScales.every((list) => new Set(list.map((v) => v.toFixed(4))).size >= 5), `每层都在扩张(各层 scale 取值数:${layerScales.map((l) => new Set(l.map((v) => v.toFixed(4))).size).join('/')})`) check('E3-ring-scales-outward-and-fades', ringScaleAt(0, 0, DEVICE_EFFECT.PULSE) >= 1 && ringScaleAt(0, 1700, DEVICE_EFFECT.PULSE) > 2.5 && ringAlphaAt(0, 1700, DEVICE_EFFECT.PULSE) < ringAlphaAt(0, 100, DEVICE_EFFECT.PULSE), `环由内向外扩张(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)})`) // 层间相位错开 ⇒ 同一时刻各层 scale 不同("连续向外扩散"的观感来源)。 const staggered = samples.some((t) => new Set( Array.from({ length: RING_LAYERS }, (_, layer) => ringScaleAt(layer, t, DEVICE_EFFECT.PULSE).toFixed(3))).size === RING_LAYERS) check('E4-layers-are-phase-staggered', staggered, '同一时刻各层 scale 互不相同(层间错开相位 ⇒ 连续扩散观感,而非同涨同落)') // 中心辉光:只给最高优先级(报警),且随时间呼吸。 const glowSamples = samples.map((t) => glowStrengthAt(t)) check('E5-glow-breathes-for-alarm-only', new Set(glowSamples.map((v) => v.toFixed(4))).size >= 5 && ringFrameAt('ALARM', 300).glow !== null && ringFrameAt('WARNING', 300).glow === null && ringFrameAt('WORK_ORDER', 300).glow === null, `辉光只给报警(三个状态辉光:${['ALARM', 'WARNING', 'WORK_ORDER'].map((s) => (ringFrameAt(s, 300).glow ? '有' : '无')).join('/')}),强度取值数 ${new Set(glowSamples.map((v) => v.toFixed(4))).size}`) // 无动效状态不得有光环(正常/离线/未知)。 check('E6-no-rings-for-static-states', ['NORMAL', 'OFFLINE', 'MAINTENANCE'].every((status) => { const frame = ringFrameAt(status, 500) return frame.rings.length === 0 && frame.glow === null }), '正常/离线/未知**没有任何光环**(决策票 07:静态状态不参与动效)') // 三种需处置状态都有环(层数一致 ⇒ 不靠层数表达优先级)。 check('E7-all-attention-states-have-rings', ['ALARM', 'WARNING', 'WORK_ORDER'].every((status) => ringFrameAt(status, 500).rings.length === RING_LAYERS), `三种需处置状态都是 ${RING_LAYERS} 层环(层数一致,差异靠节奏与色相表达)`) } const failed = checks.filter((item) => !item.pass) console.log('') console.log(`T47 设备状态动效检查:${checks.length - failed.length}/${checks.length} 项通过。`) if (failed.length) { console.log('未通过:') for (const item of failed) console.log(` · ${item.name}`) process.exit(1) } console.log('T47 设备状态动效全部通过。')