t06-assets.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. /**
  2. * T06 检查:驾驶舱自有静态资产、基础 token、占位/图标/状态标签,以及资源引用走查记录。
  3. *
  4. * 仓库没有测试运行器,项目约定不为此引入新框架(同 T01),故用零依赖 Node 断言脚本。
  5. *
  6. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t06-assets.mjs
  7. */
  8. import assert from 'node:assert/strict'
  9. import { readFileSync, readdirSync, statSync } from 'node:fs'
  10. import { dirname, join, relative } from 'node:path'
  11. import { fileURLToPath } from 'node:url'
  12. import { LEVEL_TAGS, TONES, isKnownTone } from '../../../src/modules/cockpit/domain/statusTones.js'
  13. import { ICON_NAMES, iconFor, isKnownIcon } from '../../../src/modules/cockpit/domain/icons.js'
  14. import { MODES, PANEL_LABELS, panelsForMode } from '../../../src/modules/cockpit/domain/panelCatalog.js'
  15. const here = dirname(fileURLToPath(import.meta.url))
  16. const root = join(here, '..', '..', '..')
  17. const cockpitDir = join(root, 'src', 'modules', 'cockpit')
  18. const publicCockpit = join(root, 'public', 'cockpit')
  19. const problems = []
  20. function check(name, fn) {
  21. try {
  22. fn()
  23. console.log(` PASS ${name}`)
  24. } catch (error) {
  25. problems.push(`${name}: ${error.message}`)
  26. console.log(` FAIL ${name}\n ${error.message}`)
  27. }
  28. }
  29. function read(path) {
  30. return readFileSync(path, 'utf8')
  31. }
  32. /** 递归收集目录下所有文件(相对仓库根的 POSIX 路径)。 */
  33. function walk(dir) {
  34. return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
  35. const full = join(dir, entry.name)
  36. if (entry.isDirectory()) return walk(full)
  37. return [relative(root, full).split('\\').join('/')]
  38. })
  39. }
  40. const cockpitSources = walk(cockpitDir).concat(walk(join(root, 'src', 'pages', 'cockpit')))
  41. const sourceText = new Map(cockpitSources.map((p) => [p, read(join(root, p))]))
  42. console.log('\n[A] 资产目录自持')
  43. check('A1 public/cockpit 存在且含临时底图与说明', () => {
  44. const files = readdirSync(publicCockpit)
  45. assert.ok(files.includes('aerial.webp'), '缺少 aerial.webp')
  46. assert.ok(files.includes('README.md'), '缺少 README.md(资产用途与替换约定)')
  47. const size = statSync(join(publicCockpit, 'aerial.webp')).size
  48. assert.ok(size > 1024, `aerial.webp 只有 ${size} 字节,疑似空文件`)
  49. })
  50. check('A2 不依赖插件静态资源目录', () => {
  51. const patterns = [/public\/plugins/, /\/plugins\/[\w-]+/, /\bPLS\b/, /FES_APP_PLSNAME/]
  52. const hits = []
  53. for (const [file, text] of sourceText) {
  54. patterns.forEach((re) => {
  55. if (re.test(text)) hits.push(`${file} 命中 ${re}`)
  56. })
  57. }
  58. assert.deepEqual(hits, [], `发现插件目录依赖:\n${hits.join('\n')}`)
  59. })
  60. console.log('\n[B] 基础视觉 token')
  61. const tokensPath = 'src/modules/cockpit/styles/tokens.css'
  62. const tokens = sourceText.get(tokensPath)
  63. check('B1 tokens.css 存在并集中声明主题令牌', () => {
  64. assert.ok(tokens, `${tokensPath} 不存在`)
  65. assert.ok(/\.cockpit-shell\s*\{/.test(tokens), 'token 应声明在 .cockpit-shell 作用域下')
  66. assert.ok(!/:root\s*\{/.test(tokens), '不得挂 :root,否则通用变量名会漏进宿主与插件样式')
  67. })
  68. check('B2 颜色/字体/字号/间距/层级五类 token 齐全', () => {
  69. const required = [
  70. '--u',
  71. '--bg-0',
  72. '--panel-top',
  73. '--panel-line',
  74. '--cyan',
  75. '--lv1',
  76. '--lv2',
  77. '--lv3',
  78. '--lv4',
  79. '--neutral',
  80. '--text',
  81. '--muted',
  82. '--font',
  83. '--num',
  84. '--fs-caption',
  85. '--fs-body',
  86. '--fs-title',
  87. '--space-1',
  88. '--space-2',
  89. '--space-3',
  90. '--z-map',
  91. '--z-columns',
  92. '--shadow',
  93. ]
  94. const missing = required.filter((name) => !new RegExp(`${name}\\s*:`).test(tokens))
  95. assert.deepEqual(missing, [], `缺少 token: ${missing.join(', ')}`)
  96. })
  97. check('B3 语义色只在 token 文件里声明(不准各处再写一份)', () => {
  98. const offenders = []
  99. for (const [file, text] of sourceText) {
  100. if (file === tokensPath) continue
  101. ;['--lv1', '--lv2', '--lv3', '--lv4', '--neutral', '--cyan'].forEach((name) => {
  102. if (new RegExp(`${name}\\s*:`).test(text)) offenders.push(`${file} 重复声明 ${name}`)
  103. })
  104. }
  105. assert.deepEqual(offenders, [], offenders.join('\n'))
  106. })
  107. check('B4 外壳引入 token 文件', () => {
  108. const shell = sourceText.get('src/modules/cockpit/CockpitShell.vue')
  109. assert.match(shell, /import\s+'\.\/styles\/tokens\.css'/, '外壳未引入 tokens.css')
  110. })
  111. console.log('\n[C] 地图占位 / 面板占位 / 图标 / 状态标签')
  112. check('C1 面板占位组件存在且文本契约未变', () => {
  113. const file = 'src/modules/cockpit/components/PanelPlaceholder.vue'
  114. const text = sourceText.get(file)
  115. assert.ok(text, `${file} 不存在`)
  116. assert.match(text, /面板占位 · \{\{ panelId \}\}/, '占位文本契约变化')
  117. assert.match(
  118. sourceText.get('src/modules/cockpit/CockpitShell.vue'),
  119. /<PanelPlaceholder :panel-id="panelId" \/>/,
  120. '外壳未接入面板占位组件',
  121. )
  122. })
  123. check('C2 地图占位仍在(临时底图 + 占位文案)', () => {
  124. const text = sourceText.get('src/modules/cockpit/components/MapCanvas.vue')
  125. assert.match(text, /中央一张图占位/, '地图占位文案缺失')
  126. assert.match(text, /BASE_URL/, '底图应走 BASE_URL 拼接,兼容相对部署')
  127. assert.ok(!/['"`]\/cockpit\//.test(text), '不得硬编码 /cockpit/ 绝对路径')
  128. })
  129. check('C3 图标集完整且为纯标记(无脚本)', () => {
  130. const required = ['brand', 'layers', 'points', 'event', 'resource', 'blank']
  131. const missing = required.filter((name) => !ICON_NAMES.includes(name))
  132. assert.deepEqual(missing, [], `缺少图标: ${missing.join(', ')}`)
  133. ICON_NAMES.forEach((name) => {
  134. const icon = iconFor(name)
  135. assert.match(icon.viewBox, /^0 0 \d+ \d+$/, `${name} viewBox 非法`)
  136. assert.ok(icon.inner.trim().length > 0, `${name} 内容为空`)
  137. assert.ok(!/<script/i.test(icon.inner), `${name} 含脚本标记`)
  138. })
  139. assert.equal(isKnownIcon('nope'), false, '未知图标应返回 false')
  140. assert.equal(iconFor('nope'), null, '未知图标不应渲染')
  141. })
  142. check('C4 状态标签色键与事件四级一致', () => {
  143. assert.deepEqual(TONES, ['lv1', 'lv2', 'lv3', 'lv4', 'neutral'], '色键集合变化')
  144. assert.equal(LEVEL_TAGS.length, 4, '事件四级应为 4 项')
  145. LEVEL_TAGS.forEach((tag) => assert.equal(isKnownTone(tag.tone), true, `${tag.label} 色键非法`))
  146. assert.equal(isKnownTone('red'), false, '不许用裸颜色名当色键')
  147. assert.match(
  148. sourceText.get('src/modules/cockpit/components/StatusTag.vue'),
  149. /status-tag__label/,
  150. '状态标签必须带文字(ADR 0006 不只靠颜色)',
  151. )
  152. })
  153. check('C5 三个组件都被真实引用(非死代码)', () => {
  154. const used = (component, files) =>
  155. files.filter((f) => new RegExp(`<${component}[\\s/>]`).test(sourceText.get(f)))
  156. assert.deepEqual(
  157. used('CockpitIcon', ['src/modules/cockpit/CockpitShell.vue', 'src/modules/cockpit/components/MapCanvas.vue']).length,
  158. 2,
  159. 'CockpitIcon 未被外壳与地图同时使用',
  160. )
  161. assert.equal(
  162. used('StatusTag', ['src/modules/cockpit/components/MapCanvas.vue']).length,
  163. 1,
  164. 'StatusTag 未被地图图例使用',
  165. )
  166. assert.equal(
  167. used('PanelPlaceholder', ['src/modules/cockpit/CockpitShell.vue']).length,
  168. 1,
  169. 'PanelPlaceholder 未被外壳使用',
  170. )
  171. })
  172. console.log('\n[D] 视觉可替换、不影响结构')
  173. check('D1 模式与面板结构未随视觉变化', () => {
  174. assert.deepEqual(MODES, ['overview', 'infrastructure', 'monitoring', 'warningResponse'])
  175. assert.equal(Object.keys(PANEL_LABELS).length, 16, '面板应为 16 个')
  176. MODES.forEach((mode) => assert.equal(panelsForMode(mode).length, 4, `${mode} 应为 4 块`))
  177. })
  178. check('D2 结构定义里没有视觉信息', () => {
  179. const catalog = sourceText.get('src/modules/cockpit/domain/panelCatalog.js')
  180. assert.ok(!/#[0-9a-fA-F]{3,6}/.test(catalog), 'panelCatalog 不应出现颜色值')
  181. assert.ok(!/icon/i.test(catalog), 'panelCatalog 不应出现图标名')
  182. })
  183. console.log('\n[E] 资源引用走查记录')
  184. const references = []
  185. check('E1 public/cockpit 下每个资源都有引用点', () => {
  186. const assets = readdirSync(publicCockpit).filter((f) => f !== 'README.md')
  187. assert.ok(assets.length > 0, 'public/cockpit 下没有资源')
  188. assets.forEach((asset) => {
  189. const hits = cockpitSources.filter((file) => sourceText.get(file).includes(asset))
  190. references.push({ asset, hits })
  191. assert.ok(hits.length > 0, `${asset} 没有任何源码引用`)
  192. })
  193. })
  194. console.log('\n资源引用清单')
  195. references.forEach(({ asset, hits }) => {
  196. console.log(` public/cockpit/${asset}`)
  197. hits.forEach((hit) => console.log(` ← ${hit}`))
  198. })
  199. if (problems.length > 0) {
  200. console.error(`\nT06 未通过:${problems.length} 项失败\n`)
  201. process.exit(1)
  202. }
  203. console.log('\nT06 全部通过\n')