|
|
@@ -0,0 +1,164 @@
|
|
|
+/**
|
|
|
+ * T49 一张图场景调色(科技蓝滤镜)逻辑闸门 —— 不启动浏览器。
|
|
|
+ *
|
|
|
+ * 断言面:
|
|
|
+ * · domain/sceneGrade.js 的计划口径(配置优先、脏值退默认)与调色数学;
|
|
|
+ * · 调色的语义红线:阴影偏蓝、色相顺序不换(告警红仍是红);
|
|
|
+ * · 引擎处理级:uniform 函数形态(票 38 的常量快照坑)、shader 与 domain 同序同式;
|
|
|
+ * · 服务层装配:建级 / 写 uniform / 离开销毁 / 复跑归零 / 配置重算即生效;
|
|
|
+ * · 配置模板暴露 map.grade。
|
|
|
+ */
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import { readFileSync } from 'node:fs'
|
|
|
+
|
|
|
+const root = new URL('../../../', import.meta.url)
|
|
|
+const read = (path) => readFileSync(new URL(path, root), 'utf8')
|
|
|
+const grade = await import('../../../src/modules/cockpit/domain/sceneGrade.js')
|
|
|
+const { builtinConfig, normalizeConfig } = await import('../../../src/modules/cockpit/services/cockpitConfig.js')
|
|
|
+
|
|
|
+// ---- A. 计划口径 ----
|
|
|
+{
|
|
|
+ const plan = grade.planSceneGrade({})
|
|
|
+ assert.deepEqual(
|
|
|
+ { ...plan },
|
|
|
+ { ...grade.GRADE_DEFAULTS },
|
|
|
+ '缺省 = 规格默认',
|
|
|
+ )
|
|
|
+ // 脏值一律退默认(null/''/非数/越界同口径)。
|
|
|
+ const dirty = grade.planSceneGrade({
|
|
|
+ grade: { saturation: 'abc', brightness: null, contrast: '', tintStrength: 9, tint: '#zzzzzz', enabled: false },
|
|
|
+ })
|
|
|
+ assert.equal(dirty.saturation, grade.GRADE_DEFAULTS.saturation, '非数饱和度退默认')
|
|
|
+ assert.equal(dirty.brightness, grade.GRADE_DEFAULTS.brightness, 'null 亮度退默认')
|
|
|
+ assert.equal(dirty.contrast, grade.GRADE_DEFAULTS.contrast, '空串对比度退默认')
|
|
|
+ assert.equal(dirty.tintStrength, grade.GRADE_DEFAULTS.tintStrength, '越界混合比退默认')
|
|
|
+ assert.equal(dirty.tint, grade.GRADE_DEFAULTS.tint, '非法色值退默认')
|
|
|
+ assert.equal(dirty.enabled, false, '显式 enabled=false 必须被尊重')
|
|
|
+ assert.deepEqual(
|
|
|
+ grade.planSceneGrade({ grade: null }),
|
|
|
+ { ...grade.GRADE_DEFAULTS },
|
|
|
+ 'grade=null = 未配置 = 默认',
|
|
|
+ )
|
|
|
+ console.log('PASS A1 计划口径:缺省=默认、脏值退默认、enabled=false 被尊重')
|
|
|
+}
|
|
|
+
|
|
|
+// ---- B. 调色数学(语义红线)----
|
|
|
+{
|
|
|
+ const plan = grade.planSceneGrade({})
|
|
|
+ const hex = (c) => `#${[c.r, c.g, c.b].map((x) => Math.round(clamp01(x) * 255).toString(16).padStart(2, '0')).join('')}`
|
|
|
+ const clamp01 = (x) => Math.min(1, Math.max(0, x))
|
|
|
+
|
|
|
+ const identity = grade.planSceneGrade({ grade: { saturation: 1, brightness: 1, contrast: 1, tintStrength: 0 } })
|
|
|
+ assert.deepEqual(grade.gradeColor({ r: 0.3, g: 0.6, b: 0.9 }, identity), { r: 0.3, g: 0.6, b: 0.9 }, '全中性系数 = 原样')
|
|
|
+
|
|
|
+ const gray = grade.gradeColor({ r: 0.5, g: 0.5, b: 0.5 }, plan)
|
|
|
+ assert.ok(gray.b - gray.r > 0.04 && gray.b > gray.g, `中灰偏蓝(${hex(gray)}):阴影带出科技蓝`)
|
|
|
+
|
|
|
+ const red = grade.gradeColor({ r: 1, g: 0, b: 0 }, plan)
|
|
|
+ assert.ok(red.r > red.g + 0.4 && red.r > red.b + 0.4, `告警红仍是红(${hex(red)}):色相顺序不换(票 14 红线)`)
|
|
|
+
|
|
|
+ const green = grade.gradeColor({ r: 0, g: 1, b: 0 }, plan)
|
|
|
+ assert.ok(green.g > green.r && green.g > green.b, `正常绿仍偏绿(${hex(green)}):降饱和不移相`)
|
|
|
+
|
|
|
+ const black = grade.gradeColor({ r: 0, g: 0, b: 0 }, plan)
|
|
|
+ assert.ok(black.b > black.r && black.b > 0.1, `黑场获得蓝调(${hex(black)}):暗部着色最重`)
|
|
|
+
|
|
|
+ const white = grade.gradeColor({ r: 1, g: 1, b: 1 }, plan)
|
|
|
+ assert.ok(white.r <= 1 && white.g <= 1 && white.b <= 1, '高光不越界')
|
|
|
+ // 对比度把 1 推到 1.04:shader 在对比后钳制再乘亮度 ⇒ 恰 0.96。
|
|
|
+ // domain 必须逐位一致(两处实现是"同一份数学"的契约)。
|
|
|
+ for (const channel of [white.r, white.g, white.b]) {
|
|
|
+ assert.ok(Math.abs(channel - 0.96) < 1e-9, `高光钳制位与 shader 对齐(实际 ${channel},期望 0.96)`)
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const rgb of [{ r: 2, g: -1, b: 0.5 }, { r: 0, g: 0, b: 0 }]) {
|
|
|
+ const out = grade.gradeColor(rgb, plan)
|
|
|
+ for (const channel of Object.values(out)) assert.ok(channel >= 0 && channel <= 1, '输出通道全部钳在 [0,1]')
|
|
|
+ }
|
|
|
+ console.log('PASS B1 调色数学:阴影偏蓝、红绿语义保持、高光不动、端点钳制')
|
|
|
+}
|
|
|
+
|
|
|
+// ---- C. 引擎处理级 ----
|
|
|
+{
|
|
|
+ const engine = read('src/modules/cockpit/services/cesiumEngine.js')
|
|
|
+ assert.ok(/export function createSceneGradeStage\(viewer, params\)/.test(engine), '导出 createSceneGradeStage')
|
|
|
+ assert.ok(/export function applySceneGradeUniforms\(handle,/.test(engine), '导出 applySceneGradeUniforms')
|
|
|
+ assert.ok(/name: GRADE_STAGE_NAME/.test(engine) && /'cockpit-scene-grade'/.test(engine), '处理级有唯一名字')
|
|
|
+ // uniform 必须是函数形态 —— 票 38 实测:常量会被构造时快照,事后改 state 永远不生效。
|
|
|
+ for (const name of ['gradeSaturation', 'gradeBrightness', 'gradeContrast', 'gradeTint', 'gradeTintStrength']) {
|
|
|
+ assert.ok(new RegExp(`${name}: \\(\\) => state\\.`).test(engine), `uniform ${name} 用函数形态(每帧现取)`)
|
|
|
+ }
|
|
|
+ assert.ok(/stage\.enabled = params\.enabled !== false/.test(engine), 'enabled 随计划')
|
|
|
+ // shader 与 domain 同序同式:降饱和 → 对比度 → 亮度 → 阴影偏蓝。
|
|
|
+ const shader = engine.slice(engine.indexOf('const GRADE_FRAGMENT_SHADER'))
|
|
|
+ const order = ['mix(vec3(luma), base.rgb', '(color - 0.5) * gradeContrast', 'color * gradeBrightness', 'mix(color, gradeTint']
|
|
|
+ const at = order.map((needle) => shader.indexOf(needle))
|
|
|
+ assert.ok(at.every((x) => x >= 0) && at.every((x, i) => i === 0 || x > at[i - 1]), 'shader 数学与 gradeColor() 同序同式')
|
|
|
+ assert.ok(shader.includes('0.2126, 0.7152, 0.0722'), '同一套 Rec.709 亮度权重')
|
|
|
+ assert.ok(shader.includes('out_FragColor'), '用 Cesium 当前的 out_FragColor 出口')
|
|
|
+ assert.ok(shader.includes('gradeTintStrength * (1.0 - luma)'), '阴影权重公式与 domain 一致')
|
|
|
+ console.log('PASS C1 引擎处理级:函数 uniform、enabled 随计划、shader 与 domain 同序同式')
|
|
|
+}
|
|
|
+
|
|
|
+// ---- D. 服务层装配 ----
|
|
|
+{
|
|
|
+ const service = read('src/modules/cockpit/services/mapService.js')
|
|
|
+ assert.ok(/import \{ planSceneGrade \} from '\.\.\/domain\/sceneGrade\.js'/.test(service), '服务层只 import 计划函数')
|
|
|
+ assert.ok(/let gradeParams = planSceneGrade\(\{\}\)/.test(service), '模块态从空配置起算')
|
|
|
+ assert.ok(/function ensureGradeStage\(\)/.test(service), '幂等建级入口')
|
|
|
+ assert.ok(/function paintGrade\(\)/.test(service), '统一写 uniform 与 dataset 读数')
|
|
|
+ // 读数三态:off(配置关)/ on(在跑)/ pending(配置要但没建成)—— 不把"没建成"谎报成 on。
|
|
|
+ assert.ok(
|
|
|
+ /instance\.host\.dataset\.mapGrade = !gradeParams\.enabled \? 'off' : gradeHandle \? 'on' : 'pending'/.test(service),
|
|
|
+ 'data-map-grade 三态读数(on/off/pending)',
|
|
|
+ )
|
|
|
+ // 配置把 enabled 翻开而处理级还没建:写参前补建(幂等),否则 off→on 只翻旗子不出画面。
|
|
|
+ assert.ok(/if \(!gradeHandle && gradeParams\.enabled\) ensureGradeStage\(\)/.test(service), 'off→on 配置翻开时补建级')
|
|
|
+ assert.ok(/if \(ensureGradeStage\(\)\) \{\s*\n\s*paintGrade\(\)/.test(service), '引擎就绪即建级并写参')
|
|
|
+ assert.ok(/gradeParams = planSceneGrade\(mapConfig \|\| \{\}\)\s*\n\s*paintGrade\(\)/.test(service), '配置重算立即生效')
|
|
|
+ assert.ok(/if \(gradeHandle\) \{\s*\n\s*try \{\s*\n\s*gradeHandle\.destroy\(\)/.test(service), '离开地图销毁处理级')
|
|
|
+ assert.ok(/delete record\.host\.dataset\.mapGrade/.test(service), '卸载收走读数')
|
|
|
+ assert.ok(/gradeHandle = null\s*\n\s*gradeParams = planSceneGrade\(\{\}\)/.test(service), '复跑归零不残留')
|
|
|
+ // 调色必须在雾之后建级(后处理按加入顺序执行)。
|
|
|
+ assert.ok(service.indexOf('ensureFogStage())') < service.indexOf('ensureGradeStage())'), '建级顺序在雾之后(雾出的白也被统一调色)')
|
|
|
+ // 数值读取与 hex 解析复用 fog.js 的一份(本模块不得再抄 private 副本)。
|
|
|
+ const gradeDomain = read('src/modules/cockpit/domain/sceneGrade.js')
|
|
|
+ assert.ok(!/function readNumber/.test(gradeDomain), 'sceneGrade 不再自带 readNumber 副本(复用 fog.js 导出)')
|
|
|
+ assert.ok(/import \{ parseHexColor, readNumber \} from '\.\/fog\.js'/.test(gradeDomain), '口径单一来源:parseHexColor + readNumber 都从 fog.js 复用')
|
|
|
+ // 引擎兜底色从 domain 默认派生,不再手抄浮点魔数。
|
|
|
+ const engine = read('src/modules/cockpit/services/cesiumEngine.js')
|
|
|
+ assert.ok(/import \{ GRADE_DEFAULTS, parseHexColor \} from '\.\.\/domain\/sceneGrade\.js'/.test(engine), '引擎兜底色引 domain 默认')
|
|
|
+ assert.ok(/params\.tintRgb \|\| parseHexColor\(GRADE_DEFAULTS\.tint\)/.test(engine), '兜底 = GRADE_DEFAULTS.tint(无第三层魔数)')
|
|
|
+ console.log('PASS D1 服务层装配:建级/写参/销毁/归零/配置生效/顺序在雾后/三态读数/口径复用')
|
|
|
+}
|
|
|
+
|
|
|
+// ---- D2. 配置层透传(normalizeConfig 是白名单重建:不加块 map.grade 就是死配置)----
|
|
|
+{
|
|
|
+ assert.deepEqual(builtinConfig().map.grade, {
|
|
|
+ enabled: true, saturation: null, brightness: null, contrast: null, tint: '', tintStrength: null,
|
|
|
+ }, '内置默认含 grade 形状(null/\'\' = 交给 domain 默认)')
|
|
|
+ const tuned = normalizeConfig({ map: { grade: { enabled: false, saturation: 0.6, tint: '#123456', tintStrength: 0.4 } } })
|
|
|
+ assert.deepEqual(tuned.config.map.grade, {
|
|
|
+ enabled: false, saturation: 0.6, brightness: null, contrast: null, tint: '#123456', tintStrength: 0.4,
|
|
|
+ }, 'map.grade 逐字段透传(enabled=false 必须到达 domain)')
|
|
|
+ const bad = normalizeConfig({ map: { grade: { saturation: 'abc', tint: '#xyz' } } })
|
|
|
+ assert.equal(bad.config.map.grade.saturation, null, '非数透传为 null(domain 层退默认)')
|
|
|
+ assert.equal(bad.config.map.grade.tint, '', '非法色值置空并报 issue')
|
|
|
+ assert.ok(bad.issues.some((line) => line.includes('map.grade.tint')), '色值错误必须进 issues(不静默)')
|
|
|
+ assert.deepEqual(normalizeConfig({}).config.map.grade, builtinConfig().map.grade, '未配置 = 内置默认形状')
|
|
|
+ console.log('PASS D2 配置层透传:map.grade 白名单块 + 脏值置空 + 色值报 issue')
|
|
|
+}
|
|
|
+
|
|
|
+// ---- E. 配置模板 ----
|
|
|
+{
|
|
|
+ const example = JSON.parse(read('public/cockpit/config.example.json'))
|
|
|
+ assert.deepEqual(
|
|
|
+ example.map.grade,
|
|
|
+ { enabled: true, saturation: 0.82, brightness: 0.96, contrast: 1.08, tint: '#2fa8ff', tintStrength: 0.18 },
|
|
|
+ '模板暴露 map.grade 且与代码默认一致',
|
|
|
+ )
|
|
|
+ assert.ok(example._readme.some((line) => line.includes('map.grade')), '模板 readme 说明调色的语义与红线')
|
|
|
+ console.log('PASS E1 配置模板:map.grade 与默认一致并有说明')
|
|
|
+}
|
|
|
+
|
|
|
+console.log('T49 场景调色逻辑闸门全部通过。')
|