t47-device-effects-walkthrough.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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. const opened = await readDetail()
  94. check('E1-controlled-sample-opens',
  95. opened.level === 'point' && opened.pointCode === 'MP-001',
  96. `真实点开受控样例点位:level=${opened.level} pointCode=${opened.pointCode}`)
  97. const readEffects = () => page.evaluate(() => {
  98. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  99. const dev = window.__cockpitMap
  100. const state = dev && dev.getDeviceEffects ? dev.getDeviceEffects() : null
  101. return {
  102. host: host ? {
  103. effects: host.dataset.mapDeviceEffects,
  104. abnormal: host.dataset.mapDeviceEffectsAbnormal,
  105. animated: host.dataset.mapDeviceEffectsAnimated,
  106. } : {},
  107. enabled: state ? state.enabled : null,
  108. degraded: state ? state.degraded : null,
  109. running: state ? state.running : null,
  110. tick: state ? state.tick : -1,
  111. ringLayers: state ? state.ringLayers : 0,
  112. devices: state && state.devices ? state.devices : [],
  113. fx: state && state.fx ? state.fx : [],
  114. }
  115. })
  116. // 多帧采样:闪烁(周期 900ms)等动效在两次采样间可能正好落在同一段,
  117. // 所以按 ~140ms 采 8 帧,用"取值集合的势"判定 —— 比两点比较更稳健,判据也更强
  118. // (正常/离线要求**全程只有一个取值**)。
  119. const SAMPLES = []
  120. for (let index = 0; index < 8; index += 1) {
  121. SAMPLES.push(await readEffects())
  122. await sleep(140)
  123. }
  124. const frameA = SAMPLES[0]
  125. const frameB = SAMPLES[SAMPLES.length - 1]
  126. const valuesOf = (code, key) => SAMPLES
  127. .map((frame) => (frame.devices || []).find((item) => item.code === code))
  128. .map((item) => (item && Number.isFinite(item[key]) ? Number(item[key].toFixed(5)) : null))
  129. .filter((value) => value !== null)
  130. const distinct = (code, key) => new Set(valuesOf(code, key)).size
  131. const alarmA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-ALARM')
  132. const warnA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-WARNING')
  133. const workA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-WORK')
  134. const normalA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-NORMAL')
  135. const offlineA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-OFFLINE')
  136. console.log(`SAMPLES: ${SAMPLES.length} 帧;tick ${frameA.tick}→${frameB.tick};host=${JSON.stringify(frameA.host)}`)
  137. console.log(`ALARM scale 取值 ${valuesOf('EQ-EF-ALARM', 'scale').join(',')}`)
  138. console.log(`WARNING alpha 取值 ${valuesOf('EQ-EF-WARNING', 'alpha').join(',')}`)
  139. console.log(`WORK rotation 取值 ${valuesOf('EQ-EF-WORK', 'rotation').join(',')}`)
  140. console.log(`NORMAL scale/alpha 取值 ${valuesOf('EQ-EF-NORMAL', 'scale').join(',')} / ${valuesOf('EQ-EF-NORMAL', 'alpha').join(',')}`)
  141. check('E2-effects-enabled-and-running',
  142. SCENARIO === 'device-effects-degraded'
  143. ? (frameA.enabled === true && frameA.host.effects === 'degraded' && frameA.host.animated === '0' && frameA.running === false)
  144. : (frameA.enabled === true && frameA.running === true && frameA.host.effects === 'on' && frameB.tick > frameA.tick),
  145. SCENARIO === 'device-effects-degraded'
  146. ? `降级场景:enabled=${frameA.enabled} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}`
  147. : `enabled=${frameA.enabled} running=${frameA.running} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}(tick ${frameA.tick}→${frameB.tick})`)
  148. if (SCENARIO === 'device-effects') {
  149. const fxSeries = (code, key) => SAMPLES.map((frame) =>
  150. (frame.fx || []).find((item) => item.for === code && item.kind === 'ring' && item.layer === 0)?.[key])
  151. const fxDistinct = (code, key) => new Set(fxSeries(code, key).filter(Number.isFinite).map((value) => value.toFixed(5))).size
  152. check('E3-alarm-pulses-across-frames', fxDistinct('EQ-EF-ALARM', 'scale') >= 2,
  153. '报警外围环跨帧扩张,中心符号保持清晰')
  154. check('E4-warning-blinks-across-frames', fxDistinct('EQ-EF-WARNING', 'alpha') >= 2,
  155. '预警外围环闪烁,中心不参与透明度动画')
  156. check('E5-work-order-spins-across-frames', fxDistinct('EQ-EF-WORK', 'rotation') >= 2,
  157. '工单外围环旋转,中心状态形状不转动')
  158. check('E17-center-icons-remain-opaque',
  159. ['EQ-EF-ALARM', 'EQ-EF-WARNING', 'EQ-EF-WORK'].every((code) =>
  160. valuesOf(code, 'alpha').length === SAMPLES.length && valuesOf(code, 'alpha').every((value) => value === 1)),
  161. '三台动态状态设备的中心图标在全部采样帧中保持 alpha=1')
  162. check('E6-normal-stays-static',
  163. distinct('EQ-EF-NORMAL', 'scale') === 1 && distinct('EQ-EF-NORMAL', 'alpha') === 1 && distinct('EQ-EF-NORMAL', 'rotation') === 1,
  164. `正常设备**全程静止**:scale ${normalA && normalA.scale}、alpha ${normalA && normalA.alpha}、rotation ${normalA && normalA.rotation}`)
  165. check('E7-offline-stays-static',
  166. distinct('EQ-EF-OFFLINE', 'scale') === 1 && distinct('EQ-EF-OFFLINE', 'alpha') === 1 && distinct('EQ-EF-OFFLINE', 'rotation') === 1,
  167. `离线设备**全程静止**:scale ${offlineA && offlineA.scale}、alpha ${offlineA && offlineA.alpha}、rotation ${offlineA && offlineA.rotation}`)
  168. // ── 科技感光环(决策票 07 新增方向):多层同心扩散环 + 中心辉光 ──
  169. const fxValues = (code, kind, key) => SAMPLES
  170. .flatMap((frame) => (frame.fx || []).filter((item) => item.for === code && item.kind === kind))
  171. .map((item) => (Number.isFinite(item[key]) ? Number(item[key].toFixed(5)) : null))
  172. .filter((value) => value !== null)
  173. const ringLayersOf = (code) => new Set((SAMPLES[0].fx || [])
  174. .filter((item) => item.for === code && item.kind === 'ring')
  175. .map((item) => item.layer)).size
  176. check('E13-alarm-has-multi-layer-rings',
  177. frameA.ringLayers >= 3 && ringLayersOf('EQ-EF-ALARM') === frameA.ringLayers,
  178. `报警设备有 ${ringLayersOf('EQ-EF-ALARM')} 层同心环(期望 ${frameA.ringLayers} 层)`)
  179. check('E14-rings-expand-and-fade-across-frames',
  180. new Set(fxValues('EQ-EF-ALARM', 'ring', 'scale')).size >= 3 &&
  181. new Set(fxValues('EQ-EF-ALARM', 'ring', 'alpha')).size >= 3,
  182. `环跨帧扩张渐隐:scale 取值数 ${new Set(fxValues('EQ-EF-ALARM', 'ring', 'scale')).size}、alpha 取值数 ${new Set(fxValues('EQ-EF-ALARM', 'ring', 'alpha')).size}`)
  183. check('E15-glow-only-for-alarm',
  184. fxValues('EQ-EF-ALARM', 'glow', 'alpha').length > 0 &&
  185. new Set(fxValues('EQ-EF-ALARM', 'glow', 'alpha')).size >= 2 &&
  186. fxValues('EQ-EF-WARNING', 'glow', 'alpha').length === 0 &&
  187. fxValues('EQ-EF-NORMAL', 'ring', 'scale').length === 0,
  188. `中心辉光只给报警(报警辉光取值数 ${new Set(fxValues('EQ-EF-ALARM', 'glow', 'alpha')).size};预警/正常无辉光/无环)`)
  189. check('E16-static-devices-have-no-rings',
  190. ringLayersOf('EQ-EF-NORMAL') === 0 && ringLayersOf('EQ-EF-OFFLINE') === 0,
  191. `正常/离线**没有任何光环**(决策票 07:静态状态不参与动效)`)
  192. }
  193. // ── 第 3 层:真实帧差(同一视角两帧,字节不同 ⇒ 画面确实在变)──
  194. if (SCENARIO === 'device-effects') {
  195. const shotA = await page.screenshot()
  196. await sleep(500)
  197. const shotB = await page.screenshot()
  198. check('E8-real-frame-diff-changes',
  199. Boolean(shotA) && Boolean(shotB) && shotA !== shotB,
  200. `两帧截图字节${shotA === shotB ? '相同(画面没变 → 不合格)' : '不同'}(长度 ${shotA.length} vs ${shotB.length})`)
  201. }
  202. // ── 降级场景(>30 台异常)另有判据:动画全停 + 异常计数可见 ──
  203. if (SCENARIO === 'device-effects-degraded') {
  204. const degradedNote = await page.evaluate(() => {
  205. const note = document.querySelector('[data-device-effects-degraded]')
  206. return note ? { text: (note.textContent || '').trim(), abnormal: note.getAttribute('data-device-effects-abnormal') } : null
  207. })
  208. const allStatic = SAMPLES.every((frame) => (frame.devices || []).every((item) => !item.animated))
  209. check('E10-degraded-stops-animation',
  210. frameA.host.effects === 'degraded' && frameA.enabled === true && frameA.degraded === true &&
  211. frameA.host.animated === '0' && frameA.running === false && allStatic,
  212. `降级:host=${frameA.host.effects} running=${frameA.running} 动效台数=${frameA.host.animated}(${SAMPLES.length} 帧全部无动效=${allStatic})`)
  213. check('E11-degraded-count-is-visible',
  214. Boolean(degradedNote) && degradedNote.abnormal === '31' && frameA.host.abnormal === '31',
  215. `异常计数可见:面板「${degradedNote ? degradedNote.text : '(缺失)'}」、data-abnormal=${degradedNote && degradedNote.abnormal}`)
  216. check('E12-degraded-keeps-devices-drawn',
  217. frameA.devices.length === 31,
  218. `降级后**仍画 31 台静态异常标记**(不静默删点):实际 ${frameA.devices.length} 台`)
  219. }
  220. // 不改变面板 pointer-events,以真实卡片点击核验设备详情换锚。
  221. if (SCENARIO === 'device-effects') {
  222. const panelState = () => page.evaluate(() => {
  223. const panel = document.querySelector('.cockpit-shell__detail')
  224. const box = panel?.getBoundingClientRect()
  225. return { code: panel?.dataset.anchorCode, state: panel?.dataset.anchorState,
  226. lead: Boolean(document.querySelector('[data-anchor-lead]')),
  227. box: box ? { left: box.left, right: box.right, top: box.top, bottom: box.bottom } : null }
  228. })
  229. for (const code of ['EQ-EF-ALARM', 'EQ-EF-WARNING']) {
  230. await page.click(`[data-device-layer-code="${code}"]`)
  231. await page.waitForFunction((wanted) => document.querySelector('.cockpit-shell__detail')?.dataset.anchorCode === wanted,
  232. { timeout: 10000 }, code)
  233. await sleep(300)
  234. const state = await panelState()
  235. check(`E18-device-anchor-${code}`, state.code === code && state.state === 'visible' && state.lead,
  236. `设备详情锚点=${state.code}、面板=${state.state}、连接线=${state.lead}`)
  237. await page.click('[data-detail-back]')
  238. await page.waitForFunction(() => document.querySelector('.cockpit-shell__detail')?.dataset.anchorCode === 'MP-001',
  239. { timeout: 10000 })
  240. check(`E19-return-anchor-${code}`, (await panelState()).code === 'MP-001', '返回摘要恢复监测点锚点')
  241. }
  242. }
  243. check('E9-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '无 pageerror / console.error')
  244. await page.close()
  245. await browser.close()
  246. const failed = results.filter((r) => !r.pass)
  247. console.log('')
  248. console.log(`T47 动效走查(${SCENARIO}):${results.length - failed.length}/${results.length} 项通过。`)
  249. if (failed.length) { for (const item of failed) console.log(` · ${item.id}`); process.exit(1) }
  250. console.log('T47 动效走查全部通过。')