/** * 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)。 const engineSource = readModule('services/cesiumEngine.js') assert.match(engineSource, /postProcessStages\.add\(/, '引擎应把雾 stage 加进场景') assert.equal( (engineSource.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\(\)/, '外壳应读服务层读数') assert.match(shellSource, /data-fog-chip/, '外壳要有雾读数 chip(人工与走查都能读)') // **属性名不得与宿主读数重名**:`data-map-fog` 已挂在 `.map-canvas`(运行期读数)。 // 若 chip 也用同名,`querySelector('[data-map-fog]')` 会命中**宿主**(整页文本)而不是芯片, // 断言会读到错误的元素 —— 本仓在票 07(`data-map-blocked-backend` 同名两处)栽过一次。 { // **先剥注释再扫**:上面那段说明本身就写着 `data-map-fog`, // 不剥的话守卫会被自己的散文判红(本仓 T36/F12 都踩过同一个坑)。 const shellCode = shellSource.replace(//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、无第二渲染器、曲线只算一次)。`, )