t46-detail-cards-history.mjs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. /**
  2. * 票 46 离线闸门:监测点与设备详情卡片化、分类查询与历史折叠。
  3. *
  4. * 四组断言(都不启浏览器、不联网):
  5. * A. 分类查询纯函数:chips 首项恒为「全部」且计数真实;0 计数的词表项不出现;
  6. * 词表外的分类不静默丢;过滤不改源次序、认不出的键回空数组(不"筛了个寂寞")。
  7. * B. 行视图分类键:事件按等级(词表单源 eventList/eventLevelTag)、工单按状态字典 0–7
  8. * (单源 workOrder.js);认不出的落 unknown 桶,不并进真实语义。
  9. * C. 展开详情四件事:数据时间/状态/来源/关联对象**始终保留**(缺值给占位符,不画空白)。
  10. * D. 点位设备计数 KPI:统计整段缺失 → null(KPI 条不出现,**不画一排 0**);
  11. * 计数 0 是真实值照画;色键走设备词表不借点位表。
  12. * E. 静态守卫:折叠默认收起(`<details>` 无 open)、chip 绑定齐全、换设备清分类、
  13. * 组件零 `.sort(`、零后端裸字段/原因码、只读边界(无写操作、不拼接口路径)。
  14. *
  15. * 守卫有牙(变异测试见 verify/t46-mutations.mjs 说明):
  16. * M1 filterByCategory 认不出回全量 → A4 红;M2 统计缺失回全 0 → D1 红;
  17. * M3 详情必选项没值就丢 → C1 红;M4 分类键并进 UNKNOWN → B2 红。
  18. *
  19. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t46-detail-cards-history.mjs
  20. */
  21. import assert from 'node:assert/strict'
  22. import { readFileSync } from 'node:fs'
  23. // 命名空间 import 只为 E11 一条服务:**按真实导出判**组件 import 的名字存不存在
  24. // (文本正则判不出拼错的具名 import,只有加载层判得出)。
  25. import * as equipmentDetailViewModule from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
  26. import * as detailFlowModule from '../../../src/modules/cockpit/domain/detailFlow.js'
  27. import {
  28. CATEGORY_ALL,
  29. DEFAULT_EQUIPMENT_TAB,
  30. EQUIPMENT_SECTION_PANELS,
  31. EQUIPMENT_TABS,
  32. EVENT_CATEGORY_ORDER,
  33. HISTORY_PAGE_SIZE,
  34. WORK_ORDER_CATEGORY_ORDER,
  35. categoryChips,
  36. categoryNote,
  37. eventDetailRows,
  38. eventRows,
  39. filterByCategory,
  40. pagedRows,
  41. panelsForTab,
  42. tabCount,
  43. workOrderDetailRows,
  44. workOrderRows,
  45. } from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
  46. import { deviceStatusView, pointDeviceCountsView, statusView } from '../../../src/modules/cockpit/domain/detailFlow.js'
  47. import { legendIconFor, legendVisuals } from '../../../src/modules/cockpit/domain/pointStatus.js'
  48. import { toMonitoringPointDetail } from '../../../src/modules/cockpit/domain/pointDetail.js'
  49. import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
  50. const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
  51. let passed = 0
  52. let failed = 0
  53. function check(name, condition, message) {
  54. if (condition) {
  55. passed += 1
  56. console.log(`PASS ${name}`)
  57. } else {
  58. failed += 1
  59. console.error(`FAIL ${name}: ${message}`)
  60. }
  61. }
  62. // ================================================================ A. 分类 chips 与过滤(纯函数)
  63. {
  64. const rows = eventRows([
  65. { code: 'W1', name: 'a', level: 1, status: 'ACTIVE', eventTime: '2026-10-01T08:00:00+08:00' },
  66. { code: 'W2', name: 'b', level: 2, status: 'PENDING', eventTime: '2026-10-02T09:00:00+08:00' },
  67. { code: 'W3', name: 'c', level: 2, status: 'PENDING', eventTime: '2026-10-03T10:00:00+08:00' },
  68. { code: 'W4', name: 'd', eventTime: '2026-10-04T11:00:00+08:00' },
  69. ])
  70. // A1 首项恒为「全部」,计数 = 总行数。
  71. const chips = categoryChips(rows, EVENT_CATEGORY_ORDER)
  72. check('A1-first-chip-is-all', chips[0].key === CATEGORY_ALL && chips[0].label === '全部' && chips[0].count === 4, JSON.stringify(chips[0]))
  73. // A2 词表次序固定(1级 在 2级 前),0 计数的词表项(3级/4级)不出现。
  74. // 首项是「全部」(键为空串,见 A1),所以次序串以分隔符开头。
  75. const keys = chips.map((chip) => chip.key)
  76. check(
  77. 'A2-known-order-and-zero-count-omitted',
  78. keys.join('|') === '|level:1|level:2|level:unknown',
  79. `实际次序:${keys.join('|')}(3/4级 计数 0 不该出现;未知在最后)`,
  80. )
  81. const level2 = chips.find((chip) => chip.key === 'level:2')
  82. check('A2b-counts-are-real', level2.count === 2 && chips.find((chip) => chip.key === 'level:1').count === 1, JSON.stringify(chips))
  83. // A3 词表外的分类不静默丢(后端加了新等级 → 排在已知项之后仍可见可筛)。
  84. const withExtra = categoryChips([{ categoryKey: 'level:9', categoryLabel: '9级' }, { categoryKey: 'level:1', categoryLabel: '1级' }], EVENT_CATEGORY_ORDER)
  85. check('A3-unknown-vocabulary-survives', withExtra.map((chip) => chip.key).join('|') === '|level:1|level:9', JSON.stringify(withExtra.map((chip) => chip.key)))
  86. // A4 过滤:空键=全部(同一批对象引用,不复制不改序);命中键只留该分类;认不出的键回空数组。
  87. const all = filterByCategory(rows, CATEGORY_ALL)
  88. check('A4-empty-key-is-identity', all === rows, '「全部」应原样返回同一引用(不复制、不重排)')
  89. const only2 = filterByCategory(rows, 'level:2')
  90. check('A4b-filter-hits', only2.length === 2 && only2.every((row) => row.categoryKey === 'level:2'), `命中 2 条:${only2.map((row) => row.code).join(',')}`)
  91. check('A4c-unknown-key-is-empty', filterByCategory(rows, 'level:88').length === 0, '认不出的键回空数组——回全量就是"筛了个寂寞"(变异 M1 的靶)')
  92. // A5 生效回显:全部时不说,生效时把「怎么取消」说出来(票 37 typeNote 同一立场)。
  93. check('A5-note-only-when-active', categoryNote(chips, CATEGORY_ALL) === '' && /再点同一项取消/.test(categoryNote(chips, 'level:2')) && /当前分类:2级/.test(categoryNote(chips, 'level:2')), categoryNote(chips, 'level:2'))
  94. }
  95. // ================================================================ B. 行视图的分类键(词表单源)
  96. {
  97. // B1 事件等级 1–4 用票 13 的等级词表;折叠行短读法与完整时间并存。
  98. const rows = eventRows([
  99. { code: 'W1', name: 'a', level: 1, status: 'ACTIVE', eventTime: '2026-10-01T08:00:00+08:00', value: 12, unit: 'mg/L' },
  100. { code: 'W2', name: 'b', level: 4, status: 'PENDING', eventTime: '2026-10-02T09:00:00+08:00' },
  101. ])
  102. check(
  103. 'B1-event-level-categories',
  104. rows[0].categoryKey === 'level:1' && rows[0].categoryLabel === '1级' && rows[0].levelTag.tone === 'lv1' && rows[1].categoryKey === 'level:4',
  105. `${rows[0].categoryKey}/${rows[0].categoryLabel} · ${rows[1].categoryKey}`,
  106. )
  107. check('B1b-short-and-full-time-coexist', rows[0].timeText === '10-01 08:00' && rows[0].eventTime === '2026-10-01T08:00:00+08:00', `短读 ${rows[0].timeText} / 完整 ${rows[0].eventTime}`)
  108. // B2 缺等级/脏等级 → unknown 桶 + 「等级未知」,**不并进任何真实等级**(变异 M4 的靶)。
  109. const odd = eventRows([{ code: 'W3', name: 'c' }, { code: 'W4', name: 'd', level: 'X' }])
  110. check('B2-unknown-level-not-guessed', odd.every((row) => row.categoryKey === 'level:unknown' && row.categoryLabel === '等级未知'), JSON.stringify(odd.map((row) => row.categoryKey)))
  111. // B3 工单按状态字典 0–7;认不出的码落 state:unknown。
  112. const orders = workOrderRows([
  113. { workOrderCode: 'WO-1', stateCode: 0 },
  114. { workOrderCode: 'WO-2', stateCode: 5 },
  115. { workOrderCode: 'WO-3', stateCode: 99 },
  116. { workOrderCode: 'WO-4' },
  117. ])
  118. check(
  119. 'B3-work-order-state-categories',
  120. orders[0].categoryKey === 'state:0' && orders[0].categoryLabel === '待派单' && orders[1].categoryLabel === '已结案' && orders[2].categoryKey === 'state:unknown' && orders[3].categoryKey === 'state:unknown',
  121. JSON.stringify(orders.map((row) => row.categoryKey)),
  122. )
  123. check('B3b-order-chips-order', WORK_ORDER_CATEGORY_ORDER.filter((key) => key !== 'state:unknown').length === 8, '工单分类次序 = 字典 0–7(单源 workOrder.js,不另造)')
  124. }
  125. // ================================================================ C. 展开详情:四件事始终保留
  126. {
  127. // C1 最小事件(只有编码):数据时间/状态/来源/关联设备四行**都在**,缺值给占位符(变异 M3 的靶)。
  128. const rows = eventDetailRows(eventRows([{ code: 'W1' }])[0])
  129. const labels = rows.map((row) => row.label)
  130. check(
  131. 'C1-mandatory-detail-rows',
  132. ['数据时间', '状态', '来源', '关联设备'].every((label) => labels.includes(label)),
  133. `实际:${labels.join('/')}`,
  134. )
  135. check('C1b-missing-values-are-placeholder', rows.filter((row) => ['数据时间', '状态', '来源', '关联设备'].includes(row.label)).every((row) => row.value === DETAIL_PLACEHOLDER), '缺值一律占位符,不画空白')
  136. // C2 有值时带出真实内容;可选行(等级/量值/位置)没值就不出现。
  137. const full = eventDetailRows(eventRows([{ code: 'W1', level: 2, value: 18, unit: 'mg/L', location: '井下一层', status: 'ACTIVE', eventTime: '2026-10-01T08:00:00+08:00', deviceName: '压力计-1' }])[0])
  138. const byLabel = Object.fromEntries(full.map((row) => [row.label, row.value]))
  139. check('C2-full-detail-values', byLabel['量值'] === '18 mg/L' && byLabel['位置'] === '井下一层' && byLabel['关联设备'] === '压力计-1', JSON.stringify(byLabel))
  140. const sparse = eventDetailRows(eventRows([{ code: 'W1', name: 'a' }])[0])
  141. check('C2b-optional-rows-omitted-when-empty', !sparse.some((row) => row.label === '位置' || row.label === '量值'), JSON.stringify(sparse.map((row) => row.label)))
  142. // C3 工单展开:四件事 + 计划/实际结案;关联对象那句三态文案仍来自 domain(票 21 口径)。
  143. const order = workOrderRows([{ workOrderCode: 'WO-1', stateCode: 3, associatedWithCurrentEvent: true, createTime: '2026-10-01T08:00:00+08:00', orderTypeName: '故障维修' }])[0]
  144. const detail = Object.fromEntries(workOrderDetailRows(order).map((row) => [row.label, row.value]))
  145. check(
  146. 'C3-work-order-detail-rows',
  147. detail['数据时间'] === '2026-10-01T08:00:00+08:00' && detail['状态'] === '处理中' && detail['来源'] === '故障维修' && detail['关联对象'] === '已关联当前事件' && detail['实际结案'] === DETAIL_PLACEHOLDER,
  148. JSON.stringify(detail),
  149. )
  150. }
  151. // ================================================================ D. 点位设备计数 KPI(不画虚假零值)
  152. {
  153. const rawComplete = {
  154. monitoringPointCode: 'MP-1',
  155. monitoringPointName: '测试点',
  156. pointStatus: 'ALARM',
  157. deviceStatistics: { totalDeviceCount: 5, alarmDeviceCount: 1, warningDeviceCount: 2, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 1 },
  158. devices: [],
  159. }
  160. // D1 统计整段缺失 → null(KPI 条不出现),而不是五个 0(变异 M2 的靶)。
  161. const degraded = toMonitoringPointDetail({ monitoringPointCode: 'MP-2', monitoringPointName: '缺统计点', devices: [] })
  162. check('D1-missing-statistics-hides-kpi', pointDeviceCountsView(degraded) === null, '没给统计时回 null——画一排 0 就是虚假零值')
  163. // D2 完整统计 → 五态计数;0 是真实值照画;色键来自设备词表(报警 lv1/预警 lv2/工单 lv3/正常 lv4/离线 point-offline)。
  164. const view = pointDeviceCountsView(toMonitoringPointDetail(rawComplete))
  165. const byKey = Object.fromEntries(view.map((row) => [row.key, row]))
  166. check('D2-counts-from-backend', view.length === 5 && byKey.ALARM.count === 1 && byKey.OFFLINE.count === 1, JSON.stringify(view.map((row) => `${row.key}:${row.count}`)))
  167. check('D2b-zero-is-real', byKey.WORK_ORDER.count === 0 && byKey.WORK_ORDER.label === '工单处置', '确实 0 台照画 0(与"没给统计"是两件事)')
  168. check('D2c-device-table-tones', byKey.ALARM.tone === 'lv1' && byKey.OFFLINE.tone === 'point-offline', JSON.stringify(view.map((row) => `${row.key}:${row.tone}`)))
  169. }
  170. // ================================================================ F. 标签页计划(返工第 1 条)
  171. {
  172. // F1 页签清单恰为五个且顺序/默认页符合维护者点名的形态;图片不在页签里(并入档案页)。
  173. check(
  174. 'F1-tab-plan-shape',
  175. EQUIPMENT_TABS.map((tab) => tab.name).join(',') === 'archive,metrics,warnings,alarms,workOrders' &&
  176. DEFAULT_EQUIPMENT_TAB === 'archive' &&
  177. EQUIPMENT_TABS[0].sections.join(',') === 'archive,latestImage',
  178. EQUIPMENT_TABS.map((tab) => `${tab.name}[${tab.sections.join('+')}]`).join(' '),
  179. )
  180. // F2 渲染计划仍是六块(t11 D1/D9 的独立性不能被标签页吃掉);latestImage 标了归属。
  181. check(
  182. 'F2-six-panels-still-independently-planned',
  183. EQUIPMENT_SECTION_PANELS.length === 6 &&
  184. EQUIPMENT_SECTION_PANELS.find((panel) => panel.name === 'latestImage').mergedInto === 'archive',
  185. '标签页只改呈现归属,六块分区的数据与成败一个都不合并',
  186. )
  187. // F3 页签取面板:档案页拿到两块且保持渲染计划次序;未知页签回空数组(不猜)。
  188. const fakePanels = EQUIPMENT_SECTION_PANELS.map((panel) => ({ ...panel, section: { state: 'success', data: [] } }))
  189. check(
  190. 'F3-panels-for-tab',
  191. panelsForTab(fakePanels, 'archive').map((panel) => panel.name).join(',') === 'archive,latestImage' &&
  192. panelsForTab(fakePanels, 'metrics').length === 1 &&
  193. panelsForTab(fakePanels, 'nope').length === 0,
  194. panelsForTab(fakePanels, 'archive').map((panel) => panel.name).join(','),
  195. )
  196. // F4 页签计数只给列表型页;档案/指标回空串(不是 0 —— 0 会被读成"档案有 0 条")。
  197. const listPanels = [
  198. { name: 'warnings', kind: 'events', section: { state: 'success', data: [1, 2, 3] } },
  199. { name: 'alarms', kind: 'events', section: { state: 'success', data: [1] } },
  200. { name: 'workOrders', kind: 'orders', section: { state: 'success', data: [] } },
  201. { name: 'archive', kind: 'archive', section: { state: 'success', data: {} } },
  202. ]
  203. check(
  204. 'F4-tab-counts-only-for-lists',
  205. tabCount(listPanels, 'warnings') === '3' && tabCount(listPanels, 'workOrders') === '0' && tabCount(listPanels, 'archive') === '',
  206. `预警=${tabCount(listPanels, 'warnings')} 工单=${tabCount(listPanels, 'workOrders')} 档案="${tabCount(listPanels, 'archive')}"`,
  207. )
  208. }
  209. // ================================================================ G. 分页边界(返工第 1 条:每页最多 10 项)
  210. {
  211. const rows = eventRows(Array.from({ length: 23 }, (_, index) => ({ code: `W${index}`, name: `n${index}`, level: 1 })))
  212. // G1 每页上限 10;23 条 → 3 页。
  213. const p1 = pagedRows(rows, 1)
  214. check('G1-page-size-ten', HISTORY_PAGE_SIZE === 10 && p1.rows.length === 10 && p1.pageCount === 3, `第1页 ${p1.rows.length} 条 / ${p1.pageCount} 页`)
  215. // G2 末页取余数;区间读数给"第 21–23 条"这种可核对的口径。
  216. const p3 = pagedRows(rows, 3)
  217. check('G2-last-page-remainder', p3.rows.length === 3 && p3.from === 21 && p3.to === 23 && !p3.hasNext, JSON.stringify({ n: p3.rows.length, from: p3.from, to: p3.to }))
  218. // G3 越界页码夹回(第 99 页 → 第 3 页),不返回空表冒充"没数据"。
  219. check('G3-out-of-range-clamped', pagedRows(rows, 99).page === 3 && pagedRows(rows, 0).page === 1 && pagedRows(rows, -5).page === 1, '越界与脏页码都夹回合法页')
  220. // G4 空表:0 条也是 1 页(页码控件由调用方按 pageCount>1 决定是否出现)。
  221. const empty = pagedRows([], 1)
  222. check('G4-empty-is-one-page', empty.total === 0 && empty.pageCount === 1 && empty.from === 0 && !empty.hasPrev && !empty.hasNext, JSON.stringify(empty))
  223. // G5 不排序、不改序(切片保序 = 后端次序,票 10/11 立场)。
  224. check('G5-keeps-backend-order', p1.rows.map((row) => row.code).join(',') === 'W0,W1,W2,W3,W4,W5,W6,W7,W8,W9', p1.rows.map((row) => row.code).join(','))
  225. }
  226. // ================================================================ H. 状态图例对齐(返工第 2 条)
  227. {
  228. // H1 图标登记是**单源接缝**:legendVisuals 的每一行都经 legendIconFor 取值(顶部状态栏与详情共用)。
  229. const visuals = legendVisuals()
  230. check(
  231. 'H1-icon-registry-single-source',
  232. visuals.every((visual) => visual.icon === legendIconFor(visual.key)) && visuals.every((visual) => visual.icon !== ''),
  233. `${visuals.length} 行词条的图标都来自 legendIconFor(顶部状态栏同源)`,
  234. )
  235. // H2 点位状态视图带 icon,且 tone/shape/label 与顶部图例词条逐字一致。
  236. const alarm = statusView('ALARM')
  237. const legendAlarm = visuals.find((visual) => visual.key === 'ALARM')
  238. check(
  239. 'H2-point-status-matches-top-legend',
  240. alarm.icon === legendIconFor('ALARM') && alarm.tone === legendAlarm.tone && alarm.shape === legendAlarm.shape && alarm.label === legendAlarm.label,
  241. `报警:${alarm.tone}/${alarm.shape}/${alarm.label}/${alarm.icon}`,
  242. )
  243. // H3 设备状态视图带 icon(借登记、不借点位状态语义 —— 票 44 边界仍成立)。
  244. const deviceAlarm = deviceStatusView('ALARM')
  245. check(
  246. 'H3-device-status-icon-no-table-bleed',
  247. deviceAlarm.icon === legendIconFor('ALARM') && deviceAlarm.tone === 'lv1' && deviceAlarm.shape === 'triangle' && deviceAlarm.key === 'ALARM',
  248. `设备报警:${deviceAlarm.tone}/${deviceAlarm.shape}/${deviceAlarm.icon}`,
  249. )
  250. // H4 认不出的状态:图标落 UNKNOWN 登记,**不猜成某个真实态的图标**。
  251. check('H4-unknown-status-icon-not-guessed', deviceStatusView('SOMETHING_NEW').icon === legendIconFor('UNKNOWN') && statusView('MAINTENANCE').icon === legendIconFor('UNKNOWN'), '未发布状态的图标来自 UNKNOWN 词条')
  252. // H5 空/脏键回空串(渲染层据此跳过,不画错图标)。
  253. check('H5-blank-key-no-icon', legendIconFor('') === '' && legendIconFor(undefined) === '' && legendIconFor('toString') === '', '认不出的键回空串(继承键不算登记)')
  254. }
  255. // ================================================================ E. 静态守卫(消费侧形状与边界)
  256. {
  257. const sections = read('components/EquipmentDetailSections.vue')
  258. const card = read('components/PointDetailCard.vue')
  259. const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  260. const sectionsCode = stripComments(sections)
  261. const cardCode = stripComments(card)
  262. // E1 折叠默认收起:条目是 `<details data-history-item>` 且**没有** open 属性(默认展开=没折叠)。
  263. check('E1-history-items-collapsed-by-default', /<details[\s\S]*?data-history-item/.test(sectionsCode) && !/<details[^>]*\sopen(\s|>)/.test(sectionsCode), '历史条目用 details 且不带 open——默认折叠是票 46 范围第 3 条')
  264. // E2 每条可展开详情(data-history-detail 两块都有:events 与 orders)。
  265. check('E2-expandable-detail-present', (sectionsCode.match(/data-history-detail/g) || []).length === 2, '预警/报警与工单条目各有一条展开详情')
  266. // E3 分类绑定齐全:chip 键、按下态、切换入口、过滤行(工单)与分页行(预警/报警)。
  267. check(
  268. 'E3-category-bindings-complete',
  269. /:data-category-chip="`\$\{panel\.name\}:\$\{chip\.key \|\| 'all'\}`"/.test(sectionsCode) &&
  270. /:aria-pressed="chip\.key === activeCategory\(panel\.name\)"/.test(sectionsCode) &&
  271. /@click="toggleCategory\(panel\.name, chip\.key\)"/.test(sectionsCode) &&
  272. /v-for="\(event, index\) in pagedOf\(panel\)\.rows"/.test(sectionsCode) &&
  273. /v-for="\(order, index\) in visibleRows\(panel\)"/.test(sectionsCode),
  274. 'chip 键/按下态/点击 + 预警报警走分页行 + 工单走过滤行(守卫按绑定判,不按字样判)',
  275. )
  276. // E3b 分页控件绑定齐全(返工第 1 条:每页最多 10 项;单页不画控件由 pageCount>1 门控)。
  277. check(
  278. 'E3b-pager-bindings-complete',
  279. /v-if="pagedOf\(panel\)\.pageCount > 1"/.test(sectionsCode) &&
  280. /:disabled="!pagedOf\(panel\)\.hasPrev"/.test(sectionsCode) &&
  281. /:disabled="!pagedOf\(panel\)\.hasNext"/.test(sectionsCode) &&
  282. /@click="turnPage\(panel, -1\)"/.test(sectionsCode) &&
  283. /@click="turnPage\(panel, 1\)"/.test(sectionsCode) &&
  284. /data-page-state/.test(sectionsCode),
  285. '上一页/下一页按 hasPrev/hasNext 置灰 + 页码读数 + 单页不出现控件',
  286. )
  287. // E3c 标签页绑定齐全(返工第 1 条:弃用长滚动改切换;默认页与换设备复位都要有)。
  288. check(
  289. 'E3c-tabs-bindings-complete',
  290. /role="tablist"/.test(sectionsCode) &&
  291. /:aria-selected="tab\.name === activeTab"/.test(sectionsCode) &&
  292. /v-for="panel in activePanels"/.test(sectionsCode) &&
  293. /const activeTab = ref\(DEFAULT_EQUIPMENT_TAB\)/.test(sectionsCode) &&
  294. /activeTab\.value = DEFAULT_EQUIPMENT_TAB/.test(sectionsCode),
  295. '页签可访问语义 + 只渲染当前页 + 默认档案页 + 换设备复位',
  296. )
  297. // E4 换设备清分类条件(A 的分类不得挂到 B 上)。
  298. check('E4-device-change-resets-categories', /watch\(\s*\(\) => props\.deviceCode,[\s\S]{0,200}categories\.value = \{\}/.test(sectionsCode), 'deviceCode 变化 → categories 归空')
  299. // E5 分类是视图本地状态:不进 store、不发请求(只读边界的另一半:组件里零 fetch/零接口路径)。
  300. check('E5-local-state-no-requests', !/\bfetch\s*\(|XMLHttpRequest|\/equipments\/|\/monitoring-points\//.test(sectionsCode + cardCode) && /const categories = ref\(\{\}\)/.test(sectionsCode), '分类状态在组件本地;组件不拼路径不取数')
  301. // E6 前端不重排:过滤/归组都不改后端行次序。
  302. check('E6-no-sorting-in-components', !/\.sort\(/.test(sectionsCode) && !/\.sort\(/.test(cardCode), '组件零 .sort((次序归后端与 domain 单源)')
  303. // E7 零后端原因码/裸字段上屏(沿用票 11 的清单,新增的 chip/详情绑定不许漏词)。
  304. const leakedCodes = ['READY', 'SAMPLE_OUTDATED', 'NO_EVENTS', 'NO_METRICS', 'NO_ARCHIVE', 'NO_WORK_ORDERS', 'NO_IMAGE', 'SUCCESS', 'FAILED', 'STALE', 'EMPTY'].filter((code) => new RegExp(`\\b${code}\\b`).test(sectionsCode))
  305. check('E7-no-backend-reason-codes-in-ui', leakedCodes.length === 0, leakedCodes.join(','))
  306. const leakedFields = /deviceModel|deviceSpec|metricCode|warningNo|alarmId|workOrderCode|aggregateState|stateReason|deviceStatistics|statusReasons/.test(cardCode)
  307. check('E7b-card-has-no-raw-fields', !leakedFields, '点位卡只读领域结论(pointDeviceCountsView),不认后端裸字段')
  308. // E8 点位卡 KPI 与既有钩子:v-if 挂 null 判据、编码仍是 dd、设备数仍是 b(t41 的形状)。
  309. check(
  310. 'E8-kpi-and-pinned-hooks',
  311. /v-if="deviceCountsView"/.test(cardCode) && /data-point-kpis/.test(cardCode) &&
  312. /<dd class="point-detail__mono" data-point-code-value>/.test(cardCode) &&
  313. /<b class="point-detail__kpi-value point-detail__num" data-point-device-count>/.test(cardCode),
  314. 'KPI 条按 null 收口;data-point-code-value(<dd>)/data-point-device-count(<b>) 形状保留',
  315. )
  316. // E9 词表单源:domain 的次序表来自 workOrder.js/LEVEL 词表,不抄第二张名单。
  317. const viewCode = read('domain/equipmentDetailView.js')
  318. check(
  319. 'E9-vocabulary-single-source',
  320. /WORK_ORDER_CATEGORY_ORDER = \[\.\.\.WORK_ORDER_STATES\.map/.test(viewCode) && /import \{ eventLevelTag, shortEventTime \} from '\.\/eventList\.js'/.test(viewCode),
  321. '工单次序展开自字典、等级词复用票 13 单源(另抄名单=将来必漂移)',
  322. )
  323. // E10 状态图例对齐(返工第 2 条):详情里**每个** StatusTag 都绑定了 icon(按绑定判)。
  324. // 只查"文件里出现过 icon"会被 import 或注释满足(本仓栽过多次)。
  325. const cardTags = cardCode.match(/<StatusTag[\s\S]*?\/>/g) || []
  326. check(
  327. 'E10-all-detail-status-tags-carry-icon',
  328. cardTags.length >= 3 && cardTags.every((tag) => /:icon="/.test(tag)),
  329. `详情卡内 ${cardTags.length} 个状态标签全部绑定 icon(与一张图顶部状态栏同一套视觉)`,
  330. )
  331. // E10b 图标登记的单源在 pointStatus(顶部状态栏与详情共用一个函数,不各存一份表)。
  332. // 判据:登记只在 pointStatus 一处定义;MapCanvas 经 legendVisuals 消费(不自建图标表)。
  333. const pointStatus = read('domain/pointStatus.js')
  334. const mapCanvas = read('components/MapCanvas.vue')
  335. const flowCode = read('domain/detailFlow.js')
  336. check(
  337. 'E10b-icon-registry-lives-in-one-place',
  338. /export function legendIconFor/.test(pointStatus) &&
  339. /icon: legendIconFor\(visual\.key\)/.test(pointStatus) &&
  340. /import \{[^}]*legendIconFor[^}]*\} from '\.\/pointStatus\.js'/.test(flowCode) &&
  341. (pointStatus.match(/statusAlarm/g) || []).length === 1 &&
  342. /legendVisuals\(\)/.test(mapCanvas) &&
  343. /icon: visual\.icon \|\| ''/.test(mapCanvas) &&
  344. !/statusAlarm/.test(mapCanvas),
  345. '图标登记只在 pointStatus 一处(statusAlarm 字样在该文件只出现 1 次);顶部状态栏与详情都经它取值',
  346. )
  347. // E11 组件 import 的名字**必须真是 domain 的导出**(按真实导出判,不按"字样出现过"判)。
  348. // 为什么要这条:一次把 `EVENT_CATEGORY_ORDER` 误写成 `EQUIPMENT_CATEGORY_ORDER`,
  349. // 文本守卫全绿(字样都在文件里),只有 t41 那种**真的加载 .vue** 的 SSR 闸门才炸。
  350. // 这里不靠浏览器、也不解析 .vue:直接拿 domain 模块的**命名空间**当判据,
  351. // 再对上一份"本票应当 import 的名字清单"(清单缺一即红,见 E11b)。
  352. const REQUIRED_SECTION_IMPORTS = [
  353. 'DEFAULT_EQUIPMENT_TAB',
  354. 'EQUIPMENT_TABS',
  355. 'EVENT_CATEGORY_ORDER',
  356. 'WORK_ORDER_CATEGORY_ORDER',
  357. 'archiveRows',
  358. 'categoryChips',
  359. 'categoryNote',
  360. 'emptyListText',
  361. 'equipmentPanels',
  362. 'eventDetailRows',
  363. 'eventRows',
  364. 'filterByCategory',
  365. 'imageView',
  366. 'metricRows',
  367. 'metricsSourceText',
  368. 'pagedRows',
  369. 'panelsForTab',
  370. 'tabCount',
  371. 'timelineSource',
  372. 'workOrderDetailRows',
  373. 'workOrderRows',
  374. ]
  375. const missingExports = REQUIRED_SECTION_IMPORTS.filter((name) => !(name in equipmentDetailViewModule))
  376. check(
  377. 'E11-imported-names-are-real-exports',
  378. REQUIRED_SECTION_IMPORTS.length >= 10 && missingExports.length === 0,
  379. missingExports.length ? `domain 里没有这些导出:${missingExports.join(', ')}` : `${REQUIRED_SECTION_IMPORTS.length} 个 import 名全部命中 domain 真实导出`,
  380. )
  381. // E11b 清单与源码**双向**核对:组件 import 块里的名字必须与清单完全一致
  382. // (多一个没登记的名字、或清单里的名字没被 import,都判红 —— 单向判等于没判)。
  383. // `[^}]*` 而不是 `[\s\S]*?`:后者会从文件里**第一个** `import {` 起跨(vue 那个),
  384. // 把整段 import 头都当成名字清单(实测就是这么假红的)。
  385. const importBlock = (sections.match(/import \{([^}]*)\} from '\.\.\/domain\/equipmentDetailView\.js'/) || [])[1] || ''
  386. const actualImports = importBlock.split(',').map((piece) => piece.trim().split(/\s+as\s+/)[0]).filter(Boolean)
  387. const notInList = actualImports.filter((name) => !REQUIRED_SECTION_IMPORTS.includes(name))
  388. const notImported = REQUIRED_SECTION_IMPORTS.filter((name) => !actualImports.includes(name))
  389. check(
  390. 'E11b-import-list-matches-source-both-ways',
  391. notInList.length === 0 && notImported.length === 0,
  392. `源码多出的名字:${notInList.join(',') || '无'};清单里未 import 的:${notImported.join(',') || '无'}`,
  393. )
  394. // E11c 点位卡的 domain import 同样按真实导出判。
  395. const CARD_IMPORTS = { detailFlow: ['DETAIL_LEVEL', 'deviceCards', 'deviceCardOf', 'pointDeviceCountsView', 'pointSummary', 'deviceStatusView'], equipmentDetailView: ['equipmentDetailMatches', 'aggregateUnavailable'] }
  396. const cardMissing = Object.entries(CARD_IMPORTS).flatMap(([module, names]) =>
  397. names.filter((name) => !(name in (module === 'detailFlow' ? detailFlowModule : equipmentDetailViewModule))),
  398. )
  399. check(
  400. 'E11c-card-imports-are-real-exports',
  401. cardMissing.length === 0,
  402. cardMissing.length ? `点位卡 import 了不存在的导出:${cardMissing.join(', ')}` : '点位卡两处 domain import 全部命中真实导出',
  403. )
  404. }
  405. console.log('')
  406. console.log(`T46 详情卡片化/分类查询/历史折叠:${passed}/${passed + failed} 项通过。`)
  407. if (failed) {
  408. console.log('未通过项见上方 FAIL。')
  409. process.exit(1)
  410. }
  411. assert.equal(1, 1)
  412. console.log('T46 全部通过(浏览器走查按项目规则未自动执行)。')