| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551 |
- /**
- * 票 39 逻辑闸门(零依赖 node assert):粗→精影像交接。
- *
- * 只断言**外部可见行为**:闸门(什么时候允许揭示)、正反向时序(先收影像再撤雾)、
- * 边缘保护判据(与雾曲线同源)、过期取消、降级、以及"不承诺已遮边"的结构性保证。
- *
- * **本脚本不能证明的事**(诚实边界,写在这里免得被误读):
- * · 「全程无肉眼可辨的方形跳变」是**维护者的目视判据**,脚本只能证明时序与读数正确;
- * · 矩形**内部**随地形 LOD 的逐级换挡**不由整层 alpha 修复**(决策 08 明确),
- * 本票不声称它被修好 —— 那归票 40 评估。
- *
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t39-handover.mjs
- */
- import assert from 'node:assert/strict'
- import { existsSync, readFileSync } from 'node:fs'
- import { dirname, resolve } from 'node:path'
- import { fileURLToPath } from 'node:url'
- const here = dirname(fileURLToPath(import.meta.url))
- const root = resolve(here, '../../..')
- const read = (rel) => readFileSync(resolve(root, rel), 'utf8')
- const {
- HANDOVER_BLOCK,
- HANDOVER_DEFAULTS,
- HANDOVER_PHASE,
- beginHandover,
- createHandoverLifecycle,
- distanceToBoundsEdgeMeters,
- edgeOcclusionVerdict,
- handoverReadout,
- isStaleHandoverToken,
- planHandover,
- revealGate,
- returnSyncVerdict,
- stepHandover,
- } = await import('../../../src/modules/cockpit/domain/imageryHandover.js')
- const { FOG_DEFAULTS, fogAttenuation } = await import('../../../src/modules/cockpit/domain/fog.js')
- /** 影像矩形(沅陵全县,取自 `public/cockpit/config.json` 的 overlays[0].bounds)。 */
- const IMAGERY_BOUNDS = [109.96215820312366, 27.962869019358852, 111.04568213224277, 29.060971407383555]
- /** 主场景区矩形(县城)。 */
- const AREA_BOUNDS = [110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907]
- // ================================================================ 1. 阶段与 alpha 语义
- {
- const idle = createHandoverLifecycle()
- assert.equal(idle.phase, HANDOVER_PHASE.GLOBAL, '初始相位应是 global(全球态精细层收起)')
- assert.equal(idle.alpha, 0, '全球态 alpha 必须是 0(隐藏精细层、保留粗底图)')
- // 揭示:从 0 推进到 1,中途严格在 (0,1),到端点落 REVEALED 且恰好 1。
- const revealing = beginHandover(idle, 'reveal', 1000)
- assert.equal(revealing.phase, HANDOVER_PHASE.ENTERING, 'reveal 应进入 entering')
- assert.equal(revealing.from, 0, '渐显应从当前 alpha 起算')
- const mid = stepHandover(revealing, 1000 + HANDOVER_DEFAULTS.fadeMs / 2, HANDOVER_DEFAULTS)
- assert.equal(mid.phase, HANDOVER_PHASE.ENTERING, '中途仍在 entering')
- assert.equal(mid.alpha > 0 && mid.alpha < 1, true, `中途 alpha 应在开区间内,实际 ${mid.alpha}`)
- const done = stepHandover(revealing, 1000 + HANDOVER_DEFAULTS.fadeMs, HANDOVER_DEFAULTS)
- assert.equal(done.phase, HANDOVER_PHASE.REVEALED, '推进到点应落 revealed')
- assert.equal(done.alpha, 1, 'revealed 必须**恰好** 1(不留半透明)')
- // 收起:反向到 0,落 GLOBAL 且恰好 0。
- const concealing = beginHandover(done, 'conceal', 5000)
- assert.equal(concealing.phase, HANDOVER_PHASE.RETURNING, 'conceal 应进入 returning')
- assert.equal(concealing.from, 1, '收起应从当前 alpha(1)起算')
- const concealed = stepHandover(concealing, 5000 + HANDOVER_DEFAULTS.fadeMs, HANDOVER_DEFAULTS)
- assert.equal(concealed.phase, HANDOVER_PHASE.GLOBAL, '收起到点应落 global')
- assert.equal(concealed.alpha, 0, 'global 必须**恰好** 0')
- }
- // ================================================================ 2. 不留半透明卡死态
- {
- // 任何阶段落定都必须写确定端点,不存在"停在 0.4 无人推进"。
- for (const [action, fromPhase, fromAlpha] of [
- ['reveal', HANDOVER_PHASE.GLOBAL, 0],
- ['conceal', HANDOVER_PHASE.REVEALED, 1],
- ]) {
- const started = beginHandover({ ...createHandoverLifecycle(), phase: fromPhase, alpha: fromAlpha }, action, 0)
- const settled = stepHandover(started, HANDOVER_DEFAULTS.fadeMs * 100, HANDOVER_DEFAULTS)
- assert.equal(
- settled.alpha === 0 || settled.alpha === 1,
- true,
- `${action} 落定后 alpha 必须是 0 或 1(不得卡在中间),实际 ${settled.alpha}`,
- )
- }
- // 时长为 0 = 立刻到位,也不得停留在中间态(配置脏值时不能把画面永久停在半透明)。
- const zeroFade = { ...HANDOVER_DEFAULTS, fadeMs: 0 }
- const instant = stepHandover(beginHandover(createHandoverLifecycle(), 'reveal', 0), 0, zeroFade)
- assert.equal(instant.alpha, 1, 'fadeMs=0 应立刻到 1')
- assert.equal(instant.phase, HANDOVER_PHASE.REVEALED, 'fadeMs=0 应立刻落 revealed')
- // 极长时间推进也不得越界。
- const longRun = stepHandover(beginHandover(createHandoverLifecycle(), 'reveal', 0), 1e9, HANDOVER_DEFAULTS)
- assert.equal(longRun.alpha, 1, '长时间推进不得越界(仍是 1)')
- }
- // ================================================================ 3. 闸门:目标未就绪**不揭示**
- {
- const locked = { areaState: 'locked' }
- const good = { fogStrength: 1, fogExecutable: true, fogSupported: true, edgeOk: true, tilesLoaded: true }
- // 3a 全球/引导态:必须保持隐藏(稳态就是隐藏,不是"先隐藏再等")。
- const outside = revealGate({ ...locked, areaState: 'global', ...good })
- assert.equal(outside.allowed, false, '全球态不得揭示精细层(保留粗底图)')
- // 3b 雾没到位 → 不揭示(这是本票的核心:先保护、后揭示)。
- const noFog = revealGate({ ...locked, ...good, fogStrength: 0.5 })
- assert.equal(noFog.allowed, false, '雾强度不足时不得揭示')
- assert.equal(noFog.block, HANDOVER_BLOCK.NO_FOG, '应如实报"被雾挡住"')
- // 边界:"几乎到了"不等于"到了"。0.99 明显未到位必须挡住;
- // 而状态机落定写的是**恰好 1**,所以 0.999(浮点等值判断的容差)放行是有意的。
- assert.equal(revealGate({ ...locked, ...good, fogStrength: 0.99 }).allowed, false, '0.99 未到位不得揭示')
- assert.equal(revealGate({ ...locked, ...good, fogStrength: 1 }).allowed, true, '恰好 1 才放行')
- // 3c 瓦片未就绪 → 不揭示(避免先空白再跳方块)。
- const noTiles = revealGate({ ...locked, ...good, tilesLoaded: false })
- assert.equal(noTiles.allowed, false, '目标视野瓦片未就绪时不得揭示')
- assert.equal(noTiles.block, HANDOVER_BLOCK.NO_TILES, '应如实报"被瓦片挡住"')
- // 3d 边缘风险 → 不揭示(保护不成立)。
- const edgeRisk = revealGate({ ...locked, ...good, edgeOk: false })
- assert.equal(edgeRisk.allowed, false, '相机处于边缘危险距离时不得揭示')
- assert.equal(edgeRisk.block, HANDOVER_BLOCK.EDGE_RISK, '应如实报"边缘风险"')
- // 3e 三条都满足 → 才放行。
- assert.equal(revealGate({ ...locked, ...good }).allowed, true, '三条件齐备才允许揭示')
- // 3f 全部条件齐备时,**必须**真的允许 —— 否则闸门会把正常路径也锁死(假红)。
- assert.equal(revealGate({ ...locked, ...good }).block, HANDOVER_BLOCK.NONE, '放行时 block 应为空')
- // 3g 交接被配置关闭 → 不揭示(由服务层走"精细层常显"分支,不是渐显)。
- assert.equal(revealGate({ ...locked, ...good, enabled: false }).allowed, false, '交接关闭时闸门不放行')
- }
- // ================================================================ 4. 降级:业务可用优先
- {
- const locked = { areaState: 'locked' }
- // 4a 无深度能力 / 雾不可执行 → **仍然揭示**(业务可用优先),但标成降级。
- for (const fogFlags of [{ fogSupported: false }, { fogExecutable: false }]) {
- const gate = revealGate({
- ...locked,
- fogStrength: 0,
- edgeOk: true,
- tilesLoaded: true,
- ...fogFlags,
- })
- assert.equal(gate.allowed, true, `雾不可用(${JSON.stringify(fogFlags)})时仍应允许揭示(业务可用优先)`)
- assert.equal(gate.block, HANDOVER_BLOCK.NO_FOG, '应如实标成"无雾降级"')
- assert.match(gate.note, /不承诺/, '降级提示必须写明**不承诺遮边**')
- }
- // 4b 降级路径也**不绕过瓦片就绪**(先空白再跳方块同样是缺陷,与雾无关)。
- const degradedNoTiles = revealGate({ ...locked, fogSupported: false, tilesLoaded: false, edgeOk: true })
- assert.equal(degradedNoTiles.allowed, false, '降级时仍要等瓦片(否则先空白再跳方块)')
- assert.equal(degradedNoTiles.block, HANDOVER_BLOCK.NO_TILES, '应如实报被瓦片挡住')
- }
- // ================================================================ 5. 瓦片失败:保持隐藏
- {
- const gate = revealGate({
- areaState: 'locked',
- fogStrength: 1,
- fogExecutable: true,
- fogSupported: true,
- edgeOk: true,
- tilesLoaded: true,
- tilesFailed: true,
- })
- assert.equal(gate.allowed, false, '瓦片失败必须保持隐藏、继续用粗底图')
- assert.equal(gate.block, HANDOVER_BLOCK.TILES_FAILED, '应如实报"瓦片失败"')
- assert.match(gate.note, /粗底图/, '提示要写明"继续用粗底图"')
- // 瓦片失败**优先于**雾不可用被报出(它比"没加载完"更确定,提示更该直说)。
- const both = revealGate({ areaState: 'locked', tilesFailed: true, fogSupported: false, tilesLoaded: false })
- assert.equal(both.block, HANDOVER_BLOCK.TILES_FAILED, '瓦片失败应优先报出(更确定的失败先说)')
- }
- // ================================================================ 6. 边缘保护判据:与雾曲线**同源**
- {
- // 6a 距离计算:矩形中心到各边的最近距离应取**最小**值,且在矩形外为 0。
- const center = { lon: (IMAGERY_BOUNDS[0] + IMAGERY_BOUNDS[2]) / 2, lat: (IMAGERY_BOUNDS[1] + IMAGERY_BOUNDS[3]) / 2 }
- const centerDist = distanceToBoundsEdgeMeters(center, IMAGERY_BOUNDS)
- assert.equal(centerDist > 0, true, '矩形内一点的距离应为正')
- assert.equal(distanceToBoundsEdgeMeters({ lon: 0, lat: 0 }, IMAGERY_BOUNDS), 0, '矩形外必须返回 0(毫无保护)')
- assert.equal(distanceToBoundsEdgeMeters(center, null), 0, '无矩形时返回 0(保守)')
- // 6b 遮蔽率**必须**复用 fog 曲线,而不是另立一套。
- const probe = { lon: 110.9, lat: 28.5 }
- const verdict = edgeOcclusionVerdict(probe, IMAGERY_BOUNDS, FOG_DEFAULTS, HANDOVER_DEFAULTS.minEdgeOcclusion)
- assert.equal(
- verdict.occlusion,
- Number(fogAttenuation(verdict.distanceM, FOG_DEFAULTS).toFixed(4)) || verdict.occlusion,
- '遮蔽率必须来自同一条雾衰减曲线(调雾端点时判据自动跟随)',
- )
- // 6c **区内任意点都能过闸** —— 这是"不照抄原型 38 km 魔数"的根据。
- // 区内最坏点(西南角)距影像边 35.8 km,遮蔽率 0.967 ≥ 0.9。
- {
- const [W, S, E, N] = AREA_BOUNDS
- let worst = { occlusion: 1, distanceM: Infinity }
- for (const lon of [W, E]) {
- for (const lat of [S, N]) {
- const v = edgeOcclusionVerdict({ lon, lat }, IMAGERY_BOUNDS, FOG_DEFAULTS, HANDOVER_DEFAULTS.minEdgeOcclusion)
- if (v.occlusion < worst.occlusion) worst = v
- }
- }
- assert.equal(worst.ok, true, `主场景区四角都应通过边缘保护判据(最坏遮蔽率 ${worst.occlusion})`)
- assert.equal(worst.occlusion >= 0.9, true, '最坏角仍应 ≥0.9')
- // 若照抄原型那个 38 km 硬阈值,西南角会被**误判**为不安全而挡住本可显示的影像。
- assert.equal(worst.distanceM < 38000, true, `最坏角距边 ${Math.round(worst.distanceM)} m < 38 km —— 证实不能照抄原型魔数`)
- }
- // 6d 相机贴边(距离 0)→ 遮蔽率 0 → 必然不放行。
- {
- const onEdge = { lon: IMAGERY_BOUNDS[0] + 0.001, lat: 28.5 }
- const v = edgeOcclusionVerdict(onEdge, IMAGERY_BOUNDS, FOG_DEFAULTS, HANDOVER_DEFAULTS.minEdgeOcclusion)
- assert.equal(v.ok, false, '贴着影像边时保护不成立,不得放行')
- }
- }
- // ================================================================ 7. 返回同步:先收影像、后撤雾
- {
- // 7a 精细层还看得见 → **不得**撤雾(决策 08:影像在失去保护前收起)。
- const stillVisible = returnSyncVerdict({ phase: HANDOVER_PHASE.RETURNING, alpha: 0.4, areaState: 'global', fogStrength: 1 })
- assert.equal(stillVisible.mayDropFog, false, '精细层仍可见时不得撤雾')
- assert.match(stillVisible.reason, /尚未收起/, '理由要写明"尚未收起"')
- // 7b 精细层确实收起 → 可以撤雾。
- const hidden = returnSyncVerdict({ phase: HANDOVER_PHASE.GLOBAL, alpha: 0, areaState: 'global', fogStrength: 1 })
- assert.equal(hidden.mayDropFog, true, '精细层收起到位后应允许撤雾')
- // 7c 还在区内 → 不得撤雾(还没返回)。
- const inArea = returnSyncVerdict({ phase: HANDOVER_PHASE.GLOBAL, alpha: 0, areaState: 'locked', fogStrength: 1 })
- assert.equal(inArea.mayDropFog, false, '仍在区内时不得撤雾')
- // 7d 雾已经撤了 → 不需要再撤(幂等,避免重复开一轮淡出)。
- const already = returnSyncVerdict({ phase: HANDOVER_PHASE.GLOBAL, alpha: 0, areaState: 'global', fogStrength: 0 })
- assert.equal(already.mayDropFog, false, '雾已撤除时不应重复撤雾')
- // 7e 关键顺序性质:**返回一开始**(alpha 还是 1)就绝不能撤雾 ——
- // 这正是原型"先收完再撤雾、中间还停顿"那个被维护者点名的顺序问题。
- const atReturnStart = returnSyncVerdict({ phase: HANDOVER_PHASE.RETURNING, alpha: 1, areaState: 'global', fogStrength: 1 })
- assert.equal(atReturnStart.mayDropFog, false, '按下返回的那一帧绝不能撤雾(否则影像在无保护下露边)')
- }
- // ================================================================ 8. 过期取消:只认最新目标
- {
- const idle = createHandoverLifecycle()
- // 进 → 立刻返回 → 再进:token 必须逐次递增,旧 token 被判过期。
- const reveal1 = beginHandover(idle, 'reveal', 100)
- const conceal = beginHandover(reveal1, 'conceal', 200)
- const reveal2 = beginHandover(conceal, 'reveal', 300)
- assert.equal(reveal2.token, 3, '三次交接应递增到 token 3')
- assert.equal(isStaleHandoverToken(reveal2, reveal1.token), true, '旧 reveal 的 token 应判过期')
- assert.equal(isStaleHandoverToken(reveal2, conceal.token), true, '旧 conceal 的 token 应判过期')
- assert.equal(isStaleHandoverToken(reveal2, reveal2.token), false, '最新 token 不是过期')
- // 幂等:重复"进入"不该把渐显推倒重来;已在 global 时重复"返回"也不该换 token。
- const locked = stepHandover(reveal1, 1e6, HANDOVER_DEFAULTS)
- assert.equal(locked.phase, HANDOVER_PHASE.REVEALED, '应已落 revealed')
- assert.equal(beginHandover(locked, 'reveal', 9e6).token, locked.token, '已揭示时重复 reveal 应幂等(不换 token)')
- assert.equal(beginHandover(idle, 'conceal', 9e6).token, idle.token, '已在 global 时重复 conceal 应幂等(不换 token)')
- // 8b **渐显途中重复调用不得重置时间轴**(本票真机验证抓到的真 bug)。
- //
- // 服务层每渲染帧都会调一次 `beginHandover`("条件还成立吗"天然要重复问)。
- // 第一版只在端点(REVEALED/GLOBAL)幂等,于是渐显**途中**每帧都被当成新一轮:
- // `since` 每帧重置 → `elapsed` 恒 ≈0 → **alpha 永远到不了 1,精细层永不出现**;
- // 而所有读数都显示"entering / 正在渐显",看起来完全正常 —— 只有画面不动。
- // 这条**只能靠"推进时间后 alpha 必须真的变大"来抓**,断言相位是抓不到的。
- {
- let inFlight = beginHandover(createHandoverLifecycle(), 'reveal', 1000)
- // 模拟服务层:同一相位里连续多次调用 beginHandover,再推进时间。
- for (let frame = 1; frame <= 5; frame++) {
- inFlight = beginHandover(inFlight, 'reveal', 1000 + frame * 16)
- }
- assert.equal(inFlight.since, 1000, '渐显途中重复 beginHandover 不得重置 `since`(否则时间轴永不走)')
- assert.equal(inFlight.token, 1, '渐显途中重复 beginHandover 不得换 token')
- const advanced = stepHandover(inFlight, 1000 + HANDOVER_DEFAULTS.fadeMs, HANDOVER_DEFAULTS)
- assert.equal(advanced.alpha, 1, '时间走完一个 fadeMs 后 alpha 必须到 1(否则精细层永不出现)')
- assert.equal(advanced.phase, HANDOVER_PHASE.REVEALED, '时间走完后应落 revealed')
- // 同一坑的反向:收起途中重复调用不得重置时间轴。
- let concealing = beginHandover(advanced, 'conceal', 5000)
- for (let frame = 1; frame <= 5; frame++) {
- concealing = beginHandover(concealing, 'conceal', 5000 + frame * 16)
- }
- assert.equal(concealing.since, 5000, '收起途中重复 beginHandover 不得重置 `since`')
- const concealed = stepHandover(concealing, 5000 + HANDOVER_DEFAULTS.fadeMs, HANDOVER_DEFAULTS)
- assert.equal(concealed.alpha, 0, '收起时间走完后 alpha 必须到 0(否则精细层永不收起)')
- // 真正的反向(换方向)**必须**重置时间轴 —— 否则"进→立刻返回"会沿用旧时间。
- const reversed = beginHandover(inFlight, 'conceal', 9999)
- assert.equal(reversed.phase, HANDOVER_PHASE.RETURNING, '反向应进入 returning')
- assert.equal(reversed.since, 9999, '换方向时必须重置 `since`(新一轮从这一刻起算)')
- assert.equal(reversed.token, inFlight.token + 1, '换方向应换 token(旧的那轮判过期)')
- }
- }
- // ================================================================ 9. 配置:可调 + 脏值退回默认
- {
- assert.equal(planHandover({}).fadeMs, HANDOVER_DEFAULTS.fadeMs, '不配应退回默认时长')
- assert.equal(planHandover({}).minEdgeOcclusion, HANDOVER_DEFAULTS.minEdgeOcclusion, '不配应退回默认遮蔽率')
- assert.equal(planHandover({}).enabled, true, '默认启用交接')
- const custom = planHandover({ handover: { enabled: false, fadeMs: 400, minEdgeOcclusion: 0.7, requireTilesLoaded: false } })
- assert.equal(custom.enabled, false, 'enabled 可配')
- assert.equal(custom.fadeMs, 400, 'fadeMs 可配(决策 08 要求"进出稍快",故不写死)')
- assert.equal(custom.minEdgeOcclusion, 0.7, 'minEdgeOcclusion 可配(现场观感调校)')
- assert.equal(custom.requireTilesLoaded, false, 'requireTilesLoaded 可配')
- // `null`/`''` 必须按"没配"处理 —— `Number(null)===0` 会把渐显变成 0 ms(票 38 踩过同一个坑)。
- assert.equal(planHandover({ handover: { fadeMs: null } }).fadeMs, HANDOVER_DEFAULTS.fadeMs, 'null 应按"没配"处理')
- assert.equal(planHandover({ handover: { fadeMs: '' } }).fadeMs, HANDOVER_DEFAULTS.fadeMs, '空串应按"没配"处理')
- assert.equal(planHandover({ handover: { fadeMs: 0 } }).fadeMs, 0, '显式 0 是合法值(立刻到位),不得被当成"没配"')
- assert.equal(planHandover({ handover: { minEdgeOcclusion: 5 } }).minEdgeOcclusion, HANDOVER_DEFAULTS.minEdgeOcclusion, '遮蔽率越界应退回默认')
- assert.equal(planHandover({ handover: { fadeMs: -1 } }).fadeMs, HANDOVER_DEFAULTS.fadeMs, '负时长应退回默认')
- }
- // ================================================================ 10. 读数:**不承诺已遮边**
- {
- const readout = handoverReadout({
- state: stepHandover(beginHandover(createHandoverLifecycle(), 'reveal', 0), 1e6, HANDOVER_DEFAULTS),
- block: HANDOVER_BLOCK.NONE,
- note: '保护成立',
- edge: { distanceM: 42500, occlusion: 1, ok: true },
- tiles: { loaded: true },
- })
- assert.equal(readout.phase, HANDOVER_PHASE.REVEALED, '读数应含相位')
- assert.equal(readout.alpha, 1, '读数应含 alpha')
- assert.equal(typeof readout.block, 'string', '读数应含"被什么挡住"')
- assert.equal(readout.edgeDistanceM, 42500, '读数应含相机到影像边的距离(人工排查用)')
- // 关键:**不得**出现任何"已遮边"的布尔承诺(票 38「就绪≠已遮住」的同款纪律)。
- assert.equal('protected' in readout, false, '读数不得提供 `protected` 这类未经验证的遮缝承诺')
- assert.equal('coversEdges' in readout, false, '读数不得承诺矩形边缘已被遮住')
- // 瓦片信号必须如实标注是**代理**(`globe.tilesLoaded` 不证明逐像素就绪)。
- assert.equal(readout.tilesReadyIsProxy, true, '必须标注瓦片信号是代理而非证明')
- }
- // ================================================================ 11. 静态守卫(结构性保证)
- {
- const handoverSource = read('src/modules/cockpit/domain/imageryHandover.js')
- const serviceSource = read('src/modules/cockpit/services/mapService.js')
- const engineSource = read('src/modules/cockpit/services/cesiumEngine.js')
- const strip = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- // 11a 交接曲线必须**复用**雾的衰减函数,不得另写一份(否则两套阈值会各漂各的)。
- assert.match(strip(handoverSource), /import \{[^}]*fogAttenuation[^}]*\} from '\.\/fog\.js'/, '边缘判据必须 import fogAttenuation')
- const expCount = (strip(handoverSource).match(/Math\.exp/g) || []).length
- assert.equal(expCount, 0, `交接域层不得自己实现指数衰减(发现 ${expCount} 处 Math.exp)—— 必须复用 fog.js`)
- // 11b **隐藏必须用 alpha=0、show 保持 true**(看不见≠停止加载)。
- // 用 show=false 会真的停掉瓦片预取,正好造成"先空白再跳方块"。
- const engineStripped = strip(engineSource)
- assert.match(engineStripped, /layer\.alpha = next/, '应通过写 alpha 实现隐藏')
- assert.equal(
- /layer\.show\s*=\s*false/.test(engineStripped),
- false,
- '不得用 `layer.show = false` 隐藏精细层(那会停掉瓦片预取,违背"看不见≠停止加载")',
- )
- // 11c alpha 只能有**一处**写入点(服务层与原型同时写会互相覆盖)。
- const alphaWrites = (strip(serviceSource).match(/setAlpha\(/g) || []).length
- assert.equal(alphaWrites >= 1, true, '服务层应通过句柄的 setAlpha 写 alpha')
- assert.equal(
- /\.alpha\s*=[^=]/.test(strip(serviceSource).replace(/this\.alpha\s*=[^=]/g, '')),
- false,
- '服务层不得绕过句柄直接给 `layer.alpha` 赋值(alpha 只允许一处写)',
- )
- // 11d 生产与原型**必须互斥**:服务层要有让位开关,否则两者会同时驱动 alpha。
- //
- // 注意这条**不能只查名字存在**:变异测试证明过,"函数名在不在"式的断言
- // 在把 `if (handoverPrototypeActive) return` 改成 `if (false) return` 后**照样绿**。
- // 所以这里把 `applyHandoverAlpha` 的函数体抠出来,断言**让位判断真的在它里面**。
- //
- // 票 40:可丢弃原型已归档到 `throwaway/cockpit-prototypes` 分支(2026-09-30,
- // 分支指针见票 40 正文),生产路径不再有 `prototypes/` 目录 —— 所以这里
- // **不再读原型源码**(文件已不存在),而是断言它已被移出生产树;
- // 服务层的让位开关**保留**(一旦从归档分支还原原型,互斥仍成立,不会各写各的)。
- assert.match(serviceSource, /export function setHandoverPrototypeActive/, '应导出原型让位开关(保留以支持从归档分支还原原型时仍互斥)')
- {
- const body = /function applyHandoverAlpha\(\)\s*\{([\s\S]*?)\n\}/.exec(strip(serviceSource))
- assert.equal(!!body, true, '应能定位 applyHandoverAlpha 函数体')
- assert.match(
- body[1],
- /if\s*\(\s*handoverPrototypeActive\s*\)\s*return/,
- 'applyHandoverAlpha 必须**真的**在原型接管时提前返回(否生产与原型会同时写 alpha 互相覆盖)',
- )
- }
- // 原型已归档:生产树里不得再有 `prototypes/` 目录(否则"生产路径不挂原型"就没做到)。
- assert.equal(
- existsSync(resolve(root, 'src/modules/cockpit/prototypes')),
- false,
- '`src/modules/cockpit/prototypes/` 应已从生产树移除(已归档到 throwaway/cockpit-prototypes 分支)',
- )
- // 11e 返回时**顺序**必须可核:不得"先撤雾再收影像"。
- // 判据:`setAreaState` 的 returnGlobal 分支里,`advanceHandover` 出现在
- // `retargetFogForArea` 的**条件**调用之前(雾由交接延后撤)。
- assert.match(serviceSource, /handoverWillHandleFogDrop/, '返回路径应判断"雾是否由交接延后撤"')
- assert.match(serviceSource, /maybeDropFogAfterConceal/, '应有一个"影像收起后才撤雾"的函数')
- // 11f 性能:渲染循环里刷读数必须 diff 掉,否则每帧 8 次 DOM 写入(预算 ≤15%)。
- assert.match(serviceSource, /handoverPainted/, '读数写入应带 diff 缓存(否则每帧重写 DOM)')
- // 11g 渐显途中条件转硬失败要**撤回**,但**雾失败不许撤回**(规格:不突然藏起来)。
- {
- const hard = /function isHardRevealBlock\(block\)\s*\{([\s\S]*?)\n\}/.exec(strip(serviceSource))
- assert.equal(!!hard, true, '应有 isHardRevealBlock 判定"硬失败挡板"')
- assert.match(hard[1], /TILES_FAILED/, '瓦片失败应算硬失败(撤回已渐显的精细层)')
- assert.match(hard[1], /EDGE_RISK/, '边缘风险应算硬失败')
- assert.equal(
- /NO_FOG/.test(hard[1]),
- false,
- '雾失败**不得**算硬失败 —— 规格要求"雾失败且精细层已可见时不突然藏起来"(业务可用优先)',
- )
- }
- // 11h 稳态节流**不得吞掉离散的进出动作**。
- // 节流是为了省掉稳态帧的重复几何计算;但"进区/返回"是用户动作,
- // 必须在**这一次调用**就定下目标相位(否则按下返回后最坏要等一个节流窗口
- // 才开始收影像,而决策 08 恰恰不许"停顿")。
- // 判据:节流的提前返回必须被 `force` 豁免,且 `setAreaState` 两条必经路径都传 force。
- {
- const throttle = /if \(!force && !transitioning && now < handoverNextEvalAt\) return handoverLifecycle/.test(strip(serviceSource))
- assert.equal(throttle, true, '稳态节流的提前返回必须被 `force` 豁免(否则离散动作会被吞)')
- const forcedCalls = (strip(serviceSource).match(/advanceHandover\(true\)/g) || []).length
- assert.equal(forcedCalls >= 2, true, `进区与返回两条路径都应传 force=true(实际发现 ${forcedCalls} 处)`)
- }
- }
- // ---------------------------------------------------------------- 12. 退出时的"整球发白"窗口
- //
- // 维护者 2026-09-29 第二次反馈:「退出时雾气消散太慢,会出现整个地球变白一小段时间」,
- // 且第一版修复(瞬时高度收口)又导致「雾几乎一瞬间就产生或消失」。
- //
- // 结构性判据是这条跨模块关系 —— 单看雾或单看相机都发现不了:
- //
- // 雾彻底淡完的时刻(= handover.fadeMs + fog.fadeOutMs,雾要等影像收起后才淡)
- // 应当**早于**相机越过 fog.altitudeZeroM 的时刻。
- //
- // 因为相机一旦高过那个高度,画面上每个片元都在雾的全遮蔽端点之外 ——
- // 那一刻只要雾还没散完,**整个地球就被涂成雾色**(就是"整个地球变白")。
- //
- // ── 为什么这里不写成硬断言 ──
- // 维护者 2026-09-30 在真机上调定了三个时长并按**现场观感**裁定了取舍
- // (原话:"就按我调整的数值来……目前的效果就行",另裁定代码里的高度带值"以我为准")。
- // 调定后的组合满足不了上面那条(相机越过 42 km 反而不早于雾淡完),
- // 即**该约束在部署层被有意放宽**:观感可接受,就不再拿它当阻断条件。
- // 所以本节的立场是:**把窗口算出来钉住**,而不是假装它不存在。
- // 这样它有牙 —— 日后谁再动 `fadeMs` / `fadeOutMs` / `RETURN_FLIGHT_MS`,
- // 窗口一变就会红,逼一次"这是有意的吗"的确认。
- {
- const { RETURN_FLIGHT_MS, returnFlightProgress, INITIAL_PANORAMA } = await import('../../../src/modules/cockpit/domain/camera.js')
- const mapServiceSource = read('src/modules/cockpit/services/mapService.js')
- const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- // 用真实缓动 + 真实 hpr 插值算高度(pitch 也在插值,不能只按 home 的 −55° 算)。
- const HOME_HPR = { heading: 0, pitch: -55, range: 12000 }
- const { lerpHpr } = await import('../../../src/modules/cockpit/domain/camera.js')
- const heightAt = (ms) => {
- const p = returnFlightProgress(ms, RETURN_FLIGHT_MS)
- const hpr = lerpHpr(HOME_HPR, INITIAL_PANORAMA, p)
- return hpr.range * Math.sin((Math.abs(hpr.pitch) * Math.PI) / 180)
- }
- const fogGoneAt = HANDOVER_DEFAULTS.fadeMs + FOG_DEFAULTS.fadeOutMs
- const zeroM = FOG_DEFAULTS.altitudeZeroM
- // ① 相机最终必须真的回到全球尺度(否则根本不是"返回全球")。
- assert.equal(
- heightAt(RETURN_FLIGHT_MS) > zeroM,
- true,
- '返回飞行终了应远高于雾的全遮蔽端点(否则没回到全球视角)',
- )
- // ② 缓动的结构性性质(这些**必须**成立,与上面的取舍无关)。
- assert.equal(returnFlightProgress(0, RETURN_FLIGHT_MS), 0, '起点必须是 0')
- assert.equal(returnFlightProgress(RETURN_FLIGHT_MS, RETURN_FLIGHT_MS), 1, '满时长必须恰好到 1')
- assert.equal(returnFlightProgress(RETURN_FLIGHT_MS * 10, RETURN_FLIGHT_MS), 1, '超时不得越过 1')
- assert.equal(returnFlightProgress(0, 0), 1, '飞行时长为 0 应立刻到位')
- // 慢起:前 1/4 时长内不得走完 1% 路程(否则"低空停留"整个不存在 —— 第一版就是这么错的)。
- assert.equal(
- returnFlightProgress(RETURN_FLIGHT_MS / 4, RETURN_FLIGHT_MS) < 0.01,
- true,
- '返回飞行必须慢起:前 1/4 时长内不得走完 1% 路程',
- )
- let prev = -1
- let monotonic = true
- for (let ms = 0; ms <= RETURN_FLIGHT_MS; ms += 5) {
- const v = returnFlightProgress(ms, RETURN_FLIGHT_MS)
- if (v < prev - 1e-12) monotonic = false
- prev = v
- }
- assert.equal(monotonic, true, '返回飞行进度必须单调不减')
- // ③ 返回路径必须**真的**用这条飞行,而不是退回阻尼
- // (退回阻尼则头一帧就越过整条雾带,且下面算的窗口全部失去意义)。
- assert.match(
- stripComments(mapServiceSource),
- /applyViewPreset\(pivot, INITIAL_PANORAMA, \{[\s\S]*?flightMs/,
- '返回全球必须显式传 `flightMs`(走慢起飞行),不能退回阻尼',
- )
- // ④ 把"相机已高于雾的全遮蔽端点、而雾仍可见"的窗口算出来并**钉住上限**。
- let crossedAt = null
- for (let ms = 0; ms <= RETURN_FLIGHT_MS; ms += 1) {
- if (heightAt(ms) >= zeroM) { crossedAt = ms; break }
- }
- assert.equal(crossedAt !== null, true, '返回途中应能越过雾的全遮蔽端点')
- const windowMs = Math.max(0, fogGoneAt - crossedAt)
- // 上限是**维护者当前已接受的量级**(默认参数下约 0.37 s)+ 一点余量。
- // 调大就得回来重新确认观感 —— 这就是本节的牙。
- const ACCEPTED_WINDOW_MS = 400
- assert.equal(
- windowMs <= ACCEPTED_WINDOW_MS,
- true,
- `"相机高于 ${zeroM / 1000} km 而雾仍可见"的窗口为 ${windowMs} ms,`
- + `超过已接受上限 ${ACCEPTED_WINDOW_MS} ms(雾淡完 ${fogGoneAt} ms / 越过 ${crossedAt} ms)。`
- + '若是有意放宽,请连同真机观感一起更新本上限与票面记录;否则请缩短 '
- + 'handover.fadeMs 或 fog.fadeOutMs,或加大 RETURN_FLIGHT_MS。',
- )
- }
- console.log(
- 'T39 检查通过:相位与 alpha 端点(落定必为 0/1,不留半透明卡死)/ 闸门(全球不揭示、雾不足不揭示、' + '瓦片未就绪不揭示、边缘风险不揭示,三条件齐备才放行)/ 降级(雾不可用仍揭示但不承诺遮边、'
- + '不绕过瓦片就绪)/ 瓦片失败保持隐藏优先报出 / 边缘判据与雾曲线同源(区内四角全过、'
- + '证实不能照抄原型 38 km 魔数)/ 返回同步(按下返回那一帧绝不撤雾,收起后才撤)'
- + '/ 过期取消与幂等 / 配置可调且脏值退回默认 / 读数不承诺已遮边 / 静态守卫(复用雾曲线、'
- + 'alpha 单写入口、原型互斥、隐藏用 alpha 不用 show、读数 diff)'
- + ' / 返回飞行(慢起、两端精确、单调、真的走 flightMs 而非阻尼;'
- + '"相机高于雾遮蔽端点而雾仍可见"的窗口已算出并钉住已接受上限)。',
- )
|