/** * T47 设备状态动效走查(票 47;决策票 07 问题 7 的**第 2、3 层证据**)。 * * 三层证据缺一不可(第 1 层是 `t47-device-effects.mjs` 的纯逻辑): * 2. **场景图跨帧快照**(关键层):对同一设备隔帧读两次,断言 * 报警/预警的 `scale`/`alpha` 与工单的 `rotation` **确实变了**, * 而正常/离线**完全不变**。这才是"真在跑"而不是"已配置"的证据 —— * 正面避开"拿两份 JS 数据互比"的恒真断言。 * 3. **真实帧差**:同一视角隔一小段时间截两帧,断言**字节不同**(画面确实在变)。 * 4. 人工走查:观感由维护者目视裁定(**未获人工确认不关闭本票**)。 * * 场景走 URL 通道 `?scenario=device-effects`(受控可上图样例;真实环境仍是清单-only, * 决策票 07 明写"不得从纯逻辑断言推断现场特效已生效")。 */ import { cdpLaunch } from './cdp-driver.mjs' const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000' const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' const SCENARIO = process.env.REPRO_SCENARIO || 'device-effects' const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/?scenario=${SCENARIO}#/cockpit` const results = [] function check(id, pass, evidence) { results.push({ id, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${evidence === undefined ? '' : String(evidence)}`) } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 }) console.log(`Chrome: ${await browser.version()}`) const page = await browser.newPage() const errors = [] page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e))) page.on('console', (m) => { if (m.type() === 'error') errors.push(`console.error: ${m.text()}`) }) await page.goto(ENTRY_URL, { timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) if (await page.$('[data-cockpit-intro]')) { await page.click('[data-cockpit-intro]') await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {}) } await page.waitForFunction( () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine), { timeout: 40000 }, ).catch(() => {}) await page.evaluate(() => { const dev = window.__cockpitMap if (!dev || !dev.mapSummary) return let steps = 0 const step = () => { const state = dev.mapSummary.getState() if (!state || ['complete', 'empty', 'failed', 'expired'].includes(state.phase) || steps >= 20) return dev.mapSummary.advance(); steps += 1; window.setTimeout(step, 220) } step() }) await page.waitForFunction(() => { const dev = window.__cockpitMap const s = dev && dev.mapSummary ? dev.mapSummary.getState() : null return !s || ['complete', 'empty', 'failed', 'expired'].includes(s.phase) }, { timeout: 20000 }).catch(() => {}) await page.waitForFunction(() => { const dev = window.__cockpitMap const s = dev && dev.getCameraState ? dev.getCameraState() : null return !s || s.settled === true }, { timeout: 15000 }).catch(() => {}) await sleep(1500) const locate = () => page.evaluate(() => { const dev = window.__cockpitMap const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : [] return all.find((item) => item.visible && item.code === 'MP-001') || null }) const readDetail = () => page.evaluate(() => ({ level: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.detailLevel || '', pointCode: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.pointCode || '', })) const OFFSETS = [[0, 0], [0, -6], [0, 6], [-6, 0], [6, 0], [0, -12], [0, 12], [-10, -6], [10, 6]] // 真实鼠标点开 MP-001(贴地形标记会抖几像素,做小网格扫描点击)。 for (let round = 0; round < 4; round += 1) { const now = await readDetail() if (now.level === 'point' && now.pointCode === 'MP-001') break const marker = await locate() if (marker) { for (const [dx, dy] of OFFSETS) { await page.mouse.move(1200, 300) await sleep(60) await page.mouse.move(marker.x + dx, marker.y + dy) await sleep(130) await page.mouse.click(marker.x + dx, marker.y + dy) await sleep(700) const after = await readDetail() if (after.level === 'point' && after.pointCode === 'MP-001') break } } await sleep(300) } // 遮罩让真实鼠标能命中画布(既有锚定浮层会盖住画布;仅测试态)。 await page.evaluate(() => { const detail = document.querySelector('.cockpit-shell__detail') if (detail) { detail.style.pointerEvents = 'none' detail.querySelectorAll('*').forEach((child) => { child.style.pointerEvents = 'none' }) } }) const opened = await readDetail() check('E1-controlled-sample-opens', opened.level === 'point' && opened.pointCode === 'MP-001', `真实点开受控样例点位:level=${opened.level} pointCode=${opened.pointCode}`) const readEffects = () => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const dev = window.__cockpitMap const state = dev && dev.getDeviceEffects ? dev.getDeviceEffects() : null return { host: host ? { effects: host.dataset.mapDeviceEffects, abnormal: host.dataset.mapDeviceEffectsAbnormal, animated: host.dataset.mapDeviceEffectsAnimated, } : {}, enabled: state ? state.enabled : null, degraded: state ? state.degraded : null, running: state ? state.running : null, tick: state ? state.tick : -1, ringLayers: state ? state.ringLayers : 0, devices: state && state.devices ? state.devices : [], fx: state && state.fx ? state.fx : [], } }) // 多帧采样:闪烁(周期 900ms)等动效在两次采样间可能正好落在同一段, // 所以按 ~140ms 采 8 帧,用"取值集合的势"判定 —— 比两点比较更稳健,判据也更强 // (正常/离线要求**全程只有一个取值**)。 const SAMPLES = [] for (let index = 0; index < 8; index += 1) { SAMPLES.push(await readEffects()) await sleep(140) } const frameA = SAMPLES[0] const frameB = SAMPLES[SAMPLES.length - 1] const valuesOf = (code, key) => SAMPLES .map((frame) => (frame.devices || []).find((item) => item.code === code)) .map((item) => (item && Number.isFinite(item[key]) ? Number(item[key].toFixed(5)) : null)) .filter((value) => value !== null) const distinct = (code, key) => new Set(valuesOf(code, key)).size const alarmA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-ALARM') const warnA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-WARNING') const workA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-WORK') const normalA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-NORMAL') const offlineA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-OFFLINE') console.log(`SAMPLES: ${SAMPLES.length} 帧;tick ${frameA.tick}→${frameB.tick};host=${JSON.stringify(frameA.host)}`) console.log(`ALARM scale 取值 ${valuesOf('EQ-EF-ALARM', 'scale').join(',')}`) console.log(`WARNING alpha 取值 ${valuesOf('EQ-EF-WARNING', 'alpha').join(',')}`) console.log(`WORK rotation 取值 ${valuesOf('EQ-EF-WORK', 'rotation').join(',')}`) console.log(`NORMAL scale/alpha 取值 ${valuesOf('EQ-EF-NORMAL', 'scale').join(',')} / ${valuesOf('EQ-EF-NORMAL', 'alpha').join(',')}`) check('E2-effects-enabled-and-running', SCENARIO === 'device-effects-degraded' ? (frameA.enabled === true && frameA.host.effects === 'degraded' && frameA.host.animated === '0' && frameA.running === false) : (frameA.enabled === true && frameA.running === true && frameA.host.effects === 'on' && frameB.tick > frameA.tick), SCENARIO === 'device-effects-degraded' ? `降级场景:enabled=${frameA.enabled} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}` : `enabled=${frameA.enabled} running=${frameA.running} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}(tick ${frameA.tick}→${frameB.tick})`) if (SCENARIO === 'device-effects') { check('E3-alarm-pulses-across-frames', distinct('EQ-EF-ALARM', 'scale') >= 2 || distinct('EQ-EF-ALARM', 'alpha') >= 2, `报警脉冲:scale 取值数 ${distinct('EQ-EF-ALARM', 'scale')}、alpha 取值数 ${distinct('EQ-EF-ALARM', 'alpha')}`) check('E4-warning-blinks-across-frames', distinct('EQ-EF-WARNING', 'alpha') >= 2, `预警闪烁:alpha 取值数 ${distinct('EQ-EF-WARNING', 'alpha')}(取值 ${valuesOf('EQ-EF-WARNING', 'alpha').join(',')})`) check('E5-work-order-spins-across-frames', distinct('EQ-EF-WORK', 'rotation') >= 2, `工单旋转:rotation 取值数 ${distinct('EQ-EF-WORK', 'rotation')}`) check('E6-normal-stays-static', distinct('EQ-EF-NORMAL', 'scale') === 1 && distinct('EQ-EF-NORMAL', 'alpha') === 1 && distinct('EQ-EF-NORMAL', 'rotation') === 1, `正常设备**全程静止**:scale ${normalA && normalA.scale}、alpha ${normalA && normalA.alpha}、rotation ${normalA && normalA.rotation}`) check('E7-offline-stays-static', distinct('EQ-EF-OFFLINE', 'scale') === 1 && distinct('EQ-EF-OFFLINE', 'alpha') === 1 && distinct('EQ-EF-OFFLINE', 'rotation') === 1, `离线设备**全程静止**:scale ${offlineA && offlineA.scale}、alpha ${offlineA && offlineA.alpha}、rotation ${offlineA && offlineA.rotation}`) // ── 科技感光环(决策票 07 新增方向):多层同心扩散环 + 中心辉光 ── const fxValues = (code, kind, key) => SAMPLES .flatMap((frame) => (frame.fx || []).filter((item) => item.for === code && item.kind === kind)) .map((item) => (Number.isFinite(item[key]) ? Number(item[key].toFixed(5)) : null)) .filter((value) => value !== null) const ringLayersOf = (code) => new Set((SAMPLES[0].fx || []) .filter((item) => item.for === code && item.kind === 'ring') .map((item) => item.layer)).size check('E13-alarm-has-multi-layer-rings', frameA.ringLayers >= 3 && ringLayersOf('EQ-EF-ALARM') === frameA.ringLayers, `报警设备有 ${ringLayersOf('EQ-EF-ALARM')} 层同心环(期望 ${frameA.ringLayers} 层)`) check('E14-rings-expand-and-fade-across-frames', new Set(fxValues('EQ-EF-ALARM', 'ring', 'scale')).size >= 3 && new Set(fxValues('EQ-EF-ALARM', 'ring', 'alpha')).size >= 3, `环跨帧扩张渐隐:scale 取值数 ${new Set(fxValues('EQ-EF-ALARM', 'ring', 'scale')).size}、alpha 取值数 ${new Set(fxValues('EQ-EF-ALARM', 'ring', 'alpha')).size}`) check('E15-glow-only-for-alarm', fxValues('EQ-EF-ALARM', 'glow', 'alpha').length > 0 && new Set(fxValues('EQ-EF-ALARM', 'glow', 'alpha')).size >= 2 && fxValues('EQ-EF-WARNING', 'glow', 'alpha').length === 0 && fxValues('EQ-EF-NORMAL', 'ring', 'scale').length === 0, `中心辉光只给报警(报警辉光取值数 ${new Set(fxValues('EQ-EF-ALARM', 'glow', 'alpha')).size};预警/正常无辉光/无环)`) check('E16-static-devices-have-no-rings', ringLayersOf('EQ-EF-NORMAL') === 0 && ringLayersOf('EQ-EF-OFFLINE') === 0, `正常/离线**没有任何光环**(决策票 07:静态状态不参与动效)`) } // ── 第 3 层:真实帧差(同一视角两帧,字节不同 ⇒ 画面确实在变)── if (SCENARIO === 'device-effects') { const shotA = await page.screenshot() await sleep(500) const shotB = await page.screenshot() check('E8-real-frame-diff-changes', Boolean(shotA) && Boolean(shotB) && shotA !== shotB, `两帧截图字节${shotA === shotB ? '相同(画面没变 → 不合格)' : '不同'}(长度 ${shotA.length} vs ${shotB.length})`) } // ── 降级场景(>30 台异常)另有判据:动画全停 + 异常计数可见 ── if (SCENARIO === 'device-effects-degraded') { const degradedNote = await page.evaluate(() => { const note = document.querySelector('[data-device-effects-degraded]') return note ? { text: (note.textContent || '').trim(), abnormal: note.getAttribute('data-device-effects-abnormal') } : null }) const allStatic = SAMPLES.every((frame) => (frame.devices || []).every((item) => !item.animated)) check('E10-degraded-stops-animation', frameA.host.effects === 'degraded' && frameA.enabled === true && frameA.degraded === true && frameA.host.animated === '0' && frameA.running === false && allStatic, `降级:host=${frameA.host.effects} running=${frameA.running} 动效台数=${frameA.host.animated}(${SAMPLES.length} 帧全部无动效=${allStatic})`) check('E11-degraded-count-is-visible', Boolean(degradedNote) && degradedNote.abnormal === '31' && frameA.host.abnormal === '31', `异常计数可见:面板「${degradedNote ? degradedNote.text : '(缺失)'}」、data-abnormal=${degradedNote && degradedNote.abnormal}`) check('E12-degraded-keeps-devices-drawn', frameA.devices.length === 31, `降级后**仍画 31 台静态异常标记**(不静默删点):实际 ${frameA.devices.length} 台`) } check('E9-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '无 pageerror / console.error') await page.close() await browser.close() const failed = results.filter((r) => !r.pass) console.log('') console.log(`T47 动效走查(${SCENARIO}):${results.length - failed.length}/${results.length} 项通过。`) if (failed.length) { for (const item of failed) console.log(` · ${item.id}`); process.exit(1) } console.log('T47 动效走查全部通过。')