t27-ledger-undefined.mjs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. /**
  2. * 票 27 离线回归:面板视图**不得**把 `undefined`/`NaN`/`null` 字面量渲染到界面。
  3. *
  4. * 缺陷原样(票 18 真实浏览器取证 2026-10-10,`DATA_SOURCE.REAL` 运行监测):
  5. * `已列出 0 个点位 · 本端点序列共 undefined 条(含未关联设备)`
  6. *
  7. * 根因(两条合起来才成立,缺一条都不会有这句话):
  8. * ① `fetchMonitoringDistribution()` 的 `isEmpty` 在真实空态成立 → `sectionFromResult()`
  9. * 走空分支,**不带 `data`**(`section.data === null`);外壳把 `section.data` 传给视图;
  10. * ② 台账视图的守卫写的是 `pagination.total === null`,而 `data` 为 null 时
  11. * `pagination` 是 `{}`、`total` 是 **`undefined`** —— 守卫漏了这一态,
  12. * 模板串把 `undefined` 直出;且 `PanelBlocks.vue` 的 `pageNote` 渲染在
  13. * `hasContent` 分支之前,空态也照画。
  14. *
  15. * 本脚本不联网、不启浏览器,用注入的 `fetchImpl` 走**真实消费链**
  16. * (service → sectionState → 视图)复现同一形状,并做一次跨全部面板的普查。
  17. *
  18. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t27-ledger-undefined.mjs
  19. */
  20. import assert from 'node:assert/strict'
  21. import { fetchMonitoringDistribution } from '../../../src/modules/cockpit/services/monitoringApi.js'
  22. import {
  23. monitoringPointLedgerPanel,
  24. toMonitoringDistribution,
  25. } from '../../../src/modules/cockpit/domain/monitoringPanels.js'
  26. import { implementedPanelIds, panelView } from '../../../src/modules/cockpit/domain/panelViews.js'
  27. import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
  28. const config = { dataSource: 'real', api: { baseUrl: 'http://acceptance.invalid/api/cockpit/v1' } }
  29. const respond = (body) => ({ ok: true, status: 200, json: async () => ({ code: 200, msg: 'success', data: body }) })
  30. let failed = 0
  31. async function check(name, action) {
  32. try {
  33. await action()
  34. console.log(`PASS ${name}`)
  35. } catch (error) {
  36. failed += 1
  37. console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`)
  38. }
  39. }
  40. /** 递归收集视图对象里每个字符串(面板视图全是纯数据,字符串即最终渲染面)。 */
  41. function strings(value, out = []) {
  42. if (typeof value === 'string') out.push(value)
  43. else if (Array.isArray(value)) for (const item of value) strings(item, out)
  44. else if (value && typeof value === 'object') for (const item of Object.values(value)) strings(item, out)
  45. return out
  46. }
  47. /** 界面不得出现这些字面量(缺失一律回占位符,见 `domain/fields.js` 抬头)。 */
  48. const LEAK = /\bundefined\b|\bNaN\b|\bnull\b/
  49. // ================================================================ 一、真实空态端到端
  50. await check('real empty state keeps data null and renders a readable page note', async () => {
  51. const result = await fetchMonitoringDistribution({
  52. config,
  53. fetchImpl: async () => respond({
  54. pagination: { page: 1, pageSize: 20, total: 0, totalPages: 0, hasMore: false },
  55. monitoringPoints: [],
  56. unassociatedEquipment: [],
  57. freshness: { status: 'CURRENT' },
  58. }),
  59. })
  60. // 前提:空态确实不带 data —— 这正是根因 ① 的现场,把它钉住。
  61. assert.equal(result.state, SECTION_STATE.EMPTY)
  62. assert.equal(result.data, null, '空分区不带 data(根因①)')
  63. const view = panelView('monitoringPointLedger', result.data, result)
  64. assert.ok(!LEAK.test(view.pageNote), `pageNote 不得含字面量:${JSON.stringify(view.pageNote)}`)
  65. assert.equal(view.pageNote, '', '分母未知时整句不出(宁可不说,也不报假读数)')
  66. assert.equal(view.hasMore, false, 'hasMore 也必须是布尔,不能是 undefined')
  67. })
  68. // ================================================================ 二、契约缺口形态(缺 pagination / total 为 null / 空对象)
  69. for (const [label, payload] of [
  70. ['missing pagination', { monitoringPoints: [], unassociatedEquipment: [], freshness: { status: 'CURRENT' } }],
  71. ['explicit null total', { pagination: { page: 1, pageSize: 20, total: null, totalPages: null, hasMore: false }, monitoringPoints: [], unassociatedEquipment: [], freshness: { status: 'CURRENT' } }],
  72. ['empty object payload', {}],
  73. ]) {
  74. await check(`ledger page note stays clean when ${label}`, async () => {
  75. const data = toMonitoringDistribution(payload)
  76. const view = monitoringPointLedgerPanel(data, section(SECTION_STATE.SUCCESS, { data }))
  77. assert.ok(!LEAK.test(view.pageNote), `pageNote 含字面量:${JSON.stringify(view.pageNote)}`)
  78. assert.equal(view.pageNote, '')
  79. })
  80. }
  81. await check('ledger page note is omitted even when data itself is null', async () => {
  82. const view = monitoringPointLedgerPanel(null, section(SECTION_STATE.EMPTY))
  83. assert.equal(view.pageNote, '')
  84. assert.equal(view.hasMore, false)
  85. })
  86. // ================================================================ 三、有数据时计数仍然正确(修复不能把好路径改坏)
  87. await check('ledger page note still reports the real denominator when present', async () => {
  88. const data = toMonitoringDistribution({
  89. pagination: { page: 1, pageSize: 3, total: 8, totalPages: 3, hasMore: true },
  90. monitoringPoints: [{ pointCode: 'MP-001', pointName: '供水干管监测点', pointStatus: 'NORMAL' }],
  91. unassociatedEquipment: [],
  92. freshness: { status: 'CURRENT' },
  93. excluded: {},
  94. })
  95. const view = monitoringPointLedgerPanel(data, section(SECTION_STATE.SUCCESS, { data }))
  96. assert.equal(view.pageNote, '已列出 1 个点位 · 本端点序列共 8 条(含未关联设备) · 还有下一页')
  97. assert.equal(view.hasMore, true)
  98. })
  99. await check('numeric-string denominator from the wire is still accepted', async () => {
  100. // BigDecimal 在线上是字符串(仓库既有事实),`numberish` 两种都认 —— 别把字符串误判成缺失。
  101. const data = toMonitoringDistribution({
  102. pagination: { page: 1, pageSize: 20, total: '13', totalPages: '1', hasMore: false },
  103. monitoringPoints: [],
  104. unassociatedEquipment: [],
  105. freshness: { status: 'CURRENT' },
  106. })
  107. const view = monitoringPointLedgerPanel(data, section(SECTION_STATE.SUCCESS, { data }))
  108. assert.ok(view.pageNote.includes('共 13 条'), `字符串分母应照常显示:${view.pageNote}`)
  109. })
  110. // ================================================================ 四、跨全部已注册面板的普查
  111. await check('no registered panel view leaks a missing-value literal', async () => {
  112. const shapes = {
  113. null: null,
  114. emptyObject: {},
  115. missingPagination: { points: [], equipment: [], pagination: {} },
  116. nullTotal: { points: [], equipment: [], pagination: { page: 1, pageSize: 20, total: null, totalPages: null, hasMore: false } },
  117. emptyRows: { monitoringPoints: [], unassociatedEquipment: [], abnormalEquipment: [], districtIndustry: [], trend: [], events: [], highlights: [] },
  118. }
  119. const leaks = []
  120. for (const panelId of implementedPanelIds()) {
  121. for (const [shapeName, data] of Object.entries(shapes)) {
  122. for (const state of [SECTION_STATE.EMPTY, SECTION_STATE.SUCCESS, SECTION_STATE.STALE, SECTION_STATE.FAILED]) {
  123. const view = panelView(panelId, data, section(state, { data }))
  124. if (!view) continue
  125. for (const text of strings(view)) {
  126. if (LEAK.test(text)) leaks.push(`${panelId}/${shapeName}/${state}: ${JSON.stringify(text)}`)
  127. }
  128. }
  129. }
  130. }
  131. assert.deepEqual(leaks, [], `有面板把缺失值直出:\n${leaks.join('\n')}`)
  132. })
  133. console.log('')
  134. console.log(`T27 ledger undefined: ${failed ? `${failed} FAILED` : 'all passed'}`)
  135. if (failed) process.exit(1)