t44-device-assets.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. /**
  2. * T44 设备位置符号与可替换资产接缝 纯逻辑检查(票 44;规格 16 / 决策票 10)。
  3. *
  4. * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t44-device-assets.mjs
  6. *
  7. * 覆盖验收第 8 条点名的四件事:**资产接缝与降级分类**、**尺寸层级**、**状态来源独立**、
  8. * **地形/坐标两种降级分得开**;外加"不显示经纬度数字""不散落形状字符串/资源地址"等静态守卫。
  9. */
  10. import { readFileSync } from 'node:fs'
  11. import {
  12. ASSET_MODE,
  13. ASSET_REASON,
  14. MARKER_SIZE,
  15. assetReasonText,
  16. deviceLabel,
  17. markerSizeFor,
  18. planDeviceAsset,
  19. planDevicePlacement,
  20. sizeHierarchyHolds,
  21. } from '../../../src/modules/cockpit/domain/deviceAssets.js'
  22. import {
  23. DEVICE_STATUS,
  24. DEVICE_STATUS_VISUALS,
  25. DEVICE_UNKNOWN_VISUAL,
  26. deviceLegend,
  27. deviceVisualForStatus,
  28. } from '../../../src/modules/cockpit/domain/deviceStatus.js'
  29. import { POINT_STATUS, POINT_STATUS_VISUALS } from '../../../src/modules/cockpit/domain/pointStatus.js'
  30. import { deviceCardView, statusView, deviceStatusView } from '../../../src/modules/cockpit/domain/detailFlow.js'
  31. const checks = []
  32. function check(name, pass, detail = '') {
  33. checks.push({ name, pass, detail })
  34. }
  35. const device = (overrides = {}) => ({ code: 'EQ-1', pointCode: 'MP-1', name: '供水压力计-001', status: 'NORMAL', ...overrides })
  36. // ================================================================ A 独立设备状态词表
  37. {
  38. // 五态齐全,且**不含**点位专有的 EMPTY(那是点位聚合态)。
  39. const keys = Object.keys(DEVICE_STATUS_VISUALS)
  40. check('A1-five-statuses-no-empty',
  41. keys.length === 5 && !keys.includes('EMPTY') &&
  42. ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE'].every((k) => keys.includes(k)),
  43. `设备五态:${keys.join('/')}(**无 EMPTY** —— 那是点位聚合态专有)`)
  44. // 六态(五态 + UNKNOWN)各有色键、形状、文字(颜色不是唯一线索)。
  45. const all = [...keys.map((k) => DEVICE_STATUS_VISUALS[k]), DEVICE_UNKNOWN_VISUAL]
  46. check('A2-each-has-tone-shape-label',
  47. all.every((v) => v.tone && v.shape && v.label),
  48. all.map((v) => `${v.key}:${v.tone}/${v.shape}/${v.label}`).join(' '))
  49. // **状态未知不猜正常**。
  50. const unknown = deviceVisualForStatus('SOMETHING_NEW')
  51. check('A3-unknown-not-guessed-as-normal',
  52. unknown.key === 'UNKNOWN' && unknown.key !== 'NORMAL',
  53. `认不出的状态 → ${unknown.key}「${unknown.label}」(**不猜成正常**)`)
  54. const missing = deviceVisualForStatus('')
  55. check('A4-missing-status-is-unknown', missing.key === 'UNKNOWN', '状态缺失 → UNKNOWN')
  56. // **与点位表分开**:同一字符串在两表里是各自独立的词条,且点位专有键在设备表里查不到。
  57. check('A5-parallel-not-shared',
  58. deviceVisualForStatus('EMPTY').key === 'UNKNOWN' && POINT_STATUS_VISUALS.EMPTY.key === 'EMPTY',
  59. '设备表里 `EMPTY` 认不出(落 UNKNOWN)—— 证明没有借用点位表')
  60. // 独立图例:行数 == 五态 + UNKNOWN,且与点位图例分开。
  61. const legend = deviceLegend({ ALARM: 2, NORMAL: 1 })
  62. check('A6-device-legend-independent',
  63. legend.length === 6 && legend.find((r) => r.key === 'ALARM').count === 2 &&
  64. legend.find((r) => r.key === 'OFFLINE').count === 0,
  65. `设备图例 ${legend.length} 行、按优先级排、0 不隐藏行`)
  66. // 设备卡片视图走设备表(本票修掉的越界)。
  67. const card = deviceCardView({ code: 'EQ-1', name: '设备', status: 'ALARM', statusLabel: '', statusReasons: [] })
  68. const pointStyle = statusView('ALARM')
  69. check('A7-device-card-uses-device-table',
  70. card.status.key === 'ALARM' && card.status.tone === DEVICE_STATUS_VISUALS.ALARM.tone,
  71. `设备卡片状态走设备表(${card.status.key}/${card.status.tone})`)
  72. // 关键反例:设备拿到一个"点位专有"的状态时,两张表的答案必须不同 ——
  73. // 若相同就说明设备卡片仍在借点位表(本票修正的越界)。
  74. const deviceOnEmpty = deviceStatusView('EMPTY')
  75. const pointOnEmpty = statusView('EMPTY')
  76. check('A8-device-vs-point-diverge-on-empty',
  77. deviceOnEmpty.key === 'UNKNOWN' && pointOnEmpty.key === 'EMPTY' && deviceOnEmpty.key !== pointOnEmpty.key,
  78. `同一个 'EMPTY':设备表给 ${deviceOnEmpty.key}、点位表给 ${pointOnEmpty.key}(两表**确实分开**)`)
  79. }
  80. // ================================================================ B 资产接缝与降级链
  81. {
  82. // 本期常态:正式素材未提供 → 设备专属图标,并记明原因(不是真模型、不算成功)。
  83. const icon = planDeviceAsset({ device: device(), color: '#35e39a' })
  84. check('B1-default-is-icon-not-model',
  85. icon.mode === ASSET_MODE.ICON && !!icon.image && icon.reason === ASSET_REASON.ASSET_MISSING,
  86. `默认:模式 ${icon.mode}、原因「${icon.reasonText}」(**不是**真模型)`)
  87. // 正式图片可用 → 用正式素材。
  88. const image = planDeviceAsset({ device: device(), imageUrl: '/cockpit/devices/pump.png', color: '#35e39a' })
  89. check('B2-image-when-available',
  90. image.mode === ASSET_MODE.IMAGE && image.image === '/cockpit/devices/pump.png',
  91. `正式素材可用 → 模式 ${image.mode}`)
  92. // 正式素材加载失败 → 回退图标并如实说明。
  93. const imageFailed = planDeviceAsset({ device: device(), imageUrl: '/x.png', imageFailed: true, color: '#35e39a' })
  94. check('B3-image-failure-falls-back-to-icon',
  95. imageFailed.mode === ASSET_MODE.ICON && imageFailed.reason === ASSET_REASON.ASSET_FAILED,
  96. `正式素材失败 → 回退图标并记「${imageFailed.reasonText}」`)
  97. // 未来真模型的四种不可用各记各的原因(不能混成一句话)。
  98. const modelCases = [
  99. ['missing', ASSET_REASON.MODEL_MISSING],
  100. ['invalid', ASSET_REASON.MODEL_INVALID],
  101. ['failed', ASSET_REASON.MODEL_FAILED],
  102. ['over-budget', ASSET_REASON.MODEL_OVER_BUDGET],
  103. ]
  104. check('B4-model-degrades-are-distinct',
  105. modelCases.every(([state, reason]) => planDeviceAsset({ device: device(), modelState: state }).reason === reason),
  106. `真模型四种不可用各自可分:${modelCases.map(([s]) => s).join('/')}`)
  107. // **图标资源自身失败 → 通用位置符号**(最后兜底),并记「视觉资源异常」。
  108. // 用**注入的渲染实现**真实驱动这条分支(本仓反对"传一个实现不读的键"那种恒真断言)。
  109. const iconFailed = planDeviceAsset({
  110. // 用 `ALARM`(形状 triangle)而不是 `NORMAL`(形状本身就是 circle)——
  111. // 否则"第一次调用必须抛错"的判据会被"形状恰好是 circle"蒙混过去(本票实测栽过)。
  112. device: device({ status: 'ALARM' }),
  113. color: '#ff4d5e',
  114. renderIconImpl: (shape) => {
  115. if (shape !== 'circle') throw new Error('icon asset render failed')
  116. return 'data:image/svg+xml,fallback'
  117. },
  118. })
  119. check('B5-icon-failure-falls-back-to-generic-symbol',
  120. iconFailed.mode === ASSET_MODE.FALLBACK &&
  121. iconFailed.shape === 'circle' &&
  122. iconFailed.reason === ASSET_REASON.ICON_FAILED &&
  123. iconFailed.image === 'data:image/svg+xml,fallback',
  124. `图标渲染抛错 → 模式 ${iconFailed.mode}、形状 ${iconFailed.shape}、原因「${iconFailed.reasonText}」`)
  125. // 反过来:图标渲染正常时**不**走兜底(对照组,证明上面那条不是恒真)。
  126. const iconOk = planDeviceAsset({ device: device(), color: '#35e39a', renderIconImpl: () => 'data:image/svg+xml,ok' })
  127. check('B5b-icon-ok-does-not-fall-back',
  128. iconOk.mode === ASSET_MODE.ICON && iconOk.reason !== ASSET_REASON.ICON_FAILED,
  129. `图标渲染正常 → 模式 ${iconOk.mode}(对照:这才说明兜底只在失败时出现)`)
  130. // 兜底符号仍是可画的(`circle`),且形状在形状表里存在。
  131. check('B6-fallback-shape-is-renderable', planDeviceAsset({ device: device() }).shape === 'circle',
  132. '正常设备的状态形状来自设备词表(NORMAL → circle)')
  133. // 真模型不可用时,资产模式**不得**声称 model(本票不加载真模型)。
  134. // 正面判据:模式取值集合里根本没有 `model`,且四种真模型不可用都落到 icon。
  135. const modeValues = Object.values(ASSET_MODE)
  136. check('B7-no-model-mode-exists',
  137. !modeValues.includes('model') && modeValues.length === 3 &&
  138. modelCases.every(([state]) => planDeviceAsset({ device: device(), modelState: state }).mode === ASSET_MODE.ICON),
  139. `模式集合 ${modeValues.join('/')} 里没有 model(本期不加载真模型)`)
  140. }
  141. // ================================================================ C 尺寸层级(点始终大于设备)
  142. {
  143. check('C1-device-smaller-than-point', MARKER_SIZE.device < MARKER_SIZE.point,
  144. `设备 ${MARKER_SIZE.device} < 监测点 ${MARKER_SIZE.point}`)
  145. // **任何允许的缩放**下层级都不反转(规格验收第 2 条)。
  146. const scales = [MARKER_SIZE.minScale, 1, 1.3, 2, MARKER_SIZE.maxScale, 99, 0.01]
  147. check('C2-hierarchy-holds-at-any-scale', scales.every((s) => sizeHierarchyHolds(s)),
  148. `缩放 ${scales.join('/')} 下层级都成立(夹取到 [${MARKER_SIZE.minScale}, ${MARKER_SIZE.maxScale}])`)
  149. // 越界缩放被夹取(不放大到离谱/缩到看不见)。
  150. check('C3-scale-is-clamped',
  151. markerSizeFor('monitoring-device', 999) === Math.round(MARKER_SIZE.device * MARKER_SIZE.maxScale * 100) / 100 &&
  152. markerSizeFor('monitoring-device', 0) === Math.round(MARKER_SIZE.device * MARKER_SIZE.minScale * 100) / 100,
  153. `越界缩放夹到区间内(999 → ×${MARKER_SIZE.maxScale}、0 → ×${MARKER_SIZE.minScale})`)
  154. // 非法缩放退回 1。
  155. check('C4-illegal-scale-falls-back', markerSizeFor('monitoring-device', NaN) === MARKER_SIZE.device,
  156. 'NaN 缩放退回基准尺寸')
  157. }
  158. // ================================================================ D 标签(默认不显示、不出现经纬度)
  159. {
  160. const hidden = deviceLabel(device(), { visible: false })
  161. check('D1-hidden-by-default', hidden.visible === false && hidden.name === '' && hidden.code === '',
  162. '默认不产出长文本(由界面按 visible 决定画不画)')
  163. const shown = deviceLabel(device(), { visible: true })
  164. check('D2-name-first-code-second',
  165. shown.name === '供水压力计-001' && shown.code === 'EQ-1',
  166. `hover/选中:名称为主「${shown.name}」、编码为辅「${shown.code}」`)
  167. // 没有名字时用编码顶上(不留空标签)。
  168. const noName = deviceLabel(device({ name: '' }), { visible: true })
  169. check('D3-code-fills-when-no-name', noName.name === 'EQ-1' && noName.code === '',
  170. '没有名称时用编码作主文案(不出现空标签)')
  171. // 标签内容**不含经纬度数字**(规格:「坐标图标」只代表空间位置)。
  172. // 判据覆盖**返回对象的全部字段**,不只 name/code —— 变异 M5 实测:
  173. // 只查那两个字段时,新增一个 `coord` 字段把经纬度带出去仍然全绿。
  174. const withCoord = deviceLabel(device({ longitude: 110.3991, latitude: 28.4351 }), { visible: true })
  175. const serialized = JSON.stringify(withCoord)
  176. check('D4-label-has-no-coordinates',
  177. !/110\.|28\.|经度|纬度/.test(serialized),
  178. `标签对象 ${serialized} 里不含经纬度数字(坐标不当作标签内容)`)
  179. }
  180. // ================================================================ E 地形降级 vs 坐标无效(两件事)
  181. {
  182. const ok = planDevicePlacement({ coordinateRenderable: true, terrainReady: true })
  183. check('E1-renderable-only-when-both-ok', ok.placement === 'renderable', `坐标有效 + 地形就绪 → ${ok.placement}`)
  184. const noCoord = planDevicePlacement({ coordinateRenderable: false, terrainReady: true })
  185. check('E2-invalid-coordinate-is-no-location', noCoord.placement === 'no-location',
  186. `后端判不可上图 → ${noCoord.placement}`)
  187. // **本票的关键区分**:坐标有效但地形未就绪,必须是"位置待确认"而**不是**"坐标无效"。
  188. const terrain = planDevicePlacement({ coordinateRenderable: true, terrainReady: false })
  189. check('E3-terrain-pending-is-not-no-location',
  190. terrain.placement === 'terrain-pending' && terrain.reason === ASSET_REASON.TERRAIN_PENDING &&
  191. terrain.placement !== 'no-location',
  192. `坐标有效 + 地形未就绪 → ${terrain.placement}(**不是** no-location:那是"等地图资源"不是"数据有问题")`)
  193. check('E4-terrain-reason-text-is-human',
  194. assetReasonText(terrain.reason) === '地形未就绪,位置待确认',
  195. `原因文案「${assetReasonText(terrain.reason)}」`)
  196. }
  197. // ================================================================ F 静态守卫(接缝单一、不散落)
  198. {
  199. const root = new URL('../../../src/modules/cockpit/', import.meta.url)
  200. const read = (rel) => readFileSync(new URL(rel, root), 'utf8')
  201. const engine = read('services/cesiumEngine.js')
  202. const mapService = read('services/mapService.js')
  203. const assets = read('domain/deviceAssets.js')
  204. const deviceStatus = read('domain/deviceStatus.js')
  205. const detailFlow = read('domain/detailFlow.js')
  206. const card = read('components/PointDetailCard.vue')
  207. // ① 资产接缝**只调用一次** `markerSvg`(形状/资源地址不散落)。
  208. check('F1-single-asset-seam',
  209. (assets.match(/markerSvg\(/g) || []).length === 1,
  210. '形状渲染只经 `deviceAssets.js` 一处调用 markerSvg(单一接缝)')
  211. // ② 引擎不认识形状字符串(它只画调用方给的形状)——设备层不得内联 `'circle'` 之类的**业务默认**。
  212. const deviceSyncBody = (/export function syncAssociatedDevices[\s\S]*?\n\}/.exec(engine) || [''])[0]
  213. check('F2-engine-takes-shape-from-caller',
  214. /imageOf\(device\.shape/.test(deviceSyncBody) && !/shape === 'triangle'|SHAPES\[/.test(deviceSyncBody),
  215. '引擎只画调用方给的 shape,不含业务形状判断')
  216. // ③ **不开启穿透例外**(规格 16 明写遵守正常深度关系)。
  217. // 判据只看**赋值语句**:注释里会出现这个字样(解释"故意不设"),被注释满足就是守卫比它要守的宽。
  218. check('F3-no-depth-test-exception',
  219. !/disableDepthTestDistance:/.test(deviceSyncBody),
  220. '设备位置符号遵守正常遮挡(设备图层里没有 disableDepthTestDistance 赋值)')
  221. // ④ 尺寸来自接缝,不写死字面量。
  222. check('F4-sizes-come-from-seam',
  223. /planDeviceAsset\(/.test(mapService) && /MARKER_SIZE\.point/.test(mapService) && !/markerSvg\(shape, color, 18\)/.test(mapService),
  224. '监测点与设备的尺寸都取自 `deviceAssets` 的 MARKER_SIZE(不写死 18/14)')
  225. // ⑤ 设备状态表**不 import** 点位表的状态词表(只可共用 SHAPES 片段)。
  226. check('F5-device-status-independent',
  227. !/POINT_STATUS_VISUALS|visualForStatus/.test(deviceStatus) && /SHAPES/.test(deviceStatus),
  228. '设备词表只共用 SHAPES 片段,不借用点位状态词表')
  229. // ⑥ 设备卡片视图走设备表(越界已修)。
  230. // 守卫必须**只截 `deviceCardView` 的函数体**:`pointSummary`(点位摘要)用 `statusView`
  231. // 是**正确**的,对全文匹配会被它满足而看不出越界(变异/实测都确认过)。
  232. const cardViewBody = (/export function deviceCardView[\s\S]*?\n\}/.exec(detailFlow) || [''])[0]
  233. check('F6-device-card-uses-device-table',
  234. /status: deviceStatusView\(/.test(cardViewBody) && !/status: statusView\(/.test(cardViewBody),
  235. '`deviceCardView` 的函数体里用 `deviceStatusView`、不用点位表的 `statusView`')
  236. // ⑦ 详情卡片显示资产模式与原因(验收第 5 条)。
  237. // 守卫要**连绑定一起**判:只查 `row.assetReasonText` 出现在文件里,会被 `v-if="false"`
  238. // 之类的写法满足(变异 M16 实测全绿)。
  239. check('F7-detail-shows-asset-reason',
  240. /v-if="row\.assetReasonText"/.test(card) &&
  241. /:data-device-asset-mode="row\.assetMode"/.test(card) &&
  242. /:data-device-asset-reason="row\.assetReason"/.test(card) &&
  243. /\{\{ row\.assetReasonText \}\}/.test(card),
  244. '详情卡片**绑定**了资产模式、原因码与原因文案(不是仅出现字样)')
  245. // ⑧ 设备状态词表里**没有** EMPTY(那是点位专有)。
  246. check('F8-no-empty-in-device-status',
  247. !/EMPTY:/.test(deviceStatus) && !/EMPTY:/.test(JSON.stringify(DEVICE_STATUS)),
  248. '设备词表不含 EMPTY(点位聚合态专有)')
  249. // ⑨ 点位词表未被本票改动语义(仍是六态 + UNKNOWN + UNASSOCIATED)。
  250. check('F9-point-table-untouched',
  251. Object.keys(POINT_STATUS_VISUALS).length === 6 && POINT_STATUS.EMPTY === 'EMPTY',
  252. '点位词表仍是六态(本票不改它的语义)')
  253. // ⑩ 设备长文本有**稳定截断边界**(验收第 3 条:不撑破布局)。
  254. // 只判"名称"这一处:它是唯一会变长的自由文本。`min-width: 0` 是与
  255. // `grid-template-columns: minmax(0, 1fr)` 配对生效的那一半,缺了它
  256. // `text-overflow` 不会触发(长名字直接把列撑宽)——两者必须一起判。
  257. const nameRule = (/\.point-detail__device-name\s*\{[\s\S]*?\}/.exec(card) || [''])[0]
  258. check('F10-long-text-has-truncation-boundary',
  259. /min-width:\s*0/.test(nameRule) && /overflow:\s*hidden/.test(nameRule) &&
  260. /white-space:\s*nowrap/.test(nameRule) && /text-overflow:\s*ellipsis/.test(nameRule),
  261. '设备名称列有 min-width:0 + overflow:hidden + nowrap + ellipsis(长名字截断而非撑破布局)')
  262. // ⑪ 设备词条的键集合是**受控的**。
  263. // 本票(44)当时的边界是"只含静态描述,动效归票 47";**票 47 已按决策票 07 正式
  264. // 新增 `effect` 维度**,所以这里按新契约重钉:允许 `effect`,但取值必须落在
  265. // 三个动效键或 null 之内,且**正常/离线/未知必须无动效**(决策票 07 的明确取舍)。
  266. // 判据仍是"键的集合 + 取值域"而非"是否出现某个词":只查动画字样的话,
  267. // 将来给词条加个任意键仍然全绿。
  268. const staticKeys = ['key', 'tone', 'shape', 'label', 'priority', 'effect']
  269. const visualEntries = [...Object.values(DEVICE_STATUS_VISUALS), DEVICE_UNKNOWN_VISUAL]
  270. const offenders = visualEntries
  271. .flatMap((v) => Object.keys(v).filter((k) => !staticKeys.includes(k)))
  272. const allowedEffects = [null, 'pulse', 'blink', 'spin']
  273. const badEffects = visualEntries.filter((v) => !allowedEffects.includes(v.effect)).map((v) => `${v.key}:${v.effect}`)
  274. const stillStatic = ['NORMAL', 'OFFLINE', 'UNKNOWN'].every((key) => {
  275. const entry = visualEntries.find((v) => v.key === key)
  276. return entry && entry.effect === null
  277. })
  278. check('F11-status-visuals-are-static-only',
  279. offenders.length === 0 && badEffects.length === 0 && stillStatic,
  280. `六个词条键集合受控(允许 ${staticKeys.join('/')};越界键:${offenders.join('/') || '无'})` +
  281. `、effect 取值域受控(越界:${badEffects.join('/') || '无'})、正常/离线/未知无动效=${stillStatic}`)
  282. // ⑫ 标签口径**确有生产调用点**(验收第 3 条:名称为主、编码为辅要真的用上)。
  283. // 守卫只查"文件里出现过 deviceLabel"会被 import 语句满足(本仓栽过多次),
  284. // 所以判据是**模板里真的用它的结论渲染**:既有 `row.label.name`,也有 `row.label.code`。
  285. check('F12-label-seam-has-production-caller',
  286. /import \{[^}]*deviceLabel[^}]*\} from '\.\.\/domain\/deviceAssets\.js'/.test(card) &&
  287. /row\.nameLabel\.name/.test(card) &&
  288. /row\.nameLabel\.code/.test(card),
  289. '详情清单用 `deviceLabel(row, { visible: true })` 的结论渲染(名称为主、编码为辅)')
  290. // ⑬ 接缝结论**写进了实体**(不然"地图上那台设备的形状/资产模式"根本无从断言)。
  291. // 只截 `syncAssociatedDevices` 的函数体:属性名在别处出现不算数。
  292. const engineDeviceBody = (/export function syncAssociatedDevices[\s\S]*?\n\}/.exec(engine) || [''])[0]
  293. check('F13-entity-carries-seam-conclusion',
  294. /deviceShape:\s*device\.shape/.test(engineDeviceBody) &&
  295. /assetMode:\s*device\.assetMode/.test(engineDeviceBody) &&
  296. /assetReason:\s*device\.assetReason/.test(engineDeviceBody) &&
  297. /width:\s*size/.test(engineDeviceBody),
  298. '设备实体带 `deviceShape`/`assetMode`/`assetReason`,billboard 宽高取自接缝的 size')
  299. // ⑭ 读取器**暴露**上面那些字段(否则走查只能读计划行 —— 那是"打算画什么",
  300. // 不是"画成了什么";本票实测正是栽在这里)。
  301. const readerBody = (/export function readAssociatedDevices[\s\S]*?\n\}/.exec(engine) || [''])[0]
  302. check('F14-reader-exposes-seam-conclusion',
  303. /shape:\s*get\('deviceShape'\)/.test(readerBody) &&
  304. /assetMode:\s*get\('assetMode'\)/.test(readerBody) &&
  305. /assetReason:\s*get\('assetReason'\)/.test(readerBody) &&
  306. /billboard[\s\S]{0,80}width[\s\S]{0,40}getValue\(\)/.test(readerBody),
  307. '`readAssociatedDevices` 回形状/资产模式/降级原因,尺寸读 billboard 真实宽高')
  308. }
  309. // ================================================================ G store 级端到端(替代浏览器走查的可观察证据)
  310. //
  311. // 本沙箱下 Chrome 无法启动(mojo IPC 被文件沙箱拒绝,实测 `--single-process` 也不行),
  312. // 所以"外壳走查"这一层改用**既有成例**(t15/t20 都在 Node 里用 pinia 驱动 store):
  313. // 直接走 `deviceLayerStore.syncForSelection()` 这条**真实用户路径**,断言资产接缝真的作用到产出的行上。
  314. await (async () => {
  315. const { createPinia, setActivePinia } = await import('pinia')
  316. setActivePinia(createPinia())
  317. const { useDeviceLayerStore } = await import('../../../src/modules/cockpit/stores/deviceLayerStore.js')
  318. const { useDetailStore } = await import('../../../src/modules/cockpit/stores/detailStore.js')
  319. const { useMapStore } = await import('../../../src/modules/cockpit/stores/mapStore.js')
  320. const mapStore = useMapStore()
  321. const detailStore = useDetailStore()
  322. const layerStore = useDeviceLayerStore()
  323. // 造一个"当前快照里有这个点位"的合并态(走 store 的公开字段,不碰私有)。
  324. mapStore.summary = {
  325. ...mapStore.summary,
  326. objects: [
  327. { kind: 'monitoring-point', code: 'MP-1', name: '测试点', deviceCodes: ['EQ-A', 'EQ-B'] },
  328. ],
  329. }
  330. // 详情给两台设备:一台坐标有效(可上图)、一台后端判不可上图。
  331. detailStore.setSection('pointDetail', {
  332. state: 'success',
  333. data: {
  334. code: 'MP-1',
  335. devices: [
  336. { code: 'EQ-A', name: '可上图设备', status: 'ALARM', statusLabel: '', statusReasons: [], longitude: 110.4, latitude: 28.44, coordinate: { state: 'VALID', reason: '', renderable: true, validated: true } },
  337. { code: 'EQ-B', name: '无位置设备', status: 'NORMAL', statusLabel: '', statusReasons: [], longitude: null, latitude: null, coordinate: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, validated: true } },
  338. ],
  339. },
  340. })
  341. detailStore.selection = { level: 'point', pointCode: 'MP-1', deviceCode: '' }
  342. layerStore.syncForSelection({ force: true, mode: 'overview' })
  343. const rows = layerStore.rows
  344. const drawn = rows.find((r) => r.code === 'EQ-A')
  345. const blocked = rows.find((r) => r.code === 'EQ-B')
  346. check('G1-store-produces-both-rows', rows.length === 2,
  347. `store 产出 ${rows.length} 行(快照去重 deviceCodes 两台都在清单里)`)
  348. check('G2-drawn-row-carries-asset-plan',
  349. !!drawn && drawn.shape === 'triangle' && drawn.assetMode === ASSET_MODE.ICON &&
  350. drawn.assetReason === ASSET_REASON.ASSET_MISSING && !!drawn.assetReasonText,
  351. `可上图设备的行带资产计划:形状 ${drawn && drawn.shape}、模式 ${drawn && drawn.assetMode}、原因「${drawn && drawn.assetReasonText}」`)
  352. check('G3-blocked-row-not-drawn',
  353. !!blocked && blocked.placement === 'no-location',
  354. `后端判不可上图 → ${blocked && blocked.placement}(保留在清单里)`)
  355. // 尺寸层级落到行上(可断言"点比设备大"这条关系在真实数据路径上成立)。
  356. check('G4-size-hierarchy-on-real-rows',
  357. drawn.size === MARKER_SIZE.device && sizeHierarchyHolds(1),
  358. `设备行尺寸 ${drawn.size} < 监测点 ${MARKER_SIZE.point}(层级成立)`)
  359. // 设备状态词表独立:ALARM 的色键来自设备表。
  360. check('G5-device-tone-from-device-table',
  361. drawn.visualKey === 'ALARM',
  362. `设备行视觉键 ${drawn.visualKey}(来自设备词表,非点位表)`)
  363. layerStore.reset()
  364. })()
  365. const failed = checks.filter((item) => !item.pass)
  366. console.log('')
  367. for (const item of checks) {
  368. if (!item.pass) console.log(` ✗ ${item.name}${item.detail ? ` — ${item.detail}` : ''}`)
  369. }
  370. console.log(`T44 设备位置符号与资产接缝检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  371. if (failed.length) process.exit(1)
  372. console.log('T44 设备位置符号与资产接缝全部通过。')