t38-fog.mjs 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  1. /**
  2. * T38 区内视距雾气 纯逻辑检查(票 38)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t38-fog.mjs
  6. *
  7. * 覆盖验收第 3、5、6、7、8、9 条里能脱浏览器断言的部分:衰减端点(**两端语义精确**)、
  8. * 门控(只有 locked 生效)、进出强度推进与**过期取消**、降级选择、色相判据、
  9. * 性能预算判定,以及「就绪 ≠ 已遮住」这条口径的结构性保证。
  10. * 用假引擎驱动真实的 `mapService` 路径,不需要浏览器、不需要 cesium。
  11. */
  12. import assert from 'node:assert/strict'
  13. import { readFileSync } from 'node:fs'
  14. import {
  15. FOG_DEFAULTS,
  16. FOG_DEGRADE,
  17. FOG_PHASE,
  18. FOG_PERF_BUDGET,
  19. FOG_STATE,
  20. advanceFogStrength,
  21. approachAltitudeFactor,
  22. beginFogTransition,
  23. createFogLifecycle,
  24. enterAltitudeStrength,
  25. fogAltitudeFactor,
  26. fogAttenuation,
  27. fogColorVerdict,
  28. fogPerformanceVerdict,
  29. fogReadout,
  30. isStaleFogToken,
  31. parseHexColor,
  32. planFog,
  33. planFogDegrade,
  34. shouldFogBeActive,
  35. shouldRenderFog,
  36. stepFogLifecycle,
  37. } from '../../../src/modules/cockpit/domain/fog.js'
  38. import { planPrimaryArea } from '../../../src/modules/cockpit/domain/primaryArea.js'
  39. import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
  40. const BOUNDS = [110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907]
  41. // ---------------------------------------------------------------- 1. 衰减端点(两端语义精确)
  42. assert.equal(fogAttenuation(0), 0, '0 距离必须**恰好**全清')
  43. assert.equal(fogAttenuation(1000), 0, '起雾端点之前必须恰好 0(不是"很小")')
  44. assert.equal(fogAttenuation(FOG_DEFAULTS.startDistance), 0, `恰好 ${FOG_DEFAULTS.startDistance} m 处仍应为 0`)
  45. assert.equal(fogAttenuation(FOG_DEFAULTS.endDistance), 1, `${FOG_DEFAULTS.endDistance} m 处必须**恰好**全遮蔽`)
  46. assert.equal(fogAttenuation(FOG_DEFAULTS.endDistance + 1), 1, '超过遮蔽端点仍是 1(不外插)')
  47. assert.equal(fogAttenuation(1e9), 1, '极远处仍是 1(有界)')
  48. // 中间**严格单调增浓**(不能出现回落或平台 → 否则会有"雾墙"或"远看反而更清")。
  49. let previous = 0
  50. for (let d = FOG_DEFAULTS.startDistance; d <= FOG_DEFAULTS.endDistance; d += 250) {
  51. const alpha = fogAttenuation(d)
  52. assert.equal(alpha >= previous - 1e-12, true, `衰减应单调不减,在 ${d} m 处掉了(${alpha} < ${previous})`)
  53. previous = alpha
  54. }
  55. // 中点应已相当浓("中间平滑增浓"而不是最后 1 km 突然全遮)。
  56. assert.equal(fogAttenuation(30000) > 0.5, true, `30 km(端点之间)应已明显起雾,实际 ${fogAttenuation(30000).toFixed(3)}`)
  57. // **关键回归:单纯指数衰减不算达标** —— 它在有限距离到不了 1。
  58. const exponentialOnly = (d) => 1 - Math.exp(-3 * ((d - 18000) / 24000))
  59. assert.equal(exponentialOnly(42000) < 1, true, '对照:单纯指数衰减在 42 km 处到不了 1(所以不能用它)')
  60. assert.equal(Math.abs(exponentialOnly(42000) - 1) > 1e-6, true, '对照差值是真实存在的(证明本条的判别力)')
  61. // **端点内侧**才是曲线的判别区:端点本身被 `>= end` 的早退兜住了(那是**端点语义**的保证),
  62. // 所以只断"恰好 42 km == 1"抓不住"曲线没归一化"这种错 —— 变异测试证明过这一点。
  63. // 这里补一条紧贴端点内侧的断言:归一化曲线应已收敛到 ~1,而单纯指数只到 0.95。
  64. assert.equal(
  65. fogAttenuation(41999) > 0.999,
  66. true,
  67. `端点内侧(41999 m)应已收敛到 ~1,实际 ${fogAttenuation(41999).toFixed(6)}(若曲线不归一化,这里只有 ≈0.95)`,
  68. )
  69. assert.equal(
  70. exponentialOnly(41999) < 0.96,
  71. true,
  72. '对照:同类位置上单纯指数衰减明显不到 1(这正是"不算达标"的可测差别)',
  73. )
  74. // 脏端点不得让曲线崩掉(start >= end 时退化为"以 end 为界的阶跃",而不是 NaN)。
  75. // 注意语义:反了的端点下,"距离 >= end" 就全遮蔽 —— 因为 end 是唯一的有效判据。
  76. assert.equal(fogAttenuation(50, { startDistance: 900, endDistance: 100 }), 0, '端点反了:小于 end 应回 0')
  77. assert.equal(fogAttenuation(200, { startDistance: 900, endDistance: 100 }), 1, '端点反了:大于等于 end 应回 1')
  78. assert.equal(Number.isFinite(fogAttenuation(1000, { startDistance: NaN, endDistance: 5 })), true, '脏端点不应产生 NaN')
  79. // ---------------------------------------------------------------- 2. 门控(只在 locked 生效)
  80. const areaPlan = planPrimaryArea({ primaryArea: { bounds: BOUNDS } })
  81. assert.equal(shouldFogBeActive('locked', areaPlan), true, '区内应生效')
  82. assert.equal(shouldFogBeActive('global', areaPlan), false, '纯全球态应关闭')
  83. assert.equal(shouldFogBeActive('locked', planPrimaryArea({})), false, '主场景区不可用时(未配置)也必须关闭 —— 沿票 36 的关闭路径')
  84. assert.equal(
  85. shouldFogBeActive('locked', planPrimaryArea({ primaryArea: { bounds: [110.4, 28.49, 110.3, 28.39] } })),
  86. false,
  87. '主场景区配置非法时同样关闭',
  88. )
  89. // 只有 global 阶段不渲染;有强度且非 global 才渲染。
  90. const idle = createFogLifecycle()
  91. assert.equal(shouldRenderFog(idle), false, 'global 阶段不渲染')
  92. assert.equal(shouldRenderFog({ ...idle, phase: FOG_PHASE.LOCKED, strength: 1 }), true, 'locked 且有强度应渲染')
  93. assert.equal(
  94. shouldRenderFog({ ...idle, phase: FOG_PHASE.LOCKED, strength: 1 }, { supported: false }),
  95. false,
  96. '不支持深度时不渲染(整级禁用)',
  97. )
  98. // ---------------------------------------------------------------- 3. 进出强度推进
  99. const zero = createFogLifecycle()
  100. const entering = beginFogTransition(zero, 'enter', 1000)
  101. assert.equal(entering.phase, FOG_PHASE.ENTERING, '进入应到 entering')
  102. assert.equal(entering.token > zero.token, true, '每次进出必须换 token(过期判定的依据)')
  103. // 渐显:时间中点应落在端点之间,结束时恰好 1。
  104. const midEnter = advanceFogStrength(FOG_PHASE.ENTERING, FOG_DEFAULTS.fadeInMs / 2, 0)
  105. assert.equal(midEnter.strength > 0 && midEnter.strength < 1, true, `渐显中点应在 (0,1),实际 ${midEnter.strength}`)
  106. assert.equal(advanceFogStrength(FOG_PHASE.ENTERING, FOG_DEFAULTS.fadeInMs, 0).strength, 1, '渐显结束恰好 1')
  107. assert.equal(advanceFogStrength(FOG_PHASE.ENTERING, FOG_DEFAULTS.fadeInMs * 10, 0).strength, 1, '超时也不得越过 1')
  108. // 淡出:结束恰好 0,且**从当前强度**开始(不是从 1 跳)。
  109. const exiting = beginFogTransition({ ...idle, phase: FOG_PHASE.LOCKED, strength: 0.4 }, 'exit', 2000)
  110. assert.equal(exiting.phase, FOG_PHASE.EXITING, '返回应到 exiting')
  111. assert.equal(exiting.from, 0.4, '淡出应从当前强度起算(避免"跳一下再淡")')
  112. assert.equal(advanceFogStrength(FOG_PHASE.EXITING, FOG_DEFAULTS.fadeOutMs / 2, 0.4).strength < 0.4, true, '淡出中途应已变小')
  113. assert.equal(advanceFogStrength(FOG_PHASE.EXITING, FOG_DEFAULTS.fadeOutMs, 0.4).strength, 0, '淡出结束恰好 0')
  114. assert.equal(advanceFogStrength(FOG_PHASE.EXITING, FOG_DEFAULTS.fadeOutMs * 10, 0.4).strength, 0, '超时也不得低于 0')
  115. // 阶段推进:到端点即落定(不会永远停在中间)。
  116. assert.equal(stepFogLifecycle({ ...entering, since: 0 }, FOG_DEFAULTS.fadeInMs + 10).phase, FOG_PHASE.LOCKED, '渐显到点应落 locked')
  117. assert.equal(stepFogLifecycle({ ...exiting, since: 0 }, FOG_DEFAULTS.fadeOutMs + 10).phase, FOG_PHASE.GLOBAL, '淡出到点应落 global')
  118. // 锁定态恒 1、全球态恒 0(幂等)。
  119. assert.equal(stepFogLifecycle({ ...idle, phase: FOG_PHASE.LOCKED }, 99999).strength, 1, 'locked 恒 1')
  120. assert.equal(stepFogLifecycle(idle, 99999).strength, 0, 'global 恒 0')
  121. // 幂等:已在 locked 再点"进入"不该把雾推倒重来(否则每次归位都闪一下)。
  122. const lockedState = { phase: FOG_PHASE.LOCKED, strength: 1, token: 7, since: 0, from: 1 }
  123. assert.deepEqual(beginFogTransition(lockedState, 'enter', 5000), lockedState, '重复进入应幂等')
  124. const globalState = createFogLifecycle()
  125. assert.deepEqual(beginFogTransition(globalState, 'exit', 5000), globalState, '区外重复返回应幂等')
  126. // ---------------------------------------------------------------- 4. 过期取消(连续进出只认最新目标)
  127. {
  128. // 场景:进区渐显途中立刻返回,再立刻进区 —— 最后一次必须赢。
  129. let state = createFogLifecycle()
  130. state = beginFogTransition(state, 'enter', 0) // token 1
  131. const staleToken = state.token
  132. state = beginFogTransition(state, 'exit', 100) // token 2(返回覆盖进入)
  133. state = beginFogTransition(state, 'enter', 200) // token 3(再进覆盖返回)
  134. assert.equal(state.token, 3, '连续进出应递增 token')
  135. assert.equal(isStaleFogToken(state, staleToken), true, '旧 token 必须被判过期(过期淡出不得关掉新的进入)')
  136. assert.equal(isStaleFogToken(state, state.token), false, '当前 token 不算过期')
  137. assert.equal(state.phase, FOG_PHASE.ENTERING, '最终状态必须是"正在进入",而不是被旧的返回关掉')
  138. // 推进到落定:必须是 locked,说明旧的 exit 没有把它拉回 global。
  139. const settled = stepFogLifecycle({ ...state, since: 0 }, FOG_DEFAULTS.fadeInMs + 10)
  140. assert.equal(settled.phase, FOG_PHASE.LOCKED, '连续进出后落定必须是 locked(最新目标生效)')
  141. assert.equal(settled.strength, 1, '落定强度应为 1')
  142. }
  143. // ---------------------------------------------------------------- 5. 进入高度带(只作强度输入)
  144. assert.equal(enterAltitudeStrength(80000), 0, '高于 60 km 时高度带贡献为 0')
  145. assert.equal(enterAltitudeStrength(FOG_DEFAULTS.enterAltitudeFrom), 0, '恰好 60 km 处为 0')
  146. assert.equal(enterAltitudeStrength(FOG_DEFAULTS.enterAltitudeTo), 1, '降到 20 km 处为 1')
  147. assert.equal(enterAltitudeStrength(0), 1, '低于 20 km 仍为 1(有界)')
  148. const altMid = enterAltitudeStrength(40000)
  149. assert.equal(altMid > 0 && altMid < 1, true, '40 km 处应在中间')
  150. // **它不是"可揭示精细影像"的充分条件**(决策 07 第 4 条):这条在结构上就无法从本函数得出,
  151. // 因为函数只返回一个 0..1 的数、不带任何"可交接"语义。用返回类型守住这一点。
  152. const altKeys = Object.keys({ v: enterAltitudeStrength(30000) })
  153. assert.deepEqual(altKeys, ['v'], '高度带只返回强度标量,**不含**任何"可交接/已遮缝"布尔(票 39 不得据此放行影像)')
  154. // ---------------------------------------------------------------- 5b. 相机高度收口(维护者 2026-09-29 报障后新增)
  155. //
  156. // 报障:① 进入最后一段画面变绿;② 退出时雾散太慢、整个地球变白一段时间。
  157. // 同一个根因:雾不透明度按「相机到片元的世界射线距离」算(18/42 km),
  158. // 而相机在高空时**画面上每个片元**都超出 42 km ⇒ 整屏被涂成雾色。
  159. // 修复:把强度再乘一个**高度系数**,相机高过全遮蔽端点时雾收到 0。
  160. assert.equal(fogAltitudeFactor(0), 1, '0 m(贴地)应为满强度')
  161. assert.equal(fogAltitudeFactor(FOG_DEFAULTS.altitudeFullM), 1, '恰好 altitudeFullM 处仍为满强度')
  162. assert.equal(fogAltitudeFactor(9950), 1, '**区内归位高度(约 9.95 km)必须恰为 1** —— 已验收的区内观感不得被这次修复改变')
  163. assert.equal(fogAltitudeFactor(FOG_DEFAULTS.altitudeZeroM), 0, '恰好 altitudeZeroM(= 全遮蔽端点 42 km)处应收到 0')
  164. assert.equal(fogAltitudeFactor(60000), 0, '60 km 高空应收到 0(否则退出时整屏发白)')
  165. assert.equal(fogAltitudeFactor(20000000), 0, '全景高度(20,000 km)应收到 0')
  166. {
  167. // 中间必须**单调**收口,不能有平台或回升(否则高度变化时会出现"雾突然回来"的跳变)。
  168. let prev = 1
  169. for (let alt = FOG_DEFAULTS.altitudeFullM; alt <= FOG_DEFAULTS.altitudeZeroM; alt += 500) {
  170. const value = fogAltitudeFactor(alt)
  171. assert.equal(value <= prev + 1e-9, true, `高度收口应单调不增(${alt} m 处 ${value} > 前值 ${prev})`)
  172. assert.equal(value >= 0 && value <= 1, true, `高度系数必须落在 [0,1](${alt} m 处 ${value})`)
  173. prev = value
  174. }
  175. const mid = fogAltitudeFactor((FOG_DEFAULTS.altitudeFullM + FOG_DEFAULTS.altitudeZeroM) / 2)
  176. assert.equal(mid > 0 && mid < 1, true, '两端中间应在开区间内')
  177. }
  178. // 配置可调。
  179. assert.equal(fogAltitudeFactor(9000, planFog({ fog: { altitudeFullM: 8000, altitudeZeroM: 10000 } })) > 0, true, '高度带可配')
  180. // 写反(zero <= full)→ 退化为恒 1(回到旧行为),**不得静默把雾关掉**(那是更难查的失败模式)。
  181. assert.equal(
  182. fogAltitudeFactor(60000, planFog({ fog: { altitudeFullM: 50000, altitudeZeroM: 10000 } })),
  183. 1,
  184. '高度带写反时应退化为恒 1,不得静默关雾',
  185. )
  186. assert.equal(fogAltitudeFactor(NaN), 1, '拿不到高度时按满强度(保守:宁可多雾,不要无故没雾)')
  187. assert.equal(fogAltitudeFactor(undefined), 1, 'undefined 高度同样按满强度')
  188. // ---------------------------------------------------------------- 5c. 高度系数的时间限速
  189. //
  190. // 维护者 2026-09-29 第二次反馈:「雾几乎一瞬间就产生或消失了」。根因是**量级**:
  191. // 返回全球时 range 从 12 km 变到 20,000 km(跨 1.7e6 倍),而雾带只有 20–42 km
  192. // —— 相机头一帧就越过整条带。所以瞬时高度系数必然跳变,必须限速。
  193. {
  194. // 单帧最多走 dt/slew;一帧 16.7ms、slew 400ms → 最多 0.04175。
  195. const oneFrame = approachAltitudeFactor(1, 0, 16.7, FOG_DEFAULTS)
  196. const maxStep = 16.7 / FOG_DEFAULTS.altitudeSlewMs
  197. assert.equal(Math.abs(1 - oneFrame - maxStep) < 1e-9, true, `单帧步长应恰为 dt/slew(实得 ${1 - oneFrame})`)
  198. // **核心不变量**:无论目标怎么跳,走完全程至少要 slew 毫秒 —— 不允许"一瞬间"。
  199. assert.equal(
  200. approachAltitudeFactor(1, 0, 1, FOG_DEFAULTS) > 0.99,
  201. true,
  202. '1 ms 内几乎不动(这正是"不再一瞬间散掉"的可断言含义)',
  203. )
  204. // 累计走完需要 ≥ slew 毫秒(逐帧 16.7ms 累加)。
  205. let shown = 1
  206. let elapsed = 0
  207. for (let i = 0; i < 200 && shown > 0.001; i++) {
  208. shown = approachAltitudeFactor(shown, 0, 16.7, FOG_DEFAULTS)
  209. elapsed += 16.7
  210. }
  211. assert.equal(
  212. elapsed >= FOG_DEFAULTS.altitudeSlewMs - 16.7,
  213. true,
  214. `从 1 走到 0 至少需要 ${FOG_DEFAULTS.altitudeSlewMs} ms(实得 ${elapsed.toFixed(0)} ms)`,
  215. )
  216. // 已经到端点就不动(不越界、不抖动)。
  217. assert.equal(approachAltitudeFactor(0, 0, 16.7, FOG_DEFAULTS), 0, '在 0 处保持 0')
  218. assert.equal(approachAltitudeFactor(1, 1, 16.7, FOG_DEFAULTS), 1, '在 1 处保持 1')
  219. // 单帧 dt 上限:切标签页回来时不许一步走完(否则又变成"一瞬间")。
  220. assert.equal(
  221. approachAltitudeFactor(1, 0, 999999, FOG_DEFAULTS),
  222. 1 - 120 / FOG_DEFAULTS.altitudeSlewMs,
  223. '超大 dt 应被夹到 120 ms(切标签页回来不许一步走完)',
  224. )
  225. // 显式 slew=0 = 不限速(合法意图,与其它参数的"显式 0"一致)。
  226. assert.equal(approachAltitudeFactor(1, 0, 16.7, { ...FOG_DEFAULTS, altitudeSlewMs: 0 }), 0, 'slew=0 应立刻跟到目标')
  227. // 结果必须始终落在 [0,1]。
  228. let v = 0.5
  229. for (const target of [1, 0, 1, 0, 0.7, 0]) v = approachAltitudeFactor(v, target, 16.7, FOG_DEFAULTS)
  230. assert.equal(v >= 0 && v <= 1, true, '限速结果必须始终在 [0,1]')
  231. // 可配,且脏值退回默认。
  232. assert.equal(planFog({ fog: { altitudeSlewMs: 900 } }).altitudeSlewMs, 900, '限速时长可配')
  233. assert.equal(planFog({ fog: { altitudeSlewMs: null } }).altitudeSlewMs, FOG_DEFAULTS.altitudeSlewMs, 'null 应按"没配"处理')
  234. assert.equal(planFog({ fog: { altitudeSlewMs: -1 } }).altitudeSlewMs, FOG_DEFAULTS.altitudeSlewMs, '负数应退回默认')
  235. assert.equal(planFog({ fog: { altitudeSlewMs: 0 } }).altitudeSlewMs, 0, '显式 0 是合法意图(不限速)')
  236. }
  237. // ---------------------------------------------------------------- 6. 降级选择
  238. assert.equal(planFogDegrade({ viewMode: 'ground' }).level, FOG_DEGRADE.NONE, '地面视图正常:不预先关闭')
  239. assert.equal(planFogDegrade({ viewMode: 'pipeline' }).level, FOG_DEGRADE.NONE, '**管线视图也不预先关闭**(正常情况不预关闭)')
  240. assert.equal(planFogDegrade({ viewMode: 'ground', supported: false }).level, FOG_DEGRADE.OFF, '无深度支持 → 整级关闭')
  241. assert.equal(planFogDegrade({ viewMode: 'pipeline', translucentArtifacts: true }).level, FOG_DEGRADE.REDUCED, '观察到透明错位 → 减弱')
  242. assert.equal(planFogDegrade({ viewMode: 'pipeline', translucentArtifacts: true }).strengthCap < 1, true, '减弱应真的压低强度上限')
  243. assert.equal(planFogDegrade({ viewMode: 'ground', userDisabled: true }).level, FOG_DEGRADE.OFF, '用户暂关 → 关闭')
  244. // 降级必须带原因(界面要如实提示)。
  245. for (const opts of [{ supported: false }, { userDisabled: true }, { viewMode: 'pipeline', translucentArtifacts: true }]) {
  246. assert.equal(planFogDegrade(opts).reason.length > 0, true, `降级应带原因:${JSON.stringify(opts)}`)
  247. }
  248. // 降级不改相机限位(那是票 37 的域)——本函数签名里根本没有限位参数。
  249. assert.deepEqual(
  250. Object.keys(planFogDegrade({ viewMode: 'pipeline', translucentArtifacts: true })).sort(),
  251. ['level', 'reason', 'strengthCap'],
  252. '降级计划只含雾自己的三个字段(构造性保证不碰相机限位)',
  253. )
  254. // ---------------------------------------------------------------- 7. 色相判据(冷白灰、不发绿)
  255. assert.equal(fogColorVerdict('#bdcdd6').ok, true, '规格基准色 #bdcdd6 应通过(它是被维护者认可过的起点)')
  256. assert.equal(fogColorVerdict('#c8d8e8').ok, true, '冷白偏蓝应通过')
  257. assert.equal(fogColorVerdict('#cccccc').ok, true, '纯浅灰应通过')
  258. assert.equal(fogColorVerdict('#a8d8b0').ok, false, '明显发绿应判不通过')
  259. assert.equal(fogColorVerdict('#35e39a').ok, false, '饱和绿应判不通过')
  260. assert.equal(fogColorVerdict('#04121f').ok, false, '深色底(不是白灰)应判不通过')
  261. assert.equal(fogColorVerdict('blue').ok, false, '非 #rrggbb 应判不通过')
  262. assert.match(fogColorVerdict('#a8d8b0').reasons.join(';'), /发绿/, '发绿的原因要写明(人能据此调)')
  263. // 判据用 HSV 饱和度而不是 HSL:浅色的 HSL 饱和度会误伤已认可的基准色。
  264. assert.equal(fogColorVerdict('#bdcdd6').saturation < 0.2, true, '基准色的 HSV 饱和度应低(这才区分得开浅灰与彩色)')
  265. // ---------------------------------------------------------------- 8. 「就绪 ≠ 已遮住边缘」
  266. const readout = fogReadout({ state: { ...idle, phase: FOG_PHASE.LOCKED, strength: 1 } })
  267. assert.equal(readout.state, FOG_STATE.READY, '可执行时应报 ready')
  268. assert.equal(readout.coversEdges, false, '**必须**显式声明"未证明遮住边缘"(不得给全局 protected 承诺)')
  269. assert.equal('protected' in readout, false, '读数里不得出现 protected 这类未经验证的全局承诺字段')
  270. assert.equal(readout.supported, true, '读数应含"是否支持"')
  271. assert.equal(readout.executable, true, '读数应含"是否可执行"')
  272. assert.equal(Number.isFinite(readout.strength), true, '读数应含"当前强度"')
  273. assert.equal(Number.isFinite(readout.startDistance) && Number.isFinite(readout.endDistance), true, '读数应含"当前参数"')
  274. assert.equal(fogReadout({ supported: false }).state, FOG_STATE.UNSUPPORTED, '不支持时应报 unsupported')
  275. assert.equal(fogReadout({ executable: false }).state, FOG_STATE.FAILED, '不可执行时应报 failed')
  276. // ready 与 active 是两件事:处理级可执行 ≠ 当前有雾。
  277. const readyButIdle = fogReadout({ state: idle })
  278. assert.equal(readyButIdle.state, FOG_STATE.READY, '处理级可执行')
  279. assert.equal(readyButIdle.active, false, '但全球态下并没有在起作用(两者不可混为一谈)')
  280. // ---------------------------------------------------------------- 9. 性能预算判定
  281. assert.equal(fogPerformanceVerdict(110, 100).ok, true, '增幅 10% 应在 15% 预算内')
  282. assert.equal(fogPerformanceVerdict(115, 100).ok, true, '恰好 15% 应通过(≤15%)')
  283. assert.equal(fogPerformanceVerdict(116, 100).ok, false, '增幅 16% 应不通过')
  284. assert.equal(fogPerformanceVerdict(50, 100).ok, true, '更快当然通过(比值可为负)')
  285. assert.equal(fogPerformanceVerdict(110, 0).ok, false, '没有基线不得判达标')
  286. assert.equal(fogPerformanceVerdict(110, 0).ratio, null, '无基线时比值应为 null 而不是 Infinity')
  287. assert.match(fogPerformanceVerdict(110, 0).reason, /基线/, '无基线要如实说明原因')
  288. assert.equal(FOG_PERF_BUDGET, 0.15, '预算常量应是规格的 15%')
  289. assert.match(fogPerformanceVerdict(130, 100).reason, /30\.0%/, '超预算的原因应带上实际数值')
  290. // ---------------------------------------------------------------- 10. 配置层(参数可调、脏值退回默认)
  291. const defaults = planFog({})
  292. assert.deepEqual(
  293. { s: defaults.startDistance, e: defaults.endDistance, c: defaults.color },
  294. { s: 18000, e: 42000, c: '#bdcdd6' },
  295. '不配参数时必须是规格固化的一组(18/42 km、#bdcdd6)',
  296. )
  297. const overridden = planFog({ fog: { startDistance: 5000, endDistance: 9000, color: '#c8d8e8', fadeOutMs: 400 } })
  298. assert.equal(overridden.startDistance, 5000, '起雾端点可配')
  299. assert.equal(overridden.endDistance, 9000, '遮蔽端点可配')
  300. assert.equal(overridden.color, '#c8d8e8', '雾色可配(决策 08 要求最终色相在真实画面复核)')
  301. assert.equal(overridden.fadeOutMs, 400, '淡出时长可配(决策 08 要求"比原型稍快",故不写死)')
  302. // 脏值退回默认,而不是让相机/曲线崩掉。
  303. assert.equal(planFog({ fog: { startDistance: -5 } }).startDistance, 18000, '负端点退回默认')
  304. assert.equal(planFog({ fog: { endDistance: 100 } }).endDistance, 42000, '**end <= start 时退回默认端点**(否则曲线退化)')
  305. assert.equal(planFog({ fog: { color: 'red' } }).color, '#bdcdd6', '非法色值退回默认')
  306. assert.equal(planFog({ fog: { enabled: false } }).enabled, false, '显式关掉应生效')
  307. // **关键回归:`null`/`''` 是"没配",不是 0。**
  308. // 内置默认里 `fadeInMs/fadeOutMs` 就是 `null`,而 `Number(null) === 0` —— 一旦 min 允许 0,
  309. // 它们会静默变成"0 秒渐变",表现是雾突然出现/突然消失。这是本票真机验证时抓到的 bug:
  310. // 逻辑闸门原先只喂 `{}`(字段是 undefined)所以漏掉了;这里把 null/'' 两种形态都钉住。
  311. assert.equal(planFog(builtinConfig().map).fadeInMs, FOG_DEFAULTS.fadeInMs, '内置默认(null)必须退回规格默认,不能变成 0')
  312. assert.equal(planFog(builtinConfig().map).fadeOutMs, FOG_DEFAULTS.fadeOutMs, '内置默认(null)必须退回规格默认,不能变成 0')
  313. assert.equal(planFog({ fog: { fadeInMs: '' } }).fadeInMs, FOG_DEFAULTS.fadeInMs, '空串也应按"没配"处理')
  314. assert.equal(planFog({ fog: { fadeOutMs: null } }).fadeOutMs, FOG_DEFAULTS.fadeOutMs, 'null 也应按"没配"处理')
  315. assert.equal(planFog({ fog: { fadeInMs: 0 } }).fadeInMs, 0, '但**显式写 0** 是合法意图(不要把它当成没配)')
  316. const cfg = normalizeConfig({ map: { fog: { startDistance: 5000, endDistance: 9000 } } })
  317. assert.deepEqual(cfg.issues, [], '合法雾配置不该报问题')
  318. assert.equal(cfg.config.map.fog.startDistance, 5000, '归一化应保留显式端点')
  319. const badColor = normalizeConfig({ map: { fog: { color: 'blue' } } })
  320. assert.equal(badColor.issues.length, 1, '非法色值应记一条 issue(否则维护者以为改生效了)')
  321. const badRange = normalizeConfig({ map: { fog: { startDistance: 9000, endDistance: 5000 } } })
  322. assert.equal(badRange.issues.length, 1, '端点反了应记一条 issue')
  323. assert.match(badRange.issues[0], /endDistance/, 'issue 要点明字段')
  324. assert.deepEqual(normalizeConfig({}).issues, [], '不给 map.fog 是合法部署(用规格默认)')
  325. assert.equal(builtinConfig().map.fog.enabled, true, '内置默认开启(区内该有雾;关闭是显式选择)')
  326. // ---------------------------------------------------------------- 11. 静态守卫(对抗式)
  327. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  328. const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  329. // ① 雾的曲线只在 domain 算一次;引擎侧只允许有 GLSL 里的等价实现(那是 GPU 必须的),
  330. // 但**不许再有一份 JS 曲线**(两处各写一份必然漂移,而端点语义正是验收点)。
  331. const jsFogCurveUsers = [
  332. 'services/cesiumEngine.js',
  333. 'services/mapService.js',
  334. 'CockpitShell.vue',
  335. 'domain/areaLimits.js',
  336. ].filter((file) => /1 - Math\.exp\(-3/.test(readModule(file)))
  337. assert.deepEqual(jsFogCurveUsers, [], `JS 侧不得重写雾衰减曲线(应调 domain/fog.js 的 fogAttenuation),实际:${jsFogCurveUsers.join(', ')}`)
  338. // ② 引擎侧必须只建一个 stage,且复用(不许每次进出 remove/add)。
  339. // 票 49 起引擎有了第二个后处理级(场景调色,domain/sceneGrade.js + t49 闸门),
  340. // 这条守卫因此收窄到**雾自己**的创建函数段:雾那一节里必须恰好一处 add。
  341. const engineSource = readModule('services/cesiumEngine.js')
  342. const fogSection = engineSource.slice(
  343. engineSource.indexOf('export function createFogStage'),
  344. engineSource.indexOf('export function applyFogUniforms'),
  345. )
  346. assert.match(fogSection, /postProcessStages\.add\(/, '引擎应把雾 stage 加进场景')
  347. assert.equal(
  348. (fogSection.match(/postProcessStages\.add\(/g) || []).length,
  349. 1,
  350. '自建雾只允许有**一处** add(单一 PostProcessStage —— 不引入第二套渲染器/第二个后处理级)',
  351. )
  352. assert.match(engineSource, /stage\.enabled/, '要能开关处理级(强度 0 时不跑全屏 pass)')
  353. // ②b **动态 uniform 必须用函数传**(本票最隐蔽的一个 bug,见下)。
  354. //
  355. // Cesium 1.145 源码(`Scene/PostProcessStage.js`):
  356. // · 第 460 行 每帧取值走 `stage._actualUniforms[name]`;
  357. // · 第 499 行 `actualUniforms[name] = uniforms[name]` 在 `createUniformMap()` 里**只写一次**;
  358. // · 第 479 行 `if (defined(stage._uniformMap)) return;` → map 一旦建立就**永久缓存**。
  359. // 所以「传常量 + 事后改原始对象」在首次 update 之后**再也改不动**,shader 永远拿初值。
  360. // 症状极具迷惑性:读数、`data-*`、`stage.enabled` 全说"强度 1、已启用",**画面却什么都没有**
  361. // ——维护者就是这样发现"之前能看到雾、现在看不见了"的。
  362. // 判据:uniforms 里每个动态字段都必须是 `() => ...` 形式。
  363. {
  364. const uniformsBlock = /uniforms:\s*\{([\s\S]*?)\},\s*\n\s*\}\)/.exec(engineSource)
  365. assert.equal(!!uniformsBlock, true, '应能定位 createFogStage 的 uniforms 块')
  366. const body = uniformsBlock[1]
  367. for (const name of ['fogColor', 'effectStrength', 'startDistance', 'endDistance']) {
  368. const line = new RegExp(`\\b${name}\\s*:\\s*(.+)`).exec(body)
  369. assert.equal(!!line, true, `uniforms 应含 ${name}`)
  370. assert.match(
  371. line[1].trim(),
  372. /^\(\)\s*=>/,
  373. `${name} 必须是函数形式(每帧现取);常量形式会被 Cesium 在 createUniformMap 时快照,事后改值不生效`,
  374. )
  375. }
  376. // 反向确认:常量写法(`effectStrength: state.effectStrength`)必须被判红。
  377. assert.equal(
  378. /effectStrength\s*:\s*state\.effectStrength/.test(engineSource),
  379. false,
  380. '不允许出现常量形式的 effectStrength(那会静默失效)',
  381. )
  382. }
  383. // 还要留一条**可在运行期直接证伪**的诊断口:读 Cesium 真正喂给 shader 的值。
  384. assert.match(engineSource, /readUniformDiagnostics\(\)/, '引擎要提供"实际送达 shader 的值"诊断(排查这类静默失效)')
  385. assert.match(
  386. engineSource,
  387. /_uniformMap[\s\S]{0,200}effectStrength/,
  388. '诊断应读 `_uniformMap`(每帧真正取值的路径),而不是 `_uniforms`(原始对象,读它只会自证清白)',
  389. )
  390. // ③ 不引入第二渲染器 / 不接管 Cesium 场景(ADR 0004 / 0007 / 决策 06 已废止该备选)。
  391. for (const file of ['services/cesiumEngine.js', 'services/mapService.js', 'domain/fog.js']) {
  392. const text = readModule(file)
  393. assert.equal(/from 'three'|require\('three'\)|WebGLRenderer/.test(text), false, `${file} 不得引入第二套渲染器`)
  394. }
  395. // ④ 服务层:门控跟着区域状态、生命周期归服务层、读数写进宿主 dataset。
  396. const serviceSource = readModule('services/mapService.js')
  397. // 门控必须挂在**状态转移里**(`setAreaState`),而不是随便哪个地方调一下就算。
  398. // 用按行切函数体的方式定位,避免串到别处(本仓吃过"正则串到下一个函数"的亏)。
  399. {
  400. const lines = serviceSource.split(/\r?\n/)
  401. const startLine = lines.findIndex((line) => line.includes('export function setAreaState'))
  402. assert.equal(startLine >= 0, true, '应能定位 setAreaState')
  403. let endLine = -1
  404. for (let i = startLine + 1; i < lines.length; i++) {
  405. if (/^\}/.test(lines[i])) {
  406. endLine = i
  407. break
  408. }
  409. }
  410. const body = lines
  411. .slice(startLine, endLine + 1)
  412. .join('\n')
  413. .replace(/\/\*[\s\S]*?\*\//g, '')
  414. .replace(/(^|[^:])\/\/.*$/gm, '$1')
  415. assert.match(body, /retargetFogForArea\(areaState\)/, '雾门控必须由 setAreaState 收口(区域状态一变,雾就跟着走)')
  416. }
  417. assert.match(serviceSource, /dataset\.mapFog\b/, '宿主要有 data-map-fog 读数')
  418. assert.match(serviceSource, /dataset\.mapFogStrength/, '宿主要有强度读数')
  419. assert.match(serviceSource, /dataset\.mapFogDegrade/, '宿主要有降级读数')
  420. assert.match(serviceSource, /dataset\.mapFogCoversEdges = 'no'/, '必须显式写"未证明遮住边缘"(防止日后被当成遮缝承诺)')
  421. // **待命不是故障**:真机上"引擎就绪、处理级已建、强度 0(区外待命)"时**不能**报 failed。
  422. //
  423. // 语义边界(本票真机验证时理清):`failed` 只能由**确凿证据**(创建抛错)判定,
  424. // 不能用"有没有 handle""此刻 ready 吗"这类间接推断 ——
  425. // · 引擎还没起来时没有 handle,那是启动中,不是故障;
  426. // · 隐藏标签页不渲染时 `ready` 会落回 false,那也不是故障。
  427. // 第一版拿 `ready` 当判据,于是区外待命被报成 `failed`、标签页一隐藏又翻一次,读数在闪。
  428. {
  429. const serviceText = readModule('services/mapService.js')
  430. assert.match(
  431. serviceText,
  432. /function fogExecutableForReadout\(\) \{\s*return fogSupported && !fogCreationFailed/,
  433. '可执行的判据应是「支持 且 未确凿失败」—— 与"此刻是否 ready"解耦',
  434. )
  435. // 判据里不得出现 ready / handle 这类间接信号。
  436. const helper = /function fogExecutableForReadout\(\)[\s\S]*?\n\}/.exec(serviceText)
  437. assert.equal(!!helper, true, '应能定位该判据函数体')
  438. assert.equal(
  439. /ready|fogHandle/.test(helper[0].replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')),
  440. false,
  441. '判据里不得引用 ready 或 handle(它们会把"启动中/未渲染"误报成故障)',
  442. )
  443. // 而"曾跑通过"要单独如实报出,供排查区分待命与跑过。
  444. assert.match(serviceText, /ranOnce: fogRanOnceForReadout\(\)/, '读数要单独给出"曾跑通过"(与 executable 分开)')
  445. // 失败的唯一来源必须是确凿的创建失败。
  446. assert.match(serviceText, /fogCreationFailed = true/, '要有一个明确的"确凿失败"标志')
  447. assert.equal(
  448. (serviceText.match(/fogCreationFailed = true/g) || []).length,
  449. 1,
  450. '确凿失败的置位只应有一处(catch 创建异常)',
  451. )
  452. }
  453. assert.match(serviceSource, /fogHandle\.destroy\(\)/, '离开地图要销毁处理级(复用但不泄漏)')
  454. assert.match(serviceSource, /stopFogPerfSampleListener/, '性能采样监听要能拆(票 15 的漏定时器教训)')
  455. // ④b **高度收口必须真的接到"写入强度"上**(维护者 2026-09-29 报障的修复点本身)。
  456. //
  457. // 只断言纯函数 `fogAltitudeFactor` 是不够的 —— 那条在域层绿着,而服务层完全可能
  458. // 没把它乘进去(变异测试证明过:把 `base * ...` 改回 `base` 时,域层断言照样全绿)。
  459. // 所以这里直接抠服务层的**唯一强度口径**函数体来断言。
  460. //
  461. // 而且必须乘**限速后**的系数(`fogAltitudeShown`),不能乘瞬时值 ——
  462. // 否则返回全球时相机头一帧就越过整条雾带(实算 0.15 ms),雾"一瞬间就散了"
  463. // (维护者 2026-09-29 第二次反馈)。两条一起钉住。
  464. {
  465. const serviceCode = serviceSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  466. const effective = /function fogEffectiveStrength\(\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
  467. assert.equal(!!effective, true, '应有唯一的 `fogEffectiveStrength()` 口径(防止各处各乘一遍而漂移)')
  468. assert.match(
  469. effective[1],
  470. /\*\s*fogAltitudeShown/,
  471. '写入强度必须乘上**限速后**的高度系数(否则退出时整个地球仍会变白,或雾一瞬间就散 —— 报障未修)',
  472. )
  473. assert.equal(
  474. /fogAltitudeFactor\(/.test(effective[1]),
  475. false,
  476. '强度口径里不得直接调瞬时 `fogAltitudeFactor()`(它会在返回全球时跳变)',
  477. )
  478. // 降级上限也必须在同一处,不能漏。
  479. assert.match(effective[1], /fogDegrade\.strengthCap/, '降级上限也应在同一口径里')
  480. // 瞬时高度系数的调用点只应有**两处**,都有明确职责:
  481. // ① `stepAltitudeSlew()` 里算本帧目标(唯一推进时间轴的地方);
  482. // ② `setAltitudeSlewImmediate()` 里按新参数立刻对齐。
  483. // 读数与写入一律读 `fogAltitudeShown`,不许各算一份。
  484. assert.equal(
  485. (serviceCode.match(/fogAltitudeFactor\(cameraAltitude\(\)/g) || []).length,
  486. 2,
  487. '瞬时高度系数只应在「限速推进」与「参数变更对齐」两处出现(不许散落重算强度)',
  488. )
  489. // 限速推进必须真的挂在渲染帧上(否则时间轴永不前进,雾会永远停在某个中间值)。
  490. //
  491. // 注意这里要**两条**都断言。第一版只断言了"`stepAltitudeSlew` 内部调了
  492. // `approachAltitudeFactor`",而变异测试当场证明它没牙:
  493. // 在 `stepAltitudeSlew` 开头插一句 `if (true) return fogAltitudeShown`(时间轴永不前进)
  494. // 时,那条断言照样全绿 —— 因为函数体里那一行**还在**,只是永远走不到。
  495. // 「函数写对了」≠「它真的被调用」,这是本仓第 12 票同一族教训(守卫比它要守的东西宽)。
  496. {
  497. const slewer = /function stepAltitudeSlew\(\)\s*\{([\s\S]*?)\n\}/.exec(serviceCode)
  498. assert.equal(!!slewer, true, '应有 `stepAltitudeSlew()`(限速推进的唯一落点)')
  499. assert.match(
  500. slewer[1],
  501. /approachAltitudeFactor\(/,
  502. '限速推进必须调用 `approachAltitudeFactor`(域层限速函数)',
  503. )
  504. assert.equal(
  505. /return\s+fogAltitudeShown/.test(slewer[1].split('approachAltitudeFactor')[0]),
  506. false,
  507. '`approachAltitudeFactor` 之前不得提前 return(否则时间轴永不前进)',
  508. )
  509. // **关键**:渲染帧必须真的调用它 —— 否则"雾不再一瞬间"这个修复根本没接线。
  510. const frameHook = /postRender\.addEventListener\(\(\) => \{([\s\S]*?)\n \}\)/.exec(serviceCode)
  511. assert.equal(!!frameHook, true, '应能定位渲染帧钩子')
  512. assert.match(
  513. frameHook[1],
  514. /stepAltitudeSlew\(\)/,
  515. '渲染帧必须调用 `stepAltitudeSlew()`(否则限速时间轴永不前进,雾会卡在中间值)',
  516. )
  517. // 顺序:先推进限速、再比较重画(反了就会用旧系数判"要不要重画")。
  518. const advanceAt = frameHook[1].indexOf('stepAltitudeSlew()')
  519. const compareAt = frameHook[1].indexOf('fogPaintedAltitudeFactor')
  520. assert.equal(
  521. advanceAt < compareAt,
  522. true,
  523. '渲染帧里必须先推进限速、再比较"要不要重画"(顺序反了读数会用旧系数)',
  524. )
  525. }
  526. }
  527. // ④c **交接闸门必须看"有效强度"**,而不是状态机强度。
  528. // 否则相机还在 60 km 高空(高度系数 0、画面毫无雾)时就被判"保护成立"而揭示精细层,
  529. // 矩形边缘当场露出来 —— 这正是票 39 要防的事。
  530. {
  531. const serviceCode = serviceSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  532. const gateCall = /revealGate\(\{([\s\S]*?)\}\)/.exec(serviceCode)
  533. assert.equal(!!gateCall, true, '应能定位 revealGate 调用')
  534. assert.match(
  535. gateCall[1],
  536. /fogStrength:\s*fogEffectiveStrength\(\)/,
  537. '交接闸门必须用「有效强度」(含高度收口)判雾保护是否成立,不能用状态机强度',
  538. )
  539. assert.equal(
  540. /fogStrength:\s*fogLifecycle\.strength/.test(gateCall[1]),
  541. false,
  542. '闸门不得直接用状态机强度(高空时会误判保护成立)',
  543. )
  544. }
  545. // ⑤ 外壳只读读数,不自己算**曲线**,也不自己判门控/降级。
  546. const shellSource = readModule('CockpitShell.vue')
  547. assert.match(shellSource, /getFogState\(\)/, '外壳应读服务层读数')
  548. // 顶栏清理(维护者决策:区内限位/视距雾 chip 从工具栏移除)后,雾的对外读数
  549. // 收敛到地图宿主 dataset(`data-map-fog*`,paintFog 写)——外壳不再放雾 chip。
  550. assert.equal(/data-fog-chip/.test(shellSource), false, '顶栏雾 chip 已按顶栏清理决策移除,雾读数归宿主 dataset')
  551. // **属性名不得与宿主读数重名**:`data-map-fog` 已挂在 `.map-canvas`(运行期读数)。
  552. // 若 chip 也用同名,`querySelector('[data-map-fog]')` 会命中**宿主**(整页文本)而不是芯片,
  553. // 断言会读到错误的元素 —— 本仓在票 07(`data-map-blocked-backend` 同名两处)栽过一次。
  554. {
  555. // **先剥注释再扫**:上面那段说明本身就写着 `data-map-fog`,
  556. // 不剥的话守卫会被自己的散文判红(本仓 T36/F12 都踩过同一个坑)。
  557. const shellCode = shellSource.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  558. const chipAttrs = shellCode.match(/data-map-[a-z-]*fog[a-z-]*/g) || []
  559. assert.deepEqual(chipAttrs, [], `外壳不得使用 data-map-fog* 命名(会与宿主读数撞名),实际:${chipAttrs.join(', ')}`)
  560. assert.match(readModule('services/mapService.js'), /dataset\.mapFog\b/, '宿主那边才是 data-map-fog 的归属')
  561. }
  562. {
  563. const stripped = shellSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  564. // 曲线只在 domain/引擎 GLSL 里;外壳出现这两样就是重写了一份数学。
  565. assert.equal(/fogAttenuation|1\s*-\s*Math\.exp\(/.test(stripped), false, '外壳不得自己算雾的衰减曲线')
  566. // 门控/降级判定也不许在外壳里重做(`planFogDegrade` 只在 domain,服务层消费)。
  567. assert.equal(/planFogDegrade|shouldFogBeActive/.test(stripped), false, '外壳不得自己判门控/降级(策略归服务层)')
  568. // 允许的只是**展示**(把米换算成公里给人数),所以到此为止不再扩大范围。
  569. assert.equal(/Math\.round\(fog\.startDistance/.test(stripped), true, '外壳可以展示端点(这不算重算策略)')
  570. }
  571. console.log(
  572. `T38 检查通过:衰减端点精确(0 处恰好 0、${FOG_DEFAULTS.endDistance} m 处恰好 1,且单调、单纯指数衰减对照被排除)/ 门控(只有 locked 生效,主场景区不可用即关)/ 进出推进与落定 / 过期取消(连续进出只认最新目标)/ 进入高度带只出强度标量 / 降级(正常不预关闭、默认不碰限位)/ 色相(冷白灰不发绿,基准色通过)/ 「就绪≠已遮住」结构性保证 / 性能预算 ≤15% / 配置参数可调且脏值退回默认 / 静态守卫(单一 stage、无第二渲染器、曲线只算一次)。`,
  573. )