|
|
@@ -0,0 +1,222 @@
|
|
|
+/**
|
|
|
+ * 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,
|
|
|
+ devices: state && state.devices ? state.devices : [],
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 多帧采样:闪烁(周期 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}`)
|
|
|
+}
|
|
|
+
|
|
|
+// ── 第 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 动效走查全部通过。')
|