/** * T44 设备位置符号与可替换资产接缝 纯逻辑检查(票 44;规格 16 / 决策票 10)。 * * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t44-device-assets.mjs * * 覆盖验收第 8 条点名的四件事:**资产接缝与降级分类**、**尺寸层级**、**状态来源独立**、 * **地形/坐标两种降级分得开**;外加"不显示经纬度数字""不散落形状字符串/资源地址"等静态守卫。 */ import { readFileSync } from 'node:fs' import { ASSET_MODE, ASSET_REASON, MARKER_SIZE, assetReasonText, deviceLabel, markerSizeFor, planDeviceAsset, planDevicePlacement, sizeHierarchyHolds, } from '../../../src/modules/cockpit/domain/deviceAssets.js' import { DEVICE_STATUS, DEVICE_STATUS_VISUALS, DEVICE_UNKNOWN_VISUAL, deviceLegend, deviceVisualForStatus, } from '../../../src/modules/cockpit/domain/deviceStatus.js' import { POINT_STATUS, POINT_STATUS_VISUALS } from '../../../src/modules/cockpit/domain/pointStatus.js' import { deviceCardView, statusView, deviceStatusView } from '../../../src/modules/cockpit/domain/detailFlow.js' const checks = [] function check(name, pass, detail = '') { checks.push({ name, pass, detail }) } const device = (overrides = {}) => ({ code: 'EQ-1', pointCode: 'MP-1', name: '供水压力计-001', status: 'NORMAL', ...overrides }) // ================================================================ A 独立设备状态词表 { // 五态齐全,且**不含**点位专有的 EMPTY(那是点位聚合态)。 const keys = Object.keys(DEVICE_STATUS_VISUALS) check('A1-five-statuses-no-empty', keys.length === 5 && !keys.includes('EMPTY') && ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE'].every((k) => keys.includes(k)), `设备五态:${keys.join('/')}(**无 EMPTY** —— 那是点位聚合态专有)`) // 六态(五态 + UNKNOWN)各有色键、形状、文字(颜色不是唯一线索)。 const all = [...keys.map((k) => DEVICE_STATUS_VISUALS[k]), DEVICE_UNKNOWN_VISUAL] check('A2-each-has-tone-shape-label', all.every((v) => v.tone && v.shape && v.label), all.map((v) => `${v.key}:${v.tone}/${v.shape}/${v.label}`).join(' ')) // **状态未知不猜正常**。 const unknown = deviceVisualForStatus('SOMETHING_NEW') check('A3-unknown-not-guessed-as-normal', unknown.key === 'UNKNOWN' && unknown.key !== 'NORMAL', `认不出的状态 → ${unknown.key}「${unknown.label}」(**不猜成正常**)`) const missing = deviceVisualForStatus('') check('A4-missing-status-is-unknown', missing.key === 'UNKNOWN', '状态缺失 → UNKNOWN') // **与点位表分开**:同一字符串在两表里是各自独立的词条,且点位专有键在设备表里查不到。 check('A5-parallel-not-shared', deviceVisualForStatus('EMPTY').key === 'UNKNOWN' && POINT_STATUS_VISUALS.EMPTY.key === 'EMPTY', '设备表里 `EMPTY` 认不出(落 UNKNOWN)—— 证明没有借用点位表') // 独立图例:行数 == 五态 + UNKNOWN,且与点位图例分开。 const legend = deviceLegend({ ALARM: 2, NORMAL: 1 }) check('A6-device-legend-independent', legend.length === 6 && legend.find((r) => r.key === 'ALARM').count === 2 && legend.find((r) => r.key === 'OFFLINE').count === 0, `设备图例 ${legend.length} 行、按优先级排、0 不隐藏行`) // 设备卡片视图走设备表(本票修掉的越界)。 const card = deviceCardView({ code: 'EQ-1', name: '设备', status: 'ALARM', statusLabel: '', statusReasons: [] }) const pointStyle = statusView('ALARM') check('A7-device-card-uses-device-table', card.status.key === 'ALARM' && card.status.tone === DEVICE_STATUS_VISUALS.ALARM.tone, `设备卡片状态走设备表(${card.status.key}/${card.status.tone})`) // 关键反例:设备拿到一个"点位专有"的状态时,两张表的答案必须不同 —— // 若相同就说明设备卡片仍在借点位表(本票修正的越界)。 const deviceOnEmpty = deviceStatusView('EMPTY') const pointOnEmpty = statusView('EMPTY') check('A8-device-vs-point-diverge-on-empty', deviceOnEmpty.key === 'UNKNOWN' && pointOnEmpty.key === 'EMPTY' && deviceOnEmpty.key !== pointOnEmpty.key, `同一个 'EMPTY':设备表给 ${deviceOnEmpty.key}、点位表给 ${pointOnEmpty.key}(两表**确实分开**)`) } // ================================================================ B 资产接缝与降级链 { // 本期常态:正式素材未提供 → 设备专属图标,并记明原因(不是真模型、不算成功)。 const icon = planDeviceAsset({ device: device(), color: '#35e39a' }) check('B1-default-is-icon-not-model', icon.mode === ASSET_MODE.ICON && !!icon.image && icon.reason === ASSET_REASON.ASSET_MISSING, `默认:模式 ${icon.mode}、原因「${icon.reasonText}」(**不是**真模型)`) // 正式图片可用 → 用正式素材。 const image = planDeviceAsset({ device: device(), imageUrl: '/cockpit/devices/pump.png', color: '#35e39a' }) check('B2-image-when-available', image.mode === ASSET_MODE.IMAGE && image.image === '/cockpit/devices/pump.png', `正式素材可用 → 模式 ${image.mode}`) // 正式素材加载失败 → 回退图标并如实说明。 const imageFailed = planDeviceAsset({ device: device(), imageUrl: '/x.png', imageFailed: true, color: '#35e39a' }) check('B3-image-failure-falls-back-to-icon', imageFailed.mode === ASSET_MODE.ICON && imageFailed.reason === ASSET_REASON.ASSET_FAILED, `正式素材失败 → 回退图标并记「${imageFailed.reasonText}」`) // 未来真模型的四种不可用各记各的原因(不能混成一句话)。 const modelCases = [ ['missing', ASSET_REASON.MODEL_MISSING], ['invalid', ASSET_REASON.MODEL_INVALID], ['failed', ASSET_REASON.MODEL_FAILED], ['over-budget', ASSET_REASON.MODEL_OVER_BUDGET], ] check('B4-model-degrades-are-distinct', modelCases.every(([state, reason]) => planDeviceAsset({ device: device(), modelState: state }).reason === reason), `真模型四种不可用各自可分:${modelCases.map(([s]) => s).join('/')}`) // **图标资源自身失败 → 通用位置符号**(最后兜底),并记「视觉资源异常」。 // 用**注入的渲染实现**真实驱动这条分支(本仓反对"传一个实现不读的键"那种恒真断言)。 const iconFailed = planDeviceAsset({ // 用 `ALARM`(形状 triangle)而不是 `NORMAL`(形状本身就是 circle)—— // 否则"第一次调用必须抛错"的判据会被"形状恰好是 circle"蒙混过去(本票实测栽过)。 device: device({ status: 'ALARM' }), color: '#ff4d5e', renderIconImpl: (shape) => { if (shape !== 'circle') throw new Error('icon asset render failed') return 'data:image/svg+xml,fallback' }, }) check('B5-icon-failure-falls-back-to-generic-symbol', iconFailed.mode === ASSET_MODE.FALLBACK && iconFailed.shape === 'circle' && iconFailed.reason === ASSET_REASON.ICON_FAILED && iconFailed.image === 'data:image/svg+xml,fallback', `图标渲染抛错 → 模式 ${iconFailed.mode}、形状 ${iconFailed.shape}、原因「${iconFailed.reasonText}」`) // 反过来:图标渲染正常时**不**走兜底(对照组,证明上面那条不是恒真)。 const iconOk = planDeviceAsset({ device: device(), color: '#35e39a', renderIconImpl: () => 'data:image/svg+xml,ok' }) check('B5b-icon-ok-does-not-fall-back', iconOk.mode === ASSET_MODE.ICON && iconOk.reason !== ASSET_REASON.ICON_FAILED, `图标渲染正常 → 模式 ${iconOk.mode}(对照:这才说明兜底只在失败时出现)`) // 兜底符号仍是可画的(`circle`),且形状在形状表里存在。 check('B6-fallback-shape-is-renderable', planDeviceAsset({ device: device() }).shape === 'circle', '正常设备的状态形状来自设备词表(NORMAL → circle)') // 真模型不可用时,资产模式**不得**声称 model(本票不加载真模型)。 // 正面判据:模式取值集合里根本没有 `model`,且四种真模型不可用都落到 icon。 const modeValues = Object.values(ASSET_MODE) check('B7-no-model-mode-exists', !modeValues.includes('model') && modeValues.length === 3 && modelCases.every(([state]) => planDeviceAsset({ device: device(), modelState: state }).mode === ASSET_MODE.ICON), `模式集合 ${modeValues.join('/')} 里没有 model(本期不加载真模型)`) } // ================================================================ C 尺寸层级(点始终大于设备) { check('C1-device-smaller-than-point', MARKER_SIZE.device < MARKER_SIZE.point, `设备 ${MARKER_SIZE.device} < 监测点 ${MARKER_SIZE.point}`) // **任何允许的缩放**下层级都不反转(规格验收第 2 条)。 const scales = [MARKER_SIZE.minScale, 1, 1.3, 2, MARKER_SIZE.maxScale, 99, 0.01] check('C2-hierarchy-holds-at-any-scale', scales.every((s) => sizeHierarchyHolds(s)), `缩放 ${scales.join('/')} 下层级都成立(夹取到 [${MARKER_SIZE.minScale}, ${MARKER_SIZE.maxScale}])`) // 越界缩放被夹取(不放大到离谱/缩到看不见)。 check('C3-scale-is-clamped', markerSizeFor('monitoring-device', 999) === Math.round(MARKER_SIZE.device * MARKER_SIZE.maxScale * 100) / 100 && markerSizeFor('monitoring-device', 0) === Math.round(MARKER_SIZE.device * MARKER_SIZE.minScale * 100) / 100, `越界缩放夹到区间内(999 → ×${MARKER_SIZE.maxScale}、0 → ×${MARKER_SIZE.minScale})`) // 非法缩放退回 1。 check('C4-illegal-scale-falls-back', markerSizeFor('monitoring-device', NaN) === MARKER_SIZE.device, 'NaN 缩放退回基准尺寸') } // ================================================================ D 标签(默认不显示、不出现经纬度) { const hidden = deviceLabel(device(), { visible: false }) check('D1-hidden-by-default', hidden.visible === false && hidden.name === '' && hidden.code === '', '默认不产出长文本(由界面按 visible 决定画不画)') const shown = deviceLabel(device(), { visible: true }) check('D2-name-first-code-second', shown.name === '供水压力计-001' && shown.code === 'EQ-1', `hover/选中:名称为主「${shown.name}」、编码为辅「${shown.code}」`) // 没有名字时用编码顶上(不留空标签)。 const noName = deviceLabel(device({ name: '' }), { visible: true }) check('D3-code-fills-when-no-name', noName.name === 'EQ-1' && noName.code === '', '没有名称时用编码作主文案(不出现空标签)') // 标签内容**不含经纬度数字**(规格:「坐标图标」只代表空间位置)。 // 判据覆盖**返回对象的全部字段**,不只 name/code —— 变异 M5 实测: // 只查那两个字段时,新增一个 `coord` 字段把经纬度带出去仍然全绿。 const withCoord = deviceLabel(device({ longitude: 110.3991, latitude: 28.4351 }), { visible: true }) const serialized = JSON.stringify(withCoord) check('D4-label-has-no-coordinates', !/110\.|28\.|经度|纬度/.test(serialized), `标签对象 ${serialized} 里不含经纬度数字(坐标不当作标签内容)`) } // ================================================================ E 地形降级 vs 坐标无效(两件事) { const ok = planDevicePlacement({ coordinateRenderable: true, terrainReady: true }) check('E1-renderable-only-when-both-ok', ok.placement === 'renderable', `坐标有效 + 地形就绪 → ${ok.placement}`) const noCoord = planDevicePlacement({ coordinateRenderable: false, terrainReady: true }) check('E2-invalid-coordinate-is-no-location', noCoord.placement === 'no-location', `后端判不可上图 → ${noCoord.placement}`) // **本票的关键区分**:坐标有效但地形未就绪,必须是"位置待确认"而**不是**"坐标无效"。 const terrain = planDevicePlacement({ coordinateRenderable: true, terrainReady: false }) check('E3-terrain-pending-is-not-no-location', terrain.placement === 'terrain-pending' && terrain.reason === ASSET_REASON.TERRAIN_PENDING && terrain.placement !== 'no-location', `坐标有效 + 地形未就绪 → ${terrain.placement}(**不是** no-location:那是"等地图资源"不是"数据有问题")`) check('E4-terrain-reason-text-is-human', assetReasonText(terrain.reason) === '地形未就绪,位置待确认', `原因文案「${assetReasonText(terrain.reason)}」`) } // ================================================================ F 静态守卫(接缝单一、不散落) { const root = new URL('../../../src/modules/cockpit/', import.meta.url) const read = (rel) => readFileSync(new URL(rel, root), 'utf8') const engine = read('services/cesiumEngine.js') const mapService = read('services/mapService.js') const assets = read('domain/deviceAssets.js') const deviceStatus = read('domain/deviceStatus.js') const detailFlow = read('domain/detailFlow.js') const card = read('components/PointDetailCard.vue') // ① 资产接缝**只调用一次** `markerSvg`(形状/资源地址不散落)。 check('F1-single-asset-seam', (assets.match(/markerSvg\(/g) || []).length === 1, '形状渲染只经 `deviceAssets.js` 一处调用 markerSvg(单一接缝)') // ② 引擎不认识形状字符串(它只画调用方给的形状)——设备层不得内联 `'circle'` 之类的**业务默认**。 const deviceSyncBody = (/export function syncAssociatedDevices[\s\S]*?\n\}/.exec(engine) || [''])[0] check('F2-engine-takes-shape-from-caller', /imageOf\(device\.shape/.test(deviceSyncBody) && !/shape === 'triangle'|SHAPES\[/.test(deviceSyncBody), '引擎只画调用方给的 shape,不含业务形状判断') // ③ **不开启穿透例外**(规格 16 明写遵守正常深度关系)。 // 判据只看**赋值语句**:注释里会出现这个字样(解释"故意不设"),被注释满足就是守卫比它要守的宽。 check('F3-no-depth-test-exception', !/disableDepthTestDistance:/.test(deviceSyncBody), '设备位置符号遵守正常遮挡(设备图层里没有 disableDepthTestDistance 赋值)') // ④ 尺寸来自接缝,不写死字面量。 check('F4-sizes-come-from-seam', /planDeviceAsset\(/.test(mapService) && /MARKER_SIZE\.point/.test(mapService) && !/markerSvg\(shape, color, 18\)/.test(mapService), '监测点与设备的尺寸都取自 `deviceAssets` 的 MARKER_SIZE(不写死 18/14)') // ⑤ 设备状态表**不 import** 点位表的状态词表(只可共用 SHAPES 片段)。 check('F5-device-status-independent', !/POINT_STATUS_VISUALS|visualForStatus/.test(deviceStatus) && /SHAPES/.test(deviceStatus), '设备词表只共用 SHAPES 片段,不借用点位状态词表') // ⑥ 设备卡片视图走设备表(越界已修)。 // 守卫必须**只截 `deviceCardView` 的函数体**:`pointSummary`(点位摘要)用 `statusView` // 是**正确**的,对全文匹配会被它满足而看不出越界(变异/实测都确认过)。 const cardViewBody = (/export function deviceCardView[\s\S]*?\n\}/.exec(detailFlow) || [''])[0] check('F6-device-card-uses-device-table', /status: deviceStatusView\(/.test(cardViewBody) && !/status: statusView\(/.test(cardViewBody), '`deviceCardView` 的函数体里用 `deviceStatusView`、不用点位表的 `statusView`') // ⑦ 详情卡片显示资产模式与原因(验收第 5 条)。 // 守卫要**连绑定一起**判:只查 `row.assetReasonText` 出现在文件里,会被 `v-if="false"` // 之类的写法满足(变异 M16 实测全绿)。 check('F7-detail-shows-asset-reason', /v-if="row\.assetReasonText"/.test(card) && /:data-device-asset-mode="row\.assetMode"/.test(card) && /:data-device-asset-reason="row\.assetReason"/.test(card) && /\{\{ row\.assetReasonText \}\}/.test(card), '详情卡片**绑定**了资产模式、原因码与原因文案(不是仅出现字样)') // ⑧ 设备状态词表里**没有** EMPTY(那是点位专有)。 check('F8-no-empty-in-device-status', !/EMPTY:/.test(deviceStatus) && !/EMPTY:/.test(JSON.stringify(DEVICE_STATUS)), '设备词表不含 EMPTY(点位聚合态专有)') // ⑨ 点位词表未被本票改动语义(仍是六态 + UNKNOWN + UNASSOCIATED)。 check('F9-point-table-untouched', Object.keys(POINT_STATUS_VISUALS).length === 6 && POINT_STATUS.EMPTY === 'EMPTY', '点位词表仍是六态(本票不改它的语义)') // ⑩ 设备长文本有**稳定截断边界**(验收第 3 条:不撑破布局)。 // 只判"名称"这一处:它是唯一会变长的自由文本。`min-width: 0` 是与 // `grid-template-columns: minmax(0, 1fr)` 配对生效的那一半,缺了它 // `text-overflow` 不会触发(长名字直接把列撑宽)——两者必须一起判。 const nameRule = (/\.point-detail__device-name\s*\{[\s\S]*?\}/.exec(card) || [''])[0] check('F10-long-text-has-truncation-boundary', /min-width:\s*0/.test(nameRule) && /overflow:\s*hidden/.test(nameRule) && /white-space:\s*nowrap/.test(nameRule) && /text-overflow:\s*ellipsis/.test(nameRule), '设备名称列有 min-width:0 + overflow:hidden + nowrap + ellipsis(长名字截断而非撑破布局)') // ⑪ 设备词条的键集合是**受控的**。 // 本票(44)当时的边界是"只含静态描述,动效归票 47";**票 47 已按决策票 07 正式 // 新增 `effect` 维度**,所以这里按新契约重钉:允许 `effect`,但取值必须落在 // 三个动效键或 null 之内,且**正常/离线/未知必须无动效**(决策票 07 的明确取舍)。 // 判据仍是"键的集合 + 取值域"而非"是否出现某个词":只查动画字样的话, // 将来给词条加个任意键仍然全绿。 const staticKeys = ['key', 'tone', 'shape', 'label', 'priority', 'effect'] const visualEntries = [...Object.values(DEVICE_STATUS_VISUALS), DEVICE_UNKNOWN_VISUAL] const offenders = visualEntries .flatMap((v) => Object.keys(v).filter((k) => !staticKeys.includes(k))) const allowedEffects = [null, 'pulse', 'blink', 'spin'] const badEffects = visualEntries.filter((v) => !allowedEffects.includes(v.effect)).map((v) => `${v.key}:${v.effect}`) const stillStatic = ['NORMAL', 'OFFLINE', 'UNKNOWN'].every((key) => { const entry = visualEntries.find((v) => v.key === key) return entry && entry.effect === null }) check('F11-status-visuals-are-static-only', offenders.length === 0 && badEffects.length === 0 && stillStatic, `六个词条键集合受控(允许 ${staticKeys.join('/')};越界键:${offenders.join('/') || '无'})` + `、effect 取值域受控(越界:${badEffects.join('/') || '无'})、正常/离线/未知无动效=${stillStatic}`) // ⑫ 标签口径**确有生产调用点**(验收第 3 条:名称为主、编码为辅要真的用上)。 // 守卫只查"文件里出现过 deviceLabel"会被 import 语句满足(本仓栽过多次), // 所以判据是**模板里真的用它的结论渲染**:既有 `row.label.name`,也有 `row.label.code`。 check('F12-label-seam-has-production-caller', /import \{[^}]*deviceLabel[^}]*\} from '\.\.\/domain\/deviceAssets\.js'/.test(card) && /row\.nameLabel\.name/.test(card) && /row\.nameLabel\.code/.test(card), '详情清单用 `deviceLabel(row, { visible: true })` 的结论渲染(名称为主、编码为辅)') // ⑬ 接缝结论**写进了实体**(不然"地图上那台设备的形状/资产模式"根本无从断言)。 // 只截 `syncAssociatedDevices` 的函数体:属性名在别处出现不算数。 const engineDeviceBody = (/export function syncAssociatedDevices[\s\S]*?\n\}/.exec(engine) || [''])[0] check('F13-entity-carries-seam-conclusion', /deviceShape:\s*device\.shape/.test(engineDeviceBody) && /assetMode:\s*device\.assetMode/.test(engineDeviceBody) && /assetReason:\s*device\.assetReason/.test(engineDeviceBody) && /width:\s*size/.test(engineDeviceBody), '设备实体带 `deviceShape`/`assetMode`/`assetReason`,billboard 宽高取自接缝的 size') // ⑭ 读取器**暴露**上面那些字段(否则走查只能读计划行 —— 那是"打算画什么", // 不是"画成了什么";本票实测正是栽在这里)。 const readerBody = (/export function readAssociatedDevices[\s\S]*?\n\}/.exec(engine) || [''])[0] check('F14-reader-exposes-seam-conclusion', /shape:\s*get\('deviceShape'\)/.test(readerBody) && /assetMode:\s*get\('assetMode'\)/.test(readerBody) && /assetReason:\s*get\('assetReason'\)/.test(readerBody) && /billboard[\s\S]{0,80}width[\s\S]{0,40}getValue\(\)/.test(readerBody), '`readAssociatedDevices` 回形状/资产模式/降级原因,尺寸读 billboard 真实宽高') } // ================================================================ G store 级端到端(替代浏览器走查的可观察证据) // // 本沙箱下 Chrome 无法启动(mojo IPC 被文件沙箱拒绝,实测 `--single-process` 也不行), // 所以"外壳走查"这一层改用**既有成例**(t15/t20 都在 Node 里用 pinia 驱动 store): // 直接走 `deviceLayerStore.syncForSelection()` 这条**真实用户路径**,断言资产接缝真的作用到产出的行上。 await (async () => { const { createPinia, setActivePinia } = await import('pinia') setActivePinia(createPinia()) const { useDeviceLayerStore } = await import('../../../src/modules/cockpit/stores/deviceLayerStore.js') const { useDetailStore } = await import('../../../src/modules/cockpit/stores/detailStore.js') const { useMapStore } = await import('../../../src/modules/cockpit/stores/mapStore.js') const mapStore = useMapStore() const detailStore = useDetailStore() const layerStore = useDeviceLayerStore() // 造一个"当前快照里有这个点位"的合并态(走 store 的公开字段,不碰私有)。 mapStore.summary = { ...mapStore.summary, objects: [ { kind: 'monitoring-point', code: 'MP-1', name: '测试点', deviceCodes: ['EQ-A', 'EQ-B'] }, ], } // 详情给两台设备:一台坐标有效(可上图)、一台后端判不可上图。 detailStore.setSection('pointDetail', { state: 'success', data: { code: 'MP-1', devices: [ { code: 'EQ-A', name: '可上图设备', status: 'ALARM', statusLabel: '', statusReasons: [], longitude: 110.4, latitude: 28.44, coordinate: { state: 'VALID', reason: '', renderable: true, validated: true } }, { code: 'EQ-B', name: '无位置设备', status: 'NORMAL', statusLabel: '', statusReasons: [], longitude: null, latitude: null, coordinate: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, validated: true } }, ], }, }) detailStore.selection = { level: 'point', pointCode: 'MP-1', deviceCode: '' } layerStore.syncForSelection({ force: true, mode: 'overview' }) const rows = layerStore.rows const drawn = rows.find((r) => r.code === 'EQ-A') const blocked = rows.find((r) => r.code === 'EQ-B') check('G1-store-produces-both-rows', rows.length === 2, `store 产出 ${rows.length} 行(快照去重 deviceCodes 两台都在清单里)`) check('G2-drawn-row-carries-asset-plan', !!drawn && drawn.shape === 'triangle' && drawn.assetMode === ASSET_MODE.ICON && drawn.assetReason === ASSET_REASON.ASSET_MISSING && !!drawn.assetReasonText, `可上图设备的行带资产计划:形状 ${drawn && drawn.shape}、模式 ${drawn && drawn.assetMode}、原因「${drawn && drawn.assetReasonText}」`) check('G3-blocked-row-not-drawn', !!blocked && blocked.placement === 'no-location', `后端判不可上图 → ${blocked && blocked.placement}(保留在清单里)`) // 尺寸层级落到行上(可断言"点比设备大"这条关系在真实数据路径上成立)。 check('G4-size-hierarchy-on-real-rows', drawn.size === MARKER_SIZE.device && sizeHierarchyHolds(1), `设备行尺寸 ${drawn.size} < 监测点 ${MARKER_SIZE.point}(层级成立)`) // 设备状态词表独立:ALARM 的色键来自设备表。 check('G5-device-tone-from-device-table', drawn.visualKey === 'ALARM', `设备行视觉键 ${drawn.visualKey}(来自设备词表,非点位表)`) layerStore.reset() })() const failed = checks.filter((item) => !item.pass) console.log('') for (const item of checks) { if (!item.pass) console.log(` ✗ ${item.name}${item.detail ? ` — ${item.detail}` : ''}`) } console.log(`T44 设备位置符号与资产接缝检查:${checks.length - failed.length}/${checks.length} 项通过。`) if (failed.length) process.exit(1) console.log('T44 设备位置符号与资产接缝全部通过。')