t64-operation-metrics-pagination.mjs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. /**
  2. * 票 64 离线闸门:基础设施「运行信息」指标列表接入统一分页 +「指标占位」块名纠正。
  3. *
  4. * 背景(票面):`/infrastructure/operating-information` 一次回 **6772 条**指标
  5. * (GAS 699/WATER_SUPPLY 2497/DRAINAGE 3576,2026-10-19 实测),响应 2.3 MB;
  6. * 前端曾 `listBlock('指标占位', metrics)` 全量直渲 —— 一个行业一屏几千张卡。
  7. *
  8. * 九组断言(都不启浏览器、不联网;渲染层用 Vue 自定义渲染器在纯对象树上跑真实 patch):
  9. * P/R 页码寻址与复位单源 —— 键**只用块归属**(不把筛选条件编进键,否则切走再切回会停在旧页,
  10. * 违反条文 6);复位走显式清槽 `resetListPages`(票 46/59 成例),且只清点名的块。
  11. * D 领域层(真实载荷):标题如实 + 声明分页 + 来源口径 + 行键唯一 + 行序不改 + 票 50 裁定不回归。
  12. * S 渲染层 SSR:每页 ≤10、计数行、旧措辞不上屏、空块/短列表不画控件。
  13. * N 排除项没被顺手接入(9 行的、以及**12 行但未声明**的块都照旧全画)+ 渲染层零第二份算术/出口唯一。
  14. * V 真实交互:点页码→窗口真的移动;刷新→页码保持;换片区/换行业→回第 1 页;
  15. * **切走再切回也回第 1 页**(V11);全 70 页逐页对内容(V12);到边禁用保占位;跳页超界夹回。
  16. * M mock 同构:mock 载荷走同一条分页声明路径;不足一页不画控件=**裁定后的预期行为**
  17. * (维护者 2026-10-19:以真实数据为准,不改 `contractMocks` 静态样例扩面;闸门灌数照旧)。
  18. * K SFC 真实编译(t41 教训)+ 外壳接线(当前片区传给运行信息面板=pageScope 的片区半边)。
  19. * Z pagination 单源未回归。
  20. *
  21. * 变异有牙见 t64-mutations.mjs。
  22. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t64-operation-metrics-pagination.mjs
  23. */
  24. import assert from 'node:assert/strict'
  25. import { readFileSync } from 'node:fs'
  26. import { registerHooks } from 'node:module'
  27. import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
  28. import { createRenderer, h, nextTick, ref } from '@vue/runtime-core'
  29. import { createSSRApp } from 'vue'
  30. import { renderToString } from '@vue/server-renderer'
  31. import { PAGE_SIZE, clampPage, listPageKey, pageWindow, pagedRows, readListPage, resetListPages, writeListPage } from '../../../src/modules/cockpit/domain/pagination.js'
  32. const SRC = '../../../src/modules/cockpit/'
  33. const read = (rel) => readFileSync(new URL(SRC + rel, import.meta.url), 'utf8')
  34. /** 判据钉代码,不钉说明文字(t59 成例):注释里的字样不算命中。 */
  35. const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  36. // runtime-dom 的 v-model 指令在 beforeUpdate 里读 `document.activeElement`;
  37. // 自定义渲染器的宿主是纯对象树,没有 DOM。这里只补它读的那一个属性(不引 jsdom:
  38. // 判据要的是 Vue 的 patch 行为,不是浏览器布局)。
  39. if (typeof globalThis.document === 'undefined') globalThis.document = { activeElement: null }
  40. let passed = 0
  41. let failed = 0
  42. function check(name, condition, detail = '') {
  43. if (condition) { passed += 1; console.log(`PASS ${name}`) }
  44. else { failed += 1; console.error(`FAIL ${name}: ${detail}`) }
  45. }
  46. // .vue 一律走编译钩子:默认 SSR 版;`?client` 版给自定义渲染器用(子导入按 parentURL 传播,
  47. // 否则共享子组件只有 ssrRender、客户端模式画不出东西——probe-t64 实测踩过)。
  48. registerHooks({
  49. resolve(specifier, context, nextResolve) {
  50. const resolved = nextResolve(specifier, context)
  51. const parent = context.parentURL || ''
  52. if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) {
  53. return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true }
  54. }
  55. return resolved
  56. },
  57. load(url, context, nextLoad) {
  58. if (url.endsWith('.json')) return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` }
  59. const isClient = url.endsWith('.vue?client')
  60. if (url.endsWith('.vue') || isClient) {
  61. const path = isClient ? url.slice(0, -'?client'.length) : url
  62. const source = readFileSync(new URL(path), 'utf8')
  63. const { descriptor } = parse(source, { filename: path })
  64. const script = compileScript(descriptor, { id: 't64', genDefaultAs: '__sfc__' })
  65. const template = compileTemplate({
  66. source: descriptor.template.content,
  67. filename: path,
  68. id: 't64',
  69. ssr: !isClient,
  70. compilerOptions: { bindingMetadata: script.bindings },
  71. })
  72. assert.deepEqual(template.errors, [], `${path} template`)
  73. const tail = isClient ? '__sfc__.render = render; export default __sfc__;' : '__sfc__.ssrRender = ssrRender; export default __sfc__;'
  74. return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` }
  75. }
  76. return nextLoad(url, context)
  77. },
  78. })
  79. const { operationPanel, managementPanel, listBlock, toManagementInformation, toOperatingInformation } = await import(
  80. `${SRC}domain/infrastructurePanels.js`
  81. ).then(async (m) => ({ ...m, listBlock: (await import(`${SRC}domain/panelBlocks.js`)).listBlock }))
  82. const { SECTION_STATE, section } = await import(`${SRC}domain/sectionState.js`)
  83. const { default: PanelBlocks } = await import(`${SRC}components/PanelBlocks.vue`)
  84. const { default: ClientPanel } = await import(`${SRC}components/PanelBlocks.vue?client`)
  85. const okSection = (data) => section(SECTION_STATE.SUCCESS, { data, loaded: true })
  86. const panelOf = (raw, extra) => {
  87. const data = toOperatingInformation(raw)
  88. return operationPanel(data, okSection(data), extra)
  89. }
  90. const listBlockOf = (view) => view.blocks.find((block) => block.kind === 'list')
  91. // ================================================================ P. 页码寻址单源(条文 5/6)
  92. {
  93. // P1 键只用块归属,且跨重算稳定(同一块永远同一个槽)。
  94. // **刻意不把筛选条件编进键**:编进去会让「切走再切回」读到旧槽位停在第 3 页,
  95. // 违反条文 6(切回来同样是一次条件变化)。复位走显式清槽(见 P4/R5)。
  96. check('P1 同块同键(键跨重算稳定)', listPageKey('operation|运行指标') === listPageKey('operation|运行指标'), '')
  97. check('P2 不同块各自槽位', listPageKey('operation|运行指标') !== listPageKey('management|片区 × 行业明细'), '')
  98. check('P3 脏输入不造出 undefined 假键', listPageKey(undefined) === listPageKey('') && !listPageKey(null).includes('undefined'), listPageKey(null))
  99. // P4 清槽只清点名的块,别的面板不受牵连。
  100. const seeded = writeListPage(writeListPage({}, 'operation|运行指标', 3), 'emergency|危险源', 2)
  101. const cleared = resetListPages(seeded, ['operation|运行指标'])
  102. check('P4 清槽只清点名块(其它块页码不受牵连)',
  103. readListPage(cleared, 'operation|运行指标') === 1 && readListPage(cleared, 'emergency|危险源') === 2,
  104. `operation=${readListPage(cleared, 'operation|运行指标')} emergency=${readListPage(cleared, 'emergency|危险源')}`)
  105. // P5 清槽返回新表、不改入参;空点名表是恒等(不白清一次)。
  106. check('P5 清槽不改入参且空点名是恒等',
  107. readListPage(seeded, 'operation|运行指标') === 3 && resetListPages(seeded, []) === seeded, '')
  108. }
  109. // ================================================================ R. 读写页码
  110. {
  111. check('R1 没记过的槽位读回第 1 页', readListPage({}, 'operation|运行指标') === 1, '')
  112. const table = writeListPage({}, 'operation|运行指标', 5)
  113. check('R2 写后读回同值且不改入参', readListPage(table, 'operation|运行指标') === 5 && Object.keys({}).length === 0, JSON.stringify(table))
  114. check('R3 脏值一律收成 1(NaN 不得漏进控件)',
  115. readListPage({ x: 'abc' }, 'x') === 1 && readListPage({ x: NaN }, 'x') === 1 && readListPage({ x: 0 }, 'x') === 1 && readListPage(null, 'x') === 1, '')
  116. // R4 条文 6 的正反两半:条件变化→回第 1 页;定时刷新→**不**回第 1 页。
  117. // 刷新保持=同一槽位不动;复位=清槽后读回默认 1。
  118. check('R4 刷新不动页码、清槽才复位(条文 6 两条语义互不牵连)',
  119. readListPage(table, 'operation|运行指标') === 5 && readListPage(resetListPages(table, ['operation|运行指标']), 'operation|运行指标') === 1, '')
  120. // R5 越界不在写入端夹(夹回归 pagedRows 单源):999 写进去,渲染时夹到最后一页。
  121. const clamped = pagedRows(Array.from({ length: 25 }, (_, i) => ({ key: i })), readListPage(writeListPage({}, 'a', 999), 'a'))
  122. check('R5 越界由 pagedRows 夹回最后一页(写入端不另做夹取)', clamped.page === 3 && clamped.rows.length === 5, JSON.stringify({ page: clamped.page, n: clamped.rows.length }))
  123. }
  124. // ================================================================ D. 领域层(真实载荷)
  125. const payloadOf = (name) => JSON.parse(readFileSync(new URL(`./artifacts/t51/${name}`, import.meta.url), 'utf8')).data
  126. const unfiltered = payloadOf('oi-unfiltered.json')
  127. const gasRaw = payloadOf('oi-GAS.json')
  128. const drainageRaw = payloadOf('oi-DRAINAGE.json')
  129. const manholeRaw = payloadOf('oi-MANHOLE.json')
  130. const gasPayloadBlock = (raw) => raw.industries.find((block) => block.industry.code === 'GAS')
  131. {
  132. const view = panelOf(unfiltered, { scope: 'all' })
  133. const block = listBlockOf(view)
  134. const gasBlock = gasPayloadBlock(unfiltered)
  135. // D1 块标题如实:占位期遗留词不再上屏。
  136. check('D1 块标题改为如实说法(「指标占位」已不在视图里)',
  137. block.label === '运行指标' && !JSON.stringify(view.blocks).includes('指标占位'), `label=${block.label}`)
  138. check('D2 空态文案同样如实', block.emptyText === '当前行业没有运行指标', `empty=${block.emptyText}`)
  139. // D3 分页由 domain 声明(渲染层据此切窗口;不声明就还是全量直渲)。
  140. check('D3 领域层声明本块分页', block.paginate === true, `paginate=${block.paginate}`)
  141. // D4 pageScope 与票 51 的选中块同源(不另算一套行业口径)。
  142. check('D4 pageScope=选中行业块 + 片区', block.pageScope === 'GAS|all', `scope=${block.pageScope}`)
  143. // D5 领域仍交出全部行:分页只切展示窗口,不裁数据、不改口径。
  144. check('D5 领域行数仍是整块 699(分页不裁数据)',
  145. block.rows.length === (gasBlock.metrics || []).length && block.rows.length === 699, `rows=${block.rows.length}`)
  146. // D6 行序不改(票 10/11:前端不重排)。
  147. check('D6 行次序与后端返回逐条一致(不重排)',
  148. block.rows.every((row, i) => gasBlock.metrics[i].metricName === row.title), '行序漂了就是第二个口径')
  149. // D7 每行身份唯一:真实燃气 699 条全是同一个 metricCode,
  150. // 旧键 `${industry}|${metricCode}` 整块碰撞,而清单卡是 keyed v-for(分页让身份缺失每帧都碰)。
  151. check('D7 每行 key 唯一(真实 699 条同码,旧键整块碰撞)',
  152. new Set(block.rows.map((row) => row.key)).size === 699, `distinct=${new Set(block.rows.map((row) => row.key)).size}/699`)
  153. // D8 来源与口径如实说明(票 49/50/51 既有裁定,不重开、不发明跨行业合并口径)。
  154. check('D8 来源与口径说明到位(设施逐条生成、不跨行业合并、不说成监测点)',
  155. typeof view.sourceNote === 'string' && view.sourceNote.includes('设施') && view.sourceNote.includes('不跨行业合并') && !view.sourceNote.includes('监测点'),
  156. `note=${view.sourceNote}`)
  157. check('D9 industryNote 仍与选中块同源(票 51 不回归)', view.industryNote === '当前行业:燃气', `note=${view.industryNote}`)
  158. check('D10 换行业 pageScope 跟着换', listBlockOf(panelOf(drainageRaw, { scope: 'all' })).pageScope === 'DRAINAGE|all', '')
  159. check('D11 换片区 pageScope 跟着换', listBlockOf(panelOf(unfiltered, { scope: 'south' })).pageScope === 'GAS|south', '')
  160. check('D12 extra 缺失时 pageScope 仍合法(无脏值入键)',
  161. listBlockOf(panelOf(unfiltered, undefined)).pageScope === 'GAS|' && !listBlockOf(panelOf(unfiltered, {})).pageScope.includes('undefined'), '')
  162. // D13 票 49/50 裁定不重开:定位入口整体撤下。
  163. check('D13 行 points 仍恒空、locate 仍为空(票 49/50 裁定不回归)',
  164. view.locate.length === 0 && block.rows.every((row) => (row.points || []).length === 0), '定位入口复活了')
  165. // D14 燃气块单独取数时口径一致(选中哪块说哪块)。
  166. check('D14 单行业载荷同样分页 + 同口径', listBlockOf(panelOf(gasRaw, { scope: 'all' })).paginate === true, '')
  167. }
  168. // ================================================================ S. 渲染层 SSR 真实渲染
  169. const renderPanel = async (view) => renderToString(createSSRApp({ render: () => h(PanelBlocks, { view }) }))
  170. {
  171. const html = await renderPanel(panelOf(unfiltered, { scope: 'all' }))
  172. // S1 一页 ≤10 条(条文 1):真实 699 条 → 首屏只画 10 张卡。
  173. const cards = (html.match(/data-item="/g) || []).length
  174. check('S1 每页最多 10 条(699 条首屏只画 10 张卡)', cards > 0 && cards <= PAGE_SIZE, `cards=${cards}`)
  175. // S2 计数行是「已分页」的明证;总数取整块,不由当前页反推(条文 7)。
  176. const state = (html.match(/第 (\d+)\/(\d+) 页 · 共 (\d+) 条/) || []).slice(1)
  177. check('S2 计数行「第 1/70 页 · 共 699 条」', state.join('|') === '1|70|699', `state=${state.join('|')}`)
  178. // S3 复用批次一控件:页码直达 + 输入框跳页都在(不是另画一套)。
  179. check('S3 复用共享 PagerNav(页码直达 + 跳页输入框)',
  180. /data-page-numbers/.test(html) && /data-page-number="1"/.test(html) && /data-page-jump-input/.test(html) && /aria-label="跳转到指定页"/.test(html), '控件缺件')
  181. // S4 旧措辞彻底不上屏,如实标题上屏。
  182. check('S4 标题上屏且「指标占位」彻底消失', /运行指标/.test(html) && !/指标占位/.test(html), '')
  183. // S5 来源说明进 DOM(折叠只是隐藏,信息不丢——票 56 的 v-show 纪律)。
  184. check('S5 来源说明在 DOM 里(折叠不丢文本)', /不跨行业合并/.test(html), 'sourceNote 没上屏')
  185. // S6 空块(MANHOLE 实测 0 条)不画任何控件(条文 4)。
  186. const emptyHtml = await renderPanel(panelOf(manholeRaw, { scope: 'manhole' }))
  187. check('S6 空块不画控件不画卡,只说没有运行指标',
  188. !/data-pager/.test(emptyHtml) && (emptyHtml.match(/data-item="/g) || []).length === 0 && /当前行业没有运行指标/.test(emptyHtml), '空块多画了控件')
  189. // S7 不足一页同样不画控件。
  190. const small = toOperatingInformation({
  191. 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}`] })) }],
  192. excluded: {},
  193. })
  194. const smallHtml = await renderPanel(operationPanel(small, okSection(small), { scope: 'all' }))
  195. check('S7 不足一页不画控件(条文 4)', !/data-pager/.test(smallHtml) && (smallHtml.match(/data-item="/g) || []).length === 3, '')
  196. }
  197. // ================================================================ N. 排除项没被顺手接入 + 单源纪律
  198. {
  199. const mgrData = toManagementInformation({
  200. totals: { facilityCount: 9, equipmentCount: 9 },
  201. districtIndustry: Array.from({ length: 9 }, (_, i) => ({ district: i % 3, industry: 'GAS', facilityCount: 1, equipmentCount: 1 })),
  202. excluded: {},
  203. })
  204. const mgrView = managementPanel(mgrData, okSection(mgrData))
  205. const mgrBlock = listBlockOf(mgrView)
  206. // N1 管理信息明细是规范排除项(实测 9 行):不声明、不画控件、9 行照旧全画。
  207. const mgrHtml = await renderPanel(mgrView)
  208. check('N1 管理信息明细未被顺手接入(9 行全画、零控件)',
  209. mgrBlock.paginate === undefined && !/data-pager/.test(mgrHtml) && (mgrHtml.match(/data-item="/g) || []).length === 9,
  210. `cards=${(mgrHtml.match(/data-item="/g) || []).length}`)
  211. // N8 **超过一页但未声明分页**的块(12 行的合成清单)必须仍全量画出、不画控件。
  212. // N1 抓不住「渲染层不分声明一律切窗口」这类缺陷(9 行本来就 ≤10),
  213. // 这条才有牙——它是变异 M1 的对应判据。
  214. const twelve = listBlock('危险源', Array.from({ length: 12 }, (_, i) => ({ key: `H${i}`, title: `危险源${i}`, meta: [], points: [] })), '没有危险源对象')
  215. const undeclared = { name: 'emergency', label: '应急保障', hasContent: true, section: okSection({}), blocks: [twelve], missing: { count: 0 } }
  216. const twelveHtml = await renderPanel(undeclared)
  217. check('N8 未声明分页的 12 行块全量画出、零控件(排除项由声明保护,不由运气)',
  218. (twelveHtml.match(/data-item="/g) || []).length === 12 && !/data-pager/.test(twelveHtml),
  219. `cards=${(twelveHtml.match(/data-item="/g) || []).length}`)
  220. // N2 listBlock 不传 options 时的形状一字不改(既有调用零影响)。
  221. check('N2 listBlock 默认形状不变(无新键)',
  222. JSON.stringify(Object.keys(listBlock('x', [], 'y'))) === JSON.stringify(['kind', 'label', 'rows', 'emptyText']),
  223. JSON.stringify(Object.keys(listBlock('x', [], 'y'))))
  224. // N3 rows 交出的是传入数组本身(分页不复制、不改数据)。
  225. const identity = [{ key: 'a' }]
  226. check('N3 listBlock 不复制行数组', listBlock('x', identity, 'y').rows === identity, '')
  227. // N4 渲染层零第二份翻页算术(条文 11)。
  228. // 判据**只钉分页相关**的形状:rows 切片、向上取整、自画 pager。
  229. // 不写「整个文件不许出现 .slice(」——KPI 图标名大小写就用了一次 slice(第 110 行),
  230. // 那是既有无关代码;写宽了就是票 53 M9 记过的「守卫比要守的宽」(会把好改动判红,
  231. // 早晚被人当噪音绕过去,那时它才真的没牙)。
  232. const panelSrc = stripComments(read('components/PanelBlocks.vue'))
  233. check('N4 渲染层不自写翻页算术(无 rows.slice/Math.ceil/自画 pager)',
  234. !/rows\.slice\(/.test(panelSrc) && !/Math\.(ceil|trunc)\(/.test(panelSrc) && !/equipment-pager|panel-pager__btn|>上一页</.test(panelSrc), '长出第二份算术')
  235. // N5 翻页出口唯一:list 分支只接 PagerNav 的 @page 一处。
  236. check('N5 翻页出口唯一(@page→gotoListPage 一处 + 共享控件导入)',
  237. (panelSrc.match(/@page="\(target\) => gotoListPage\(block, target\)"/g) || []).length === 1 && /import PagerNav from '\.\/PagerNav\.vue'/.test(panelSrc), '')
  238. // N6 分页只画给声明块,且不由渲染层按名字/面板特判(谁该分页只有 domain 知道)。
  239. check('N6 PagerNav 由 block.paginate 门控(不按块标题/面板名特判)',
  240. /v-if="block\.paginate === true"/.test(panelSrc) && !/block\.label === '运行指标'/.test(panelSrc) && !/props\.view\.name === 'operation'/.test(panelSrc), '渲染层在按名字猜')
  241. // N7 声明块走 pagedRows 单源、未声明块走原数组(一条委托,不分叉出第二份兜底形状)。
  242. check('N7 窗口取数只有一条委托(pagedRows 单源)',
  243. /return pagedRows\(rows, pageOf\(block\)\)/.test(panelSrc) && /if \(block\.paginate !== true\) return \{ rows, page: 1, pageCount: 1, total: rows\.length \}/.test(panelSrc), '')
  244. }
  245. // ================================================================ V. 真实交互(客户端 patch)
  246. const nodeOps = {
  247. createElement: (tag) => ({ tag, props: {}, children: [], parent: null, text: '', style: {}, addEventListener() {}, removeEventListener() {} }),
  248. createText: (text) => ({ tag: '#text', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
  249. createComment: (text) => ({ tag: '#comment', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
  250. setText: (node, text) => { node.text = text },
  251. setElementText: (node, text) => { node.text = text; node.children = [] },
  252. insert: (child, parent, anchor) => {
  253. if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child)
  254. child.parent = parent
  255. if (!parent) return
  256. if (anchor) { const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, child) }
  257. else parent.children.push(child)
  258. },
  259. remove: (child) => { if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child); child.parent = null },
  260. parentNode: (node) => node.parent,
  261. nextSibling: (node) => (node.parent ? node.parent.children[node.parent.children.indexOf(node) + 1] || null : null),
  262. querySelector: () => null,
  263. setScopeId: () => {},
  264. patchProp: (el, key, _prev, next) => { el.props[key] = next },
  265. }
  266. function findAll(node, predicate, acc = []) {
  267. if (!node) return acc
  268. if (predicate(node)) acc.push(node)
  269. for (const child of node.children || []) findAll(child, predicate, acc)
  270. return acc
  271. }
  272. function findNode(node, predicate) {
  273. if (!node) return null
  274. if (predicate(node)) return node
  275. for (const child of node.children || []) { const hit = findNode(child, predicate); if (hit) return hit }
  276. return null
  277. }
  278. function textOf(node) {
  279. if (!node) return ''
  280. const own = node.tag === '#text' ? node.text : typeof node.text === 'string' ? node.text : ''
  281. return own + (node.children || []).map(textOf).join('')
  282. }
  283. const byAttr = (name) => (node) => !!node.props && name in node.props
  284. const hasClass = (name) => (node) => String((node.props && node.props['class']) || '').includes(name)
  285. {
  286. const viewRef = ref(panelOf(unfiltered, { scope: 'all' }))
  287. const app = createRenderer(nodeOps).createApp({ render: () => h(ClientPanel, { view: viewRef.value }) })
  288. app.config.warnHandler = () => {}
  289. const container = nodeOps.createElement('#root')
  290. app.mount(container)
  291. const pageRows = () => viewRef.value.blocks[1].rows
  292. const cards = () => findAll(container, byAttr('data-item')).map((card) => ({
  293. key: card.props['data-item'],
  294. title: textOf(findNode(card, hasClass('panel-item__title'))),
  295. meta: textOf(findNode(card, hasClass('panel-item__meta'))),
  296. }))
  297. const stateText = () => textOf(findNode(container, byAttr('data-page-state')))
  298. const clickPage = async (n) => {
  299. const btn = findNode(container, (node) => String(node.props && node.props['data-page-number']) === String(n))
  300. assert.ok(btn && typeof btn.props.onClick === 'function', `页码按钮 ${n} 不在`)
  301. btn.props.onClick(new Proxy({}, { get: () => undefined }))
  302. await nextTick()
  303. }
  304. // V1 点页码直达 → 窗口真的移动到第 11–20 行(逐条对内容,不是只数卡片)。
  305. await clickPage(2)
  306. const want2 = pageRows().slice(10, 20)
  307. const got2 = cards()
  308. check('V1 点页码 2 → 画的是第 11–20 行(逐条对 key/标题/读数)',
  309. got2.length === 10 && got2.every((c, i) => c.key === want2[i].key && c.meta === want2[i].meta.join(' · ') && c.title === want2[i].title),
  310. `got=${JSON.stringify(got2[0])} want=${JSON.stringify({ key: want2[0].key, title: want2[0].title })}`)
  311. // V2 计数行随页码更新(条文 9)。
  312. check('V2 计数行随页码更新为「第 2/70 页 · 共 699 条」', stateText() === '第 2/70 页 · 共 699 条', `state=${stateText()}`)
  313. // V3 同条件刷新(周期 tick 会重算出一批全新块对象)→ 页码必须保持第 2 页。
  314. viewRef.value = panelOf(unfiltered, { scope: 'all' })
  315. await nextTick()
  316. check('V3 数据刷新不复位页码(停在第 3 页的人不回第 1 页)',
  317. stateText() === '第 2/70 页 · 共 699 条' && cards()[0].key === want2[0].key, `state=${stateText()}`)
  318. // V4 换片区 → 回第 1 页。
  319. viewRef.value = panelOf(unfiltered, { scope: 'south' })
  320. await nextTick()
  321. check('V4 换片区筛选页码复位第 1 页', stateText() === '第 1/70 页 · 共 699 条' && cards()[0].key === pageRows()[0].key, `state=${stateText()}`)
  322. // V5 换行业 → 回第 1 页并按新行业重算页数。
  323. const drainageView = panelOf(drainageRaw, { scope: 'south' })
  324. viewRef.value = drainageView
  325. await nextTick()
  326. const dPages = Math.ceil(drainageView.blocks[1].rows.length / PAGE_SIZE)
  327. check('V5 换行业页码复位并按新行业重算页数',
  328. stateText() === `第 1/${dPages} 页 · 共 ${drainageView.blocks[1].rows.length} 条` && cards()[0].key === drainageView.blocks[1].rows[0].key,
  329. `state=${stateText()}`)
  330. // V6 到末页:下一页 disabled 但按钮仍在(保占位,计数行不跳——票 46 既有语义)。
  331. await clickPage(dPages)
  332. const next = findNode(container, byAttr('data-page-next'))
  333. const prev = findNode(container, byAttr('data-page-prev'))
  334. check('V6 末页下一页 disabled 且保留占位(按钮不消失)',
  335. !!next && next.props.disabled === true && !!prev && prev.props.disabled === false,
  336. `next=${next && next.props.disabled} prev=${prev && prev.props.disabled}`)
  337. // V7 末页只画余数(不补空卡)。
  338. const tail = drainageView.blocks[1].rows.length % PAGE_SIZE
  339. check('V7 末页只画余数', cards().length === (tail || PAGE_SIZE), `cards=${cards().length} tail=${tail}`)
  340. // V8 跳页输入超界 → 由 pagedRows 夹回最后一页(条文 6 + 跳页节:不报错、不清列表)。
  341. viewRef.value = panelOf(unfiltered, { scope: 'all' })
  342. await nextTick()
  343. const jumpInput = findNode(container, byAttr('data-page-jump-input'))
  344. assert.ok(jumpInput && typeof jumpInput.props['onUpdate:modelValue'] === 'function', '跳页输入框接线缺失')
  345. jumpInput.props['onUpdate:modelValue']('999')
  346. await nextTick()
  347. findNode(container, byAttr('data-page-jump-btn')).props.onClick()
  348. await nextTick()
  349. check('V8 跳页超界夹回最后一页(不空表、不报错)',
  350. stateText() === '第 70/70 页 · 共 699 条' && cards().length === 699 - 690, `state=${stateText()} cards=${cards().length}`)
  351. // V9 跳页与页码共用同一个翻页出口(不新增第二套页码状态)。
  352. check('V9 跳页后当前页标记同步(同一套页码状态)',
  353. String(findNode(container, (n) => n.props && n.props['aria-current'] === 'page')?.props['data-page-number']) === '70', '两套页码')
  354. // V10 整页 DOM 只有 10 张卡:一屏几千张卡的形态被构造上堵死。
  355. await clickPage(1)
  356. check('V10 任意时刻画出的卡 ≤ 10(含刷新后)', cards().length <= PAGE_SIZE, `cards=${cards().length}`)
  357. // V11 条文 6 的反例回归(本票第一版踩过的坑):切走再**切回**也必须回第 1 页。
  358. // 把筛选条件编进页码键时,「切回燃气」会读到上次留下的槽位停在第 3 页——
  359. // 看起来像「记住了用户位置」,实际违反条文 6(切回来同样是一次条件变化)。
  360. {
  361. const gasView = panelOf(unfiltered, { scope: 'all' })
  362. viewRef.value = gasView
  363. await nextTick()
  364. // 窗口在 >7 页时收缩(第 1 页的窗口是「1 2 … 70」),先到第 2 页,第 3 页才出现在按钮里。
  365. await clickPage(2)
  366. await clickPage(3)
  367. check('V11a 切回全行业后先停在第 3 页(前置条件成立)', stateText() === '第 3/70 页 · 共 699 条', `state=${stateText()}`)
  368. // 切到排水(条件变)→ 复位
  369. viewRef.value = panelOf(drainageRaw, { scope: 'all' })
  370. await nextTick()
  371. check('V11b 切到排水复位第 1 页', stateText().startsWith('第 1/'), `state=${stateText()}`)
  372. // 再切回燃气(又一次条件变化)→ 仍回第 1 页,**不得**回到刚才的第 3 页
  373. viewRef.value = gasView
  374. await nextTick()
  375. check('V11c 切回燃气仍回第 1 页(旧设计的缺陷:会停在第 3 页)',
  376. stateText() === '第 1/70 页 · 共 699 条' && cards()[0].key === gasView.blocks[1].rows[0].key,
  377. `state=${stateText()}`)
  378. }
  379. // V12 **逐页全扫**(把诊断探针固化成闸门):699 条 → 70 页,每一页画出来的
  380. // key/标题/读数必须等于 domain 行的对应切片。这是内容级判据 ——
  381. // 只数卡片数(S1/V10)抓不到「页码显示对、实际画错行」这类窗口起点算错的缺陷。
  382. {
  383. viewRef.value = panelOf(unfiltered, { scope: 'all' })
  384. await nextTick()
  385. const all = pageRows()
  386. const pages = Math.ceil(all.length / PAGE_SIZE)
  387. let mismatches = 0
  388. let firstBad = ''
  389. for (let page = 1; page <= pages; page += 1) {
  390. await clickPage(page)
  391. const got = cards()
  392. const want = all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
  393. if (got.length !== want.length) { mismatches += 1; firstBad = firstBad || `page ${page}: 画 ${got.length} 张 vs 应有 ${want.length} 张`; continue }
  394. for (let i = 0; i < want.length; i += 1) {
  395. if (got[i].key !== want[i].key || got[i].meta !== want[i].meta.join(' · ') || got[i].title !== want[i].title) {
  396. mismatches += 1
  397. 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(' · ') })}`
  398. break
  399. }
  400. }
  401. }
  402. check(`V12 全 ${pages} 页逐页与 domain 切片完全一致(窗口算术与内容不错位)`, mismatches === 0, `${mismatches} 页错配 · ${firstBad}`)
  403. }
  404. }
  405. // ================================================================ K/Z 编译与单源未回归
  406. // ================================================================ M. mock 同构(票 08/18/38/51 同一条纪律)
  407. // 真实链路与 mock 必须过同一个 domain + 同一个渲染层,读出同一个结论;
  408. // 否则分页只在真机有效、mock 上是另一套行为(票 51 记过的假绿)。
  409. {
  410. const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`)
  411. const transports = {}
  412. registerCockpitMocks({ handlers: { registerMockTransport: (path, handler) => { transports[path] = handler } } })
  413. const mockGet = async (query = '') => (await transports['/infrastructure/operating-information']({ path: '/infrastructure/operating-information' + query }))
  414. const mockView = panelOf(await mockGet(), { scope: 'all' })
  415. const mockBlock = listBlockOf(mockView)
  416. // M1 mock 载荷也声明分页并按同一规则寻址(同一张嘴,不按数据源分叉)。
  417. check('M1 mock 载荷同样声明分页(同一张嘴,不因数据源分叉)',
  418. mockBlock.paginate === true && mockBlock.label === '运行指标' && mockBlock.pageScope === 'GAS|all',
  419. `scope=${mockBlock.pageScope} paginate=${mockBlock.paginate}`)
  420. // M2 mock 每个行业块只有 2–3 条 ⇒ pageCount 恒 1 ⇒ 整块 pager **不出现**。
  421. // 这是**裁定后的预期行为**(维护者 2026-10-19:验收与走查以真实数据为准,
  422. // 不为演示扩写 mock 样例)——符合条文 4,不是缺陷,也**不要**为此把样例补到 >10 条。
  423. // 多页与边界行为归 V 组(真实载荷逐页对账);真实观感归票 18 联调窗口。
  424. const mockHtml = await renderPanel(mockView)
  425. check('M2 mock 载荷不足一页 ⇒ 不画控件(预期行为;已裁定不为演示扩写 mock 样例)',
  426. !/data-pager/.test(mockHtml) && mockBlock.rows.length < PAGE_SIZE && (mockHtml.match(/data-item="/g) || []).length === mockBlock.rows.length,
  427. `rows=${mockBlock.rows.length}`)
  428. // M3 选行业后 mock 仍是单页(mock 样例没跨到 >10 条),行为与真实同形而非特例。
  429. const waterMock = panelOf(await mockGet('?industry=WATER_SUPPLY'), { scope: 'all' })
  430. check('M3 mock 选行业仍走同一条分页路径(pageScope 随回显块换)',
  431. listBlockOf(waterMock).pageScope === 'WATER_SUPPLY|all' && listBlockOf(waterMock).paginate === true,
  432. `scope=${listBlockOf(waterMock).pageScope}`)
  433. }
  434. // ================================================================ K/Z 编译与单源未回归
  435. {
  436. const compileSfc = (label, rel) => {
  437. let ok = false
  438. let why = ''
  439. try {
  440. const source = read(rel)
  441. const { descriptor, errors } = parse(source, { filename: rel })
  442. assert.deepEqual(errors, [], `${rel} parse`)
  443. const script = compileScript(descriptor, { id: 't64' })
  444. const template = compileTemplate({
  445. source: descriptor.template.content, filename: rel, id: 't64',
  446. compilerOptions: { bindingMetadata: script.bindings },
  447. })
  448. assert.deepEqual(template.errors, [], `${rel} template`)
  449. ok = true
  450. } catch (error) { why = error.message.split('\n')[0] }
  451. check(`K ${label} 编译通过`, ok, why || `${rel} 应无编译错误`)
  452. }
  453. compileSfc('PanelBlocks', 'components/PanelBlocks.vue')
  454. compileSfc('PagerNav', 'components/PagerNav.vue')
  455. compileSfc('EquipmentDetailSections', 'components/EquipmentDetailSections.vue')
  456. }
  457. // K2 外壳接线(不整份 import CockpitShell——它会拉起 store/cesium 全图,且那是并行在制品的共享面;
  458. // 这里钉住「换片区复位判据的输入」这一条接线本身,源码级判据)。
  459. {
  460. const shellSrc = read('CockpitShell.vue')
  461. check('K2 外壳把当前片区传给运行信息面板(pageScope 的片区半边有真实输入)',
  462. /panelId === 'operation'\) extra = \{ scope: scope\.value \}/.test(shellSrc),
  463. '外壳没接线 ⇒ 换片区不复位(D11 只测 domain 侧,抓不到这条断链)')
  464. }
  465. check('Z1 pagination 单源未回归(PAGE_SIZE/窗口/夹回/空表 1 页)',
  466. PAGE_SIZE === 10 && pageWindow(5, 8).join(',') === '1,…,4,5,6,…,8' && clampPage(99, 10) === 10 && pagedRows([], 1).pageCount === 1, '')
  467. console.log('')
  468. console.log(`T64 运行指标分页与块名纠正:${passed}/${passed + failed} 项通过。`)
  469. if (failed) { console.log('未通过项见上方 FAIL。'); process.exit(1) }
  470. console.log('T64 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')