t38-fog-walkthrough.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /**
  2. * T38 真机走查:区内视距雾的可观测行为与生命周期(票 38)。
  3. *
  4. * 逻辑闸门(t38-fog.mjs)把曲线、门控、降级、色相判据测透了,这里只测**浏览器里才成立**的部分:
  5. * · stage 真的挂在场景上(且**只有一个** —— 不引入第二渲染器/第二个后处理级);
  6. * · 进区 → 雾启用并推进到强度 1;返回 → 淡出到 0 并停在 global;
  7. * · **连续进出只认最新目标**(进 → 立刻返回 → 立刻再进,最终必须是 locked 强度 1,
  8. * 而不是被旧的那轮淡出关掉);
  9. * · 降级可演示:无深度支持 / 观察到透明错位(减弱)/ 用户暂关,且**相机限位不受影响**;
  10. * · 读数齐全(支持/可执行/强度/参数/原因),且 `coversEdges` 恒为 no(不承诺已遮缝);
  11. * · 性能采样能取到帧间隔(**明说这是渲染事件间隔、不是 GPU 时间,也不能单独判达标**)。
  12. *
  13. * **不做的事(诚实边界)**:最终色相的目视确认、以及"矩形边缘确实被遮住"的观感判定
  14. * 都是**维护者的**验收(决策 08 要求真实画面复核);本脚本只提供可观测读数与截图,不代判。
  15. * 性能采样(A9)只给出参考读数,**不**宣称 ≤15% 达标(B 与 A 的对照还需无原型基线)。
  16. *
  17. * **前置条件:标签页必须可见。** 隐藏标签页里浏览器冻结 rAF、Cesium 也不渲染,
  18. * 于是强度推进与性能采样都取不到数据(A9 会因 `count=0` 判红)。
  19. * 那是环境限制而非功能缺陷 —— 本票真机验证时就撞上过(该标签页 `document.hidden=true`)。
  20. *
  21. * 运行(需先起 `yarn dev`,并把浏览器标签页切到前台):
  22. * node .scratch/cockpit-shell-panel-layout/verify/t38-fog-walkthrough.mjs
  23. * 按 AGENTS.md:本脚本不在改代码后自动运行,由维护者明确要求时才跑。
  24. */
  25. import { readFileSync } from 'node:fs'
  26. import { join } from 'node:path'
  27. import { tmpdir } from 'node:os'
  28. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  29. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  30. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  31. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  32. const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
  33. const results = []
  34. function check(id, desc, pass, evidence) {
  35. results.push({ id, desc, pass })
  36. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  37. }
  38. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  39. const puppeteer = (await import(PUPPETEER)).default
  40. const browser = await puppeteer.launch({
  41. executablePath: CHROME,
  42. headless: true,
  43. userDataDir: join(tmpdir(), 'cockpit-t38-profile'),
  44. defaultViewport: { width: 1680, height: 950 },
  45. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  46. })
  47. console.log(`Chrome: ${await browser.version()}`)
  48. /** 等雾的强度达到目标(或阶段落定),避免死等毫秒。 */
  49. async function waitFog(page, predicate, timeoutMs = 15000) {
  50. await page
  51. .waitForFunction(
  52. (src) => {
  53. const api = window.__cockpitMap
  54. if (!api) return false
  55. const fog = api.getFogState()
  56. // 只支持简单谓词:阶段相等或强度达标(避免把函数传进页面)。
  57. if (src.kind === 'phase') return fog.phase === src.value
  58. if (src.kind === 'strengthAtLeast') return fog.strength >= src.value
  59. if (src.kind === 'strengthZero') return fog.strength <= 0.0001
  60. return false
  61. },
  62. { timeout: timeoutMs, polling: 120 },
  63. predicate,
  64. )
  65. .catch(() => {})
  66. await sleep(150)
  67. }
  68. const readFog = (page) =>
  69. page.evaluate(() => {
  70. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  71. const api = window.__cockpitMap
  72. const fog = api ? api.getFogState() : null
  73. return {
  74. hostFog: host ? host.dataset.mapFog : '',
  75. hostPhase: host ? host.dataset.mapFogPhase : '',
  76. hostStrength: host ? host.dataset.mapFogStrength : '',
  77. hostDegrade: host ? host.dataset.mapFogDegrade : '',
  78. hostCoversEdges: host ? host.dataset.mapFogCoversEdges : '',
  79. hostStages: host ? host.dataset.mapFogStages : '',
  80. hostRangeMax: host ? host.dataset.mapRangeMax : '',
  81. areaState: api ? api.getAreaState() : '',
  82. fog,
  83. // chip 用 `[data-fog-chip]` 而不是 `[data-map-fog]`:后者同时挂在地图宿主上
  84. // (整页文本),querySelector 会先命中宿主 —— 那样这条断言读的是错误元素。
  85. chip: (document.querySelector('[data-fog-chip]') || {}).textContent?.trim() || '',
  86. }
  87. })
  88. try {
  89. const page = await browser.newPage()
  90. const errors = []
  91. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  92. page.on('console', (m) => {
  93. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  94. })
  95. await page.evaluateOnNewDocument((injected) => {
  96. window.__COCKPIT_CONFIG__ = injected
  97. }, config)
  98. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  99. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  100. await page.waitForFunction(
  101. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
  102. { timeout: 60000 },
  103. )
  104. await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 })
  105. await sleep(1500)
  106. // ---------- A1 参数就是规格那一套 ----------
  107. const initial = await readFog(page)
  108. check(
  109. 'A1-spec-params',
  110. '参数按规格固化:世界射线距离 18,000/42,000 m、基准雾色 #bdcdd6(缺省不配也是这一套)',
  111. initial.fog.params.startDistance === 18000 &&
  112. initial.fog.params.endDistance === 42000 &&
  113. initial.fog.params.color.toLowerCase() === '#bdcdd6',
  114. JSON.stringify({ s: initial.fog.params.startDistance, e: initial.fog.params.endDistance, c: initial.fog.params.color }),
  115. )
  116. // ---------- A2 区外:关闭、单 stage ----------
  117. check(
  118. 'A2-global-fog-off',
  119. '区外(global):雾关闭(强度 0、阶段 global),但处理级**已挂且只有一个**(复用而非每次重建)',
  120. initial.areaState === 'global' && initial.fog.strength === 0 && initial.fog.phase === 'global' && initial.hostStages === '1',
  121. JSON.stringify({ area: initial.areaState, strength: initial.fog.strength, phase: initial.fog.phase, stages: initial.hostStages }),
  122. )
  123. // ---------- A3 读数齐全 + 不承诺已遮缝 ----------
  124. check(
  125. 'A3-readout-complete-no-false-promise',
  126. '读数含支持/可执行/是否跑过/强度/参数/原因;且 coversEdges 恒为 no(**不提供未经验证的"已遮缝"承诺**)',
  127. initial.fog.supported === true &&
  128. typeof initial.fog.executable === 'boolean' &&
  129. typeof initial.fog.ranOnce === 'boolean' &&
  130. typeof initial.fog.strength === 'number' &&
  131. typeof initial.fog.startDistance === 'number' &&
  132. initial.fog.coversEdges === false &&
  133. initial.hostCoversEdges === 'no',
  134. JSON.stringify({
  135. supported: initial.fog.supported,
  136. executable: initial.fog.executable,
  137. ranOnce: initial.fog.ranOnce,
  138. coversEdges: initial.fog.coversEdges,
  139. host: initial.hostCoversEdges,
  140. }),
  141. )
  142. // ---------- A3b 待命不得报成失败(本票真机验证时踩到的读数 bug) ----------
  143. // 区外待命时若报 failed,用户会在最不该报警的时刻看到"视距雾不可用"。
  144. check(
  145. 'A3b-standby-is-not-failure',
  146. '区外待命(强度 0)时读数必须是 ready 而不是 failed(待命 ≠ 故障)',
  147. initial.areaState === 'global' && initial.fog.state === 'ready' && initial.fog.executable === true,
  148. JSON.stringify({ area: initial.areaState, state: initial.fog.state, executable: initial.fog.executable }),
  149. )
  150. // ---------- A4 进区 → 雾启用并达到满强度 ----------
  151. await page.evaluate(() => window.__cockpitMap.enterArea())
  152. await waitFog(page, { kind: 'strengthAtLeast', value: 1 })
  153. await page.evaluate(() => window.__cockpitMap.goHome())
  154. await waitFog(page, { kind: 'phase', value: 'locked' })
  155. const locked = await readFog(page)
  156. check(
  157. 'A4-locked-fog-on',
  158. '进区(locked):雾阶段落 locked、强度到 1、宿主读数同步、chip 显示强度与距离带',
  159. locked.areaState === 'locked' &&
  160. locked.fog.phase === 'locked' &&
  161. locked.fog.strength === 1 &&
  162. Number(locked.hostStrength) === 1 &&
  163. locked.chip.includes('视距雾'),
  164. JSON.stringify({ area: locked.areaState, phase: locked.fog.phase, strength: locked.fog.strength, chip: locked.chip }),
  165. )
  166. // ---------- A5 连续进出只认最新目标 ----------
  167. // 进 → 立刻返回 → 立刻再进;旧的淡出**不得**把新的进入关掉。
  168. await page.evaluate(() => {
  169. window.__cockpitMap.returnGlobal()
  170. window.__cockpitMap.enterArea()
  171. window.__cockpitMap.returnGlobal()
  172. window.__cockpitMap.enterArea()
  173. })
  174. await waitFog(page, { kind: 'phase', value: 'locked' })
  175. const rapid = await readFog(page)
  176. check(
  177. 'A5-rapid-toggle-latest-wins',
  178. '连续进/返 4 次后只认最新目标:阶段锁在 locked、强度仍为 1(过期淡出没有关掉新的进入)',
  179. rapid.fog.phase === 'locked' && rapid.fog.strength === 1 && rapid.areaState === 'locked',
  180. JSON.stringify({ phase: rapid.fog.phase, strength: rapid.fog.strength, area: rapid.areaState }),
  181. )
  182. // ---------- A5b 读数不抖(隐藏/不渲染时不得在 ready 与 failed 之间跳) ----------
  183. // `stage.ready` 随渲染活动变化:隐藏标签页不渲染时会落回 false。
  184. // 采样一段时间,state 必须始终是 ready —— 若拿 ready 当判据,这里会看到 failed 混进来。
  185. {
  186. const samples = []
  187. for (let i = 0; i < 5; i++) {
  188. const s = await readFog(page)
  189. samples.push(s.fog.state)
  190. await sleep(400)
  191. }
  192. const unique = [...new Set(samples)]
  193. check(
  194. 'A5b-readout-stable',
  195. `读数在连续采样中保持稳定(不得在 ready/failed 之间跳)`,
  196. unique.length === 1 && unique[0] === 'ready',
  197. JSON.stringify({ samples }),
  198. )
  199. }
  200. // ---------- A6 返回全球 → 淡出并停在 global ----------
  201. await page.evaluate(() => window.__cockpitMap.returnGlobal())
  202. await waitFog(page, { kind: 'phase', value: 'global' })
  203. const backGlobal = await readFog(page)
  204. check(
  205. 'A6-return-fades-out',
  206. '返回全球:雾淡出到 0 并停在 global 阶段(不是突然消失,也不是留着不走)',
  207. backGlobal.fog.phase === 'global' && backGlobal.fog.strength === 0 && backGlobal.areaState === 'global',
  208. JSON.stringify({ phase: backGlobal.fog.phase, strength: backGlobal.fog.strength }),
  209. )
  210. // ---------- A7 降级:减弱(观察透明错位)与暂关,且不动相机限位 ----------
  211. await page.evaluate(() => window.__cockpitMap.enterArea())
  212. await waitFog(page, { kind: 'strengthAtLeast', value: 1 })
  213. const beforeDegrade = await readFog(page)
  214. await page.evaluate(() => window.__cockpitMap.setViewMode('pipeline'))
  215. await sleep(600)
  216. const normalPipeline = await readFog(page)
  217. await page.evaluate(() => window.__cockpitMap.setFogTranslucentArtifacts(true))
  218. await sleep(400)
  219. const reduced = await readFog(page)
  220. await page.evaluate(() => window.__cockpitMap.setFogUserDisabled(true))
  221. await sleep(400)
  222. const off = await readFog(page)
  223. check(
  224. 'A7-degrade-reduced-then-off',
  225. '降级可演示:**正常管线视图不预关闭**;观察到透明错位 → 减弱(强度被压低);用户暂关 → 关闭并带原因',
  226. normalPipeline.fog.degrade === 'none' &&
  227. normalPipeline.fog.strength === 1 &&
  228. reduced.fog.degrade === 'reduced' &&
  229. Number(reduced.hostStrength) < 1 &&
  230. off.fog.degrade === 'off' &&
  231. Number(off.hostStrength) === 0 &&
  232. off.fog.reason.length > 0,
  233. JSON.stringify({
  234. normalPipeline: { degrade: normalPipeline.fog.degrade, strength: normalPipeline.fog.strength },
  235. reduced: { degrade: reduced.fog.degrade, strength: reduced.fog.strength },
  236. off: { degrade: off.fog.degrade, strength: off.fog.strength, reason: off.fog.reason },
  237. }),
  238. )
  239. check(
  240. 'A7b-degrade-keeps-camera-limits',
  241. '降级**不动相机限位**(票 37 的承诺:可读性降级不解除限位)——上限仍是 home.range',
  242. beforeDegrade.hostRangeMax === reduced.hostRangeMax && reduced.hostRangeMax === '12000',
  243. JSON.stringify({ before: beforeDegrade.hostRangeMax, after: reduced.hostRangeMax }),
  244. )
  245. // ---------- A8 恢复:暂关与减弱都能还原 ----------
  246. await page.evaluate(() => {
  247. window.__cockpitMap.setFogUserDisabled(false)
  248. window.__cockpitMap.setFogTranslucentArtifacts(false)
  249. })
  250. await sleep(400)
  251. const restored = await readFog(page)
  252. check(
  253. 'A8-degrade-recoverable',
  254. '恢复后回到正常档且强度回到 1(降级是**可逆**的,不是一次性关掉)',
  255. restored.fog.degrade === 'none' && restored.fog.strength === 1,
  256. JSON.stringify({ degrade: restored.fog.degrade, strength: restored.fog.strength }),
  257. )
  258. // ---------- A9 性能采样可跑(并如实标注口径) ----------
  259. await page.evaluate(() => window.__cockpitMap.startFogPerfSample('with-fog'))
  260. await sleep(3000)
  261. const perf = await page.evaluate(() => window.__cockpitMap.stopFogPerfSample())
  262. check(
  263. 'A9-perf-sample-collects',
  264. '性能采样能取到帧间隔并算出 P95;读数**明确标注**这是渲染事件间隔、不是 GPU 时间、不能单独判达标',
  265. !!perf && perf.count > 0 && Number.isFinite(perf.p95FrameIntervalMs) && /不是 GPU 时间/.test(perf.note),
  266. JSON.stringify(perf),
  267. )
  268. check('A10-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  269. } finally {
  270. await browser.close()
  271. }
  272. const failed = results.filter((r) => !r.pass)
  273. console.log('')
  274. console.log(`T38 走查:${results.length - failed.length}/${results.length} 项通过。`)
  275. if (failed.length) {
  276. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  277. process.exit(1)
  278. }
  279. console.log('T38 走查全部通过。')
  280. console.log('注意:最终色相与"矩形边缘是否真被遮住"的观感判定属维护者验收,本脚本不代判。')