t06-assets.mjs 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  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. // 票 17:外壳按 panelId 分派 —— 四块走真实面板,未注册的 panelId 仍走占位。
  118. // 票 20 起 16 块面板已全部接上真实视图,所以占位组件现在是**未注册 panelId 的兜底**;
  119. // 这里断言的是「占位组件仍带着 panelId 绑定被真实使用」,
  120. // 而不是钉死它前面不许出现任何指令(`v-else` 不改变这条契约)。
  121. assert.match(
  122. sourceText.get('src/modules/cockpit/CockpitShell.vue'),
  123. /<PanelPlaceholder[^>]*:panel-id="panelId"/,
  124. '外壳未接入面板占位组件',
  125. )
  126. })
  127. check('C2 地图占位仍在(临时底图 + 占位文案)', () => {
  128. const text = sourceText.get('src/modules/cockpit/components/MapCanvas.vue')
  129. assert.match(text, /中央一张图占位/, '地图占位文案缺失')
  130. assert.match(text, /BASE_URL/, '底图应走 BASE_URL 拼接,兼容相对部署')
  131. assert.ok(!/['"`]\/cockpit\//.test(text), '不得硬编码 /cockpit/ 绝对路径')
  132. })
  133. check('C3 图标集完整且为纯标记(无脚本)', () => {
  134. const required = ['brand', 'layers', 'points', 'event', 'resource', 'blank']
  135. const missing = required.filter((name) => !ICON_NAMES.includes(name))
  136. assert.deepEqual(missing, [], `缺少图标: ${missing.join(', ')}`)
  137. ICON_NAMES.forEach((name) => {
  138. const icon = iconFor(name)
  139. assert.match(icon.viewBox, /^0 0 \d+ \d+$/, `${name} viewBox 非法`)
  140. assert.ok(icon.inner.trim().length > 0, `${name} 内容为空`)
  141. assert.ok(!/<script/i.test(icon.inner), `${name} 含脚本标记`)
  142. })
  143. assert.equal(isKnownIcon('nope'), false, '未知图标应返回 false')
  144. assert.equal(iconFor('nope'), null, '未知图标不应渲染')
  145. })
  146. check('C4 状态标签色键与事件四级一致', () => {
  147. assert.deepEqual(TONES, ['lv1', 'lv2', 'lv3', 'lv4', 'neutral', 'point-offline', 'point-empty', 'point-unknown', 'point-unassociated'], '色键集合变化')
  148. assert.deepEqual(LEVEL_TAGS.map((tag) => tag.tone), ['lv1', 'lv2', 'lv3', 'lv4'], '事件四级不使用点位状态扩展色')
  149. assert.equal(LEVEL_TAGS.length, 4, '事件四级应为 4 项')
  150. LEVEL_TAGS.forEach((tag) => assert.equal(isKnownTone(tag.tone), true, `${tag.label} 色键非法`))
  151. assert.equal(isKnownTone('red'), false, '不许用裸颜色名当色键')
  152. assert.match(
  153. sourceText.get('src/modules/cockpit/components/StatusTag.vue'),
  154. /status-tag__label/,
  155. '状态标签必须带文字(ADR 0006 不只靠颜色)',
  156. )
  157. })
  158. check('C5 三个组件都被真实引用(非死代码)', () => {
  159. const used = (component, files) =>
  160. files.filter((f) => new RegExp(`<${component}[\\s/>]`).test(sourceText.get(f)))
  161. assert.deepEqual(
  162. used('CockpitIcon', ['src/modules/cockpit/CockpitShell.vue', 'src/modules/cockpit/components/MapCanvas.vue']).length,
  163. 2,
  164. 'CockpitIcon 未被外壳与地图同时使用',
  165. )
  166. assert.equal(
  167. used('StatusTag', ['src/modules/cockpit/components/MapCanvas.vue']).length,
  168. 1,
  169. 'StatusTag 未被地图图例使用',
  170. )
  171. assert.equal(
  172. used('PanelPlaceholder', ['src/modules/cockpit/CockpitShell.vue']).length,
  173. 1,
  174. 'PanelPlaceholder 未被外壳使用',
  175. )
  176. })
  177. console.log('\n[D] 视觉可替换、不影响结构')
  178. check('D1 模式与面板结构未随视觉变化', () => {
  179. assert.deepEqual(MODES, ['overview', 'infrastructure', 'monitoring', 'warningResponse'])
  180. assert.equal(Object.keys(PANEL_LABELS).length, 16, '面板应为 16 个')
  181. MODES.forEach((mode) => assert.equal(panelsForMode(mode).length, 4, `${mode} 应为 4 块`))
  182. })
  183. check('D2 结构定义里没有视觉信息', () => {
  184. const catalog = sourceText.get('src/modules/cockpit/domain/panelCatalog.js')
  185. assert.ok(!/#[0-9a-fA-F]{3,6}/.test(catalog), 'panelCatalog 不应出现颜色值')
  186. assert.ok(!/icon/i.test(catalog), 'panelCatalog 不应出现图标名')
  187. })
  188. console.log('\n[E] 资源引用走查记录')
  189. const references = []
  190. check('E1 public/cockpit 下每个资源都有引用点', () => {
  191. // 「资源」指要被代码引用的东西;说明与部署模板不是资源,不需要引用点:
  192. // README.md —— 讲约定
  193. // *.example.json —— 部署配置模板(票 04),由运维复制成 config.json,代码永远不引用它
  194. const notAnAsset = /^(README\.md|.*\.example\.json)$/
  195. const assets = readdirSync(publicCockpit).filter((f) => !notAnAsset.test(f))
  196. assert.ok(assets.length > 0, 'public/cockpit 下没有资源')
  197. assets.forEach((asset) => {
  198. const hits = cockpitSources.filter((file) => sourceText.get(file).includes(asset))
  199. references.push({ asset, hits })
  200. assert.ok(hits.length > 0, `${asset} 没有任何源码引用`)
  201. })
  202. })
  203. console.log('\n资源引用清单')
  204. references.forEach(({ asset, hits }) => {
  205. console.log(` public/cockpit/${asset}`)
  206. hits.forEach((hit) => console.log(` ← ${hit}`))
  207. })
  208. if (problems.length > 0) {
  209. console.error(`\nT06 未通过:${problems.length} 项失败\n`)
  210. process.exit(1)
  211. }
  212. console.log('\nT06 全部通过\n')