|
@@ -0,0 +1,511 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 票 64 离线闸门:基础设施「运行信息」指标列表接入统一分页 +「指标占位」块名纠正。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 背景(票面):`/infrastructure/operating-information` 一次回 **6772 条**指标
|
|
|
|
|
+ * (GAS 699/WATER_SUPPLY 2497/DRAINAGE 3576,2026-10-19 实测),响应 2.3 MB;
|
|
|
|
|
+ * 前端曾 `listBlock('指标占位', metrics)` 全量直渲 —— 一个行业一屏几千张卡。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 九组断言(都不启浏览器、不联网;渲染层用 Vue 自定义渲染器在纯对象树上跑真实 patch):
|
|
|
|
|
+ * P/R 页码寻址与复位单源 —— 键**只用块归属**(不把筛选条件编进键,否则切走再切回会停在旧页,
|
|
|
|
|
+ * 违反条文 6);复位走显式清槽 `resetListPages`(票 46/59 成例),且只清点名的块。
|
|
|
|
|
+ * D 领域层(真实载荷):标题如实 + 声明分页 + 来源口径 + 行键唯一 + 行序不改 + 票 50 裁定不回归。
|
|
|
|
|
+ * S 渲染层 SSR:每页 ≤10、计数行、旧措辞不上屏、空块/短列表不画控件。
|
|
|
|
|
+ * N 排除项没被顺手接入(9 行的、以及**12 行但未声明**的块都照旧全画)+ 渲染层零第二份算术/出口唯一。
|
|
|
|
|
+ * V 真实交互:点页码→窗口真的移动;刷新→页码保持;换片区/换行业→回第 1 页;
|
|
|
|
|
+ * **切走再切回也回第 1 页**(V11);全 70 页逐页对内容(V12);到边禁用保占位;跳页超界夹回。
|
|
|
|
|
+ * M mock 同构:mock 载荷走同一条分页声明路径;不足一页不画控件=**裁定后的预期行为**
|
|
|
|
|
+ * (维护者 2026-10-19:以真实数据为准,不改 `contractMocks` 静态样例扩面;闸门灌数照旧)。
|
|
|
|
|
+ * K SFC 真实编译(t41 教训)+ 外壳接线(当前片区传给运行信息面板=pageScope 的片区半边)。
|
|
|
|
|
+ * Z pagination 单源未回归。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 变异有牙见 t64-mutations.mjs。
|
|
|
|
|
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t64-operation-metrics-pagination.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, ref } from '@vue/runtime-core'
|
|
|
|
|
+import { createSSRApp } from 'vue'
|
|
|
|
|
+import { renderToString } from '@vue/server-renderer'
|
|
|
|
|
+import { PAGE_SIZE, clampPage, listPageKey, pageWindow, pagedRows, readListPage, resetListPages, writeListPage } from '../../../src/modules/cockpit/domain/pagination.js'
|
|
|
|
|
+
|
|
|
|
|
+const SRC = '../../../src/modules/cockpit/'
|
|
|
|
|
+const read = (rel) => readFileSync(new URL(SRC + rel, import.meta.url), 'utf8')
|
|
|
|
|
+/** 判据钉代码,不钉说明文字(t59 成例):注释里的字样不算命中。 */
|
|
|
|
|
+const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
|
|
|
+
|
|
|
|
|
+// runtime-dom 的 v-model 指令在 beforeUpdate 里读 `document.activeElement`;
|
|
|
|
|
+// 自定义渲染器的宿主是纯对象树,没有 DOM。这里只补它读的那一个属性(不引 jsdom:
|
|
|
|
|
+// 判据要的是 Vue 的 patch 行为,不是浏览器布局)。
|
|
|
|
|
+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 传播,
|
|
|
|
|
+// 否则共享子组件只有 ssrRender、客户端模式画不出东西——probe-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: 't64', genDefaultAs: '__sfc__' })
|
|
|
|
|
+ const template = compileTemplate({
|
|
|
|
|
+ source: descriptor.template.content,
|
|
|
|
|
+ filename: path,
|
|
|
|
|
+ id: 't64',
|
|
|
|
|
+ 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 { operationPanel, managementPanel, listBlock, toManagementInformation, toOperatingInformation } = await import(
|
|
|
|
|
+ `${SRC}domain/infrastructurePanels.js`
|
|
|
|
|
+).then(async (m) => ({ ...m, listBlock: (await import(`${SRC}domain/panelBlocks.js`)).listBlock }))
|
|
|
|
|
+const { SECTION_STATE, section } = await import(`${SRC}domain/sectionState.js`)
|
|
|
|
|
+const { default: PanelBlocks } = await import(`${SRC}components/PanelBlocks.vue`)
|
|
|
|
|
+const { default: ClientPanel } = await import(`${SRC}components/PanelBlocks.vue?client`)
|
|
|
|
|
+
|
|
|
|
|
+const okSection = (data) => section(SECTION_STATE.SUCCESS, { data, loaded: true })
|
|
|
|
|
+const panelOf = (raw, extra) => {
|
|
|
|
|
+ const data = toOperatingInformation(raw)
|
|
|
|
|
+ return operationPanel(data, okSection(data), extra)
|
|
|
|
|
+}
|
|
|
|
|
+const listBlockOf = (view) => view.blocks.find((block) => block.kind === 'list')
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ P. 页码寻址单源(条文 5/6)
|
|
|
|
|
+
|
|
|
|
|
+{
|
|
|
|
|
+ // P1 键只用块归属,且跨重算稳定(同一块永远同一个槽)。
|
|
|
|
|
+ // **刻意不把筛选条件编进键**:编进去会让「切走再切回」读到旧槽位停在第 3 页,
|
|
|
|
|
+ // 违反条文 6(切回来同样是一次条件变化)。复位走显式清槽(见 P4/R5)。
|
|
|
|
|
+ check('P1 同块同键(键跨重算稳定)', listPageKey('operation|运行指标') === listPageKey('operation|运行指标'), '')
|
|
|
|
|
+ check('P2 不同块各自槽位', listPageKey('operation|运行指标') !== listPageKey('management|片区 × 行业明细'), '')
|
|
|
|
|
+ check('P3 脏输入不造出 undefined 假键', listPageKey(undefined) === listPageKey('') && !listPageKey(null).includes('undefined'), listPageKey(null))
|
|
|
|
|
+ // P4 清槽只清点名的块,别的面板不受牵连。
|
|
|
|
|
+ const seeded = writeListPage(writeListPage({}, 'operation|运行指标', 3), 'emergency|危险源', 2)
|
|
|
|
|
+ const cleared = resetListPages(seeded, ['operation|运行指标'])
|
|
|
|
|
+ check('P4 清槽只清点名块(其它块页码不受牵连)',
|
|
|
|
|
+ readListPage(cleared, 'operation|运行指标') === 1 && readListPage(cleared, 'emergency|危险源') === 2,
|
|
|
|
|
+ `operation=${readListPage(cleared, 'operation|运行指标')} emergency=${readListPage(cleared, 'emergency|危险源')}`)
|
|
|
|
|
+ // P5 清槽返回新表、不改入参;空点名表是恒等(不白清一次)。
|
|
|
|
|
+ check('P5 清槽不改入参且空点名是恒等',
|
|
|
|
|
+ readListPage(seeded, 'operation|运行指标') === 3 && resetListPages(seeded, []) === seeded, '')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ R. 读写页码
|
|
|
|
|
+
|
|
|
|
|
+{
|
|
|
|
|
+ check('R1 没记过的槽位读回第 1 页', readListPage({}, 'operation|运行指标') === 1, '')
|
|
|
|
|
+ const table = writeListPage({}, 'operation|运行指标', 5)
|
|
|
|
|
+ check('R2 写后读回同值且不改入参', readListPage(table, 'operation|运行指标') === 5 && Object.keys({}).length === 0, JSON.stringify(table))
|
|
|
|
|
+ check('R3 脏值一律收成 1(NaN 不得漏进控件)',
|
|
|
|
|
+ readListPage({ x: 'abc' }, 'x') === 1 && readListPage({ x: NaN }, 'x') === 1 && readListPage({ x: 0 }, 'x') === 1 && readListPage(null, 'x') === 1, '')
|
|
|
|
|
+ // R4 条文 6 的正反两半:条件变化→回第 1 页;定时刷新→**不**回第 1 页。
|
|
|
|
|
+ // 刷新保持=同一槽位不动;复位=清槽后读回默认 1。
|
|
|
|
|
+ check('R4 刷新不动页码、清槽才复位(条文 6 两条语义互不牵连)',
|
|
|
|
|
+ readListPage(table, 'operation|运行指标') === 5 && readListPage(resetListPages(table, ['operation|运行指标']), 'operation|运行指标') === 1, '')
|
|
|
|
|
+ // R5 越界不在写入端夹(夹回归 pagedRows 单源):999 写进去,渲染时夹到最后一页。
|
|
|
|
|
+ const clamped = pagedRows(Array.from({ length: 25 }, (_, i) => ({ key: i })), readListPage(writeListPage({}, 'a', 999), 'a'))
|
|
|
|
|
+ check('R5 越界由 pagedRows 夹回最后一页(写入端不另做夹取)', clamped.page === 3 && clamped.rows.length === 5, JSON.stringify({ page: clamped.page, n: clamped.rows.length }))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ D. 领域层(真实载荷)
|
|
|
|
|
+
|
|
|
|
|
+const payloadOf = (name) => JSON.parse(readFileSync(new URL(`./artifacts/t51/${name}`, import.meta.url), 'utf8')).data
|
|
|
|
|
+const unfiltered = payloadOf('oi-unfiltered.json')
|
|
|
|
|
+const gasRaw = payloadOf('oi-GAS.json')
|
|
|
|
|
+const drainageRaw = payloadOf('oi-DRAINAGE.json')
|
|
|
|
|
+const manholeRaw = payloadOf('oi-MANHOLE.json')
|
|
|
|
|
+const gasPayloadBlock = (raw) => raw.industries.find((block) => block.industry.code === 'GAS')
|
|
|
|
|
+
|
|
|
|
|
+{
|
|
|
|
|
+ const view = panelOf(unfiltered, { scope: 'all' })
|
|
|
|
|
+ const block = listBlockOf(view)
|
|
|
|
|
+ const gasBlock = gasPayloadBlock(unfiltered)
|
|
|
|
|
+
|
|
|
|
|
+ // D1 块标题如实:占位期遗留词不再上屏。
|
|
|
|
|
+ check('D1 块标题改为如实说法(「指标占位」已不在视图里)',
|
|
|
|
|
+ block.label === '运行指标' && !JSON.stringify(view.blocks).includes('指标占位'), `label=${block.label}`)
|
|
|
|
|
+ check('D2 空态文案同样如实', block.emptyText === '当前行业没有运行指标', `empty=${block.emptyText}`)
|
|
|
|
|
+ // D3 分页由 domain 声明(渲染层据此切窗口;不声明就还是全量直渲)。
|
|
|
|
|
+ check('D3 领域层声明本块分页', block.paginate === true, `paginate=${block.paginate}`)
|
|
|
|
|
+ // D4 pageScope 与票 51 的选中块同源(不另算一套行业口径)。
|
|
|
|
|
+ check('D4 pageScope=选中行业块 + 片区', block.pageScope === 'GAS|all', `scope=${block.pageScope}`)
|
|
|
|
|
+ // D5 领域仍交出全部行:分页只切展示窗口,不裁数据、不改口径。
|
|
|
|
|
+ check('D5 领域行数仍是整块 699(分页不裁数据)',
|
|
|
|
|
+ block.rows.length === (gasBlock.metrics || []).length && block.rows.length === 699, `rows=${block.rows.length}`)
|
|
|
|
|
+ // D6 行序不改(票 10/11:前端不重排)。
|
|
|
|
|
+ check('D6 行次序与后端返回逐条一致(不重排)',
|
|
|
|
|
+ block.rows.every((row, i) => gasBlock.metrics[i].metricName === row.title), '行序漂了就是第二个口径')
|
|
|
|
|
+ // D7 每行身份唯一:真实燃气 699 条全是同一个 metricCode,
|
|
|
|
|
+ // 旧键 `${industry}|${metricCode}` 整块碰撞,而清单卡是 keyed v-for(分页让身份缺失每帧都碰)。
|
|
|
|
|
+ check('D7 每行 key 唯一(真实 699 条同码,旧键整块碰撞)',
|
|
|
|
|
+ new Set(block.rows.map((row) => row.key)).size === 699, `distinct=${new Set(block.rows.map((row) => row.key)).size}/699`)
|
|
|
|
|
+ // D8 来源与口径如实说明(票 49/50/51 既有裁定,不重开、不发明跨行业合并口径)。
|
|
|
|
|
+ check('D8 来源与口径说明到位(设施逐条生成、不跨行业合并、不说成监测点)',
|
|
|
|
|
+ typeof view.sourceNote === 'string' && view.sourceNote.includes('设施') && view.sourceNote.includes('不跨行业合并') && !view.sourceNote.includes('监测点'),
|
|
|
|
|
+ `note=${view.sourceNote}`)
|
|
|
|
|
+ check('D9 industryNote 仍与选中块同源(票 51 不回归)', view.industryNote === '当前行业:燃气', `note=${view.industryNote}`)
|
|
|
|
|
+ check('D10 换行业 pageScope 跟着换', listBlockOf(panelOf(drainageRaw, { scope: 'all' })).pageScope === 'DRAINAGE|all', '')
|
|
|
|
|
+ check('D11 换片区 pageScope 跟着换', listBlockOf(panelOf(unfiltered, { scope: 'south' })).pageScope === 'GAS|south', '')
|
|
|
|
|
+ check('D12 extra 缺失时 pageScope 仍合法(无脏值入键)',
|
|
|
|
|
+ listBlockOf(panelOf(unfiltered, undefined)).pageScope === 'GAS|' && !listBlockOf(panelOf(unfiltered, {})).pageScope.includes('undefined'), '')
|
|
|
|
|
+ // D13 票 49/50 裁定不重开:定位入口整体撤下。
|
|
|
|
|
+ check('D13 行 points 仍恒空、locate 仍为空(票 49/50 裁定不回归)',
|
|
|
|
|
+ view.locate.length === 0 && block.rows.every((row) => (row.points || []).length === 0), '定位入口复活了')
|
|
|
|
|
+ // D14 燃气块单独取数时口径一致(选中哪块说哪块)。
|
|
|
|
|
+ check('D14 单行业载荷同样分页 + 同口径', listBlockOf(panelOf(gasRaw, { scope: 'all' })).paginate === true, '')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ S. 渲染层 SSR 真实渲染
|
|
|
|
|
+
|
|
|
|
|
+const renderPanel = async (view) => renderToString(createSSRApp({ render: () => h(PanelBlocks, { view }) }))
|
|
|
|
|
+
|
|
|
|
|
+{
|
|
|
|
|
+ const html = await renderPanel(panelOf(unfiltered, { scope: 'all' }))
|
|
|
|
|
+ // S1 一页 ≤10 条(条文 1):真实 699 条 → 首屏只画 10 张卡。
|
|
|
|
|
+ const cards = (html.match(/data-item="/g) || []).length
|
|
|
|
|
+ check('S1 每页最多 10 条(699 条首屏只画 10 张卡)', cards > 0 && cards <= PAGE_SIZE, `cards=${cards}`)
|
|
|
|
|
+ // S2 计数行是「已分页」的明证;总数取整块,不由当前页反推(条文 7)。
|
|
|
|
|
+ const state = (html.match(/第 (\d+)\/(\d+) 页 · 共 (\d+) 条/) || []).slice(1)
|
|
|
|
|
+ check('S2 计数行「第 1/70 页 · 共 699 条」', state.join('|') === '1|70|699', `state=${state.join('|')}`)
|
|
|
|
|
+ // S3 复用批次一控件:页码直达 + 输入框跳页都在(不是另画一套)。
|
|
|
|
|
+ check('S3 复用共享 PagerNav(页码直达 + 跳页输入框)',
|
|
|
|
|
+ /data-page-numbers/.test(html) && /data-page-number="1"/.test(html) && /data-page-jump-input/.test(html) && /aria-label="跳转到指定页"/.test(html), '控件缺件')
|
|
|
|
|
+ // S4 旧措辞彻底不上屏,如实标题上屏。
|
|
|
|
|
+ check('S4 标题上屏且「指标占位」彻底消失', /运行指标/.test(html) && !/指标占位/.test(html), '')
|
|
|
|
|
+ // S5 来源说明进 DOM(折叠只是隐藏,信息不丢——票 56 的 v-show 纪律)。
|
|
|
|
|
+ check('S5 来源说明在 DOM 里(折叠不丢文本)', /不跨行业合并/.test(html), 'sourceNote 没上屏')
|
|
|
|
|
+ // S6 空块(MANHOLE 实测 0 条)不画任何控件(条文 4)。
|
|
|
|
|
+ const emptyHtml = await renderPanel(panelOf(manholeRaw, { scope: 'manhole' }))
|
|
|
|
|
+ check('S6 空块不画控件不画卡,只说没有运行指标',
|
|
|
|
|
+ !/data-pager/.test(emptyHtml) && (emptyHtml.match(/data-item="/g) || []).length === 0 && /当前行业没有运行指标/.test(emptyHtml), '空块多画了控件')
|
|
|
|
|
+ // S7 不足一页同样不画控件。
|
|
|
|
|
+ const small = toOperatingInformation({
|
|
|
|
|
+ industries: [{ industry: { code: 'GAS', label: '燃气' }, state: 'READY', metrics: Array.from({ length: 3 }, (_, i) => ({ metricCode: `m${i}`, metricName: `指标${i}`, category: 'SUPPLY', value: '1', unit: 'MPa', loadState: 'NORMAL', pointCodes: [`F${i}`] })) }],
|
|
|
|
|
+ excluded: {},
|
|
|
|
|
+ })
|
|
|
|
|
+ const smallHtml = await renderPanel(operationPanel(small, okSection(small), { scope: 'all' }))
|
|
|
|
|
+ check('S7 不足一页不画控件(条文 4)', !/data-pager/.test(smallHtml) && (smallHtml.match(/data-item="/g) || []).length === 3, '')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ N. 排除项没被顺手接入 + 单源纪律
|
|
|
|
|
+
|
|
|
|
|
+{
|
|
|
|
|
+ const mgrData = toManagementInformation({
|
|
|
|
|
+ totals: { facilityCount: 9, equipmentCount: 9 },
|
|
|
|
|
+ districtIndustry: Array.from({ length: 9 }, (_, i) => ({ district: i % 3, industry: 'GAS', facilityCount: 1, equipmentCount: 1 })),
|
|
|
|
|
+ excluded: {},
|
|
|
|
|
+ })
|
|
|
|
|
+ const mgrView = managementPanel(mgrData, okSection(mgrData))
|
|
|
|
|
+ const mgrBlock = listBlockOf(mgrView)
|
|
|
|
|
+ // N1 管理信息明细是规范排除项(实测 9 行):不声明、不画控件、9 行照旧全画。
|
|
|
|
|
+ const mgrHtml = await renderPanel(mgrView)
|
|
|
|
|
+ check('N1 管理信息明细未被顺手接入(9 行全画、零控件)',
|
|
|
|
|
+ mgrBlock.paginate === undefined && !/data-pager/.test(mgrHtml) && (mgrHtml.match(/data-item="/g) || []).length === 9,
|
|
|
|
|
+ `cards=${(mgrHtml.match(/data-item="/g) || []).length}`)
|
|
|
|
|
+ // N8 **超过一页但未声明分页**的块(12 行的合成清单)必须仍全量画出、不画控件。
|
|
|
|
|
+ // N1 抓不住「渲染层不分声明一律切窗口」这类缺陷(9 行本来就 ≤10),
|
|
|
|
|
+ // 这条才有牙——它是变异 M1 的对应判据。
|
|
|
|
|
+ const twelve = listBlock('危险源', Array.from({ length: 12 }, (_, i) => ({ key: `H${i}`, title: `危险源${i}`, meta: [], points: [] })), '没有危险源对象')
|
|
|
|
|
+ const undeclared = { name: 'emergency', label: '应急保障', hasContent: true, section: okSection({}), blocks: [twelve], missing: { count: 0 } }
|
|
|
|
|
+ const twelveHtml = await renderPanel(undeclared)
|
|
|
|
|
+ check('N8 未声明分页的 12 行块全量画出、零控件(排除项由声明保护,不由运气)',
|
|
|
|
|
+ (twelveHtml.match(/data-item="/g) || []).length === 12 && !/data-pager/.test(twelveHtml),
|
|
|
|
|
+ `cards=${(twelveHtml.match(/data-item="/g) || []).length}`)
|
|
|
|
|
+ // N2 listBlock 不传 options 时的形状一字不改(既有调用零影响)。
|
|
|
|
|
+ check('N2 listBlock 默认形状不变(无新键)',
|
|
|
|
|
+ JSON.stringify(Object.keys(listBlock('x', [], 'y'))) === JSON.stringify(['kind', 'label', 'rows', 'emptyText']),
|
|
|
|
|
+ JSON.stringify(Object.keys(listBlock('x', [], 'y'))))
|
|
|
|
|
+ // N3 rows 交出的是传入数组本身(分页不复制、不改数据)。
|
|
|
|
|
+ const identity = [{ key: 'a' }]
|
|
|
|
|
+ check('N3 listBlock 不复制行数组', listBlock('x', identity, 'y').rows === identity, '')
|
|
|
|
|
+ // N4 渲染层零第二份翻页算术(条文 11)。
|
|
|
|
|
+ // 判据**只钉分页相关**的形状:rows 切片、向上取整、自画 pager。
|
|
|
|
|
+ // 不写「整个文件不许出现 .slice(」——KPI 图标名大小写就用了一次 slice(第 110 行),
|
|
|
|
|
+ // 那是既有无关代码;写宽了就是票 53 M9 记过的「守卫比要守的宽」(会把好改动判红,
|
|
|
|
|
+ // 早晚被人当噪音绕过去,那时它才真的没牙)。
|
|
|
|
|
+ const panelSrc = stripComments(read('components/PanelBlocks.vue'))
|
|
|
|
|
+ check('N4 渲染层不自写翻页算术(无 rows.slice/Math.ceil/自画 pager)',
|
|
|
|
|
+ !/rows\.slice\(/.test(panelSrc) && !/Math\.(ceil|trunc)\(/.test(panelSrc) && !/equipment-pager|panel-pager__btn|>上一页</.test(panelSrc), '长出第二份算术')
|
|
|
|
|
+ // N5 翻页出口唯一:list 分支只接 PagerNav 的 @page 一处。
|
|
|
|
|
+ check('N5 翻页出口唯一(@page→gotoListPage 一处 + 共享控件导入)',
|
|
|
|
|
+ (panelSrc.match(/@page="\(target\) => gotoListPage\(block, target\)"/g) || []).length === 1 && /import PagerNav from '\.\/PagerNav\.vue'/.test(panelSrc), '')
|
|
|
|
|
+ // N6 分页只画给声明块,且不由渲染层按名字/面板特判(谁该分页只有 domain 知道)。
|
|
|
|
|
+ check('N6 PagerNav 由 block.paginate 门控(不按块标题/面板名特判)',
|
|
|
|
|
+ /v-if="block\.paginate === true"/.test(panelSrc) && !/block\.label === '运行指标'/.test(panelSrc) && !/props\.view\.name === 'operation'/.test(panelSrc), '渲染层在按名字猜')
|
|
|
|
|
+ // N7 声明块走 pagedRows 单源、未声明块走原数组(一条委托,不分叉出第二份兜底形状)。
|
|
|
|
|
+ check('N7 窗口取数只有一条委托(pagedRows 单源)',
|
|
|
|
|
+ /return pagedRows\(rows, pageOf\(block\)\)/.test(panelSrc) && /if \(block\.paginate !== true\) return \{ rows, page: 1, pageCount: 1, total: rows\.length \}/.test(panelSrc), '')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ V. 真实交互(客户端 patch)
|
|
|
|
|
+
|
|
|
|
|
+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 hasClass = (name) => (node) => String((node.props && node.props['class']) || '').includes(name)
|
|
|
|
|
+
|
|
|
|
|
+{
|
|
|
|
|
+ const viewRef = ref(panelOf(unfiltered, { scope: 'all' }))
|
|
|
|
|
+ const app = createRenderer(nodeOps).createApp({ render: () => h(ClientPanel, { view: viewRef.value }) })
|
|
|
|
|
+ app.config.warnHandler = () => {}
|
|
|
|
|
+ const container = nodeOps.createElement('#root')
|
|
|
|
|
+ app.mount(container)
|
|
|
|
|
+
|
|
|
|
|
+ const pageRows = () => viewRef.value.blocks[1].rows
|
|
|
|
|
+ const cards = () => findAll(container, byAttr('data-item')).map((card) => ({
|
|
|
|
|
+ key: card.props['data-item'],
|
|
|
|
|
+ title: textOf(findNode(card, hasClass('panel-item__title'))),
|
|
|
|
|
+ meta: textOf(findNode(card, hasClass('panel-item__meta'))),
|
|
|
|
|
+ }))
|
|
|
|
|
+ const stateText = () => textOf(findNode(container, byAttr('data-page-state')))
|
|
|
|
|
+ const clickPage = async (n) => {
|
|
|
|
|
+ const btn = findNode(container, (node) => String(node.props && node.props['data-page-number']) === String(n))
|
|
|
|
|
+ assert.ok(btn && typeof btn.props.onClick === 'function', `页码按钮 ${n} 不在`)
|
|
|
|
|
+ btn.props.onClick(new Proxy({}, { get: () => undefined }))
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // V1 点页码直达 → 窗口真的移动到第 11–20 行(逐条对内容,不是只数卡片)。
|
|
|
|
|
+ await clickPage(2)
|
|
|
|
|
+ const want2 = pageRows().slice(10, 20)
|
|
|
|
|
+ const got2 = cards()
|
|
|
|
|
+ check('V1 点页码 2 → 画的是第 11–20 行(逐条对 key/标题/读数)',
|
|
|
|
|
+ got2.length === 10 && got2.every((c, i) => c.key === want2[i].key && c.meta === want2[i].meta.join(' · ') && c.title === want2[i].title),
|
|
|
|
|
+ `got=${JSON.stringify(got2[0])} want=${JSON.stringify({ key: want2[0].key, title: want2[0].title })}`)
|
|
|
|
|
+ // V2 计数行随页码更新(条文 9)。
|
|
|
|
|
+ check('V2 计数行随页码更新为「第 2/70 页 · 共 699 条」', stateText() === '第 2/70 页 · 共 699 条', `state=${stateText()}`)
|
|
|
|
|
+
|
|
|
|
|
+ // V3 同条件刷新(周期 tick 会重算出一批全新块对象)→ 页码必须保持第 2 页。
|
|
|
|
|
+ viewRef.value = panelOf(unfiltered, { scope: 'all' })
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ check('V3 数据刷新不复位页码(停在第 3 页的人不回第 1 页)',
|
|
|
|
|
+ stateText() === '第 2/70 页 · 共 699 条' && cards()[0].key === want2[0].key, `state=${stateText()}`)
|
|
|
|
|
+
|
|
|
|
|
+ // V4 换片区 → 回第 1 页。
|
|
|
|
|
+ viewRef.value = panelOf(unfiltered, { scope: 'south' })
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ check('V4 换片区筛选页码复位第 1 页', stateText() === '第 1/70 页 · 共 699 条' && cards()[0].key === pageRows()[0].key, `state=${stateText()}`)
|
|
|
|
|
+
|
|
|
|
|
+ // V5 换行业 → 回第 1 页并按新行业重算页数。
|
|
|
|
|
+ const drainageView = panelOf(drainageRaw, { scope: 'south' })
|
|
|
|
|
+ viewRef.value = drainageView
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ const dPages = Math.ceil(drainageView.blocks[1].rows.length / PAGE_SIZE)
|
|
|
|
|
+ check('V5 换行业页码复位并按新行业重算页数',
|
|
|
|
|
+ stateText() === `第 1/${dPages} 页 · 共 ${drainageView.blocks[1].rows.length} 条` && cards()[0].key === drainageView.blocks[1].rows[0].key,
|
|
|
|
|
+ `state=${stateText()}`)
|
|
|
|
|
+
|
|
|
|
|
+ // V6 到末页:下一页 disabled 但按钮仍在(保占位,计数行不跳——票 46 既有语义)。
|
|
|
|
|
+ await clickPage(dPages)
|
|
|
|
|
+ const next = findNode(container, byAttr('data-page-next'))
|
|
|
|
|
+ const prev = findNode(container, byAttr('data-page-prev'))
|
|
|
|
|
+ check('V6 末页下一页 disabled 且保留占位(按钮不消失)',
|
|
|
|
|
+ !!next && next.props.disabled === true && !!prev && prev.props.disabled === false,
|
|
|
|
|
+ `next=${next && next.props.disabled} prev=${prev && prev.props.disabled}`)
|
|
|
|
|
+ // V7 末页只画余数(不补空卡)。
|
|
|
|
|
+ const tail = drainageView.blocks[1].rows.length % PAGE_SIZE
|
|
|
|
|
+ check('V7 末页只画余数', cards().length === (tail || PAGE_SIZE), `cards=${cards().length} tail=${tail}`)
|
|
|
|
|
+
|
|
|
|
|
+ // V8 跳页输入超界 → 由 pagedRows 夹回最后一页(条文 6 + 跳页节:不报错、不清列表)。
|
|
|
|
|
+ viewRef.value = panelOf(unfiltered, { scope: 'all' })
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ const jumpInput = findNode(container, byAttr('data-page-jump-input'))
|
|
|
|
|
+ assert.ok(jumpInput && typeof jumpInput.props['onUpdate:modelValue'] === 'function', '跳页输入框接线缺失')
|
|
|
|
|
+ jumpInput.props['onUpdate:modelValue']('999')
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ findNode(container, byAttr('data-page-jump-btn')).props.onClick()
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ check('V8 跳页超界夹回最后一页(不空表、不报错)',
|
|
|
|
|
+ stateText() === '第 70/70 页 · 共 699 条' && cards().length === 699 - 690, `state=${stateText()} cards=${cards().length}`)
|
|
|
|
|
+ // V9 跳页与页码共用同一个翻页出口(不新增第二套页码状态)。
|
|
|
|
|
+ check('V9 跳页后当前页标记同步(同一套页码状态)',
|
|
|
|
|
+ String(findNode(container, (n) => n.props && n.props['aria-current'] === 'page')?.props['data-page-number']) === '70', '两套页码')
|
|
|
|
|
+
|
|
|
|
|
+ // V10 整页 DOM 只有 10 张卡:一屏几千张卡的形态被构造上堵死。
|
|
|
|
|
+ await clickPage(1)
|
|
|
|
|
+ check('V10 任意时刻画出的卡 ≤ 10(含刷新后)', cards().length <= PAGE_SIZE, `cards=${cards().length}`)
|
|
|
|
|
+
|
|
|
|
|
+ // V11 条文 6 的反例回归(本票第一版踩过的坑):切走再**切回**也必须回第 1 页。
|
|
|
|
|
+ // 把筛选条件编进页码键时,「切回燃气」会读到上次留下的槽位停在第 3 页——
|
|
|
|
|
+ // 看起来像「记住了用户位置」,实际违反条文 6(切回来同样是一次条件变化)。
|
|
|
|
|
+ {
|
|
|
|
|
+ const gasView = panelOf(unfiltered, { scope: 'all' })
|
|
|
|
|
+ viewRef.value = gasView
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ // 窗口在 >7 页时收缩(第 1 页的窗口是「1 2 … 70」),先到第 2 页,第 3 页才出现在按钮里。
|
|
|
|
|
+ await clickPage(2)
|
|
|
|
|
+ await clickPage(3)
|
|
|
|
|
+ check('V11a 切回全行业后先停在第 3 页(前置条件成立)', stateText() === '第 3/70 页 · 共 699 条', `state=${stateText()}`)
|
|
|
|
|
+ // 切到排水(条件变)→ 复位
|
|
|
|
|
+ viewRef.value = panelOf(drainageRaw, { scope: 'all' })
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ check('V11b 切到排水复位第 1 页', stateText().startsWith('第 1/'), `state=${stateText()}`)
|
|
|
|
|
+ // 再切回燃气(又一次条件变化)→ 仍回第 1 页,**不得**回到刚才的第 3 页
|
|
|
|
|
+ viewRef.value = gasView
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ check('V11c 切回燃气仍回第 1 页(旧设计的缺陷:会停在第 3 页)',
|
|
|
|
|
+ stateText() === '第 1/70 页 · 共 699 条' && cards()[0].key === gasView.blocks[1].rows[0].key,
|
|
|
|
|
+ `state=${stateText()}`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // V12 **逐页全扫**(把诊断探针固化成闸门):699 条 → 70 页,每一页画出来的
|
|
|
|
|
+ // key/标题/读数必须等于 domain 行的对应切片。这是内容级判据 ——
|
|
|
|
|
+ // 只数卡片数(S1/V10)抓不到「页码显示对、实际画错行」这类窗口起点算错的缺陷。
|
|
|
|
|
+ {
|
|
|
|
|
+ viewRef.value = panelOf(unfiltered, { scope: 'all' })
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ const all = pageRows()
|
|
|
|
|
+ const pages = Math.ceil(all.length / PAGE_SIZE)
|
|
|
|
|
+ let mismatches = 0
|
|
|
|
|
+ let firstBad = ''
|
|
|
|
|
+ for (let page = 1; page <= pages; page += 1) {
|
|
|
|
|
+ await clickPage(page)
|
|
|
|
|
+ const got = cards()
|
|
|
|
|
+ const want = all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
|
|
|
|
+ if (got.length !== want.length) { mismatches += 1; firstBad = firstBad || `page ${page}: 画 ${got.length} 张 vs 应有 ${want.length} 张`; continue }
|
|
|
|
|
+ for (let i = 0; i < want.length; i += 1) {
|
|
|
|
|
+ if (got[i].key !== want[i].key || got[i].meta !== want[i].meta.join(' · ') || got[i].title !== want[i].title) {
|
|
|
|
|
+ mismatches += 1
|
|
|
|
|
+ firstBad = firstBad || `page ${page} row ${i}: got ${JSON.stringify(got[i])} want ${JSON.stringify({ key: want[i].key, title: want[i].title, meta: want[i].meta.join(' · ') })}`
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ check(`V12 全 ${pages} 页逐页与 domain 切片完全一致(窗口算术与内容不错位)`, mismatches === 0, `${mismatches} 页错配 · ${firstBad}`)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ K/Z 编译与单源未回归
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ M. mock 同构(票 08/18/38/51 同一条纪律)
|
|
|
|
|
+// 真实链路与 mock 必须过同一个 domain + 同一个渲染层,读出同一个结论;
|
|
|
|
|
+// 否则分页只在真机有效、mock 上是另一套行为(票 51 记过的假绿)。
|
|
|
|
|
+{
|
|
|
|
|
+ const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`)
|
|
|
|
|
+ const transports = {}
|
|
|
|
|
+ registerCockpitMocks({ handlers: { registerMockTransport: (path, handler) => { transports[path] = handler } } })
|
|
|
|
|
+ const mockGet = async (query = '') => (await transports['/infrastructure/operating-information']({ path: '/infrastructure/operating-information' + query }))
|
|
|
|
|
+
|
|
|
|
|
+ const mockView = panelOf(await mockGet(), { scope: 'all' })
|
|
|
|
|
+ const mockBlock = listBlockOf(mockView)
|
|
|
|
|
+ // M1 mock 载荷也声明分页并按同一规则寻址(同一张嘴,不按数据源分叉)。
|
|
|
|
|
+ check('M1 mock 载荷同样声明分页(同一张嘴,不因数据源分叉)',
|
|
|
|
|
+ mockBlock.paginate === true && mockBlock.label === '运行指标' && mockBlock.pageScope === 'GAS|all',
|
|
|
|
|
+ `scope=${mockBlock.pageScope} paginate=${mockBlock.paginate}`)
|
|
|
|
|
+ // M2 mock 每个行业块只有 2–3 条 ⇒ pageCount 恒 1 ⇒ 整块 pager **不出现**。
|
|
|
|
|
+ // 这是**裁定后的预期行为**(维护者 2026-10-19:验收与走查以真实数据为准,
|
|
|
|
|
+ // 不为演示扩写 mock 样例)——符合条文 4,不是缺陷,也**不要**为此把样例补到 >10 条。
|
|
|
|
|
+ // 多页与边界行为归 V 组(真实载荷逐页对账);真实观感归票 18 联调窗口。
|
|
|
|
|
+ const mockHtml = await renderPanel(mockView)
|
|
|
|
|
+ check('M2 mock 载荷不足一页 ⇒ 不画控件(预期行为;已裁定不为演示扩写 mock 样例)',
|
|
|
|
|
+ !/data-pager/.test(mockHtml) && mockBlock.rows.length < PAGE_SIZE && (mockHtml.match(/data-item="/g) || []).length === mockBlock.rows.length,
|
|
|
|
|
+ `rows=${mockBlock.rows.length}`)
|
|
|
|
|
+ // M3 选行业后 mock 仍是单页(mock 样例没跨到 >10 条),行为与真实同形而非特例。
|
|
|
|
|
+ const waterMock = panelOf(await mockGet('?industry=WATER_SUPPLY'), { scope: 'all' })
|
|
|
|
|
+ check('M3 mock 选行业仍走同一条分页路径(pageScope 随回显块换)',
|
|
|
|
|
+ listBlockOf(waterMock).pageScope === 'WATER_SUPPLY|all' && listBlockOf(waterMock).paginate === true,
|
|
|
|
|
+ `scope=${listBlockOf(waterMock).pageScope}`)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ K/Z 编译与单源未回归
|
|
|
|
|
+
|
|
|
|
|
+{
|
|
|
|
|
+ const compileSfc = (label, rel) => {
|
|
|
|
|
+ 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: 't64' })
|
|
|
|
|
+ const template = compileTemplate({
|
|
|
|
|
+ source: descriptor.template.content, filename: rel, id: 't64',
|
|
|
|
|
+ compilerOptions: { bindingMetadata: script.bindings },
|
|
|
|
|
+ })
|
|
|
|
|
+ assert.deepEqual(template.errors, [], `${rel} template`)
|
|
|
|
|
+ ok = true
|
|
|
|
|
+ } catch (error) { why = error.message.split('\n')[0] }
|
|
|
|
|
+ check(`K ${label} 编译通过`, ok, why || `${rel} 应无编译错误`)
|
|
|
|
|
+ }
|
|
|
|
|
+ compileSfc('PanelBlocks', 'components/PanelBlocks.vue')
|
|
|
|
|
+ compileSfc('PagerNav', 'components/PagerNav.vue')
|
|
|
|
|
+ compileSfc('EquipmentDetailSections', 'components/EquipmentDetailSections.vue')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// K2 外壳接线(不整份 import CockpitShell——它会拉起 store/cesium 全图,且那是并行在制品的共享面;
|
|
|
|
|
+// 这里钉住「换片区复位判据的输入」这一条接线本身,源码级判据)。
|
|
|
|
|
+{
|
|
|
|
|
+ const shellSrc = read('CockpitShell.vue')
|
|
|
|
|
+ check('K2 外壳把当前片区传给运行信息面板(pageScope 的片区半边有真实输入)',
|
|
|
|
|
+ /panelId === 'operation'\) extra = \{ scope: scope\.value \}/.test(shellSrc),
|
|
|
|
|
+ '外壳没接线 ⇒ 换片区不复位(D11 只测 domain 侧,抓不到这条断链)')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+check('Z1 pagination 单源未回归(PAGE_SIZE/窗口/夹回/空表 1 页)',
|
|
|
|
|
+ PAGE_SIZE === 10 && pageWindow(5, 8).join(',') === '1,…,4,5,6,…,8' && clampPage(99, 10) === 10 && pagedRows([], 1).pageCount === 1, '')
|
|
|
|
|
+
|
|
|
|
|
+console.log('')
|
|
|
|
|
+console.log(`T64 运行指标分页与块名纠正:${passed}/${passed + failed} 项通过。`)
|
|
|
|
|
+if (failed) { console.log('未通过项见上方 FAIL。'); process.exit(1) }
|
|
|
|
|
+console.log('T64 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')
|