/** * 票 60 离线闸门:统一分页批次二 —— 点位摘要两级清单(设备卡片、关联设备图层清单、未命中子清单)。 * * 五组断言(不启浏览器、不联网;渲染层用 Vue 自定义渲染器在纯对象树上跑真实 patch,t64 成例): * S. SSR/静态守卫:PagerNav 的 showCount 默认 true(票 46/59 既有形态不变)、计数行是唯一受控块; * 点位卡三处接线 :show-count="false" 且零第二份翻页算术;设备详情侧未被牵连。 * R. 真实渲染(行为级):每页 ≤10;窗口逐条对内容;三清单页码互相独立; * pageCount<=1 不画控件;到边禁用保占位;页码直达/跳页夹回;空态文案沿用既有。 * K. 票 42 口径不变:store 仍持全部编码(含待确认/未命中),总数只认快照口径; * DOM 窗口化后总数/归类计数/图例纹丝不动(互斥归类之和仍等于全量行数)。 * Z. 复位与保持(条文 5/6):换点位/换设备/返回第一级 → 页码回第 1 页; * 同点位重进与数据刷新(selection 换了对象但标量键同值)→ **不**复位;越界夹回最后一页。 * C. SFC 真实编译(t41/59 教训:拼错的绑定只有加载层判得出)。 * * 变异有牙见 t60-mutations.mjs。 * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t60-point-detail-lists.mjs */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { registerHooks } from 'node:module' import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' import { createRenderer, h, nextTick } from '@vue/runtime-core' import { createPinia, setActivePinia } from 'pinia' const SRC = '../../../src/modules/cockpit/' const read = (rel) => readFileSync(new URL(SRC + rel, import.meta.url), 'utf8') const stripComments = (text) => text.replace(//g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') // runtime-dom 的 v-model 在 beforeUpdate 读 document.activeElement;自定义渲染器的宿主是纯对象树。 if (typeof globalThis.document === 'undefined') globalThis.document = { activeElement: null } let passed = 0 let failed = 0 function check(name, condition, detail = '') { if (condition) { passed += 1; console.log(`PASS ${name}`) } else { failed += 1; console.error(`FAIL ${name}: ${detail}`) } } // .vue 编译钩子:SSR 默认版 + `?client` 版(子导入按 parentURL 传播,t64 实测成例)。 registerHooks({ resolve(specifier, context, nextResolve) { const resolved = nextResolve(specifier, context) const parent = context.parentURL || '' if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) { return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true } } return resolved }, load(url, context, nextLoad) { if (url.endsWith('.json')) return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` } const isClient = url.endsWith('.vue?client') if (url.endsWith('.vue') || isClient) { const path = isClient ? url.slice(0, -'?client'.length) : url const source = readFileSync(new URL(path), 'utf8') const { descriptor } = parse(source, { filename: path }) const script = compileScript(descriptor, { id: 't60', genDefaultAs: '__sfc__' }) const template = compileTemplate({ source: descriptor.template.content, filename: path, id: 't60', ssr: !isClient, compilerOptions: { bindingMetadata: script.bindings }, }) assert.deepEqual(template.errors, [], `${path} template`) const tail = isClient ? '__sfc__.render = render; export default __sfc__;' : '__sfc__.ssrRender = ssrRender; export default __sfc__;' return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` } } return nextLoad(url, context) }, }) const pinia = createPinia() setActivePinia(pinia) const config = { dataSource: 'mock', api: { baseUrl: '', token: '' } } const { useDetailStore } = await import(`${SRC}stores/detailStore.js`) const { useMapStore } = await import(`${SRC}stores/mapStore.js`) const { useDeviceLayerStore } = await import(`${SRC}stores/deviceLayerStore.js`) const { registerMockTransport, resetMockTransportsForTest } = await import(`${SRC}services/cockpitApi.js`) const { default: ClientCard } = await import(`${SRC}components/PointDetailCard.vue?client`) // ================================================================ 场景数据(真实链路灌入,不喂视图半成品) const VALIDATION = { state: 'VALID', reason: '', crs: 'WGS84', renderable: true } // P-MANY:快照 15 台 + 详情独有 12 台(筛选生效 → 未命中)→ 卡片 27、命中清单 15、未命中 12。 const SNAPSHOT_15 = Array.from({ length: 15 }, (_, i) => `EQ-${String(i + 1).padStart(2, '0')}`) const DETAIL_27 = Array.from({ length: 27 }, (_, i) => `EQ-${String(i + 1).padStart(2, '0')}`) const deviceRaw = (code) => ({ deviceCode: code, deviceName: `设备${code}`, deviceTypeName: '压力计', deviceStatus: 'NORMAL', statusLabel: '正常', longitude: 110.4, latitude: 28.44, coordinateValidation: VALIDATION, }) const detailPayload = (pointCode, codes) => ({ monitoringPointCode: pointCode, monitoringPointName: `${pointCode} 点位`, pointStatus: 'NORMAL', statusLabel: '正常', devices: codes.map(deviceRaw), deviceStatistics: { totalDeviceCount: codes.length, normalDeviceCount: codes.length }, coordinateValidation: VALIDATION, }) const snapshotPayload = (pointCode, codes, extraPoints = []) => ({ page: 1, pageSize: 50, total: 1 + extraPoints.length, complete: true, snapshotAt: '2026-10-20T10:00:00+08:00', monitoringPoints: [{ pointCode, pointName: `${pointCode} 点位`, pointStatus: 'NORMAL', deviceCodes: codes, longitude: 110.4, latitude: 28.44, coordinateValidation: VALIDATION, }, ...extraPoints], unassociatedEquipment: [], }) const detail = useDetailStore() detail.configure({ config }) const map = useMapStore() map.setup({ config }) const layer = useDeviceLayerStore() resetMockTransportsForTest() registerMockTransport('/map-snapshot', () => snapshotPayload('P-MANY', SNAPSHOT_15, [ { pointCode: 'P-FEW', pointName: 'P-FEW 点位', pointStatus: 'NORMAL', deviceCodes: ['EQ-01', 'EQ-02'], longitude: 110.5, latitude: 28.45, coordinateValidation: VALIDATION, }, { pointCode: 'P-ZERO', pointName: 'P-ZERO 点位', pointStatus: 'EMPTY', deviceCodes: [], longitude: 110.6, latitude: 28.46, coordinateValidation: VALIDATION, }, ])) registerMockTransport('/monitoring-points/P-MANY', () => detailPayload('P-MANY', DETAIL_27)) registerMockTransport('/monitoring-points/P-FEW', () => detailPayload('P-FEW', ['EQ-01', 'EQ-02'])) registerMockTransport('/monitoring-points/P-ZERO', () => detailPayload('P-ZERO', [])) await map.advance() await detail.openPoint('P-MANY') // 票 14:设备级筛选生效(快照是命中子集)→ 详情独有落「未命中」,展开子清单才有得翻。 layer.syncForSelection({ force: true, mode: 'overview', filterActive: true }) // ================================================================ 假 DOM 渲染器(t64 成例) const nodeOps = { createElement: (tag) => ({ tag, props: {}, children: [], parent: null, text: '', style: {}, addEventListener() {}, removeEventListener() {} }), createText: (text) => ({ tag: '#text', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }), createComment: (text) => ({ tag: '#comment', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }), setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text; node.children = [] }, insert: (child, parent, anchor) => { if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child) child.parent = parent if (!parent) return if (anchor) { const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, child) } else parent.children.push(child) }, remove: (child) => { if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child); child.parent = null }, parentNode: (node) => node.parent, nextSibling: (node) => (node.parent ? node.parent.children[node.parent.children.indexOf(node) + 1] || null : null), querySelector: () => null, setScopeId: () => {}, patchProp: (el, key, _prev, next) => { el.props[key] = next }, } function findAll(node, predicate, acc = []) { if (!node) return acc if (predicate(node)) acc.push(node) for (const child of node.children || []) findAll(child, predicate, acc) return acc } function findNode(node, predicate) { if (!node) return null if (predicate(node)) return node for (const child of node.children || []) { const hit = findNode(child, predicate); if (hit) return hit } return null } function textOf(node) { if (!node) return '' const own = node.tag === '#text' ? node.text : typeof node.text === 'string' ? node.text : '' return own + (node.children || []).map(textOf).join('') } const byAttr = (name) => (node) => !!node.props && name in node.props const pagerWrap = (which) => findNode(container, (n) => String(n.props && n.props['data-point-pager']) === which) const app = createRenderer(nodeOps).createApp({ render: () => h(ClientCard, { locationReason: '' }) }) app.config.warnHandler = () => {} app.use(pinia) const container = nodeOps.createElement('#root') app.mount(container) const cardCodes = () => findAll(container, byAttr('data-device-card')).map((n) => n.props['data-device-code']) const cardIndexAttr = () => findAll(container, byAttr('data-device-card')).map((n) => Number(n.props['data-device-card'])) const layerCodes = () => findAll(container, (n) => 'data-device-layer-code' in (n.props || {}) && n.props['data-device-layer-row'] !== 'unmatched').map((n) => n.props['data-device-layer-code']) const unmatchedCodes = () => findAll(container, (n) => n.props && n.props['data-device-layer-row'] === 'unmatched').map((n) => n.props['data-device-layer-code']) const pagers = () => findAll(container, byAttr('data-pager')) const clickPage = async (which, n) => { const wrap = pagerWrap(which) assert.ok(wrap, `pager 包装层 ${which} 不在 DOM`) const btn = findNode(wrap, (node) => String(node.props && node.props['data-page-number']) === String(n)) assert.ok(btn && typeof btn.props.onClick === 'function', `pager ${which} 的页码按钮 ${n} 不在`) btn.props.onClick(new Proxy({}, { get: () => undefined })) await nextTick() } const clickPrevNext = async (which, attr) => { const btn = findNode(pagerWrap(which), byAttr(attr)) assert.ok(btn && typeof btn.props.onClick === 'function', `${which} 缺 ${attr}`) btn.props.onClick(new Proxy({}, { get: () => undefined })) await nextTick() } // ================================================================ R. 真实渲染(行为级) { // R1 设备卡片首屏只画 10 张(27 条 → 条文 1)。 check('R1 设备卡片每页 ≤10(27 条首屏 10 张)', cardCodes().length === 10, `got=${cardCodes().length}`) // R2 关联清单首屏 10 行(命中 15 台)。 check('R2 关联设备清单每页 ≤10(15 台首屏 10 行)', layerCodes().length === 10, `got=${layerCodes().length}`) // R3 三处 pager 都在(cards/layer 两页起步;unmatched 折叠未展开 ⇒ 其 pager 随折叠不存在)。 check('R3 两清单的 pager 都接线', !!pagerWrap('cards') && !!pagerWrap('layer') && pagers().length === 2, `pagers=${pagers().length}`) // R4 计数行不画(维护者裁定 2):三处 pager 里都没有 data-page-state。 const stateNodes = pagers().flatMap((p) => findAll(p, byAttr('data-page-state'))) check('R4 本组件 pager 一律不画计数行(无第二个总数口径)', stateNodes.length === 0, `state=${stateNodes.length}`) // R5 到边禁用保占位:第 1 页「上一页」disabled 但节点在。 const prev = findNode(pagerWrap('cards'), byAttr('data-page-prev')) check('R5 到边按钮 disabled 且保留占位', !!prev && prev.props.disabled === true, `prev=${prev && prev.props.disabled}`) // R6 点页码 2 → 窗口真的移动(逐条对编码,不是只数卡片)。 await clickPage('cards', 2) check('R6 卡片点页码 2 → 画第 11–20 台(逐条对编码)', cardCodes().join(',') === DETAIL_27.slice(10, 20).join(','), cardCodes().join(',')) // R7 独立页码:卡片翻页不牵连关联清单(它还在第 1 页)。 check('R7 两清单页码互相独立', layerCodes().join(',') === SNAPSHOT_15.slice(0, 10).join(','), layerCodes().join(',')) // R8 data-device-card 下标是**全局位置**(走查/探针按它取第 N 台,翻页不得让每页都从 0 数)。 check('R8 卡片 data-device-card 续全局下标(第 2 页是 10..19)', cardIndexAttr().join(',') === '10,11,12,13,14,15,16,17,18,19', cardIndexAttr().join(',')) // R9 关联清单翻页:第 2 页只剩余数 5 行(不补空行)。 await clickPage('layer', 2) check('R9 关联清单末页只画余数', layerCodes().join(',') === SNAPSHOT_15.slice(10, 15).join(','), layerCodes().join(',')) // R10 末页「下一页」disabled 且仍在。 const nextOnLast = findNode(pagerWrap('layer'), byAttr('data-page-next')) check('R10 末页下一页 disabled 保占位', !!nextOnLast && nextOnLast.props.disabled === true, `next=${nextOnLast && nextOnLast.props.disabled}`) // R11 上一页走同一出口:layer 从 2 回 1。 await clickPrevNext('layer', 'data-page-prev') check('R11 上一页回到第 1 页窗口', layerCodes().join(',') === SNAPSHOT_15.slice(0, 10).join(','), layerCodes().join(',')) } // ================================================================ T. 未命中子清单(裁定 1:第三套独立页码) { const toggle = findNode(container, byAttr('data-device-more-toggle')) assert.ok(toggle && typeof toggle.props.onClick === 'function', '「另有 N 台」入口不在 DOM') check('T1 折叠钮文案仍是全量 12 台', textOf(toggle).includes('另有 12 台'), textOf(toggle)) toggle.props.onClick(new Proxy({}, { get: () => undefined })) await nextTick() // T2 展开 → 第一页 ≤10 且独立第三套 pager 出现。 check('T2 未命中子清单每页 ≤10(12 条 → 首屏 10 行)', unmatchedCodes().length === 10, `got=${unmatchedCodes().length}`) check('T3 第三套独立页码就位', !!pagerWrap('unmatched') && pagers().length === 3, `pagers=${pagers().length}`) // T4 翻第 2 页 → 余数 2 行;前两清单窗口不受牵连。 const cardsBefore = cardCodes().join(',') const layerBefore = layerCodes().join(',') await clickPage('unmatched', 2) check('T4 未命中第 2 页只画余数', unmatchedCodes().join(',') === DETAIL_27.slice(25, 27).join(','), unmatchedCodes().join(',')) check('T4b 第三套翻页不牵连前两处', cardCodes().join(',') === cardsBefore && layerCodes().join(',') === layerBefore, '') // T5 折叠再展开:另一套语义——差额计数(另有 N 台)始终读**全量**,不是当前页。 const more = findNode(container, byAttr('data-device-more')) check('T5 另有 N 台读数仍是全量 12(与窗口无关)', more && String(more.props['data-device-more-count']) === '12', `count=${more && more.props['data-device-more-count']}`) } // ================================================================ K. 票 42 口径不变(窗口化只发生在渲染) // pinia 的 store 实例把 ref 解包成本体:这里读 layer.rows / layer.total 直接是数组与数字。 { // K1 store 全量保留:27 行、总数 27(15 快照 + 12 未命中——命中子集口径下含未命中)。 check('K1 store.rows 仍持全部编码(27 行,窗口化只在渲染层)', layer.rows.length === 27, `rows=${layer.rows.length}`) check('K1b 总数只认当前快照口径(15 命中 + 12 未命中 = 27)', layer.total === 27, `total=${layer.total}`) // K2 DOM 当前页 ≠ 全量:标题读 27,清单只画 5(layer 在第 2 页)。 const totalNode = findNode(container, byAttr('data-device-layer-total')) const drawnNode = findNode(container, byAttr('data-device-layer-drawn')) check('K2 标题「总数 27 台(已上图 15)」不受窗口影响', totalNode && textOf(totalNode) === '27' && drawnNode && textOf(drawnNode) === '15', `total=${totalNode && textOf(totalNode)} drawn=${drawnNode && textOf(drawnNode)}`) // K3 互斥归类之和 = 全量行数(每台恰好落一类,与窗口无关)。 const countSum = Object.values(layer.counts).reduce((acc, v) => acc + v, 0) check('K3 互斥归类之和仍等于全量 27', countSum === 27 && layer.counts.drawn === 15 && layer.counts.unmatched === 12, JSON.stringify(layer.counts)) // K4 图例合计 = 全量(差额可追溯不受分页影响)。 const legendSum = layer.legend.reduce((acc, row) => acc + row.count, 0) check('K4 设备图例合计 = 全量 27', legendSum === 27, `legend=${legendSum}`) } // ================================================================ Z. 复位与保持(条文 5/6) { // Z1 刷新数据(同点位重取,selection 标量键不变)→ 页码**保持**。 await detail.reloadPointDetail('P-MANY') layer.syncForSelection({ mode: 'overview', filterActive: true }) await nextTick() check('Z1 数据刷新不复位页码(卡片还在第 2 页)', cardCodes()[0] === DETAIL_27[10], `first=${cardCodes()[0]}`) // Z2 越界夹回:刷新后数据变少(改开 P-FEW 前先把卡片停末页再换数据)——用跳 999 夹回第 3 页。 const jumpInput = findNode(pagerWrap('cards'), byAttr('data-page-jump-input')) assert.ok(jumpInput && typeof jumpInput.props['onUpdate:modelValue'] === 'function', '跳页输入框未接线') jumpInput.props['onUpdate:modelValue']('999') await nextTick() findNode(pagerWrap('cards'), byAttr('data-page-jump-btn')).props.onClick() await nextTick() check('Z2 跳页超界夹回最后一页(7 条余数,空表不出现)', cardCodes().join(',') === DETAIL_27.slice(20, 27).join(','), cardCodes().join(',')) // Z3 重进同一点位(selection 换新对象、标量键同值)→ **不**误复位(票 43 的稳定键教训)。 await detail.openPoint('P-MANY') await nextTick() check('Z3 同点位重进不误复位(标量键同值不触发 watch)', cardCodes()[0] === DETAIL_27[20], `first=${cardCodes()[0]}`) // Z4 换点位 → 三处页码全回第 1 页;P-FEW 只有 2 条 → pageCount<=1 → 控件**整块不画**。 await detail.openPoint('P-FEW') layer.syncForSelection({ force: true, mode: 'overview', filterActive: true }) await nextTick() check('Z4 换点位回第 1 页窗口', cardCodes().join(',') === 'EQ-01,EQ-02', cardCodes().join(',')) check('Z4b 短列表(≤10)三处控件都不画(条文 4)', pagers().length === 0, `pagers=${pagers().length}`) // Z5 进设备级再返回第一级 → 页码复位(返回第一级也算一次层级条件变化)。 await detail.openPoint('P-MANY') layer.syncForSelection({ force: true, mode: 'overview', filterActive: true }) await clickPage('cards', 2) layer.selectDevice('EQ-03') await nextTick() layer.backToPointLevel() await nextTick() check('Z5 返回第一级页码回第 1 页', cardCodes()[0] === 'EQ-01' && layerCodes().length === 10, `first=${cardCodes()[0]}`) // Z6 空态文案沿用既有:P-ZERO(详情 0 台)→「该监测点当前没有关联设备」。 await detail.openPoint('P-ZERO') layer.syncForSelection({ force: true, mode: 'overview', filterActive: true }) await nextTick() const empty = findNode(container, byAttr('data-device-cards-empty')) check('Z6 空态文案原样保留(未被分页改写)', !!empty && textOf(empty).includes('该监测点当前没有关联设备'), textOf(empty)) check('Z6b 空清单不画控件', pagers().length === 0, `pagers=${pagers().length}`) } // ================================================================ S. 静态守卫(单源与默认形态不变) { const card = stripComments(read('components/PointDetailCard.vue')) const pager = stripComments(read('components/PagerNav.vue')) const sections = stripComments(read('components/EquipmentDetailSections.vue')) // S1 PagerNav 的 showCount 默认 true:票 46/59 既有消费方(设备详情三清单)形态零改动。 check('S1 showCount 默认 true(既有计数行不回归)', /showCount: \{ type: Boolean, default: true \}/.test(pager), '默认值被改了=批次一形态回归') check('S2 计数行是唯一受 showCount 门控的块(翻页能力不受它影响)', /v-if="showCount" class="pager-nav__state" data-page-state/.test(pager) && (pager.match(/v-if="showCount"/g) || []).length === 1, '') // S3 设备详情侧没被顺手关计数行(data-page-state 断言面保留,t59-S9 同源)。 check('S3 EquipmentDetailSections 不传 show-count(既有计数行保留)', !/show-count/.test(sections) && (sections.match(/ `\$\{selection\.value\.pointCode\}\|\$\{selection\.value\.deviceCode\}`/.test(card) && !/watch\(\s*\(\) => \[selection\.value\.pointCode/.test(card) && /pages\.value = \{ cards: 1, layer: 1, unmatched: 1 \}/.test(card), '复位触发器形状变了') // S8 数据源不被窗口反噬:store 的 rows/total/counts/legend 仍直接进标题与图例(t42/t44 断言面)。 check('S8 标题总数仍读 store total、归类仍读 store counts', /data-device-layer-total>\{\{ deviceLayerTotal \}\}\{\{ deviceCounts\.drawn \|\| 0 \}\}