|
|
@@ -0,0 +1,404 @@
|
|
|
+/**
|
|
|
+ * 票 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(/<!--[\s\S]*?-->/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(/<PagerNav\b/g) || []).length === 2, '设备详情三清单的计数行不该在本票变')
|
|
|
+ // S4 点位卡三处接线都关计数行、都走同一出口(唯一 @page→gotoPage;无第二套翻页算术)。
|
|
|
+ check('S4 点位卡三处接共享 PagerNav 且都 :show-count="false"',
|
|
|
+ (card.match(/<PagerNav\b/g) || []).length === 3 && (card.match(/:show-count="false"/g) || []).length === 3,
|
|
|
+ `uses=${(card.match(/<PagerNav\b/g) || []).length}`)
|
|
|
+ check('S5 三处共用唯一翻页出口 gotoPage(key, target)',
|
|
|
+ (card.match(/@page="\(target\) => gotoPage\('[a-z]+', target\)"/g) || []).length === 3 && /function gotoPage\(key, target\)/.test(card), '')
|
|
|
+ // S6 窗口取数只有一条 pagedRows 委托;组件零 Math/slice(条文 11)。
|
|
|
+ check('S6 窗口计算单源一条委托',
|
|
|
+ /return pagedRows\(rows, pages\.value\[key\]\)/.test(card) && !/Math\.(ceil|trunc|floor)/.test(card) && !/\.slice\(/.test(card), '组件里长出第二份算术')
|
|
|
+ // S7 页码状态是稳定标量键三元表 + 复位 watch 收**标量字符串**(票 43 教训:getter 回数组会引用不等假复位)。
|
|
|
+ check('S7 三清单各自独立页码 + 复位键是标量',
|
|
|
+ /const pages = ref\(\{ cards: 1, layer: 1, unmatched: 1 \}\)/.test(card) &&
|
|
|
+ /watch\(\s*\(\) => `\$\{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 \}\}</.test(card) && /data-device-layer-drawn>\{\{ deviceCounts\.drawn \|\| 0 \}\}</.test(card), '总数口径被组件改写')
|
|
|
+ // S9 空态文案原样在源码里(判绑定文本而不是新措辞)。
|
|
|
+ check('S9 空态文案沿用既有', card.includes('该监测点当前没有关联设备'), '文案被改了')
|
|
|
+}
|
|
|
+
|
|
|
+// ================================================================ C. SFC 真实编译
|
|
|
+
|
|
|
+for (const [label, rel] of [['card', 'components/PointDetailCard.vue'], ['pagernav', 'components/PagerNav.vue'], ['sections', 'components/EquipmentDetailSections.vue']]) {
|
|
|
+ let ok = false
|
|
|
+ let why = ''
|
|
|
+ try {
|
|
|
+ const source = read(rel)
|
|
|
+ const { descriptor, errors } = parse(source, { filename: rel })
|
|
|
+ assert.deepEqual(errors, [], `${rel} parse`)
|
|
|
+ const script = compileScript(descriptor, { id: 't60' })
|
|
|
+ const template = compileTemplate({ source: descriptor.template.content, filename: rel, id: 't60', compilerOptions: { bindingMetadata: script.bindings } })
|
|
|
+ assert.deepEqual(template.errors, [], `${rel} template`)
|
|
|
+ ok = true
|
|
|
+ } catch (error) {
|
|
|
+ why = error.message.split('\n')[0]
|
|
|
+ }
|
|
|
+ check(`C1-${label}-compiles`, ok, why)
|
|
|
+}
|
|
|
+
|
|
|
+resetMockTransportsForTest()
|
|
|
+layer.reset()
|
|
|
+detail.reset()
|
|
|
+console.log('')
|
|
|
+console.log(`T60 点位摘要两级清单分页:${passed}/${passed + failed} 项通过。`)
|
|
|
+if (failed) { console.log('未通过项见上方 FAIL。'); process.exit(1) }
|
|
|
+console.log('T60 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')
|