/** * T42 关联监测设备临时图层 纯逻辑检查(票 42;规格 14 / 决策票 05)。 * * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t42-device-layer.mjs * * 覆盖验收第 9 条点名的三件事:**集合分类**、**快照生命周期**、**计数互斥**; * 外加坐标准入只认后端、设备身份不冒充监测点、模式可见性、摘要口径不被污染等静态守卫。 */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { DEVICE_ATTENTION_STATUSES, DEVICE_LAYER_KIND, DEVICE_PLACEMENT, DEVICE_PLACEMENT_TEXT, LAYER_MODE, associatedLegend, classifyDevice, countPlacements, crossEndpointNotice, isVisibleInMode, planAssociatedDevices, uniqueCodes, } from '../../../src/modules/cockpit/domain/deviceLayer.js' const checks = [] function check(name, pass, detail = '') { checks.push({ name, pass, detail }) } /** 设备卡片工厂:默认「有有效坐标」,便于逐条只改一个维度。 */ function card(overrides = {}) { return { code: 'EQ-1', name: '设备一', typeName: '压力计', status: 'NORMAL', statusLabel: '正常', statusReasons: [], longitude: 110.4, latitude: 28.44, coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true }, ...overrides, } } // ================================================================ A 集合分类(每台恰好一类) { const drawn = classifyDevice({ card: card(), inSnapshot: true, detailOwned: false, mode: LAYER_MODE.OVERVIEW }) check('A1-valid-coord-is-drawn', drawn === DEVICE_PLACEMENT.DRAWN, `有效坐标 → ${drawn}`) // 后端判不可上图:**只认 `renderable`**,前端不重算经纬度。 const blocked = classifyDevice({ card: card({ coordinate: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, validated: true } }), inSnapshot: true, detailOwned: false, mode: LAYER_MODE.OVERVIEW, }) check('A2-unverified-goes-no-location', blocked === DEVICE_PLACEMENT.NO_LOCATION, `UNVERIFIED/renderable=false → ${blocked}(现网正式边界:清单-only)`) // 坐标准入通过但经纬度缺失 → 数据不可用(**不是**无地图位置,两者原因不同)。 const noCoord = classifyDevice({ card: card({ longitude: null, latitude: null }), inSnapshot: true, detailOwned: false, mode: LAYER_MODE.OVERVIEW, }) check('A3-missing-numbers-go-unavailable', noCoord === DEVICE_PLACEMENT.UNAVAILABLE, `renderable=true 但经纬度缺失 → ${noCoord}(与"后端判无位置"分开)`) // 详情还没回来 → 待确认(不算上图、也不算无位置)。 const pending = classifyDevice({ card: null, inSnapshot: true, detailOwned: false, mode: LAYER_MODE.OVERVIEW }) check('A4-no-card-is-pending', pending === DEVICE_PLACEMENT.PENDING, `详情未到 → ${pending}`) // 详情独有(不在当前快照清单里)→ 不一致,禁止上图。 const inconsistent = classifyDevice({ card: card(), inSnapshot: false, detailOwned: true, mode: LAYER_MODE.OVERVIEW }) check('A5-detail-only-is-inconsistent', inconsistent === DEVICE_PLACEMENT.INCONSISTENT, `详情独有 → ${inconsistent}(禁止上图)`) // 基础设施隐藏;预警处置只留报警/预警。 check('A6-infrastructure-hides', !isVisibleInMode(LAYER_MODE.INFRASTRUCTURE, 'ALARM'), '基础设施模式整体隐藏关联设备图层') check('A7-warning-response-only-attention', isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'ALARM') && isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'WARNING') && !isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'NORMAL') && !isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'OFFLINE') && !isVisibleInMode(LAYER_MODE.WARNING_RESPONSE, 'WORK_ORDER'), `预警处置只显示 ${DEVICE_ATTENTION_STATUSES.join('/')}(正常/离线/工单都不上图)`) // 状态未知**不猜成正常**、也不因为未知而在总览被隐藏。 check('A8-unknown-status-still-shown-in-overview', isVisibleInMode(LAYER_MODE.OVERVIEW, ''), '总体概览/运行监测不凭状态隐藏(状态未知也显示,视觉归票 44)') const unknownHidden = classifyDevice({ card: card({ status: '' }), inSnapshot: true, detailOwned: false, mode: LAYER_MODE.WARNING_RESPONSE, }) check('A9-unknown-hidden-in-warning-response', unknownHidden === DEVICE_PLACEMENT.HIDDEN, `预警处置里状态未知 → ${unknownHidden}(不猜成报警)`) // 六类归类各有中文说法(界面不另写措辞)。 check('A10-placement-text-complete', Object.values(DEVICE_PLACEMENT).every((key) => !!DEVICE_PLACEMENT_TEXT[key]), `归类文案齐备:${Object.values(DEVICE_PLACEMENT_TEXT).join(' / ')}`) } // ================================================================ B 快照清单 × 详情(差异如实并列) { // 规格点名的算例:快照 `[A,B]`、详情 `[B,C]`。 const plan = planAssociatedDevices({ snapshotCodes: ['A', 'B'], cards: [card({ code: 'B', name: '乙设备' }), card({ code: 'C', name: '丙设备' })], mode: LAYER_MODE.OVERVIEW, pointCode: 'MP-1', }) check('B1-total-comes-from-snapshot', plan.total === 2, `总数=当前快照去重 deviceCodes(${plan.total}),不含详情独有`) // **总数 ≠ 清单行数**:清单会多出「详情独有」的行,拿行数当总数会虚报关联设备数。 // (本票真机走查抓到过这个 UI 口径错误:总数显示 2 而快照只有 1 台。) check('B1b-total-is-not-row-count', plan.rows.length === 3 && plan.total === 2, `清单 ${plan.rows.length} 行 ≠ 总数 ${plan.total}(详情独有那行不计入总数)`) check('B2-snapshot-only-is-pending', plan.rows.some((row) => row.code === 'A' && row.placement === DEVICE_PLACEMENT.PENDING), 'A 以编码占位并标「设备详情待确认」') check('B3-detail-only-is-inconsistent', plan.rows.some((row) => row.code === 'C' && row.placement === DEVICE_PLACEMENT.INCONSISTENT), 'C 保留卡片但标「与当前快照不一致」且禁止上图') check('B4-all-codes-kept', plan.rows.length === 3 && ['A', 'B', 'C'].every((code) => plan.rows.some((r) => r.code === code)), `两边都不静默丢弃:${plan.rows.map((r) => r.code).join(',')}`) check('B5-only-snapshot-drawn', plan.rows.filter((row) => row.placement === DEVICE_PLACEMENT.DRAWN).map((row) => row.code).join(',') === 'B', '只有同时属于快照清单且坐标准入有效的设备进入显示候选') const notice = crossEndpointNotice(plan.rows) check('B6-cross-endpoint-notice', notice.risky && notice.text.includes('不同版'), `跨端点风险如实提示:「${notice.text}」`) // 完全一致时不该无端报警。 const clean = planAssociatedDevices({ snapshotCodes: ['A'], cards: [card({ code: 'A' })], mode: LAYER_MODE.OVERVIEW, pointCode: 'MP-1', }) check('B7-no-notice-when-consistent', crossEndpointNotice(clean.rows).risky === false, '快照与详情一致时不弹风险提示(避免恒真提示)') // 去重:后端源顺序去重未排序,前端不重排。 check('B8-dedupe-keeps-order', JSON.stringify(uniqueCodes(['B', 'A', 'B', '', ' ', 'C', 'A'])) === JSON.stringify(['B', 'A', 'C']), `去重保序:${uniqueCodes(['B', 'A', 'B', '', ' ', 'C', 'A']).join(',')}`) // 清单尚未加载(快照里还没有这个点位)→ 全空且如实可辨。 const notLoaded = planAssociatedDevices({ snapshotCodes: [], cards: [], mode: LAYER_MODE.OVERVIEW, pointCode: 'MP-X' }) check('B9-empty-snapshot-yields-no-rows', notLoaded.total === 0 && notLoaded.rows.length === 0, '点位不在当前快照时总数为 0(调用方据此显示"待当前快照核对",不猜总数)') } // ================================================================ C 计数互斥(各类之和可解释总数) { // 计数互斥的算例**刻意只用"数据可用"的设备**:把优先级判定留给 C6/C7 专门测, // 两条断言各管一件事(否则退回模式优先后先红的是 C3,C6 的判据就形同虚设 —— 变异 M12 实测)。 const plan = planAssociatedDevices({ snapshotCodes: ['A', 'C', 'D', 'F'], cards: [ card({ code: 'A', status: 'ALARM' }), // 报警 → 预警处置里可上图 card({ code: 'C', status: 'NORMAL' }), // 数据没问题,只是本模式不显示 // D 无卡片 → 待确认 card({ code: 'F', status: 'WARNING' }), // 预警 → 也可上图 ], mode: LAYER_MODE.WARNING_RESPONSE, pointCode: 'MP-1', }) const counts = plan.counts const sum = Object.values(counts).reduce((total, value) => total + value, 0) check('C1-counts-are-mutually-exclusive', sum === plan.rows.length, `六类之和 ${sum} == 行数 ${plan.rows.length}(每台恰好一类)`) check('C2-drawn-count-matches-rows', counts[DEVICE_PLACEMENT.DRAWN] === 2 && counts[DEVICE_PLACEMENT.DRAWN] === plan.rows.filter((r) => r.placement === DEVICE_PLACEMENT.DRAWN).length, `已上图计数与行一致:${counts[DEVICE_PLACEMENT.DRAWN]}(报警 + 预警两台)`) check('C3-each-reason-has-a-row', counts[DEVICE_PLACEMENT.HIDDEN] === 1 && counts[DEVICE_PLACEMENT.PENDING] === 1, `差额各有实例可解释:${JSON.stringify(counts)}`) // **数据可用性先于模式可见性**(本票实现时抓到并修掉的真问题)。 // 算例必须是「模式**会**隐藏它、且数据也有问题」的组合 —— 否则两种顺序结果相同、判据形同虚设: // · `NORMAL` + `renderable:false`:先判模式 → HIDDEN(谎话:切模式也看不到它); // 先判数据 → NO_LOCATION(实话:它任何模式都上不了图)。 // (早先用 `ALARM` 当算例是错的:ALARM 在预警处置里可见,两种顺序都落 NO_LOCATION, // 变异 M12 因此测不出来 —— 这正是变异测试的价值。) const blockedInWarningMode = classifyDevice({ card: card({ status: 'NORMAL', coordinate: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false, validated: true } }), inSnapshot: true, detailOwned: false, mode: LAYER_MODE.WARNING_RESPONSE, }) check('C6-data-availability-beats-mode-visibility', blockedInWarningMode === DEVICE_PLACEMENT.NO_LOCATION, `预警处置里正常但坐标被否决 → ${blockedInWarningMode}(**不是**"模式隐藏":切模式也看不到它)`) // 反过来:数据好的设备在预警处置里因为状态被隐藏,这才是真的"模式隐藏"。 const normalInWarningMode = classifyDevice({ card: card({ status: 'NORMAL' }), inSnapshot: true, detailOwned: false, mode: LAYER_MODE.WARNING_RESPONSE, }) check('C7-good-data-hidden-by-mode', normalInWarningMode === DEVICE_PLACEMENT.HIDDEN, `预警处置里正常设备 → ${normalInWarningMode}(数据没问题,只是本模式不显示)`) // 独立图例:只统计本图层,且**合计 == 行数**(与摘要图例互不计入)。 // // 行数**不写死**:分类是跟着规格长的(票 44 追加了 `terrain-pending`), // 钉死 6 行会让"分类变多"这件事在闸门里表现为假红(本票实测)。 // 真正要守的判据是"图例与计数同源且完整",所以判"行数 == 分类数"。 const legend = associatedLegend(counts) const legendSum = legend.reduce((total, row) => total + row.count, 0) const placementKinds = Object.keys(DEVICE_PLACEMENT).length check('C4-legend-independent-and-complete', legendSum === plan.rows.length && legend.length === placementKinds, `设备图例合计 ${legendSum} == 行数 ${plan.rows.length};图例 ${legend.length} 行 == 分类 ${placementKinds} 类(独立于摘要图例)`) // 空输入不炸、全 0。 const empty = countPlacements([]) check('C5-empty-counts-zero', Object.values(empty).every((value) => value === 0), '空清单各类为 0') // 票 44:**坐标有效但地形未就绪** → 单列 `terrain-pending`(规格明写要与坐标无效分开)。 const terrainPending = classifyDevice({ card: card(), inSnapshot: true, detailOwned: false, mode: LAYER_MODE.OVERVIEW, terrainReady: false, }) check('C8-terrain-pending-is-its-own-class', terrainPending === DEVICE_PLACEMENT.TERRAIN_PENDING, `坐标有效 + 地形未就绪 → ${terrainPending}(不是 no-location、也不算已上图)`) // 对照组:地形就绪时同一台设备正常可上图(证明是地形那一条在起作用)。 const terrainReady = classifyDevice({ card: card(), inSnapshot: true, detailOwned: false, mode: LAYER_MODE.OVERVIEW, terrainReady: true, }) check('C9-terrain-ready-same-device-draws', terrainReady === DEVICE_PLACEMENT.DRAWN, `同一台设备地形就绪 → ${terrainReady}(对照组,证明判据是地形而非数据)`) } // ================================================================ D 静态守卫(口径不被污染) { 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 store = read('stores/deviceLayerStore.js') const selection = read('pointSelection.js') const shell = read('CockpitShell.vue') // ① 独立数据源:设备图层与摘要标记层必须是两个数据源(结构性保证不写回摘要)。 check('D1-separate-data-source', /export const DEVICE_MARKER_SOURCE = 'cockpit-associated-device-markers'/.test(engine) && /export const POINT_MARKER_SOURCE = 'cockpit-point-markers'/.test(engine) && /DEVICE_MARKER_SOURCE/.test(mapService), '关联设备有自己的数据源,与摘要点位标记分开') // ② 设备身份同时带 deviceCode 与 monitoringPointCode,**绝不写 pointCode**(不冒充监测点)。 const syncBody = (/export function syncAssociatedDevices[\s\S]*?\n\}/.exec(engine) || [''])[0] check('D2-device-identity-not-point-identity', /deviceCode: device\.code/.test(syncBody) && /monitoringPointCode: device\.pointCode/.test(syncBody) && !/\bpointCode:/.test(syncBody.replace(/monitoringPointCode/g, '')), '设备实体带 deviceCode + monitoringPointCode,且不写 pointCode(不冒充监测点)') // ③ 设备图层不写摘要计数(`data-map-markers*` 只由摘要路径写)。 // 按**函数体**切片:设备图层的读数走 `data-map-devices*`,与摘要的 `data-map-markers*` 分开。 const devicePaintBody = (/function paintDeviceState\(\)[\s\S]*?\n\}/.exec(mapService) || [''])[0] check('D3-device-layer-does-not-touch-summary-counters', /dataset\.mapDevices\b/.test(devicePaintBody) && !/mapMarkers/.test(devicePaintBody), '设备图层只写 data-map-devices*,不碰摘要的 data-map-markers*') // ④ 坐标准入只认后端 renderable(不重算经纬度范围)。 const domain = read('domain/deviceLayer.js') check('D4-admission-only-backend-renderable', /isRenderable\(card\)/.test(domain) && !/longitude\s*[<>]=?\s*-?1[78]0/.test(domain), '准入只认 isRenderable(后端结论),前端不另算经纬度范围') // ⑤ 绝不复制监测点坐标:设备行只从卡片取坐标。 check('D5-never-copies-point-coordinates', /longitude: card \? card\.longitude : null/.test(domain) && !/longitude:\s*object\.longitude|point\.longitude/.test(domain), '设备坐标只来自设备卡片,绝不借用点位坐标或人工偏移') // ⑥ 快照变化先撤旧图层(规格明写)。 // 守卫必须**只截 `invalidateForSnapshot` 自己的函数体**:整个文件里 `clearAssociatedDevices()` // 还有别的调用点(`syncForSelection` 的空选择分支、`reset`),对全文匹配会被它们满足 // —— 变异 M16 实测:拿掉这一处仍全绿。这是本仓第四次栽在"守卫比它要守的东西宽"上。 const invalidateBody = (/function invalidateForSnapshot\([^)]*\)[\s\S]*?\n \}/.exec(store) || [''])[0] check('D6-snapshot-change-clears-first', /clearAssociatedDevices\(\)/.test(invalidateBody) && invalidateBody.indexOf('clearAssociatedDevices()') < invalidateBody.indexOf('syncForSelection') && /syncDeviceLayerForSnapshot/.test(shell), '快照变化时**先**撤下旧图层再等新的(撤下在重算之前;外壳确实调了它)') // ⑦ 同快照翻页不重建:计划指纹去重。 check('D7-same-plan-not-repainted', /lastPlanKey/.test(store) && /key === lastPlanKey/.test(store), '同一份计划(同点位同清单)不重画 ⇒ 翻页不闪烁') // ⑧ 统一入口:地图与台账都走 selectMonitoringPoint,且它启动设备图层。 check('D8-single-entry-starts-device-layer', /deviceLayerStore\.syncForSelection/.test(selection), '统一选择入口启动设备图层(地图与台账因此行为一致)') // ⑨ 卸载时拆监听并复位(票 15/37 的"漏取消"坑)。 check('D9-teardown', /for \(const stop of stopDeviceWatches\) stop\(\)/.test(shell) && /deviceLayerStore\.reset\(\)/.test(shell), '卸载时拆掉四个监听并复位设备图层') // ⑩ 设备类型键与摘要对象并列而不相同。 check('D10-device-kind-distinct', DEVICE_LAYER_KIND === 'monitoring-device' && !/DEVICE_LAYER_KIND\s*=\s*'monitoring-point'/.test(domain), `设备类型键 ${DEVICE_LAYER_KIND} 与 monitoring-point 并列而不相同`) } 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(`T42 关联设备图层检查:${checks.length - failed.length}/${checks.length} 项通过。`) if (failed.length) process.exit(1) console.log('T42 关联设备图层全部通过。')