t47-device-effects-walkthrough.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. /**
  2. * T47 设备状态动效走查(票 47;决策票 07 问题 7 的**第 2、3 层证据**)。
  3. *
  4. * 三层证据缺一不可(第 1 层是 `t47-device-effects.mjs` 的纯逻辑):
  5. * 2. **场景图跨帧快照**(关键层):对同一设备隔帧读两次,断言
  6. * 报警/预警的 `scale`/`alpha` 与工单的 `rotation` **确实变了**,
  7. * 而正常/离线**完全不变**。这才是"真在跑"而不是"已配置"的证据 ——
  8. * 正面避开"拿两份 JS 数据互比"的恒真断言。
  9. * 3. **真实帧差**:同一视角隔一小段时间截两帧,断言**字节不同**(画面确实在变)。
  10. * 4. 人工走查:观感由维护者目视裁定(**未获人工确认不关闭本票**)。
  11. *
  12. * 场景走 URL 通道 `?scenario=device-effects`(受控可上图样例;真实环境仍是清单-only,
  13. * 决策票 07 明写"不得从纯逻辑断言推断现场特效已生效")。
  14. */
  15. import { cdpLaunch } from './cdp-driver.mjs'
  16. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  17. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  18. const SCENARIO = process.env.REPRO_SCENARIO || 'device-effects'
  19. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/?scenario=${SCENARIO}#/cockpit`
  20. const results = []
  21. function check(id, pass, evidence) {
  22. results.push({ id, pass })
  23. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${evidence === undefined ? '' : String(evidence)}`)
  24. }
  25. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  26. const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 })
  27. console.log(`Chrome: ${await browser.version()}`)
  28. const page = await browser.newPage()
  29. const errors = []
  30. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  31. page.on('console', (m) => { if (m.type() === 'error') errors.push(`console.error: ${m.text()}`) })
  32. await page.goto(ENTRY_URL, { timeout: 60000 })
  33. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  34. if (await page.$('[data-cockpit-intro]')) {
  35. await page.click('[data-cockpit-intro]')
  36. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  37. }
  38. await page.waitForFunction(
  39. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  40. { timeout: 40000 },
  41. ).catch(() => {})
  42. await page.evaluate(() => {
  43. const dev = window.__cockpitMap
  44. if (!dev || !dev.mapSummary) return
  45. let steps = 0
  46. const step = () => {
  47. const state = dev.mapSummary.getState()
  48. if (!state || ['complete', 'empty', 'failed', 'expired'].includes(state.phase) || steps >= 20) return
  49. dev.mapSummary.advance(); steps += 1; window.setTimeout(step, 220)
  50. }
  51. step()
  52. })
  53. await page.waitForFunction(() => {
  54. const dev = window.__cockpitMap
  55. const s = dev && dev.mapSummary ? dev.mapSummary.getState() : null
  56. return !s || ['complete', 'empty', 'failed', 'expired'].includes(s.phase)
  57. }, { timeout: 20000 }).catch(() => {})
  58. await page.waitForFunction(() => {
  59. const dev = window.__cockpitMap
  60. const s = dev && dev.getCameraState ? dev.getCameraState() : null
  61. return !s || s.settled === true
  62. }, { timeout: 15000 }).catch(() => {})
  63. await sleep(1500)
  64. const locate = () => page.evaluate(() => {
  65. const dev = window.__cockpitMap
  66. const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : []
  67. return all.find((item) => item.visible && item.code === 'MP-001') || null
  68. })
  69. const readDetail = () => page.evaluate(() => ({
  70. level: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.detailLevel || '',
  71. pointCode: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.pointCode || '',
  72. }))
  73. const OFFSETS = [[0, 0], [0, -6], [0, 6], [-6, 0], [6, 0], [0, -12], [0, 12], [-10, -6], [10, 6]]
  74. // 真实鼠标点开 MP-001(贴地形标记会抖几像素,做小网格扫描点击)。
  75. for (let round = 0; round < 4; round += 1) {
  76. const now = await readDetail()
  77. if (now.level === 'point' && now.pointCode === 'MP-001') break
  78. const marker = await locate()
  79. if (marker) {
  80. for (const [dx, dy] of OFFSETS) {
  81. await page.mouse.move(1200, 300)
  82. await sleep(60)
  83. await page.mouse.move(marker.x + dx, marker.y + dy)
  84. await sleep(130)
  85. await page.mouse.click(marker.x + dx, marker.y + dy)
  86. await sleep(700)
  87. const after = await readDetail()
  88. if (after.level === 'point' && after.pointCode === 'MP-001') break
  89. }
  90. }
  91. await sleep(300)
  92. }
  93. // 遮罩让真实鼠标能命中画布(既有锚定浮层会盖住画布;仅测试态)。
  94. await page.evaluate(() => {
  95. const detail = document.querySelector('.cockpit-shell__detail')
  96. if (detail) {
  97. detail.style.pointerEvents = 'none'
  98. detail.querySelectorAll('*').forEach((child) => { child.style.pointerEvents = 'none' })
  99. }
  100. })
  101. const opened = await readDetail()
  102. check('E1-controlled-sample-opens',
  103. opened.level === 'point' && opened.pointCode === 'MP-001',
  104. `真实点开受控样例点位:level=${opened.level} pointCode=${opened.pointCode}`)
  105. const readEffects = () => page.evaluate(() => {
  106. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  107. const dev = window.__cockpitMap
  108. const state = dev && dev.getDeviceEffects ? dev.getDeviceEffects() : null
  109. return {
  110. host: host ? {
  111. effects: host.dataset.mapDeviceEffects,
  112. abnormal: host.dataset.mapDeviceEffectsAbnormal,
  113. animated: host.dataset.mapDeviceEffectsAnimated,
  114. } : {},
  115. enabled: state ? state.enabled : null,
  116. degraded: state ? state.degraded : null,
  117. running: state ? state.running : null,
  118. tick: state ? state.tick : -1,
  119. devices: state && state.devices ? state.devices : [],
  120. }
  121. })
  122. // 多帧采样:闪烁(周期 900ms)等动效在两次采样间可能正好落在同一段,
  123. // 所以按 ~140ms 采 8 帧,用"取值集合的势"判定 —— 比两点比较更稳健,判据也更强
  124. // (正常/离线要求**全程只有一个取值**)。
  125. const SAMPLES = []
  126. for (let index = 0; index < 8; index += 1) {
  127. SAMPLES.push(await readEffects())
  128. await sleep(140)
  129. }
  130. const frameA = SAMPLES[0]
  131. const frameB = SAMPLES[SAMPLES.length - 1]
  132. const valuesOf = (code, key) => SAMPLES
  133. .map((frame) => (frame.devices || []).find((item) => item.code === code))
  134. .map((item) => (item && Number.isFinite(item[key]) ? Number(item[key].toFixed(5)) : null))
  135. .filter((value) => value !== null)
  136. const distinct = (code, key) => new Set(valuesOf(code, key)).size
  137. const alarmA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-ALARM')
  138. const warnA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-WARNING')
  139. const workA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-WORK')
  140. const normalA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-NORMAL')
  141. const offlineA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-OFFLINE')
  142. console.log(`SAMPLES: ${SAMPLES.length} 帧;tick ${frameA.tick}→${frameB.tick};host=${JSON.stringify(frameA.host)}`)
  143. console.log(`ALARM scale 取值 ${valuesOf('EQ-EF-ALARM', 'scale').join(',')}`)
  144. console.log(`WARNING alpha 取值 ${valuesOf('EQ-EF-WARNING', 'alpha').join(',')}`)
  145. console.log(`WORK rotation 取值 ${valuesOf('EQ-EF-WORK', 'rotation').join(',')}`)
  146. console.log(`NORMAL scale/alpha 取值 ${valuesOf('EQ-EF-NORMAL', 'scale').join(',')} / ${valuesOf('EQ-EF-NORMAL', 'alpha').join(',')}`)
  147. check('E2-effects-enabled-and-running',
  148. SCENARIO === 'device-effects-degraded'
  149. ? (frameA.enabled === true && frameA.host.effects === 'degraded' && frameA.host.animated === '0' && frameA.running === false)
  150. : (frameA.enabled === true && frameA.running === true && frameA.host.effects === 'on' && frameB.tick > frameA.tick),
  151. SCENARIO === 'device-effects-degraded'
  152. ? `降级场景:enabled=${frameA.enabled} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}`
  153. : `enabled=${frameA.enabled} running=${frameA.running} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}(tick ${frameA.tick}→${frameB.tick})`)
  154. if (SCENARIO === 'device-effects') {
  155. check('E3-alarm-pulses-across-frames',
  156. distinct('EQ-EF-ALARM', 'scale') >= 2 || distinct('EQ-EF-ALARM', 'alpha') >= 2,
  157. `报警脉冲:scale 取值数 ${distinct('EQ-EF-ALARM', 'scale')}、alpha 取值数 ${distinct('EQ-EF-ALARM', 'alpha')}`)
  158. check('E4-warning-blinks-across-frames',
  159. distinct('EQ-EF-WARNING', 'alpha') >= 2,
  160. `预警闪烁:alpha 取值数 ${distinct('EQ-EF-WARNING', 'alpha')}(取值 ${valuesOf('EQ-EF-WARNING', 'alpha').join(',')})`)
  161. check('E5-work-order-spins-across-frames',
  162. distinct('EQ-EF-WORK', 'rotation') >= 2,
  163. `工单旋转:rotation 取值数 ${distinct('EQ-EF-WORK', 'rotation')}`)
  164. check('E6-normal-stays-static',
  165. distinct('EQ-EF-NORMAL', 'scale') === 1 && distinct('EQ-EF-NORMAL', 'alpha') === 1 && distinct('EQ-EF-NORMAL', 'rotation') === 1,
  166. `正常设备**全程静止**:scale ${normalA && normalA.scale}、alpha ${normalA && normalA.alpha}、rotation ${normalA && normalA.rotation}`)
  167. check('E7-offline-stays-static',
  168. distinct('EQ-EF-OFFLINE', 'scale') === 1 && distinct('EQ-EF-OFFLINE', 'alpha') === 1 && distinct('EQ-EF-OFFLINE', 'rotation') === 1,
  169. `离线设备**全程静止**:scale ${offlineA && offlineA.scale}、alpha ${offlineA && offlineA.alpha}、rotation ${offlineA && offlineA.rotation}`)
  170. }
  171. // ── 第 3 层:真实帧差(同一视角两帧,字节不同 ⇒ 画面确实在变)──
  172. if (SCENARIO === 'device-effects') {
  173. const shotA = await page.screenshot()
  174. await sleep(500)
  175. const shotB = await page.screenshot()
  176. check('E8-real-frame-diff-changes',
  177. Boolean(shotA) && Boolean(shotB) && shotA !== shotB,
  178. `两帧截图字节${shotA === shotB ? '相同(画面没变 → 不合格)' : '不同'}(长度 ${shotA.length} vs ${shotB.length})`)
  179. }
  180. // ── 降级场景(>30 台异常)另有判据:动画全停 + 异常计数可见 ──
  181. if (SCENARIO === 'device-effects-degraded') {
  182. const degradedNote = await page.evaluate(() => {
  183. const note = document.querySelector('[data-device-effects-degraded]')
  184. return note ? { text: (note.textContent || '').trim(), abnormal: note.getAttribute('data-device-effects-abnormal') } : null
  185. })
  186. const allStatic = SAMPLES.every((frame) => (frame.devices || []).every((item) => !item.animated))
  187. check('E10-degraded-stops-animation',
  188. frameA.host.effects === 'degraded' && frameA.enabled === true && frameA.degraded === true &&
  189. frameA.host.animated === '0' && frameA.running === false && allStatic,
  190. `降级:host=${frameA.host.effects} running=${frameA.running} 动效台数=${frameA.host.animated}(${SAMPLES.length} 帧全部无动效=${allStatic})`)
  191. check('E11-degraded-count-is-visible',
  192. Boolean(degradedNote) && degradedNote.abnormal === '31' && frameA.host.abnormal === '31',
  193. `异常计数可见:面板「${degradedNote ? degradedNote.text : '(缺失)'}」、data-abnormal=${degradedNote && degradedNote.abnormal}`)
  194. check('E12-degraded-keeps-devices-drawn',
  195. frameA.devices.length === 31,
  196. `降级后**仍画 31 台静态异常标记**(不静默删点):实际 ${frameA.devices.length} 台`)
  197. }
  198. check('E9-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '无 pageerror / console.error')
  199. await page.close()
  200. await browser.close()
  201. const failed = results.filter((r) => !r.pass)
  202. console.log('')
  203. console.log(`T47 动效走查(${SCENARIO}):${results.length - failed.length}/${results.length} 项通过。`)
  204. if (failed.length) { for (const item of failed) console.log(` · ${item.id}`); process.exit(1) }
  205. console.log('T47 动效走查全部通过。')