/** * T38 真机走查:区内视距雾的可观测行为与生命周期(票 38)。 * * 逻辑闸门(t38-fog.mjs)把曲线、门控、降级、色相判据测透了,这里只测**浏览器里才成立**的部分: * · stage 真的挂在场景上(且**只有一个** —— 不引入第二渲染器/第二个后处理级); * · 进区 → 雾启用并推进到强度 1;返回 → 淡出到 0 并停在 global; * · **连续进出只认最新目标**(进 → 立刻返回 → 立刻再进,最终必须是 locked 强度 1, * 而不是被旧的那轮淡出关掉); * · 降级可演示:无深度支持 / 观察到透明错位(减弱)/ 用户暂关,且**相机限位不受影响**; * · 读数齐全(支持/可执行/强度/参数/原因),且 `coversEdges` 恒为 no(不承诺已遮缝); * · 性能采样能取到帧间隔(**明说这是渲染事件间隔、不是 GPU 时间,也不能单独判达标**)。 * * **不做的事(诚实边界)**:最终色相的目视确认、以及"矩形边缘确实被遮住"的观感判定 * 都是**维护者的**验收(决策 08 要求真实画面复核);本脚本只提供可观测读数与截图,不代判。 * 性能采样(A9)只给出参考读数,**不**宣称 ≤15% 达标(B 与 A 的对照还需无原型基线)。 * * **前置条件:标签页必须可见。** 隐藏标签页里浏览器冻结 rAF、Cesium 也不渲染, * 于是强度推进与性能采样都取不到数据(A9 会因 `count=0` 判红)。 * 那是环境限制而非功能缺陷 —— 本票真机验证时就撞上过(该标签页 `document.hidden=true`)。 * * 运行(需先起 `yarn dev`,并把浏览器标签页切到前台): * node .scratch/cockpit-shell-panel-layout/verify/t38-fog-walkthrough.mjs * 按 AGENTS.md:本脚本不在改代码后自动运行,由维护者明确要求时才跑。 */ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000' const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit` const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js' const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8')) const results = [] function check(id, desc, pass, evidence) { results.push({ id, desc, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`) } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const puppeteer = (await import(PUPPETEER)).default const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(tmpdir(), 'cockpit-t38-profile'), defaultViewport: { width: 1680, height: 950 }, args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'], }) console.log(`Chrome: ${await browser.version()}`) /** 等雾的强度达到目标(或阶段落定),避免死等毫秒。 */ async function waitFog(page, predicate, timeoutMs = 15000) { await page .waitForFunction( (src) => { const api = window.__cockpitMap if (!api) return false const fog = api.getFogState() // 只支持简单谓词:阶段相等或强度达标(避免把函数传进页面)。 if (src.kind === 'phase') return fog.phase === src.value if (src.kind === 'strengthAtLeast') return fog.strength >= src.value if (src.kind === 'strengthZero') return fog.strength <= 0.0001 return false }, { timeout: timeoutMs, polling: 120 }, predicate, ) .catch(() => {}) await sleep(150) } const readFog = (page) => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const api = window.__cockpitMap const fog = api ? api.getFogState() : null return { hostFog: host ? host.dataset.mapFog : '', hostPhase: host ? host.dataset.mapFogPhase : '', hostStrength: host ? host.dataset.mapFogStrength : '', hostDegrade: host ? host.dataset.mapFogDegrade : '', hostCoversEdges: host ? host.dataset.mapFogCoversEdges : '', hostStages: host ? host.dataset.mapFogStages : '', hostRangeMax: host ? host.dataset.mapRangeMax : '', areaState: api ? api.getAreaState() : '', fog, // chip 用 `[data-fog-chip]` 而不是 `[data-map-fog]`:后者同时挂在地图宿主上 // (整页文本),querySelector 会先命中宿主 —— 那样这条断言读的是错误元素。 chip: (document.querySelector('[data-fog-chip]') || {}).textContent?.trim() || '', } }) try { 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.evaluateOnNewDocument((injected) => { window.__COCKPIT_CONFIG__ = injected }, config) await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) await page.waitForFunction( () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready', { timeout: 60000 }, ) await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 }) await sleep(1500) // ---------- A1 参数就是规格那一套 ---------- const initial = await readFog(page) check( 'A1-spec-params', '参数按规格固化:世界射线距离 18,000/42,000 m、基准雾色 #bdcdd6(缺省不配也是这一套)', initial.fog.params.startDistance === 18000 && initial.fog.params.endDistance === 42000 && initial.fog.params.color.toLowerCase() === '#bdcdd6', JSON.stringify({ s: initial.fog.params.startDistance, e: initial.fog.params.endDistance, c: initial.fog.params.color }), ) // ---------- A2 区外:关闭、单 stage ---------- check( 'A2-global-fog-off', '区外(global):雾关闭(强度 0、阶段 global),但处理级**已挂且只有一个**(复用而非每次重建)', initial.areaState === 'global' && initial.fog.strength === 0 && initial.fog.phase === 'global' && initial.hostStages === '1', JSON.stringify({ area: initial.areaState, strength: initial.fog.strength, phase: initial.fog.phase, stages: initial.hostStages }), ) // ---------- A3 读数齐全 + 不承诺已遮缝 ---------- check( 'A3-readout-complete-no-false-promise', '读数含支持/可执行/是否跑过/强度/参数/原因;且 coversEdges 恒为 no(**不提供未经验证的"已遮缝"承诺**)', initial.fog.supported === true && typeof initial.fog.executable === 'boolean' && typeof initial.fog.ranOnce === 'boolean' && typeof initial.fog.strength === 'number' && typeof initial.fog.startDistance === 'number' && initial.fog.coversEdges === false && initial.hostCoversEdges === 'no', JSON.stringify({ supported: initial.fog.supported, executable: initial.fog.executable, ranOnce: initial.fog.ranOnce, coversEdges: initial.fog.coversEdges, host: initial.hostCoversEdges, }), ) // ---------- A3b 待命不得报成失败(本票真机验证时踩到的读数 bug) ---------- // 区外待命时若报 failed,用户会在最不该报警的时刻看到"视距雾不可用"。 check( 'A3b-standby-is-not-failure', '区外待命(强度 0)时读数必须是 ready 而不是 failed(待命 ≠ 故障)', initial.areaState === 'global' && initial.fog.state === 'ready' && initial.fog.executable === true, JSON.stringify({ area: initial.areaState, state: initial.fog.state, executable: initial.fog.executable }), ) // ---------- A4 进区 → 雾启用并达到满强度 ---------- await page.evaluate(() => window.__cockpitMap.enterArea()) await waitFog(page, { kind: 'strengthAtLeast', value: 1 }) await page.evaluate(() => window.__cockpitMap.goHome()) await waitFog(page, { kind: 'phase', value: 'locked' }) const locked = await readFog(page) check( 'A4-locked-fog-on', '进区(locked):雾阶段落 locked、强度到 1、宿主读数同步、chip 显示强度与距离带', locked.areaState === 'locked' && locked.fog.phase === 'locked' && locked.fog.strength === 1 && Number(locked.hostStrength) === 1 && locked.chip.includes('视距雾'), JSON.stringify({ area: locked.areaState, phase: locked.fog.phase, strength: locked.fog.strength, chip: locked.chip }), ) // ---------- A5 连续进出只认最新目标 ---------- // 进 → 立刻返回 → 立刻再进;旧的淡出**不得**把新的进入关掉。 await page.evaluate(() => { window.__cockpitMap.returnGlobal() window.__cockpitMap.enterArea() window.__cockpitMap.returnGlobal() window.__cockpitMap.enterArea() }) await waitFog(page, { kind: 'phase', value: 'locked' }) const rapid = await readFog(page) check( 'A5-rapid-toggle-latest-wins', '连续进/返 4 次后只认最新目标:阶段锁在 locked、强度仍为 1(过期淡出没有关掉新的进入)', rapid.fog.phase === 'locked' && rapid.fog.strength === 1 && rapid.areaState === 'locked', JSON.stringify({ phase: rapid.fog.phase, strength: rapid.fog.strength, area: rapid.areaState }), ) // ---------- A5b 读数不抖(隐藏/不渲染时不得在 ready 与 failed 之间跳) ---------- // `stage.ready` 随渲染活动变化:隐藏标签页不渲染时会落回 false。 // 采样一段时间,state 必须始终是 ready —— 若拿 ready 当判据,这里会看到 failed 混进来。 { const samples = [] for (let i = 0; i < 5; i++) { const s = await readFog(page) samples.push(s.fog.state) await sleep(400) } const unique = [...new Set(samples)] check( 'A5b-readout-stable', `读数在连续采样中保持稳定(不得在 ready/failed 之间跳)`, unique.length === 1 && unique[0] === 'ready', JSON.stringify({ samples }), ) } // ---------- A6 返回全球 → 淡出并停在 global ---------- await page.evaluate(() => window.__cockpitMap.returnGlobal()) await waitFog(page, { kind: 'phase', value: 'global' }) const backGlobal = await readFog(page) check( 'A6-return-fades-out', '返回全球:雾淡出到 0 并停在 global 阶段(不是突然消失,也不是留着不走)', backGlobal.fog.phase === 'global' && backGlobal.fog.strength === 0 && backGlobal.areaState === 'global', JSON.stringify({ phase: backGlobal.fog.phase, strength: backGlobal.fog.strength }), ) // ---------- A7 降级:减弱(观察透明错位)与暂关,且不动相机限位 ---------- await page.evaluate(() => window.__cockpitMap.enterArea()) await waitFog(page, { kind: 'strengthAtLeast', value: 1 }) const beforeDegrade = await readFog(page) await page.evaluate(() => window.__cockpitMap.setViewMode('pipeline')) await sleep(600) const normalPipeline = await readFog(page) await page.evaluate(() => window.__cockpitMap.setFogTranslucentArtifacts(true)) await sleep(400) const reduced = await readFog(page) await page.evaluate(() => window.__cockpitMap.setFogUserDisabled(true)) await sleep(400) const off = await readFog(page) check( 'A7-degrade-reduced-then-off', '降级可演示:**正常管线视图不预关闭**;观察到透明错位 → 减弱(强度被压低);用户暂关 → 关闭并带原因', normalPipeline.fog.degrade === 'none' && normalPipeline.fog.strength === 1 && reduced.fog.degrade === 'reduced' && Number(reduced.hostStrength) < 1 && off.fog.degrade === 'off' && Number(off.hostStrength) === 0 && off.fog.reason.length > 0, JSON.stringify({ normalPipeline: { degrade: normalPipeline.fog.degrade, strength: normalPipeline.fog.strength }, reduced: { degrade: reduced.fog.degrade, strength: reduced.fog.strength }, off: { degrade: off.fog.degrade, strength: off.fog.strength, reason: off.fog.reason }, }), ) check( 'A7b-degrade-keeps-camera-limits', '降级**不动相机限位**(票 37 的承诺:可读性降级不解除限位)——上限仍是 home.range', beforeDegrade.hostRangeMax === reduced.hostRangeMax && reduced.hostRangeMax === '12000', JSON.stringify({ before: beforeDegrade.hostRangeMax, after: reduced.hostRangeMax }), ) // ---------- A8 恢复:暂关与减弱都能还原 ---------- await page.evaluate(() => { window.__cockpitMap.setFogUserDisabled(false) window.__cockpitMap.setFogTranslucentArtifacts(false) }) await sleep(400) const restored = await readFog(page) check( 'A8-degrade-recoverable', '恢复后回到正常档且强度回到 1(降级是**可逆**的,不是一次性关掉)', restored.fog.degrade === 'none' && restored.fog.strength === 1, JSON.stringify({ degrade: restored.fog.degrade, strength: restored.fog.strength }), ) // ---------- A9 性能采样可跑(并如实标注口径) ---------- await page.evaluate(() => window.__cockpitMap.startFogPerfSample('with-fog')) await sleep(3000) const perf = await page.evaluate(() => window.__cockpitMap.stopFogPerfSample()) check( 'A9-perf-sample-collects', '性能采样能取到帧间隔并算出 P95;读数**明确标注**这是渲染事件间隔、不是 GPU 时间、不能单独判达标', !!perf && perf.count > 0 && Number.isFinite(perf.p95FrameIntervalMs) && /不是 GPU 时间/.test(perf.note), JSON.stringify(perf), ) check('A10-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3))) } finally { await browser.close() } const failed = results.filter((r) => !r.pass) console.log('') console.log(`T38 走查:${results.length - failed.length}/${results.length} 项通过。`) if (failed.length) { console.log('未通过:' + failed.map((f) => f.id).join(', ')) process.exit(1) } console.log('T38 走查全部通过。') console.log('注意:最终色相与"矩形边缘是否真被遮住"的观感判定属维护者验收,本脚本不代判。')