| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609 |
- /**
- * T38 区内视距雾气 纯逻辑检查(票 38)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t38-fog.mjs
- *
- * 覆盖验收第 3、5、6、7、8、9 条里能脱浏览器断言的部分:衰减端点(**两端语义精确**)、
- * 门控(只有 locked 生效)、进出强度推进与**过期取消**、降级选择、色相判据、
- * 性能预算判定,以及「就绪 ≠ 已遮住」这条口径的结构性保证。
- * 用假引擎驱动真实的 `mapService` 路径,不需要浏览器、不需要 cesium。
- */
- import assert from 'node:assert/strict'
- import { readFileSync } from 'node:fs'
- import {
- FOG_DEFAULTS,
- FOG_DEGRADE,
- FOG_PHASE,
- FOG_PERF_BUDGET,
- FOG_STATE,
- advanceFogStrength,
- approachAltitudeFactor,
- beginFogTransition,
- createFogLifecycle,
- enterAltitudeStrength,
- fogAltitudeFactor,
- fogAttenuation,
- fogColorVerdict,
- fogPerformanceVerdict,
- fogReadout,
- isStaleFogToken,
- parseHexColor,
- planFog,
- planFogDegrade,
- shouldFogBeActive,
- shouldRenderFog,
- stepFogLifecycle,
- } from '../../../src/modules/cockpit/domain/fog.js'
- import { planPrimaryArea } from '../../../src/modules/cockpit/domain/primaryArea.js'
- import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
- const BOUNDS = [110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907]
- // ---------------------------------------------------------------- 1. 衰减端点(两端语义精确)
- assert.equal(fogAttenuation(0), 0, '0 距离必须**恰好**全清')
- assert.equal(fogAttenuation(1000), 0, '起雾端点之前必须恰好 0(不是"很小")')
- assert.equal(fogAttenuation(FOG_DEFAULTS.startDistance), 0, `恰好 ${FOG_DEFAULTS.startDistance} m 处仍应为 0`)
- assert.equal(fogAttenuation(FOG_DEFAULTS.endDistance), 1, `${FOG_DEFAULTS.endDistance} m 处必须**恰好**全遮蔽`)
- assert.equal(fogAttenuation(FOG_DEFAULTS.endDistance + 1), 1, '超过遮蔽端点仍是 1(不外插)')
- assert.equal(fogAttenuation(1e9), 1, '极远处仍是 1(有界)')
- // 中间**严格单调增浓**(不能出现回落或平台 → 否则会有"雾墙"或"远看反而更清")。
- let previous = 0
- for (let d = FOG_DEFAULTS.startDistance; d <= FOG_DEFAULTS.endDistance; d += 250) {
- const alpha = fogAttenuation(d)
- assert.equal(alpha >= previous - 1e-12, true, `衰减应单调不减,在 ${d} m 处掉了(${alpha} < ${previous})`)
- previous = alpha
- }
- // 中点应已相当浓("中间平滑增浓"而不是最后 1 km 突然全遮)。
- assert.equal(fogAttenuation(30000) > 0.5, true, `30 km(端点之间)应已明显起雾,实际 ${fogAttenuation(30000).toFixed(3)}`)
- // **关键回归:单纯指数衰减不算达标** —— 它在有限距离到不了 1。
- const exponentialOnly = (d) => 1 - Math.exp(-3 * ((d - 18000) / 24000))
- assert.equal(exponentialOnly(42000) < 1, true, '对照:单纯指数衰减在 42 km 处到不了 1(所以不能用它)')
- assert.equal(Math.abs(exponentialOnly(42000) - 1) > 1e-6, true, '对照差值是真实存在的(证明本条的判别力)')
- // **端点内侧**才是曲线的判别区:端点本身被 `>= end` 的早退兜住了(那是**端点语义**的保证),
- // 所以只断"恰好 42 km == 1"抓不住"曲线没归一化"这种错 —— 变异测试证明过这一点。
- // 这里补一条紧贴端点内侧的断言:归一化曲线应已收敛到 ~1,而单纯指数只到 0.95。
- assert.equal(
- fogAttenuation(41999) > 0.999,
- true,
- `端点内侧(41999 m)应已收敛到 ~1,实际 ${fogAttenuation(41999).toFixed(6)}(若曲线不归一化,这里只有 ≈0.95)`,
- )
- assert.equal(
- exponentialOnly(41999) < 0.96,
- true,
- '对照:同类位置上单纯指数衰减明显不到 1(这正是"不算达标"的可测差别)',
- )
- // 脏端点不得让曲线崩掉(start >= end 时退化为"以 end 为界的阶跃",而不是 NaN)。
- // 注意语义:反了的端点下,"距离 >= end" 就全遮蔽 —— 因为 end 是唯一的有效判据。
- assert.equal(fogAttenuation(50, { startDistance: 900, endDistance: 100 }), 0, '端点反了:小于 end 应回 0')
- assert.equal(fogAttenuation(200, { startDistance: 900, endDistance: 100 }), 1, '端点反了:大于等于 end 应回 1')
- assert.equal(Number.isFinite(fogAttenuation(1000, { startDistance: NaN, endDistance: 5 })), true, '脏端点不应产生 NaN')
- // ---------------------------------------------------------------- 2. 门控(只在 locked 生效)
- const areaPlan = planPrimaryArea({ primaryArea: { bounds: BOUNDS } })
- assert.equal(shouldFogBeActive('locked', areaPlan), true, '区内应生效')
- assert.equal(shouldFogBeActive('global', areaPlan), false, '纯全球态应关闭')
- assert.equal(shouldFogBeActive('locked', planPrimaryArea({})), false, '主场景区不可用时(未配置)也必须关闭 —— 沿票 36 的关闭路径')
- assert.equal(
- shouldFogBeActive('locked', planPrimaryArea({ primaryArea: { bounds: [110.4, 28.49, 110.3, 28.39] } })),
- false,
- '主场景区配置非法时同样关闭',
- )
- // 只有 global 阶段不渲染;有强度且非 global 才渲染。
- const idle = createFogLifecycle()
- assert.equal(shouldRenderFog(idle), false, 'global 阶段不渲染')
- assert.equal(shouldRenderFog({ ...idle, phase: FOG_PHASE.LOCKED, strength: 1 }), true, 'locked 且有强度应渲染')
- assert.equal(
- shouldRenderFog({ ...idle, phase: FOG_PHASE.LOCKED, strength: 1 }, { supported: false }),
- false,
- '不支持深度时不渲染(整级禁用)',
- )
- // ---------------------------------------------------------------- 3. 进出强度推进
- const zero = createFogLifecycle()
- const entering = beginFogTransition(zero, 'enter', 1000)
- assert.equal(entering.phase, FOG_PHASE.ENTERING, '进入应到 entering')
- assert.equal(entering.token > zero.token, true, '每次进出必须换 token(过期判定的依据)')
- // 渐显:时间中点应落在端点之间,结束时恰好 1。
- const midEnter = advanceFogStrength(FOG_PHASE.ENTERING, FOG_DEFAULTS.fadeInMs / 2, 0)
- assert.equal(midEnter.strength > 0 && midEnter.strength < 1, true, `渐显中点应在 (0,1),实际 ${midEnter.strength}`)
- assert.equal(advanceFogStrength(FOG_PHASE.ENTERING, FOG_DEFAULTS.fadeInMs, 0).strength, 1, '渐显结束恰好 1')
- assert.equal(advanceFogStrength(FOG_PHASE.ENTERING, FOG_DEFAULTS.fadeInMs * 10, 0).strength, 1, '超时也不得越过 1')
- // 淡出:结束恰好 0,且**从当前强度**开始(不是从 1 跳)。
- const exiting = beginFogTransition({ ...idle, phase: FOG_PHASE.LOCKED, strength: 0.4 }, 'exit', 2000)
- assert.equal(exiting.phase, FOG_PHASE.EXITING, '返回应到 exiting')
- assert.equal(exiting.from, 0.4, '淡出应从当前强度起算(避免"跳一下再淡")')
- assert.equal(advanceFogStrength(FOG_PHASE.EXITING, FOG_DEFAULTS.fadeOutMs / 2, 0.4).strength < 0.4, true, '淡出中途应已变小')
- assert.equal(advanceFogStrength(FOG_PHASE.EXITING, FOG_DEFAULTS.fadeOutMs, 0.4).strength, 0, '淡出结束恰好 0')
- assert.equal(advanceFogStrength(FOG_PHASE.EXITING, FOG_DEFAULTS.fadeOutMs * 10, 0.4).strength, 0, '超时也不得低于 0')
- // 阶段推进:到端点即落定(不会永远停在中间)。
- assert.equal(stepFogLifecycle({ ...entering, since: 0 }, FOG_DEFAULTS.fadeInMs + 10).phase, FOG_PHASE.LOCKED, '渐显到点应落 locked')
- assert.equal(stepFogLifecycle({ ...exiting, since: 0 }, FOG_DEFAULTS.fadeOutMs + 10).phase, FOG_PHASE.GLOBAL, '淡出到点应落 global')
- // 锁定态恒 1、全球态恒 0(幂等)。
- assert.equal(stepFogLifecycle({ ...idle, phase: FOG_PHASE.LOCKED }, 99999).strength, 1, 'locked 恒 1')
- assert.equal(stepFogLifecycle(idle, 99999).strength, 0, 'global 恒 0')
- // 幂等:已在 locked 再点"进入"不该把雾推倒重来(否则每次归位都闪一下)。
- const lockedState = { phase: FOG_PHASE.LOCKED, strength: 1, token: 7, since: 0, from: 1 }
- assert.deepEqual(beginFogTransition(lockedState, 'enter', 5000), lockedState, '重复进入应幂等')
- const globalState = createFogLifecycle()
- assert.deepEqual(beginFogTransition(globalState, 'exit', 5000), globalState, '区外重复返回应幂等')
- // ---------------------------------------------------------------- 4. 过期取消(连续进出只认最新目标)
- {
- // 场景:进区渐显途中立刻返回,再立刻进区 —— 最后一次必须赢。
- let state = createFogLifecycle()
- state = beginFogTransition(state, 'enter', 0) // token 1
- const staleToken = state.token
- state = beginFogTransition(state, 'exit', 100) // token 2(返回覆盖进入)
- state = beginFogTransition(state, 'enter', 200) // token 3(再进覆盖返回)
- assert.equal(state.token, 3, '连续进出应递增 token')
- assert.equal(isStaleFogToken(state, staleToken), true, '旧 token 必须被判过期(过期淡出不得关掉新的进入)')
- assert.equal(isStaleFogToken(state, state.token), false, '当前 token 不算过期')
- assert.equal(state.phase, FOG_PHASE.ENTERING, '最终状态必须是"正在进入",而不是被旧的返回关掉')
- // 推进到落定:必须是 locked,说明旧的 exit 没有把它拉回 global。
- const settled = stepFogLifecycle({ ...state, since: 0 }, FOG_DEFAULTS.fadeInMs + 10)
- assert.equal(settled.phase, FOG_PHASE.LOCKED, '连续进出后落定必须是 locked(最新目标生效)')
- assert.equal(settled.strength, 1, '落定强度应为 1')
- }
- // ---------------------------------------------------------------- 5. 进入高度带(只作强度输入)
- assert.equal(enterAltitudeStrength(80000), 0, '高于 60 km 时高度带贡献为 0')
- assert.equal(enterAltitudeStrength(FOG_DEFAULTS.enterAltitudeFrom), 0, '恰好 60 km 处为 0')
- assert.equal(enterAltitudeStrength(FOG_DEFAULTS.enterAltitudeTo), 1, '降到 20 km 处为 1')
- assert.equal(enterAltitudeStrength(0), 1, '低于 20 km 仍为 1(有界)')
- const altMid = enterAltitudeStrength(40000)
- assert.equal(altMid > 0 && altMid < 1, true, '40 km 处应在中间')
- // **它不是"可揭示精细影像"的充分条件**(决策 07 第 4 条):这条在结构上就无法从本函数得出,
- // 因为函数只返回一个 0..1 的数、不带任何"可交接"语义。用返回类型守住这一点。
- const altKeys = Object.keys({ v: enterAltitudeStrength(30000) })
- assert.deepEqual(altKeys, ['v'], '高度带只返回强度标量,**不含**任何"可交接/已遮缝"布尔(票 39 不得据此放行影像)')
- // ---------------------------------------------------------------- 5b. 相机高度收口(维护者 2026-09-29 报障后新增)
- //
- // 报障:① 进入最后一段画面变绿;② 退出时雾散太慢、整个地球变白一段时间。
- // 同一个根因:雾不透明度按「相机到片元的世界射线距离」算(18/42 km),
- // 而相机在高空时**画面上每个片元**都超出 42 km ⇒ 整屏被涂成雾色。
- // 修复:把强度再乘一个**高度系数**,相机高过全遮蔽端点时雾收到 0。
- assert.equal(fogAltitudeFactor(0), 1, '0 m(贴地)应为满强度')
- assert.equal(fogAltitudeFactor(FOG_DEFAULTS.altitudeFullM), 1, '恰好 altitudeFullM 处仍为满强度')
- assert.equal(fogAltitudeFactor(9950), 1, '**区内归位高度(约 9.95 km)必须恰为 1** —— 已验收的区内观感不得被这次修复改变')
- assert.equal(fogAltitudeFactor(FOG_DEFAULTS.altitudeZeroM), 0, '恰好 altitudeZeroM(= 全遮蔽端点 42 km)处应收到 0')
- assert.equal(fogAltitudeFactor(60000), 0, '60 km 高空应收到 0(否则退出时整屏发白)')
- assert.equal(fogAltitudeFactor(20000000), 0, '全景高度(20,000 km)应收到 0')
- {
- // 中间必须**单调**收口,不能有平台或回升(否则高度变化时会出现"雾突然回来"的跳变)。
- let prev = 1
- for (let alt = FOG_DEFAULTS.altitudeFullM; alt <= FOG_DEFAULTS.altitudeZeroM; alt += 500) {
- const value = fogAltitudeFactor(alt)
- assert.equal(value <= prev + 1e-9, true, `高度收口应单调不增(${alt} m 处 ${value} > 前值 ${prev})`)
- assert.equal(value >= 0 && value <= 1, true, `高度系数必须落在 [0,1](${alt} m 处 ${value})`)
- prev = value
- }
- const mid = fogAltitudeFactor((FOG_DEFAULTS.altitudeFullM + FOG_DEFAULTS.altitudeZeroM) / 2)
- assert.equal(mid > 0 && mid < 1, true, '两端中间应在开区间内')
- }
- // 配置可调。
- assert.equal(fogAltitudeFactor(9000, planFog({ fog: { altitudeFullM: 8000, altitudeZeroM: 10000 } })) > 0, true, '高度带可配')
- // 写反(zero <= full)→ 退化为恒 1(回到旧行为),**不得静默把雾关掉**(那是更难查的失败模式)。
- assert.equal(
- fogAltitudeFactor(60000, planFog({ fog: { altitudeFullM: 50000, altitudeZeroM: 10000 } })),
- 1,
- '高度带写反时应退化为恒 1,不得静默关雾',
- )
- assert.equal(fogAltitudeFactor(NaN), 1, '拿不到高度时按满强度(保守:宁可多雾,不要无故没雾)')
- assert.equal(fogAltitudeFactor(undefined), 1, 'undefined 高度同样按满强度')
- // ---------------------------------------------------------------- 5c. 高度系数的时间限速
- //
- // 维护者 2026-09-29 第二次反馈:「雾几乎一瞬间就产生或消失了」。根因是**量级**:
- // 返回全球时 range 从 12 km 变到 20,000 km(跨 1.7e6 倍),而雾带只有 20–42 km
- // —— 相机头一帧就越过整条带。所以瞬时高度系数必然跳变,必须限速。
- {
- // 单帧最多走 dt/slew;一帧 16.7ms、slew 400ms → 最多 0.04175。
- const oneFrame = approachAltitudeFactor(1, 0, 16.7, FOG_DEFAULTS)
- const maxStep = 16.7 / FOG_DEFAULTS.altitudeSlewMs
- assert.equal(Math.abs(1 - oneFrame - maxStep) < 1e-9, true, `单帧步长应恰为 dt/slew(实得 ${1 - oneFrame})`)
- // **核心不变量**:无论目标怎么跳,走完全程至少要 slew 毫秒 —— 不允许"一瞬间"。
- assert.equal(
- approachAltitudeFactor(1, 0, 1, FOG_DEFAULTS) > 0.99,
- true,
- '1 ms 内几乎不动(这正是"不再一瞬间散掉"的可断言含义)',
- )
- // 累计走完需要 ≥ slew 毫秒(逐帧 16.7ms 累加)。
- let shown = 1
- let elapsed = 0
- for (let i = 0; i < 200 && shown > 0.001; i++) {
- shown = approachAltitudeFactor(shown, 0, 16.7, FOG_DEFAULTS)
- elapsed += 16.7
- }
- assert.equal(
- elapsed >= FOG_DEFAULTS.altitudeSlewMs - 16.7,
- true,
- `从 1 走到 0 至少需要 ${FOG_DEFAULTS.altitudeSlewMs} ms(实得 ${elapsed.toFixed(0)} ms)`,
- )
- // 已经到端点就不动(不越界、不抖动)。
- assert.equal(approachAltitudeFactor(0, 0, 16.7, FOG_DEFAULTS), 0, '在 0 处保持 0')
- assert.equal(approachAltitudeFactor(1, 1, 16.7, FOG_DEFAULTS), 1, '在 1 处保持 1')
- // 单帧 dt 上限:切标签页回来时不许一步走完(否则又变成"一瞬间")。
- assert.equal(
- approachAltitudeFactor(1, 0, 999999, FOG_DEFAULTS),
- 1 - 120 / FOG_DEFAULTS.altitudeSlewMs,
- '超大 dt 应被夹到 120 ms(切标签页回来不许一步走完)',
- )
- // 显式 slew=0 = 不限速(合法意图,与其它参数的"显式 0"一致)。
- assert.equal(approachAltitudeFactor(1, 0, 16.7, { ...FOG_DEFAULTS, altitudeSlewMs: 0 }), 0, 'slew=0 应立刻跟到目标')
- // 结果必须始终落在 [0,1]。
- let v = 0.5
- for (const target of [1, 0, 1, 0, 0.7, 0]) v = approachAltitudeFactor(v, target, 16.7, FOG_DEFAULTS)
- assert.equal(v >= 0 && v <= 1, true, '限速结果必须始终在 [0,1]')
- // 可配,且脏值退回默认。
- assert.equal(planFog({ fog: { altitudeSlewMs: 900 } }).altitudeSlewMs, 900, '限速时长可配')
- assert.equal(planFog({ fog: { altitudeSlewMs: null } }).altitudeSlewMs, FOG_DEFAULTS.altitudeSlewMs, 'null 应按"没配"处理')
- assert.equal(planFog({ fog: { altitudeSlewMs: -1 } }).altitudeSlewMs, FOG_DEFAULTS.altitudeSlewMs, '负数应退回默认')
- assert.equal(planFog({ fog: { altitudeSlewMs: 0 } }).altitudeSlewMs, 0, '显式 0 是合法意图(不限速)')
- }
- // ---------------------------------------------------------------- 6. 降级选择
- assert.equal(planFogDegrade({ viewMode: 'ground' }).level, FOG_DEGRADE.NONE, '地面视图正常:不预先关闭')
- assert.equal(planFogDegrade({ viewMode: 'pipeline' }).level, FOG_DEGRADE.NONE, '**管线视图也不预先关闭**(正常情况不预关闭)')
- assert.equal(planFogDegrade({ viewMode: 'ground', supported: false }).level, FOG_DEGRADE.OFF, '无深度支持 → 整级关闭')
- assert.equal(planFogDegrade({ viewMode: 'pipeline', translucentArtifacts: true }).level, FOG_DEGRADE.REDUCED, '观察到透明错位 → 减弱')
- assert.equal(planFogDegrade({ viewMode: 'pipeline', translucentArtifacts: true }).strengthCap < 1, true, '减弱应真的压低强度上限')
- assert.equal(planFogDegrade({ viewMode: 'ground', userDisabled: true }).level, FOG_DEGRADE.OFF, '用户暂关 → 关闭')
- // 降级必须带原因(界面要如实提示)。
- for (const opts of [{ supported: false }, { userDisabled: true }, { viewMode: 'pipeline', translucentArtifacts: true }]) {
- assert.equal(planFogDegrade(opts).reason.length > 0, true, `降级应带原因:${JSON.stringify(opts)}`)
- }
- // 降级不改相机限位(那是票 37 的域)——本函数签名里根本没有限位参数。
- assert.deepEqual(
- Object.keys(planFogDegrade({ viewMode: 'pipeline', translucentArtifacts: true })).sort(),
- ['level', 'reason', 'strengthCap'],
- '降级计划只含雾自己的三个字段(构造性保证不碰相机限位)',
- )
- // ---------------------------------------------------------------- 7. 色相判据(冷白灰、不发绿)
- assert.equal(fogColorVerdict('#bdcdd6').ok, true, '规格基准色 #bdcdd6 应通过(它是被维护者认可过的起点)')
- assert.equal(fogColorVerdict('#c8d8e8').ok, true, '冷白偏蓝应通过')
- assert.equal(fogColorVerdict('#cccccc').ok, true, '纯浅灰应通过')
- assert.equal(fogColorVerdict('#a8d8b0').ok, false, '明显发绿应判不通过')
- assert.equal(fogColorVerdict('#35e39a').ok, false, '饱和绿应判不通过')
- assert.equal(fogColorVerdict('#04121f').ok, false, '深色底(不是白灰)应判不通过')
- assert.equal(fogColorVerdict('blue').ok, false, '非 #rrggbb 应判不通过')
- assert.match(fogColorVerdict('#a8d8b0').reasons.join(';'), /发绿/, '发绿的原因要写明(人能据此调)')
- // 判据用 HSV 饱和度而不是 HSL:浅色的 HSL 饱和度会误伤已认可的基准色。
- assert.equal(fogColorVerdict('#bdcdd6').saturation < 0.2, true, '基准色的 HSV 饱和度应低(这才区分得开浅灰与彩色)')
- // ---------------------------------------------------------------- 8. 「就绪 ≠ 已遮住边缘」
- const readout = fogReadout({ state: { ...idle, phase: FOG_PHASE.LOCKED, strength: 1 } })
- assert.equal(readout.state, FOG_STATE.READY, '可执行时应报 ready')
- assert.equal(readout.coversEdges, false, '**必须**显式声明"未证明遮住边缘"(不得给全局 protected 承诺)')
- assert.equal('protected' in readout, false, '读数里不得出现 protected 这类未经验证的全局承诺字段')
- assert.equal(readout.supported, true, '读数应含"是否支持"')
- assert.equal(readout.executable, true, '读数应含"是否可执行"')
- assert.equal(Number.isFinite(readout.strength), true, '读数应含"当前强度"')
- assert.equal(Number.isFinite(readout.startDistance) && Number.isFinite(readout.endDistance), true, '读数应含"当前参数"')
- assert.equal(fogReadout({ supported: false }).state, FOG_STATE.UNSUPPORTED, '不支持时应报 unsupported')
- assert.equal(fogReadout({ executable: false }).state, FOG_STATE.FAILED, '不可执行时应报 failed')
- // ready 与 active 是两件事:处理级可执行 ≠ 当前有雾。
- const readyButIdle = fogReadout({ state: idle })
- assert.equal(readyButIdle.state, FOG_STATE.READY, '处理级可执行')
- assert.equal(readyButIdle.active, false, '但全球态下并没有在起作用(两者不可混为一谈)')
- // ---------------------------------------------------------------- 9. 性能预算判定
- assert.equal(fogPerformanceVerdict(110, 100).ok, true, '增幅 10% 应在 15% 预算内')
- assert.equal(fogPerformanceVerdict(115, 100).ok, true, '恰好 15% 应通过(≤15%)')
- assert.equal(fogPerformanceVerdict(116, 100).ok, false, '增幅 16% 应不通过')
- assert.equal(fogPerformanceVerdict(50, 100).ok, true, '更快当然通过(比值可为负)')
- assert.equal(fogPerformanceVerdict(110, 0).ok, false, '没有基线不得判达标')
- assert.equal(fogPerformanceVerdict(110, 0).ratio, null, '无基线时比值应为 null 而不是 Infinity')
- assert.match(fogPerformanceVerdict(110, 0).reason, /基线/, '无基线要如实说明原因')
- assert.equal(FOG_PERF_BUDGET, 0.15, '预算常量应是规格的 15%')
- assert.match(fogPerformanceVerdict(130, 100).reason, /30\.0%/, '超预算的原因应带上实际数值')
- // ---------------------------------------------------------------- 10. 配置层(参数可调、脏值退回默认)
- const defaults = planFog({})
- assert.deepEqual(
- { s: defaults.startDistance, e: defaults.endDistance, c: defaults.color },
- { s: 18000, e: 42000, c: '#bdcdd6' },
- '不配参数时必须是规格固化的一组(18/42 km、#bdcdd6)',
- )
- const overridden = planFog({ fog: { startDistance: 5000, endDistance: 9000, color: '#c8d8e8', fadeOutMs: 400 } })
- assert.equal(overridden.startDistance, 5000, '起雾端点可配')
- assert.equal(overridden.endDistance, 9000, '遮蔽端点可配')
- assert.equal(overridden.color, '#c8d8e8', '雾色可配(决策 08 要求最终色相在真实画面复核)')
- assert.equal(overridden.fadeOutMs, 400, '淡出时长可配(决策 08 要求"比原型稍快",故不写死)')
- // 脏值退回默认,而不是让相机/曲线崩掉。
- assert.equal(planFog({ fog: { startDistance: -5 } }).startDistance, 18000, '负端点退回默认')
- assert.equal(planFog({ fog: { endDistance: 100 } }).endDistance, 42000, '**end <= start 时退回默认端点**(否则曲线退化)')
- assert.equal(planFog({ fog: { color: 'red' } }).color, '#bdcdd6', '非法色值退回默认')
- assert.equal(planFog({ fog: { enabled: false } }).enabled, false, '显式关掉应生效')
- // **关键回归:`null`/`''` 是"没配",不是 0。**
- // 内置默认里 `fadeInMs/fadeOutMs` 就是 `null`,而 `Number(null) === 0` —— 一旦 min 允许 0,
- // 它们会静默变成"0 秒渐变",表现是雾突然出现/突然消失。这是本票真机验证时抓到的 bug:
- // 逻辑闸门原先只喂 `{}`(字段是 undefined)所以漏掉了;这里把 null/'' 两种形态都钉住。
- assert.equal(planFog(builtinConfig().map).fadeInMs, FOG_DEFAULTS.fadeInMs, '内置默认(null)必须退回规格默认,不能变成 0')
- assert.equal(planFog(builtinConfig().map).fadeOutMs, FOG_DEFAULTS.fadeOutMs, '内置默认(null)必须退回规格默认,不能变成 0')
- assert.equal(planFog({ fog: { fadeInMs: '' } }).fadeInMs, FOG_DEFAULTS.fadeInMs, '空串也应按"没配"处理')
- assert.equal(planFog({ fog: { fadeOutMs: null } }).fadeOutMs, FOG_DEFAULTS.fadeOutMs, 'null 也应按"没配"处理')
- assert.equal(planFog({ fog: { fadeInMs: 0 } }).fadeInMs, 0, '但**显式写 0** 是合法意图(不要把它当成没配)')
- const cfg = normalizeConfig({ map: { fog: { startDistance: 5000, endDistance: 9000 } } })
- assert.deepEqual(cfg.issues, [], '合法雾配置不该报问题')
- assert.equal(cfg.config.map.fog.startDistance, 5000, '归一化应保留显式端点')
- const badColor = normalizeConfig({ map: { fog: { color: 'blue' } } })
- assert.equal(badColor.issues.length, 1, '非法色值应记一条 issue(否则维护者以为改生效了)')
- const badRange = normalizeConfig({ map: { fog: { startDistance: 9000, endDistance: 5000 } } })
- assert.equal(badRange.issues.length, 1, '端点反了应记一条 issue')
- assert.match(badRange.issues[0], /endDistance/, 'issue 要点明字段')
- assert.deepEqual(normalizeConfig({}).issues, [], '不给 map.fog 是合法部署(用规格默认)')
- assert.equal(builtinConfig().map.fog.enabled, true, '内置默认开启(区内该有雾;关闭是显式选择)')
- // ---------------------------------------------------------------- 11. 静态守卫(对抗式)
- const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
- // ① 雾的曲线只在 domain 算一次;引擎侧只允许有 GLSL 里的等价实现(那是 GPU 必须的),
- // 但**不许再有一份 JS 曲线**(两处各写一份必然漂移,而端点语义正是验收点)。
- const jsFogCurveUsers = [
- 'services/cesiumEngine.js',
- 'services/mapService.js',
- 'CockpitShell.vue',
- 'domain/areaLimits.js',
- ].filter((file) => /1 - Math\.exp\(-3/.test(readModule(file)))
- assert.deepEqual(jsFogCurveUsers, [], `JS 侧不得重写雾衰减曲线(应调 domain/fog.js 的 fogAttenuation),实际:${jsFogCurveUsers.join(', ')}`)
- // ② 引擎侧必须只建一个 stage,且复用(不许每次进出 remove/add)。
- // 票 49 起引擎有了第二个后处理级(场景调色,domain/sceneGrade.js + t49 闸门),
- // 这条守卫因此收窄到**雾自己**的创建函数段:雾那一节里必须恰好一处 add。
- const engineSource = readModule('services/cesiumEngine.js')
- const fogSection = engineSource.slice(
- engineSource.indexOf('export function createFogStage'),
- engineSource.indexOf('export function applyFogUniforms'),
- )
- assert.match(fogSection, /postProcessStages\.add\(/, '引擎应把雾 stage 加进场景')
- assert.equal(
- (fogSection.match(/postProcessStages\.add\(/g) || []).length,
- 1,
- '自建雾只允许有**一处** add(单一 PostProcessStage —— 不引入第二套渲染器/第二个后处理级)',
- )
- assert.match(engineSource, /stage\.enabled/, '要能开关处理级(强度 0 时不跑全屏 pass)')
- // ②b **动态 uniform 必须用函数传**(本票最隐蔽的一个 bug,见下)。
- //
- // Cesium 1.145 源码(`Scene/PostProcessStage.js`):
- // · 第 460 行 每帧取值走 `stage._actualUniforms[name]`;
- // · 第 499 行 `actualUniforms[name] = uniforms[name]` 在 `createUniformMap()` 里**只写一次**;
- // · 第 479 行 `if (defined(stage._uniformMap)) return;` → map 一旦建立就**永久缓存**。
- // 所以「传常量 + 事后改原始对象」在首次 update 之后**再也改不动**,shader 永远拿初值。
- // 症状极具迷惑性:读数、`data-*`、`stage.enabled` 全说"强度 1、已启用",**画面却什么都没有**
- // ——维护者就是这样发现"之前能看到雾、现在看不见了"的。
- // 判据:uniforms 里每个动态字段都必须是 `() => ...` 形式。
- {
- const uniformsBlock = /uniforms:\s*\{([\s\S]*?)\},\s*\n\s*\}\)/.exec(engineSource)
- assert.equal(!!uniformsBlock, true, '应能定位 createFogStage 的 uniforms 块')
- const body = uniformsBlock[1]
- for (const name of ['fogColor', 'effectStrength', 'startDistance', 'endDistance']) {
- const line = new RegExp(`\\b${name}\\s*:\\s*(.+)`).exec(body)
- assert.equal(!!line, true, `uniforms 应含 ${name}`)
- assert.match(
- line[1].trim(),
- /^\(\)\s*=>/,
- `${name} 必须是函数形式(每帧现取);常量形式会被 Cesium 在 createUniformMap 时快照,事后改值不生效`,
- )
- }
- // 反向确认:常量写法(`effectStrength: state.effectStrength`)必须被判红。
- assert.equal(
- /effectStrength\s*:\s*state\.effectStrength/.test(engineSource),
- false,
- '不允许出现常量形式的 effectStrength(那会静默失效)',
- )
- }
- // 还要留一条**可在运行期直接证伪**的诊断口:读 Cesium 真正喂给 shader 的值。
- assert.match(engineSource, /readUniformDiagnostics\(\)/, '引擎要提供"实际送达 shader 的值"诊断(排查这类静默失效)')
- assert.match(
- engineSource,
- /_uniformMap[\s\S]{0,200}effectStrength/,
- '诊断应读 `_uniformMap`(每帧真正取值的路径),而不是 `_uniforms`(原始对象,读它只会自证清白)',
- )
- // ③ 不引入第二渲染器 / 不接管 Cesium 场景(ADR 0004 / 0007 / 决策 06 已废止该备选)。
- for (const file of ['services/cesiumEngine.js', 'services/mapService.js', 'domain/fog.js']) {
- const text = readModule(file)
- assert.equal(/from 'three'|require\('three'\)|WebGLRenderer/.test(text), false, `${file} 不得引入第二套渲染器`)
- }
- // ④ 服务层:门控跟着区域状态、生命周期归服务层、读数写进宿主 dataset。
- const serviceSource = readModule('services/mapService.js')
- // 门控必须挂在**状态转移里**(`setAreaState`),而不是随便哪个地方调一下就算。
- // 用按行切函数体的方式定位,避免串到别处(本仓吃过"正则串到下一个函数"的亏)。
- {
- const lines = serviceSource.split(/\r?\n/)
- const startLine = lines.findIndex((line) => line.includes('export function setAreaState'))
- assert.equal(startLine >= 0, true, '应能定位 setAreaState')
- let endLine = -1
- for (let i = startLine + 1; i < lines.length; i++) {
- if (/^\}/.test(lines[i])) {
- endLine = i
- break
- }
- }
- const body = lines
- .slice(startLine, endLine + 1)
- .join('\n')
- .replace(/\/\*[\s\S]*?\*\//g, '')
- .replace(/(^|[^:])\/\/.*$/gm, '$1')
- assert.match(body, /retargetFogForArea\(areaState\)/, '雾门控必须由 setAreaState 收口(区域状态一变,雾就跟着走)')
- }
- assert.match(serviceSource, /dataset\.mapFog\b/, '宿主要有 data-map-fog 读数')
- assert.match(serviceSource, /dataset\.mapFogStrength/, '宿主要有强度读数')
- assert.match(serviceSource, /dataset\.mapFogDegrade/, '宿主要有降级读数')
- assert.match(serviceSource, /dataset\.mapFogCoversEdges = 'no'/, '必须显式写"未证明遮住边缘"(防止日后被当成遮缝承诺)')
- // **待命不是故障**:真机上"引擎就绪、处理级已建、强度 0(区外待命)"时**不能**报 failed。
- //
- // 语义边界(本票真机验证时理清):`failed` 只能由**确凿证据**(创建抛错)判定,
- // 不能用"有没有 handle""此刻 ready 吗"这类间接推断 ——
- // · 引擎还没起来时没有 handle,那是启动中,不是故障;
- // · 隐藏标签页不渲染时 `ready` 会落回 false,那也不是故障。
- // 第一版拿 `ready` 当判据,于是区外待命被报成 `failed`、标签页一隐藏又翻一次,读数在闪。
- {
- const serviceText = readModule('services/mapService.js')
- assert.match(
- serviceText,
- /function fogExecutableForReadout\(\) \{\s*return fogSupported && !fogCreationFailed/,
- '可执行的判据应是「支持 且 未确凿失败」—— 与"此刻是否 ready"解耦',
- )
- // 判据里不得出现 ready / handle 这类间接信号。
- const helper = /function fogExecutableForReadout\(\)[\s\S]*?\n\}/.exec(serviceText)
- assert.equal(!!helper, true, '应能定位该判据函数体')
- assert.equal(
- /ready|fogHandle/.test(helper[0].replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')),
- false,
- '判据里不得引用 ready 或 handle(它们会把"启动中/未渲染"误报成故障)',
- )
- // 而"曾跑通过"要单独如实报出,供排查区分待命与跑过。
- assert.match(serviceText, /ranOnce: fogRanOnceForReadout\(\)/, '读数要单独给出"曾跑通过"(与 executable 分开)')
- // 失败的唯一来源必须是确凿的创建失败。
- assert.match(serviceText, /fogCreationFailed = true/, '要有一个明确的"确凿失败"标志')
- assert.equal(
- (serviceText.match(/fogCreationFailed = true/g) || []).length,
- 1,
- '确凿失败的置位只应有一处(catch 创建异常)',
- )
- }
- assert.match(serviceSource, /fogHandle\.destroy\(\)/, '离开地图要销毁处理级(复用但不泄漏)')
- assert.match(serviceSource, /stopFogPerfSampleListener/, '性能采样监听要能拆(票 15 的漏定时器教训)')
- // ④b **高度收口必须真的接到"写入强度"上**(维护者 2026-09-29 报障的修复点本身)。
- //
- // 只断言纯函数 `fogAltitudeFactor` 是不够的 —— 那条在域层绿着,而服务层完全可能
- // 没把它乘进去(变异测试证明过:把 `base * ...` 改回 `base` 时,域层断言照样全绿)。
- // 所以这里直接抠服务层的**唯一强度口径**函数体来断言。
- //
- // 而且必须乘**限速后**的系数(`fogAltitudeShown`),不能乘瞬时值 ——
- // 否则返回全球时相机头一帧就越过整条雾带(实算 0.15 ms),雾"一瞬间就散了"
- // (维护者 2026-09-29 第二次反馈)。两条一起钉住。
- {
- const serviceCode = serviceSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- const effective = /function fogEffectiveStrength\(\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
- assert.equal(!!effective, true, '应有唯一的 `fogEffectiveStrength()` 口径(防止各处各乘一遍而漂移)')
- assert.match(
- effective[1],
- /\*\s*fogAltitudeShown/,
- '写入强度必须乘上**限速后**的高度系数(否则退出时整个地球仍会变白,或雾一瞬间就散 —— 报障未修)',
- )
- assert.equal(
- /fogAltitudeFactor\(/.test(effective[1]),
- false,
- '强度口径里不得直接调瞬时 `fogAltitudeFactor()`(它会在返回全球时跳变)',
- )
- // 降级上限也必须在同一处,不能漏。
- assert.match(effective[1], /fogDegrade\.strengthCap/, '降级上限也应在同一口径里')
- // 瞬时高度系数的调用点只应有**两处**,都有明确职责:
- // ① `stepAltitudeSlew()` 里算本帧目标(唯一推进时间轴的地方);
- // ② `setAltitudeSlewImmediate()` 里按新参数立刻对齐。
- // 读数与写入一律读 `fogAltitudeShown`,不许各算一份。
- assert.equal(
- (serviceCode.match(/fogAltitudeFactor\(cameraAltitude\(\)/g) || []).length,
- 2,
- '瞬时高度系数只应在「限速推进」与「参数变更对齐」两处出现(不许散落重算强度)',
- )
- // 限速推进必须真的挂在渲染帧上(否则时间轴永不前进,雾会永远停在某个中间值)。
- //
- // 注意这里要**两条**都断言。第一版只断言了"`stepAltitudeSlew` 内部调了
- // `approachAltitudeFactor`",而变异测试当场证明它没牙:
- // 在 `stepAltitudeSlew` 开头插一句 `if (true) return fogAltitudeShown`(时间轴永不前进)
- // 时,那条断言照样全绿 —— 因为函数体里那一行**还在**,只是永远走不到。
- // 「函数写对了」≠「它真的被调用」,这是本仓第 12 票同一族教训(守卫比它要守的东西宽)。
- {
- const slewer = /function stepAltitudeSlew\(\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
- assert.equal(!!slewer, true, '应有 `stepAltitudeSlew()`(限速推进的唯一落点)')
- assert.match(
- slewer[1],
- /approachAltitudeFactor\(/,
- '限速推进必须调用 `approachAltitudeFactor`(域层限速函数)',
- )
- assert.equal(
- /return\s+fogAltitudeShown/.test(slewer[1].split('approachAltitudeFactor')[0]),
- false,
- '`approachAltitudeFactor` 之前不得提前 return(否则时间轴永不前进)',
- )
- // **关键**:渲染帧必须真的调用它 —— 否则"雾不再一瞬间"这个修复根本没接线。
- const frameHook = /postRender\.addEventListener\(\(\) => \{([\s\S]*?)\n \}\)/.exec(serviceCode)
- assert.equal(!!frameHook, true, '应能定位渲染帧钩子')
- assert.match(
- frameHook[1],
- /stepAltitudeSlew\(\)/,
- '渲染帧必须调用 `stepAltitudeSlew()`(否则限速时间轴永不前进,雾会卡在中间值)',
- )
- // 顺序:先推进限速、再比较重画(反了就会用旧系数判"要不要重画")。
- const advanceAt = frameHook[1].indexOf('stepAltitudeSlew()')
- const compareAt = frameHook[1].indexOf('fogPaintedAltitudeFactor')
- assert.equal(
- advanceAt < compareAt,
- true,
- '渲染帧里必须先推进限速、再比较"要不要重画"(顺序反了读数会用旧系数)',
- )
- }
- }
- // ④c **交接闸门必须看"有效强度"**,而不是状态机强度。
- // 否则相机还在 60 km 高空(高度系数 0、画面毫无雾)时就被判"保护成立"而揭示精细层,
- // 矩形边缘当场露出来 —— 这正是票 39 要防的事。
- {
- const serviceCode = serviceSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- const gateCall = /revealGate\(\{([\s\S]*?)\}\)/.exec(serviceCode)
- assert.equal(!!gateCall, true, '应能定位 revealGate 调用')
- assert.match(
- gateCall[1],
- /fogStrength:\s*fogEffectiveStrength\(\)/,
- '交接闸门必须用「有效强度」(含高度收口)判雾保护是否成立,不能用状态机强度',
- )
- assert.equal(
- /fogStrength:\s*fogLifecycle\.strength/.test(gateCall[1]),
- false,
- '闸门不得直接用状态机强度(高空时会误判保护成立)',
- )
- }
- // ⑤ 外壳只读读数,不自己算**曲线**,也不自己判门控/降级。
- const shellSource = readModule('CockpitShell.vue')
- assert.match(shellSource, /getFogState\(\)/, '外壳应读服务层读数')
- // 顶栏清理(维护者决策:区内限位/视距雾 chip 从工具栏移除)后,雾的对外读数
- // 收敛到地图宿主 dataset(`data-map-fog*`,paintFog 写)——外壳不再放雾 chip。
- assert.equal(/data-fog-chip/.test(shellSource), false, '顶栏雾 chip 已按顶栏清理决策移除,雾读数归宿主 dataset')
- // **属性名不得与宿主读数重名**:`data-map-fog` 已挂在 `.map-canvas`(运行期读数)。
- // 若 chip 也用同名,`querySelector('[data-map-fog]')` 会命中**宿主**(整页文本)而不是芯片,
- // 断言会读到错误的元素 —— 本仓在票 07(`data-map-blocked-backend` 同名两处)栽过一次。
- {
- // **先剥注释再扫**:上面那段说明本身就写着 `data-map-fog`,
- // 不剥的话守卫会被自己的散文判红(本仓 T36/F12 都踩过同一个坑)。
- const shellCode = shellSource.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- const chipAttrs = shellCode.match(/data-map-[a-z-]*fog[a-z-]*/g) || []
- assert.deepEqual(chipAttrs, [], `外壳不得使用 data-map-fog* 命名(会与宿主读数撞名),实际:${chipAttrs.join(', ')}`)
- assert.match(readModule('services/mapService.js'), /dataset\.mapFog\b/, '宿主那边才是 data-map-fog 的归属')
- }
- {
- const stripped = shellSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- // 曲线只在 domain/引擎 GLSL 里;外壳出现这两样就是重写了一份数学。
- assert.equal(/fogAttenuation|1\s*-\s*Math\.exp\(/.test(stripped), false, '外壳不得自己算雾的衰减曲线')
- // 门控/降级判定也不许在外壳里重做(`planFogDegrade` 只在 domain,服务层消费)。
- assert.equal(/planFogDegrade|shouldFogBeActive/.test(stripped), false, '外壳不得自己判门控/降级(策略归服务层)')
- // 允许的只是**展示**(把米换算成公里给人数),所以到此为止不再扩大范围。
- assert.equal(/Math\.round\(fog\.startDistance/.test(stripped), true, '外壳可以展示端点(这不算重算策略)')
- }
- console.log(
- `T38 检查通过:衰减端点精确(0 处恰好 0、${FOG_DEFAULTS.endDistance} m 处恰好 1,且单调、单纯指数衰减对照被排除)/ 门控(只有 locked 生效,主场景区不可用即关)/ 进出推进与落定 / 过期取消(连续进出只认最新目标)/ 进入高度带只出强度标量 / 降级(正常不预关闭、默认不碰限位)/ 色相(冷白灰不发绿,基准色通过)/ 「就绪≠已遮住」结构性保证 / 性能预算 ≤15% / 配置参数可调且脏值退回默认 / 静态守卫(单一 stage、无第二渲染器、曲线只算一次)。`,
- )
|