t49-scene-grade.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /**
  2. * T49 一张图场景调色(科技蓝滤镜)逻辑闸门 —— 不启动浏览器。
  3. *
  4. * 断言面:
  5. * · domain/sceneGrade.js 的计划口径(配置优先、脏值退默认)与调色数学;
  6. * · 调色的语义红线:阴影偏蓝、色相顺序不换(告警红仍是红);
  7. * · 引擎处理级:uniform 函数形态(票 38 的常量快照坑)、shader 与 domain 同序同式;
  8. * · 服务层装配:建级 / 写 uniform / 离开销毁 / 复跑归零 / 配置重算即生效;
  9. * · 配置模板暴露 map.grade。
  10. */
  11. import assert from 'node:assert/strict'
  12. import { readFileSync } from 'node:fs'
  13. const root = new URL('../../../', import.meta.url)
  14. const read = (path) => readFileSync(new URL(path, root), 'utf8')
  15. const grade = await import('../../../src/modules/cockpit/domain/sceneGrade.js')
  16. const { builtinConfig, normalizeConfig } = await import('../../../src/modules/cockpit/services/cockpitConfig.js')
  17. // ---- A. 计划口径 ----
  18. {
  19. const plan = grade.planSceneGrade({})
  20. assert.deepEqual(
  21. { ...plan },
  22. { ...grade.GRADE_DEFAULTS },
  23. '缺省 = 规格默认',
  24. )
  25. // 脏值一律退默认(null/''/非数/越界同口径)。
  26. const dirty = grade.planSceneGrade({
  27. grade: { saturation: 'abc', brightness: null, contrast: '', tintStrength: 9, tint: '#zzzzzz', enabled: false },
  28. })
  29. assert.equal(dirty.saturation, grade.GRADE_DEFAULTS.saturation, '非数饱和度退默认')
  30. assert.equal(dirty.brightness, grade.GRADE_DEFAULTS.brightness, 'null 亮度退默认')
  31. assert.equal(dirty.contrast, grade.GRADE_DEFAULTS.contrast, '空串对比度退默认')
  32. assert.equal(dirty.tintStrength, grade.GRADE_DEFAULTS.tintStrength, '越界混合比退默认')
  33. assert.equal(dirty.tint, grade.GRADE_DEFAULTS.tint, '非法色值退默认')
  34. assert.equal(dirty.enabled, false, '显式 enabled=false 必须被尊重')
  35. assert.deepEqual(
  36. grade.planSceneGrade({ grade: null }),
  37. { ...grade.GRADE_DEFAULTS },
  38. 'grade=null = 未配置 = 默认',
  39. )
  40. console.log('PASS A1 计划口径:缺省=默认、脏值退默认、enabled=false 被尊重')
  41. }
  42. // ---- B. 调色数学(语义红线)----
  43. {
  44. const plan = grade.planSceneGrade({})
  45. const hex = (c) => `#${[c.r, c.g, c.b].map((x) => Math.round(clamp01(x) * 255).toString(16).padStart(2, '0')).join('')}`
  46. const clamp01 = (x) => Math.min(1, Math.max(0, x))
  47. const identity = grade.planSceneGrade({ grade: { saturation: 1, brightness: 1, contrast: 1, tintStrength: 0 } })
  48. assert.deepEqual(grade.gradeColor({ r: 0.3, g: 0.6, b: 0.9 }, identity), { r: 0.3, g: 0.6, b: 0.9 }, '全中性系数 = 原样')
  49. const gray = grade.gradeColor({ r: 0.5, g: 0.5, b: 0.5 }, plan)
  50. assert.ok(gray.b - gray.r > 0.04 && gray.b > gray.g, `中灰偏蓝(${hex(gray)}):阴影带出科技蓝`)
  51. const red = grade.gradeColor({ r: 1, g: 0, b: 0 }, plan)
  52. assert.ok(red.r > red.g + 0.4 && red.r > red.b + 0.4, `告警红仍是红(${hex(red)}):色相顺序不换(票 14 红线)`)
  53. const green = grade.gradeColor({ r: 0, g: 1, b: 0 }, plan)
  54. assert.ok(green.g > green.r && green.g > green.b, `正常绿仍偏绿(${hex(green)}):降饱和不移相`)
  55. const black = grade.gradeColor({ r: 0, g: 0, b: 0 }, plan)
  56. assert.ok(black.b > black.r && black.b > 0.1, `黑场获得蓝调(${hex(black)}):暗部着色最重`)
  57. const white = grade.gradeColor({ r: 1, g: 1, b: 1 }, plan)
  58. assert.ok(white.r <= 1 && white.g <= 1 && white.b <= 1, '高光不越界')
  59. // 对比度把 1 推到 1.04:shader 在对比后钳制再乘亮度 ⇒ 恰 0.96。
  60. // domain 必须逐位一致(两处实现是"同一份数学"的契约)。
  61. for (const channel of [white.r, white.g, white.b]) {
  62. assert.ok(Math.abs(channel - 0.96) < 1e-9, `高光钳制位与 shader 对齐(实际 ${channel},期望 0.96)`)
  63. }
  64. for (const rgb of [{ r: 2, g: -1, b: 0.5 }, { r: 0, g: 0, b: 0 }]) {
  65. const out = grade.gradeColor(rgb, plan)
  66. for (const channel of Object.values(out)) assert.ok(channel >= 0 && channel <= 1, '输出通道全部钳在 [0,1]')
  67. }
  68. console.log('PASS B1 调色数学:阴影偏蓝、红绿语义保持、高光不动、端点钳制')
  69. }
  70. // ---- C. 引擎处理级 ----
  71. {
  72. const engine = read('src/modules/cockpit/services/cesiumEngine.js')
  73. assert.ok(/export function createSceneGradeStage\(viewer, params\)/.test(engine), '导出 createSceneGradeStage')
  74. assert.ok(/export function applySceneGradeUniforms\(handle,/.test(engine), '导出 applySceneGradeUniforms')
  75. assert.ok(/name: GRADE_STAGE_NAME/.test(engine) && /'cockpit-scene-grade'/.test(engine), '处理级有唯一名字')
  76. // uniform 必须是函数形态 —— 票 38 实测:常量会被构造时快照,事后改 state 永远不生效。
  77. for (const name of ['gradeSaturation', 'gradeBrightness', 'gradeContrast', 'gradeTint', 'gradeTintStrength']) {
  78. assert.ok(new RegExp(`${name}: \\(\\) => state\\.`).test(engine), `uniform ${name} 用函数形态(每帧现取)`)
  79. }
  80. assert.ok(/stage\.enabled = params\.enabled !== false/.test(engine), 'enabled 随计划')
  81. // shader 与 domain 同序同式:降饱和 → 对比度 → 亮度 → 阴影偏蓝。
  82. const shader = engine.slice(engine.indexOf('const GRADE_FRAGMENT_SHADER'))
  83. const order = ['mix(vec3(luma), base.rgb', '(color - 0.5) * gradeContrast', 'color * gradeBrightness', 'mix(color, gradeTint']
  84. const at = order.map((needle) => shader.indexOf(needle))
  85. assert.ok(at.every((x) => x >= 0) && at.every((x, i) => i === 0 || x > at[i - 1]), 'shader 数学与 gradeColor() 同序同式')
  86. assert.ok(shader.includes('0.2126, 0.7152, 0.0722'), '同一套 Rec.709 亮度权重')
  87. assert.ok(shader.includes('out_FragColor'), '用 Cesium 当前的 out_FragColor 出口')
  88. assert.ok(shader.includes('gradeTintStrength * (1.0 - luma)'), '阴影权重公式与 domain 一致')
  89. console.log('PASS C1 引擎处理级:函数 uniform、enabled 随计划、shader 与 domain 同序同式')
  90. }
  91. // ---- D. 服务层装配 ----
  92. {
  93. const service = read('src/modules/cockpit/services/mapService.js')
  94. assert.ok(/import \{ planSceneGrade \} from '\.\.\/domain\/sceneGrade\.js'/.test(service), '服务层只 import 计划函数')
  95. assert.ok(/let gradeParams = planSceneGrade\(\{\}\)/.test(service), '模块态从空配置起算')
  96. assert.ok(/function ensureGradeStage\(\)/.test(service), '幂等建级入口')
  97. assert.ok(/function paintGrade\(\)/.test(service), '统一写 uniform 与 dataset 读数')
  98. // 读数三态:off(配置关)/ on(在跑)/ pending(配置要但没建成)—— 不把"没建成"谎报成 on。
  99. assert.ok(
  100. /instance\.host\.dataset\.mapGrade = !gradeParams\.enabled \? 'off' : gradeHandle \? 'on' : 'pending'/.test(service),
  101. 'data-map-grade 三态读数(on/off/pending)',
  102. )
  103. // 配置把 enabled 翻开而处理级还没建:写参前补建(幂等),否则 off→on 只翻旗子不出画面。
  104. assert.ok(/if \(!gradeHandle && gradeParams\.enabled\) ensureGradeStage\(\)/.test(service), 'off→on 配置翻开时补建级')
  105. assert.ok(/if \(ensureGradeStage\(\)\) \{\s*\n\s*paintGrade\(\)/.test(service), '引擎就绪即建级并写参')
  106. assert.ok(/gradeParams = planSceneGrade\(mapConfig \|\| \{\}\)\s*\n\s*paintGrade\(\)/.test(service), '配置重算立即生效')
  107. assert.ok(/if \(gradeHandle\) \{\s*\n\s*try \{\s*\n\s*gradeHandle\.destroy\(\)/.test(service), '离开地图销毁处理级')
  108. assert.ok(/delete record\.host\.dataset\.mapGrade/.test(service), '卸载收走读数')
  109. assert.ok(/gradeHandle = null\s*\n\s*gradeParams = planSceneGrade\(\{\}\)/.test(service), '复跑归零不残留')
  110. // 调色必须在雾之后建级(后处理按加入顺序执行)。
  111. assert.ok(service.indexOf('ensureFogStage())') < service.indexOf('ensureGradeStage())'), '建级顺序在雾之后(雾出的白也被统一调色)')
  112. // 数值读取与 hex 解析复用 fog.js 的一份(本模块不得再抄 private 副本)。
  113. const gradeDomain = read('src/modules/cockpit/domain/sceneGrade.js')
  114. assert.ok(!/function readNumber/.test(gradeDomain), 'sceneGrade 不再自带 readNumber 副本(复用 fog.js 导出)')
  115. assert.ok(/import \{ parseHexColor, readNumber \} from '\.\/fog\.js'/.test(gradeDomain), '口径单一来源:parseHexColor + readNumber 都从 fog.js 复用')
  116. // 引擎兜底色从 domain 默认派生,不再手抄浮点魔数。
  117. const engine = read('src/modules/cockpit/services/cesiumEngine.js')
  118. assert.ok(/import \{ GRADE_DEFAULTS, parseHexColor \} from '\.\.\/domain\/sceneGrade\.js'/.test(engine), '引擎兜底色引 domain 默认')
  119. assert.ok(/params\.tintRgb \|\| parseHexColor\(GRADE_DEFAULTS\.tint\)/.test(engine), '兜底 = GRADE_DEFAULTS.tint(无第三层魔数)')
  120. console.log('PASS D1 服务层装配:建级/写参/销毁/归零/配置生效/顺序在雾后/三态读数/口径复用')
  121. }
  122. // ---- D2. 配置层透传(normalizeConfig 是白名单重建:不加块 map.grade 就是死配置)----
  123. {
  124. assert.deepEqual(builtinConfig().map.grade, {
  125. enabled: true, saturation: null, brightness: null, contrast: null, tint: '', tintStrength: null,
  126. }, '内置默认含 grade 形状(null/\'\' = 交给 domain 默认)')
  127. const tuned = normalizeConfig({ map: { grade: { enabled: false, saturation: 0.6, tint: '#123456', tintStrength: 0.4 } } })
  128. assert.deepEqual(tuned.config.map.grade, {
  129. enabled: false, saturation: 0.6, brightness: null, contrast: null, tint: '#123456', tintStrength: 0.4,
  130. }, 'map.grade 逐字段透传(enabled=false 必须到达 domain)')
  131. const bad = normalizeConfig({ map: { grade: { saturation: 'abc', tint: '#xyz' } } })
  132. assert.equal(bad.config.map.grade.saturation, null, '非数透传为 null(domain 层退默认)')
  133. assert.equal(bad.config.map.grade.tint, '', '非法色值置空并报 issue')
  134. assert.ok(bad.issues.some((line) => line.includes('map.grade.tint')), '色值错误必须进 issues(不静默)')
  135. assert.deepEqual(normalizeConfig({}).config.map.grade, builtinConfig().map.grade, '未配置 = 内置默认形状')
  136. console.log('PASS D2 配置层透传:map.grade 白名单块 + 脏值置空 + 色值报 issue')
  137. }
  138. // ---- E. 配置模板 ----
  139. {
  140. const example = JSON.parse(read('public/cockpit/config.example.json'))
  141. assert.deepEqual(
  142. example.map.grade,
  143. { enabled: true, saturation: 0.82, brightness: 0.96, contrast: 1.08, tint: '#2fa8ff', tintStrength: 0.18 },
  144. '模板暴露 map.grade 且与代码默认一致',
  145. )
  146. assert.ok(example._readme.some((line) => line.includes('map.grade')), '模板 readme 说明调色的语义与红线')
  147. console.log('PASS E1 配置模板:map.grade 与默认一致并有说明')
  148. }
  149. console.log('T49 场景调色逻辑闸门全部通过。')