/** * 票 18(批次 5:装饰/纹理替换)专项断言面。 * * 立场同 batch2-token-check.mjs:零依赖 Node 静态断言,不运行构建/dev/浏览器。 * 覆盖四件可断言事实: * [A] 资源化装饰:manifest 条目存在、文件可达、hash 一致、status=formal、有注册表与消费点 * [B] 无死装饰资源:运行时图标目录不再有未登记的装饰候选文件 * [C] 代码化装饰无散落主题色:四角切角 data-URI 不再内联色值、第二底色走 --bg-1 * [D] 消费方式合法:样式层不出现物理资产路径(经语义 key 注入),装饰不碰结构属性 */ import assert from 'node:assert/strict' import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs' import { createHash } from 'node:crypto' import { fileURLToPath } from 'node:url' import { dirname, join, relative } from 'node:path' const here = dirname(fileURLToPath(import.meta.url)) const root = join(here, '..', '..', '..') const cockpitDir = join(root, 'src', 'modules', 'cockpit') const read = (p) => readFileSync(join(root, p), 'utf8') const problems = [] function check(name, fn) { try { fn() console.log(` PASS ${name}`) } catch (error) { problems.push(`${name}: ${error.message}`) console.log(` FAIL ${name}\n ${error.message}`) } } const manifest = JSON.parse(read('public/cockpit/assets/manifest.json')) const shell = read('src/modules/cockpit/CockpitShell.vue') const panel = read('src/modules/cockpit/components/CockpitPanel.vue') const mapCanvas = read('src/modules/cockpit/components/MapCanvas.vue') const icons = read('src/modules/cockpit/domain/icons.js') const tokens = read('src/modules/cockpit/styles/tokens.css') console.log('\n[A] 资源化装饰(光池)manifest 与消费链') check('A1 icon.brand.pool 条目存在且 formal', () => { const e = manifest.entries.find((x) => x.key === 'icon.brand.pool') assert.ok(e, 'manifest 缺 icon.brand.pool 条目') assert.equal(e.status, 'formal') assert.equal(e.format, 'png') assert.equal(e.renderMode, 'image') assert.equal(e.dimensions.width, 489) assert.equal(e.dimensions.height, 128) }) check('A2 光池文件真实存在且 hash 一致(含 HTML 兜底拒绝)', () => { const e = manifest.entries.find((x) => x.key === 'icon.brand.pool') const abs = join(root, 'public/cockpit', e.path) assert.ok(existsSync(abs), `文件不存在: ${e.path}`) const buf = readFileSync(abs) assert.ok(!(buf[0] === 0x3c && buf.subarray(0, 15).toString().includes('!DOCTYPE')), '取到 HTML 兜底页') assert.equal(`sha256:${createHash('sha256').update(buf).digest('hex')}`, e.hash) }) check('A3 注册表与语义 key 一致(icons.js brandPool -> icon.brand.pool)', () => { assert.match(icons, /brandPool:\s*'icon\.brand\.pool'/) }) check('A4 消费点存在:Shell 经 --brand-pool-url 语义注入,样式无物理路径', () => { assert.match(shell, /iconFor\('brandPool'\)/, 'navDecorStyle 未解析 brandPool') assert.match(shell, /--brand-pool-url'/, '未注入 --brand-pool-url') assert.match(shell, /--brand-pool-url, none\)/, 'CSS 未消费 --brand-pool-url(缺失退化)') assert.doesNotMatch(shell, /assets\/icons\/brand-pool\.png/, '样式层出现物理路径(违反共识 §3)') }) check('A5 光池按设计 frame 布局:489/128 比例、不参与拾取、缺失自动不画', () => { assert.match(shell, /aspect-ratio:\s*489\s*\/\s*128/) assert.match(shell, /pointer-events:\s*none/, '装饰必须 pointer-events:none') }) console.log('\n[B] 无死装饰资源(共识 §1:没有消费点的图不进运行时)') check('B1 装饰候选不在运行时目录(已挪归档或改名)', () => { const iconDir = join(root, 'public/cockpit/assets/icons') const files = readdirSync(iconDir, { recursive: true }).map(String) const gone = [ 'brand-glow-1.png', 'brand-glow-3.png', 'panel-subtitle-decoration.png', 'panel-subtitle-lead.png', 'select-bg.png', 'select-bg-base.png', 'topbar-line.png', 'panel-corner-tl.png', 'panel-corner-tr.png', 'panel-corner-bl.png', 'panel-corner-br.png', ] const left = gone.filter((f) => files.includes(f)) assert.deepEqual(left, [], `装饰候选仍在运行时目录: ${left.join(', ')}`) assert.ok(files.includes('brand-pool.png'), 'brand-pool.png 应在运行时目录(icon.brand.pool 消费中)') }) check('B2 归档留痕:unconsumed-decor/ 含 11 个候选 + LEDGER 索引', () => { const archDir = join(root, '.scratch/lanhu-cockpit-asset-replacement/design-archive-v2/unconsumed-decor') assert.ok(existsSync(archDir), '归档目录不存在') const files = readdirSync(archDir).filter((f) => f.endsWith('.png')) assert.equal(files.length, 11, `归档应为 11 个候选,实际 ${files.length}`) assert.ok(existsSync(join(archDir, 'LEDGER.md')), 'LEDGER.md 索引缺失') }) console.log('\n[C] 代码化装饰无散落主题色(规格 03:色值单一来源)') check('C1 面板四角切角 data-URI 不再内联主题色(%2301abea 清零,几何白母版)', () => { assert.doesNotMatch(panel, /%2301abea/, '四角 data-URI 仍内联 #01abea') assert.match(panel, /%23fff/, '四角 data-URI 应为白色几何母版') assert.match(panel, /background-color:\s*var\(--panel-line-hi/, '四角应经 mask 染 --panel-line-hi') assert.match(panel, /-webkit-mask:[\s\S]*--corner-tl/, '应改用 mask 简写消费四角(mask-image 不收层语法)') }) check('C2 第二底色 --bg-1 已建槽位且有消费点(氛围底 + 地图画布)', () => { assert.match(tokens, /--bg-1:\s*#02091b/, 'tokens.css 缺 --bg-1') assert.match(shell, /var\(--bg-1,\s*#02091b\)/, 'Shell 氛围底未消费 --bg-1') assert.match(mapCanvas, /var\(--bg-1,\s*#02091b\)/, 'MapCanvas 画布底未消费 --bg-1') assert.doesNotMatch(shell, /linear-gradient\(180deg,\s*#010514/, 'Shell 氛围底仍有裸 hex 渐变') assert.doesNotMatch(mapCanvas, /background:\s*#02091b/, 'MapCanvas 仍有裸 hex 画布底') }) console.log('\n[D] 结构保护(差异判断表:装饰不碰结构)') check('D1 装饰改动未触及面板结构属性(panelId/收放/滚动边界选择器仍在)', () => { assert.match(panel, /data-panel-id="panelId"/) assert.match(panel, /is-collapsed/) assert.match(panel, /overflow-y:\s*auto/) assert.match(panel, /overflow-x:\s*hidden/) }) check('D2 批次 2 既有 token 断言面未破坏(语义槽未被装饰批改名)', () => { for (const name of ['--cyan', '--cyan-hi', '--cyan-d', '--bg-0', '--panel-line-hi']) { assert.match(tokens, new RegExp(`${name}:`), `${name} 缺失`) } assert.doesNotMatch(tokens, /--fs-metric/) }) if (problems.length) { console.log(`\n${problems.length} 项失败`) process.exit(1) } console.log('\n批次 5 装饰断言面全部通过。')