| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659 |
- /**
- * T10 监测点详情与设备卡片 纯逻辑检查(票 10)。
- *
- * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
- * 运行:node .scratch/cockpit-shell-panel-layout/verify/t10-point-detail.mjs
- *
- * 六段:
- * A 两级状态机:地图对象 → 选择 → 点位级 → 设备级 → 返回/关闭(纯函数,不取数、不碰地图)
- * B 点位摘要与设备卡片视图:名称/编码/状态/原因/关联设备数量;**卡片顺序原样来自后端**
- * C store:按编码取数、同一目标不重复取数、A→B 竞态收口、选设备不取数、失败不白屏
- * D 关联设备不重复上图:地图对象与标记计划在整条详情流程里一个字节不变(含对抗式反证)
- * E 点选手势与拾取边界:拖拽不点选、只认左键、只拾点位标记
- * F 静态守卫:组件不拼接口路径、不认后端裸字段、不对设备卡片排序
- *
- * 契约依据:CONTEXT.md「两级详情」(先点位摘要+设备卡片,再选设备进设备详情)与
- * `CockpitMonitoringPointDetailResponse` 的字段(`domain/pointDetail.js` 已按它适配)。
- */
- import { readFileSync } from 'node:fs'
- import { createPinia, setActivePinia } from 'pinia'
- import {
- DETAIL_LEVEL,
- DETAIL_PLACEHOLDER,
- associatedDeviceCount,
- backToPoint,
- closeDetail,
- deviceCardOf,
- deviceCards,
- emptyDetailSelection,
- isSamePointTarget,
- openDevice,
- openPoint,
- pointSummary,
- reasonText,
- selectionFromMapObject,
- statusView,
- } from '../../../src/modules/cockpit/domain/detailFlow.js'
- import { toMonitoringPointDetail } from '../../../src/modules/cockpit/domain/pointDetail.js'
- import { POINT_STATUS_VISUALS, UNKNOWN_VISUAL, planPointMarkers } from '../../../src/modules/cockpit/domain/pointStatus.js'
- import { registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
- import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
- import { CLICK_MAX_DRAG_PX, attachPointPick, isClickGesture } from '../../../src/modules/cockpit/services/pointPick.js'
- import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
- import { useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js'
- const checks = []
- function check(name, pass, evidence) {
- checks.push({ name, pass })
- console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
- }
- const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
- const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
- const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
- /** 去掉注释只留代码:本文件的注释会**解释**被禁的东西,直接对全文正则会拿假阳性换通过率。 */
- const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- const readCode = (relative) => code(read(relative))
- /** 造一张设备卡片(后端 `CockpitMonitoringPointDeviceCard`)。 */
- const rawDevice = ({ code: deviceCode = 'EQ-001', name = '供水压力计-001', typeName = '压力计', status = 'ALARM', label = '报警', reasons = [] } = {}) => ({
- deviceCode,
- deviceName: name,
- deviceTypeName: typeName,
- deviceStatus: status,
- statusLabel: label,
- statusReasons: reasons,
- statusTime: '2026-09-02T09:59:00+08:00',
- })
- /** 造一个后端监测点详情载荷。 */
- const rawPointDetail = ({ code = 'MP-002', name = '燃气调压站监测点', status = 'ALARM', devices = [], statistics, reasonList } = {}) => ({
- monitoringPointCode: code,
- monitoringPointName: name,
- pointStatus: status,
- statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[status] || '',
- statusReasons: reasonList || [{ status, deviceCode: 'EQ-001', reason: '压力超高' }],
- deviceStatistics:
- statistics === undefined
- ? {
- totalDeviceCount: devices.length,
- alarmDeviceCount: 1,
- warningDeviceCount: 0,
- workOrderDeviceCount: 0,
- normalDeviceCount: 1,
- offlineDeviceCount: 0,
- }
- : statistics,
- devices,
- coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
- statusDictionary: [{ status: 'ALARM', label: '报警', priority: 1 }],
- })
- const detailOf = (options) => toMonitoringPointDetail(rawPointDetail(options))
- /**
- * 假元素:把 DOM 事件序列在 node 里跑一遍(`attachPointPick` 只要求
- * `addEventListener`/`removeEventListener`,所以不需要 jsdom)。
- */
- function fakeElement() {
- const listeners = new Map()
- return {
- addEventListener(type, handler) {
- listeners.set(type, [...(listeners.get(type) || []), handler])
- },
- removeEventListener(type, handler) {
- listeners.set(type, (listeners.get(type) || []).filter((item) => item !== handler))
- },
- emit(type, event) {
- for (const handler of listeners.get(type) || []) handler(event)
- },
- count(type) {
- return (listeners.get(type) || []).length
- },
- }
- }
- /** 地图对象(`domain/mapSummary.js` 的形状)。 */const mapObject = (kind, code, name = `${code} 点位`) => ({
- kind,
- code,
- name,
- status: kind === 'monitoring-point' ? 'NORMAL' : '',
- statusReasons: [],
- longitude: 110.141,
- latitude: 28.452,
- coordinate: { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true },
- missing: [],
- })
- // ================================================================ A 两级状态机
- {
- const empty = emptyDetailSelection()
- check(
- 'A1-empty-selection-is-none',
- empty.level === DETAIL_LEVEL.NONE && empty.pointCode === '' && empty.deviceCode === '',
- JSON.stringify(empty),
- )
- const pointLevel = openPoint(empty, 'MP-002')
- check(
- 'A2-open-point-enters-first-level',
- pointLevel.level === DETAIL_LEVEL.POINT && pointLevel.pointCode === 'MP-002' && pointLevel.deviceCode === '',
- JSON.stringify(pointLevel),
- )
- const deviceLevel = openDevice(pointLevel, 'EQ-009')
- check(
- 'A3-select-device-enters-second-level',
- deviceLevel.level === DETAIL_LEVEL.EQUIPMENT && deviceLevel.pointCode === 'MP-002' && deviceLevel.deviceCode === 'EQ-009',
- `点位编码保留:${deviceLevel.pointCode}`,
- )
- // 换点位要把设备选择清掉:否则会把上一台设备的详情挂在新的点位下面。
- const switched = openPoint(deviceLevel, 'MP-003')
- check(
- 'A4-switching-point-clears-device',
- switched.level === DETAIL_LEVEL.POINT && switched.pointCode === 'MP-003' && switched.deviceCode === '',
- JSON.stringify(switched),
- )
- const back = backToPoint(deviceLevel)
- check(
- 'A5-back-returns-to-point-without-device',
- back.level === DETAIL_LEVEL.POINT && back.pointCode === 'MP-002' && back.deviceCode === '',
- JSON.stringify(back),
- )
- // 未关联设备直接进第二级:它没有点位摘要可回,「返回点位」不该把用户送到不存在的点位。
- const unassociated = selectionFromMapObject(mapObject('unassociated-equipment', 'EQ-100', '井盖-100'))
- check(
- 'A6-unassociated-equipment-goes-straight-to-device',
- unassociated.level === DETAIL_LEVEL.EQUIPMENT && unassociated.pointCode === '' && unassociated.deviceCode === 'EQ-100' && backToPoint(unassociated).level === DETAIL_LEVEL.NONE,
- JSON.stringify(unassociated),
- )
- check(
- 'A7-point-marker-maps-to-point-level',
- selectionFromMapObject(mapObject('monitoring-point', 'MP-002')).level === DETAIL_LEVEL.POINT,
- '点监测点 → 第一级',
- )
- check(
- 'A8-unknown-object-opens-nothing',
- selectionFromMapObject({ kind: 'pipeline', code: 'PL-1' }) === null &&
- selectionFromMapObject(mapObject('monitoring-point', '')) === null &&
- selectionFromMapObject(null) === null,
- '认不出的对象与没有编码的对象都不打开卡片(不猜)',
- )
- check(
- 'A9-close-is-empty',
- closeDetail().level === DETAIL_LEVEL.NONE && openDevice(pointLevel, '').deviceCode === '',
- '关闭回空选择;空编码不改变选择',
- )
- check(
- 'A10-same-point-target',
- isSamePointTarget(deviceLevel, 'MP-002') === true && isSamePointTarget(deviceLevel, 'MP-003') === false && isSamePointTarget(empty, 'MP-002') === false,
- '同一目标(含已选设备那一级)才判「不用重复取数」',
- )
- }
- // ================================================================ B 摘要与卡片视图
- {
- const detail = detailOf({
- code: 'MP-002',
- name: '燃气调压站监测点',
- status: 'ALARM',
- devices: [rawDevice({ code: 'EQ-002', name: '智能井盖-002', status: 'NORMAL', label: '正常' }), rawDevice({ code: 'EQ-001', reasons: ['压力超高'] })],
- })
- const summary = pointSummary(detail)
- check(
- 'B1-summary-has-five-fields',
- ['name', 'code', 'status', 'reason', 'deviceCount'].every((key) => key in summary),
- Object.keys(summary).join(', '),
- )
- check(
- 'B2-summary-values',
- summary.name === '燃气调压站监测点' && summary.code === 'MP-002' && summary.status.label === '报警' && summary.status.key === 'ALARM' && summary.deviceCount === 2,
- JSON.stringify({ name: summary.name, code: summary.code, status: summary.status.label, count: summary.deviceCount }),
- )
- check('B3-summary-reason', summary.reason === '压力超高', `原因:${summary.reason}`)
- check(
- 'B4-summary-status-has-shape-and-label',
- !!summary.status.shape && !!summary.status.tone && !!summary.status.label,
- `${summary.status.tone}/${summary.status.shape}/${summary.status.label}`,
- )
- // 原因:去重保序、混吃两种形状(点位是对象数组、设备卡片是字符串数组)。
- check(
- 'B5-reason-dedupes-and-keeps-order',
- reasonText([{ reason: '压力超高' }, { reason: '通信中断' }, { reason: '压力超高' }]) === '压力超高;通信中断' &&
- reasonText(['压力超高', '压力超高']) === '压力超高',
- reasonText([{ reason: '压力超高' }, { reason: '通信中断' }, { reason: '压力超高' }]),
- )
- check(
- 'B6-no-reason-is-empty-not-normal',
- reasonText([]) === '' && reasonText(undefined) === '' && pointSummary(detailOf({ reasonList: [] })).reason === '',
- '后端没给原因时是空串(界面显示「暂无原因」),不是「正常」',
- )
- check(
- 'B7-unknown-status-not-guessed',
- statusView('MAINTENANCE').key === UNKNOWN_VISUAL.key && pointSummary(detailOf({ status: 'MAINTENANCE' })).status.label === UNKNOWN_VISUAL.label,
- `认不出的点位状态 → ${UNKNOWN_VISUAL.label}`,
- )
- check(
- 'B8-backend-label-wins',
- statusView('ALARM', '报警(后端词)').label === '报警(后端词)' && POINT_STATUS_VISUALS.ALARM.label === '报警',
- '后端给了 statusLabel 就用后端的词,前端不另造业务词汇',
- )
- check(
- 'B9-missing-fields-placeholder',
- pointSummary(detailOf({ name: '' })).name === DETAIL_PLACEHOLDER && deviceCards(detailOf({ devices: [rawDevice({ name: '' })] }))[0].name === DETAIL_PLACEHOLDER,
- `缺字段显示 ${DETAIL_PLACEHOLDER},不留空白也不编造`,
- )
- // 关联设备数量:后端统计是权威值;统计整段缺失时按已返回卡片数如实报,不显示假 0。
- check(
- 'B10-count-prefers-backend-statistics',
- associatedDeviceCount(detailOf({ devices: [rawDevice(), rawDevice({ code: 'EQ-003' })], statistics: { totalDeviceCount: 7, alarmDeviceCount: 1, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 0 } })) === 7,
- '后端给 7 台、只回了 2 张卡片时,摘要仍说 7(统计是权威值)',
- )
- check(
- 'B11-count-falls-back-to-cards-when-statistics-missing',
- associatedDeviceCount(detailOf({ devices: [rawDevice(), rawDevice({ code: 'EQ-003' })], statistics: null })) === 2,
- '统计缺失时按已返回的 2 张卡片报,不显示「0 台」',
- )
- check(
- 'B12-count-zero-is-real-zero',
- associatedDeviceCount(detailOf({ devices: [], statistics: { totalDeviceCount: 0, alarmDeviceCount: 0, warningDeviceCount: 0, workOrderDeviceCount: 0, normalDeviceCount: 0, offlineDeviceCount: 0 } })) === 0,
- '后端确实说 0 台时就是 0(真 0 不是缺失)',
- )
- // 验收第 3 条:设备卡片顺序由后端返回顺序决定 —— 两种排列都原样输出,说明顺序来自输入。
- const firstOrder = ['EQ-B', 'EQ-A', 'EQ-C']
- const secondOrder = ['EQ-C', 'EQ-B', 'EQ-A']
- const cardsOf = (order) => deviceCards(detailOf({ devices: order.map((code) => rawDevice({ code })) })).map((card) => card.code)
- check(
- 'B13-device-card-order-is-backend-order',
- cardsOf(firstOrder).join(',') === firstOrder.join(',') && cardsOf(secondOrder).join(',') === secondOrder.join(','),
- `${cardsOf(firstOrder).join(',')} / ${cardsOf(secondOrder).join(',')}`,
- )
- check(
- 'B14-card-view-fields',
- (() => {
- const card = deviceCards(detailOf({ devices: [rawDevice({ code: 'EQ-1', typeName: '压力计', status: 'ALARM', label: '报警', reasons: ['压力超高'] })] }))[0]
- return card.code === 'EQ-1' && card.typeName === '压力计' && card.status.label === '报警' && card.reason === '压力超高'
- })(),
- '卡片带编码/名称/类型/状态/原因',
- )
- check(
- 'B15-device-card-of',
- deviceCardOf(detail, 'EQ-001').code === 'EQ-001' && deviceCardOf(detail, 'EQ-404') === null && deviceCardOf(detail, '') === null,
- '第二级的表头按设备编码取卡片,取不到回 null',
- )
- }
- // ================================================================ C store:取数与层级
- {
- setActivePinia(createPinia())
- const detailStore = useDetailStore()
- const mapStore = useMapStore()
- let pointRequests = 0
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', mapSnapshot: { pageSize: 2 }, handlers: { registerMockTransport, resetMockTransportsForTest } })
- // 计数用:精确路径优先于 `/monitoring-points/*` 模板(票 05 的匹配顺序)。
- registerMockTransport('/monitoring-points/MP-COUNT', () => {
- pointRequests += 1
- return rawPointDetail({ code: 'MP-COUNT', devices: [rawDevice({ code: 'EQ-002' }), rawDevice({ code: 'EQ-001' })] })
- })
- detailStore.configure({ config: mockConfig })
- mapStore.setup({ config: mockConfig })
- await mapStore.advance()
- const selection = await detailStore.openPoint('MP-COUNT')
- const section = detailStore.sections.pointDetail
- check(
- 'C1-open-point-loads-first-level',
- selection.level === DETAIL_LEVEL.POINT && selection.pointCode === 'MP-COUNT' && section.state === 'success' && section.data.code === 'MP-COUNT' && section.loaded === true,
- JSON.stringify({ level: selection.level, state: section.state, code: section.data && section.data.code }),
- )
- check('C2-fetch-happened-once', pointRequests === 1, `请求 ${pointRequests} 次`)
- // 同一个点位再点一次:层级回到第一级,但不再发请求(点位摘要没变)。
- await detailStore.selectDevice('EQ-001')
- const reopened = await detailStore.openPoint('MP-COUNT')
- check(
- 'C3-same-point-does-not-refetch',
- reopened.level === DETAIL_LEVEL.POINT && reopened.deviceCode === '' && pointRequests === 1,
- `再次打开后请求数仍为 ${pointRequests}`,
- )
- // 第二级:换层级,并按设备编码加载设备详情聚合(票 11 接手了这条取数;
- // 票 10 的票面原本就写明「设备详情聚合的加载归票 11」——那条接缝当时是**延期**,
- // 不是永久契约,所以这里断言的是票 11 之后的行为)。
- const deviceLevel = await detailStore.selectDevice('EQ-001')
- check(
- 'C4-select-device-enters-level-and-loads-by-code',
- deviceLevel.level === DETAIL_LEVEL.EQUIPMENT &&
- deviceLevel.deviceCode === 'EQ-001' &&
- deviceLevel.pointCode === 'MP-COUNT' &&
- detailStore.sections.equipmentAggregate.loaded === true &&
- detailStore.sections.equipmentAggregate.data.deviceCode === 'EQ-001' &&
- pointRequests === 1,
- JSON.stringify({
- level: deviceLevel.level,
- aggregateCode: detailStore.sections.equipmentAggregate.data.deviceCode,
- pointRequests,
- }),
- )
- check(
- 'C5-point-detail-kept-for-device-header',
- deviceCardOf(detailStore.sections.pointDetail.data, deviceLevel.deviceCode).code === 'EQ-001',
- '第二级的表头仍读第一级已加载的数据(不必再请求)',
- )
- // 返回第一级:不清掉已加载的点位摘要,也不再取数。
- const back = detailStore.backToPoint()
- check(
- 'C6-back-keeps-loaded-section',
- back.level === DETAIL_LEVEL.POINT && detailStore.sections.pointDetail.loaded === true && pointRequests === 1,
- `返回后请求数仍为 ${pointRequests}`,
- )
- // 竞态:先点 A(慢)再点 B(快),B 的数据必须赢,A 迟到不得覆盖。
- let releaseA = null
- let releaseBCalled = false
- registerMockTransport('/monitoring-points/MP-A', () => new Promise((resolve) => {
- releaseA = () => resolve(rawPointDetail({ code: 'MP-A', name: 'A 点位' }))
- }))
- registerMockTransport('/monitoring-points/MP-B', () => {
- releaseBCalled = true
- return Promise.resolve(rawPointDetail({ code: 'MP-B', name: 'B 点位' }))
- })
- const pendingA = detailStore.openPoint('MP-A')
- const doneB = await detailStore.openPoint('MP-B')
- const afterB = detailStore.sections.pointDetail.data.code
- releaseA()
- await pendingA
- await new Promise((resolve) => setTimeout(resolve, 0))
- check(
- 'C7-race-latest-wins',
- releaseBCalled === true && afterB === 'MP-B' && doneB.pointCode === 'MP-B' && detailStore.sections.pointDetail.data.code === 'MP-B',
- `B 落地后 section.code=${detailStore.sections.pointDetail.data.code}(A 迟到被丢弃)`,
- )
- // 关闭:选择回空,界面据此收掉浮层。
- const closed = detailStore.closeDetail()
- check('C8-close-clears-selection', closed.level === DETAIL_LEVEL.NONE && detailStore.selection.pointCode === '', JSON.stringify(closed))
- // 失败样例:分区如实失败,但选择仍在点位级(界面显示失败态,不是白屏、也不自动关窗)。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'failed', onlyPath: '/monitoring-points/*', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const failedStore = useDetailStore()
- failedStore.reset()
- failedStore.configure({ config: mockConfig })
- await failedStore.openPoint('MP-FAIL')
- check(
- 'C9-failure-keeps-selection-and-reports',
- failedStore.selection.level === DETAIL_LEVEL.POINT &&
- failedStore.sections.pointDetail.state === 'failed' &&
- failedStore.sections.pointDetail.loaded === true &&
- failedStore.sections.pointDetail.data === null,
- JSON.stringify({ level: failedStore.selection.level, state: failedStore.sections.pointDetail.state }),
- )
- // 失败之后**再点同一个点位**要能重试(留下恢复路径):第一次抛错、第二次成功。
- let flakyAttempts = 0
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- registerMockTransport('/monitoring-points/MP-FLAKY', () => {
- flakyAttempts += 1
- if (flakyAttempts === 1) throw new Error('mock: 第一次读取失败')
- return rawPointDetail({ code: 'MP-FLAKY', name: '重试点位' })
- })
- const retryStore = useDetailStore()
- retryStore.reset()
- retryStore.configure({ config: mockConfig })
- await retryStore.openPoint('MP-FLAKY')
- const firstAttemptState = retryStore.sections.pointDetail.state
- await retryStore.openPoint('MP-FLAKY')
- check(
- 'C11-failed-point-can-be-retried',
- firstAttemptState === 'failed' &&
- flakyAttempts === 2 &&
- retryStore.sections.pointDetail.state === 'success' &&
- retryStore.sections.pointDetail.data.code === 'MP-FLAKY',
- `第一次 ${firstAttemptState} → 再点一次(第 ${flakyAttempts} 次请求)后 ${retryStore.sections.pointDetail.state}`,
- )
- // 未关联设备入口:没有点位编码,不请求点位详情。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const deviceOnly = useDetailStore()
- deviceOnly.reset()
- deviceOnly.configure({ config: mockConfig })
- await deviceOnly.openDevice('EQ-100')
- check(
- 'C10-unassociated-device-needs-no-point-request',
- deviceOnly.selection.level === DETAIL_LEVEL.EQUIPMENT &&
- deviceOnly.selection.pointCode === '' &&
- deviceOnly.sections.pointDetail.loaded === false,
- JSON.stringify(deviceOnly.selection),
- )
- // ---- D 关联设备不重复上图 ----
- // 回到默认契约样例:`/monitoring-points/*` 会给两台关联设备(EQ-001 / EQ-002)。
- // 前后各拍一次地图对象与标记计划,验证整条详情流程(取数 → 设备卡片 → 选设备)都没碰地图。
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const objectsBeforeDetail = mapStore.summary.objects.map((item) => `${item.kind}|${item.code}`)
- const planBefore = planPointMarkers(mapStore.summary.objects).markers.map((marker) => marker.key)
- await detailStore.openPoint('MP-COUNT')
- detailStore.selectDevice('EQ-001')
- const objectsAfter = mapStore.summary.objects.map((item) => `${item.kind}|${item.code}`)
- const planAfter = planPointMarkers(mapStore.summary.objects).markers.map((marker) => marker.key)
- const detailCode = detailStore.sections.pointDetail.data
- const detailDeviceCodes = detailCode ? detailCode.devices.map((card) => card.code) : []
- check(
- 'D1-map-objects-untouched-by-detail-flow',
- objectsAfter.join(',') === objectsBeforeDetail.join(',') && planAfter.join(',') === planBefore.join(','),
- `${objectsAfter.length} 个地图对象、${planAfter.length} 个标记,详情流程前后一致`,
- )
- check(
- 'D2-associated-devices-are-not-markers',
- detailDeviceCodes.length === 2 && detailDeviceCodes.every((deviceCode) => !planAfter.some((key) => key.endsWith(`|${deviceCode}`))),
- `详情里的设备 ${detailDeviceCodes.join(', ')} 都不在标记计划里(${planAfter.join(' / ')})`,
- )
- // 对抗式反证:把设备当**地图对象**喂进计划确实会多出标记 —— 所以上面那条不是恒真。
- const wouldBeMarkers = planPointMarkers(
- detailDeviceCodes.map((deviceCode) => mapObject('unassociated-equipment', deviceCode, deviceCode)),
- )
- check(
- 'D3-guard-has-teeth',
- wouldBeMarkers.markers.length === detailDeviceCodes.length && wouldBeMarkers.markers.length > 0,
- `把设备当地图对象喂进去会多出 ${wouldBeMarkers.markers.length} 个标记(因此「不喂」才是有意义的约束)`,
- )
- }
- // ================================================================ E 点选手势与拾取边界
- {
- check(
- 'E1-click-gesture-threshold',
- isClickGesture({ x: 100, y: 100 }, { x: 100, y: 100 }) === true &&
- isClickGesture({ x: 100, y: 100 }, { x: 102, y: 102 }) === true &&
- isClickGesture({ x: 100, y: 100 }, { x: 100 + CLICK_MAX_DRAG_PX + 2, y: 100 }) === false &&
- isClickGesture({ x: 100, y: 100 }, { x: 40, y: 220 }) === false,
- `阈值 ${CLICK_MAX_DRAG_PX}px:拖拽不点选`,
- )
- check(
- 'E2-gesture-safe-on-missing-input',
- isClickGesture(null, { x: 1, y: 1 }) === false && isClickGesture({ x: 0, y: 0 }, undefined) === false,
- )
- const pickCode = readCode('services/pointPick.js')
- check(
- 'E3-pick-input-only-observes',
- !/preventDefault|stopPropagation/.test(pickCode) && /event\.button === 0/.test(pickCode) && /pointerdown/.test(pickCode) && /pointerup/.test(pickCode),
- '只观察、只认左键:相机拖拽手感不受影响',
- )
- check(
- 'E4-pick-input-has-detach',
- /export function attachPointPick/.test(pickCode) && /removeEventListener/.test(pickCode),
- '返回 detach:离开 /cockpit 时摘干净',
- )
- // 真行为(脱浏览器):拿一个假元素把 DOM 事件序列跑一遍 ——
- // 「点一下开卡片、拖一下不开、右键不开、摘掉之后不再开」四件事在绑定层就定死。
- {
- const element = fakeElement()
- const picks = []
- const detach = attachPointPick(element, (point) => picks.push(point))
- element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
- element.emit('pointerup', { pointerId: 1, clientX: 101, clientY: 100 })
- check(
- 'E4b-real-click-fires-pick',
- picks.length === 1 && picks[0].x === 101 && picks[0].y === 100,
- `点一下 → onPick(${JSON.stringify(picks[0] || null)})`,
- )
- element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
- element.emit('pointerup', { pointerId: 1, clientX: 160, clientY: 100 })
- element.emit('pointerdown', { button: 2, clientX: 100, clientY: 100, pointerId: 1 })
- element.emit('pointerup', { pointerId: 1, clientX: 100, clientY: 100 })
- element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
- element.emit('pointercancel', { pointerId: 1, clientX: 100, clientY: 100 })
- check('E4c-drag-right-button-cancel-do-not-pick', picks.length === 1, `拖拽/右键/取消之后仍只有 ${picks.length} 次点选`)
- detach()
- element.emit('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
- element.emit('pointerup', { pointerId: 1, clientX: 100, clientY: 100 })
- check(
- 'E4d-detach-removes-listeners',
- element.count('pointerdown') === 0 && element.count('pointerup') === 0 && picks.length === 1,
- '摘掉监听后不再触发点选',
- )
- }
- const engineCode = readCode('services/cesiumEngine.js')
- check(
- 'E5-engine-pick-only-marker-identity',
- /export function pickPointMarkerAt/.test(engineCode) &&
- /picked\.id/.test(engineCode) &&
- /properties\.pointCode/.test(engineCode) &&
- /return \{ kind: .*code: .*status: .*\} \| null|return \{ kind/.test(engineCode),
- '只认带 pointCode 的实体,且回的是身份(kind/code/status),不漏 Cesium 实体',
- )
- check(
- 'E6-pick-uses-canvas-coordinates',
- /getBoundingClientRect/.test(engineCode) && /windowX - rect\.left/.test(engineCode),
- '事件给窗口坐标、scene.pick 吃画布坐标,先减去画布原点',
- )
- const mapServiceCode = readCode('services/mapService.js')
- check(
- 'E7-pick-lifecycle-symmetric',
- /record\.detachPickInput = attachPointPick/.test(mapServiceCode) &&
- /record\.detachPickInput\(\)/.test(mapServiceCode) &&
- /export function onPointPick/.test(mapServiceCode),
- '引擎就绪时挂、离开时摘;订阅接口由组件使用',
- )
- check(
- 'E8-pick-failure-opens-nothing',
- /if \(!picked\) return null/.test(mapServiceCode),
- '拾不到标记(点空白/地形/影像)时不打开任何详情',
- )
- }
- // ================================================================ F 静态守卫
- {
- const cardCode = readCode('components/PointDetailCard.vue')
- const shellCode = readCode('CockpitShell.vue')
- const flowCode = readCode('domain/detailFlow.js')
- check(
- 'F1-card-does-not-build-api-paths',
- !/\/monitoring-points\/|\/equipments\//.test(cardCode) && !/fetch\(/.test(cardCode),
- '接口路径只在 services/detailApi.js,组件不自己拼',
- )
- // 判据只针对**代码体的字段访问**:先剥掉 import 行 ——
- // 票 44 移入了 `domain/deviceStatus.js`,文件名恰好与后端裸字段同名,
- // 不剥离就会把「导入设备状态词表」误判成「组件读后端字段」(守卫误伤,实测确认)。
- // 这是收窄而不是放宽:真正的字段访问仍会被抓到。
- const cardCodeBody = cardCode
- .split('\n')
- .filter((line) => !/^\s*import\s/.test(line))
- .join('\n')
- check(
- 'F2-card-has-no-backend-field-names',
- !/monitoringPointCode|monitoringPointName|pointStatus|deviceStatistics|statusReasons|deviceStatus/.test(cardCodeBody),
- '组件只读领域字段(domain/detailFlow.js 的视图对象)',
- )
- check(
- 'F3-no-sorting-of-device-cards',
- !/\.sort\(/.test(cardCode) && !/\.sort\(/.test(flowCode),
- '设备卡片不排序(验收第 3 条:顺序由后端返回顺序决定)',
- )
- // 详情的三处(domain/store/组件)都不许碰地图标记:标记计划的唯一来源是 mapStore 的地图摘要。
- // 注意分两级判:外壳本来就要用 mapService 的相机/归位入口(票 30),所以对「是否 import mapService」
- // 只查详情自身那三处;而「碰不碰标记层」对四处都查。
- const detailFiles = ['domain/detailFlow.js', 'stores/detailStore.js', 'components/PointDetailCard.vue']
- const markerOffenders = [...detailFiles, 'CockpitShell.vue'].filter((file) =>
- /planPointMarkers|syncPointMarkersForSummary|clearPointMarkers/.test(readCode(file)),
- )
- const serviceOffenders = detailFiles.filter((file) => /mapService/.test(readCode(file)))
- check(
- 'F4-detail-path-never-touches-markers',
- markerOffenders.length === 0 && serviceOffenders.length === 0,
- markerOffenders.length || serviceOffenders.length
- ? `碰了标记层:${markerOffenders.join(', ')};直接依赖 mapService:${serviceOffenders.join(', ')}`
- : '详情流程与地图标记层零耦合(关联设备不可能被重复画上去)',
- )
- // 标记计划的唯一裁决点(票 46 更新):MapCanvas 只把**计划**交给服务层,
- // `planPointMarkers` 全仓只在 `domain/modeLayerPlan.js` 一处调用 ——
- // 模式可见性在领域层收口,服务层/组件都不做第二次过滤(意图与票 10 一致:一份计划,不散落)。
- const mapCanvasCode = readCode('components/MapCanvas.vue')
- const planPointMarkersCallers = ['domain/modeLayerPlan.js', 'services/mapService.js', 'components/MapCanvas.vue']
- .map((file) => ({ file, count: (readCode(file).match(/planPointMarkers\(/g) || []).length }))
- check(
- 'F5-marker-source-is-map-summary-only',
- /syncPointMarkersForPlan\(\{/.test(mapCanvasCode) &&
- planPointMarkersCallers.every((item) => (item.file === 'domain/modeLayerPlan.js') === (item.count === 1)),
- 'mapLayerPlan 是 planPointMarkers 的唯一调用点,MapCanvas 只把计划交给 syncPointMarkersForPlan',
- )
- check(
- 'F6-card-mounts-detail-store',
- /useDetailStore/.test(cardCode) && /detailStore\.selectDevice/.test(cardCode) && /detailStore\.closeDetail/.test(cardCode),
- '组件的动作全部交回 store(组件里没有自己的状态机副本)',
- )
- check(
- 'F7-shell-positions-card-without-map-rebuild',
- /v-if="detailOpen &&/.test(shellCode) && /cockpit-shell__detail/.test(shellCode) && /PointDetailCard/.test(shellCode),
- '浮层是 main 的兄弟并由外壳定位:地图节点结构不变(票 02 的常驻语义;票 46 起条件含选择态暂停)',
- )
- }
- const failed = checks.filter((item) => !item.pass)
- console.log('')
- console.log(`T10 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:' + failed.map((item) => item.name).join(', '))
- process.exit(1)
- }
|