/** * 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) }