|
|
@@ -0,0 +1,444 @@
|
|
|
+/**
|
|
|
+ * 票 46 离线闸门:监测点与设备详情卡片化、分类查询与历史折叠。
|
|
|
+ *
|
|
|
+ * 四组断言(都不启浏览器、不联网):
|
|
|
+ * A. 分类查询纯函数:chips 首项恒为「全部」且计数真实;0 计数的词表项不出现;
|
|
|
+ * 词表外的分类不静默丢;过滤不改源次序、认不出的键回空数组(不"筛了个寂寞")。
|
|
|
+ * B. 行视图分类键:事件按等级(词表单源 eventList/eventLevelTag)、工单按状态字典 0–7
|
|
|
+ * (单源 workOrder.js);认不出的落 unknown 桶,不并进真实语义。
|
|
|
+ * C. 展开详情四件事:数据时间/状态/来源/关联对象**始终保留**(缺值给占位符,不画空白)。
|
|
|
+ * D. 点位设备计数 KPI:统计整段缺失 → null(KPI 条不出现,**不画一排 0**);
|
|
|
+ * 计数 0 是真实值照画;色键走设备词表不借点位表。
|
|
|
+ * E. 静态守卫:折叠默认收起(`<details>` 无 open)、chip 绑定齐全、换设备清分类、
|
|
|
+ * 组件零 `.sort(`、零后端裸字段/原因码、只读边界(无写操作、不拼接口路径)。
|
|
|
+ *
|
|
|
+ * 守卫有牙(变异测试见 verify/t46-mutations.mjs 说明):
|
|
|
+ * M1 filterByCategory 认不出回全量 → A4 红;M2 统计缺失回全 0 → D1 红;
|
|
|
+ * M3 详情必选项没值就丢 → C1 红;M4 分类键并进 UNKNOWN → B2 红。
|
|
|
+ *
|
|
|
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t46-detail-cards-history.mjs
|
|
|
+ */
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import { readFileSync } from 'node:fs'
|
|
|
+// 命名空间 import 只为 E11 一条服务:**按真实导出判**组件 import 的名字存不存在
|
|
|
+// (文本正则判不出拼错的具名 import,只有加载层判得出)。
|
|
|
+import * as equipmentDetailViewModule from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
|
|
|
+import * as detailFlowModule from '../../../src/modules/cockpit/domain/detailFlow.js'
|
|
|
+import {
|
|
|
+ CATEGORY_ALL,
|
|
|
+ DEFAULT_EQUIPMENT_TAB,
|
|
|
+ EQUIPMENT_SECTION_PANELS,
|
|
|
+ EQUIPMENT_TABS,
|
|
|
+ EVENT_CATEGORY_ORDER,
|
|
|
+ HISTORY_PAGE_SIZE,
|
|
|
+ WORK_ORDER_CATEGORY_ORDER,
|
|
|
+ categoryChips,
|
|
|
+ categoryNote,
|
|
|
+ eventDetailRows,
|
|
|
+ eventRows,
|
|
|
+ filterByCategory,
|
|
|
+ pagedRows,
|
|
|
+ panelsForTab,
|
|
|
+ tabCount,
|
|
|
+ workOrderDetailRows,
|
|
|
+ workOrderRows,
|
|
|
+} from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
|
|
|
+import { deviceStatusView, pointDeviceCountsView, statusView } from '../../../src/modules/cockpit/domain/detailFlow.js'
|
|
|
+import { legendIconFor, legendVisuals } from '../../../src/modules/cockpit/domain/pointStatus.js'
|
|
|
+import { toMonitoringPointDetail } from '../../../src/modules/cockpit/domain/pointDetail.js'
|
|
|
+import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
|
|
|
+
|
|
|
+const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
|
|
|
+
|
|
|
+let passed = 0
|
|
|
+let failed = 0
|
|
|
+function check(name, condition, message) {
|
|
|
+ if (condition) {
|
|
|
+ passed += 1
|
|
|
+ console.log(`PASS ${name}`)
|
|
|
+ } else {
|
|
|
+ failed += 1
|
|
|
+ console.error(`FAIL ${name}: ${message}`)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ================================================================ A. 分类 chips 与过滤(纯函数)
|
|
|
+
|
|
|
+{
|
|
|
+ const rows = eventRows([
|
|
|
+ { code: 'W1', name: 'a', level: 1, status: 'ACTIVE', eventTime: '2026-10-01T08:00:00+08:00' },
|
|
|
+ { code: 'W2', name: 'b', level: 2, status: 'PENDING', eventTime: '2026-10-02T09:00:00+08:00' },
|
|
|
+ { code: 'W3', name: 'c', level: 2, status: 'PENDING', eventTime: '2026-10-03T10:00:00+08:00' },
|
|
|
+ { code: 'W4', name: 'd', eventTime: '2026-10-04T11:00:00+08:00' },
|
|
|
+ ])
|
|
|
+
|
|
|
+ // A1 首项恒为「全部」,计数 = 总行数。
|
|
|
+ const chips = categoryChips(rows, EVENT_CATEGORY_ORDER)
|
|
|
+ check('A1-first-chip-is-all', chips[0].key === CATEGORY_ALL && chips[0].label === '全部' && chips[0].count === 4, JSON.stringify(chips[0]))
|
|
|
+
|
|
|
+ // A2 词表次序固定(1级 在 2级 前),0 计数的词表项(3级/4级)不出现。
|
|
|
+ // 首项是「全部」(键为空串,见 A1),所以次序串以分隔符开头。
|
|
|
+ const keys = chips.map((chip) => chip.key)
|
|
|
+ check(
|
|
|
+ 'A2-known-order-and-zero-count-omitted',
|
|
|
+ keys.join('|') === '|level:1|level:2|level:unknown',
|
|
|
+ `实际次序:${keys.join('|')}(3/4级 计数 0 不该出现;未知在最后)`,
|
|
|
+ )
|
|
|
+ const level2 = chips.find((chip) => chip.key === 'level:2')
|
|
|
+ check('A2b-counts-are-real', level2.count === 2 && chips.find((chip) => chip.key === 'level:1').count === 1, JSON.stringify(chips))
|
|
|
+
|
|
|
+ // A3 词表外的分类不静默丢(后端加了新等级 → 排在已知项之后仍可见可筛)。
|
|
|
+ const withExtra = categoryChips([{ categoryKey: 'level:9', categoryLabel: '9级' }, { categoryKey: 'level:1', categoryLabel: '1级' }], EVENT_CATEGORY_ORDER)
|
|
|
+ check('A3-unknown-vocabulary-survives', withExtra.map((chip) => chip.key).join('|') === '|level:1|level:9', JSON.stringify(withExtra.map((chip) => chip.key)))
|
|
|
+
|
|
|
+ // A4 过滤:空键=全部(同一批对象引用,不复制不改序);命中键只留该分类;认不出的键回空数组。
|
|
|
+ const all = filterByCategory(rows, CATEGORY_ALL)
|
|
|
+ check('A4-empty-key-is-identity', all === rows, '「全部」应原样返回同一引用(不复制、不重排)')
|
|
|
+ const only2 = filterByCategory(rows, 'level:2')
|
|
|
+ check('A4b-filter-hits', only2.length === 2 && only2.every((row) => row.categoryKey === 'level:2'), `命中 2 条:${only2.map((row) => row.code).join(',')}`)
|
|
|
+ check('A4c-unknown-key-is-empty', filterByCategory(rows, 'level:88').length === 0, '认不出的键回空数组——回全量就是"筛了个寂寞"(变异 M1 的靶)')
|
|
|
+
|
|
|
+ // A5 生效回显:全部时不说,生效时把「怎么取消」说出来(票 37 typeNote 同一立场)。
|
|
|
+ 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'))
|
|
|
+}
|
|
|
+
|
|
|
+// ================================================================ B. 行视图的分类键(词表单源)
|
|
|
+
|
|
|
+{
|
|
|
+ // B1 事件等级 1–4 用票 13 的等级词表;折叠行短读法与完整时间并存。
|
|
|
+ const rows = eventRows([
|
|
|
+ { code: 'W1', name: 'a', level: 1, status: 'ACTIVE', eventTime: '2026-10-01T08:00:00+08:00', value: 12, unit: 'mg/L' },
|
|
|
+ { code: 'W2', name: 'b', level: 4, status: 'PENDING', eventTime: '2026-10-02T09:00:00+08:00' },
|
|
|
+ ])
|
|
|
+ check(
|
|
|
+ 'B1-event-level-categories',
|
|
|
+ rows[0].categoryKey === 'level:1' && rows[0].categoryLabel === '1级' && rows[0].levelTag.tone === 'lv1' && rows[1].categoryKey === 'level:4',
|
|
|
+ `${rows[0].categoryKey}/${rows[0].categoryLabel} · ${rows[1].categoryKey}`,
|
|
|
+ )
|
|
|
+ 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}`)
|
|
|
+
|
|
|
+ // B2 缺等级/脏等级 → unknown 桶 + 「等级未知」,**不并进任何真实等级**(变异 M4 的靶)。
|
|
|
+ const odd = eventRows([{ code: 'W3', name: 'c' }, { code: 'W4', name: 'd', level: 'X' }])
|
|
|
+ check('B2-unknown-level-not-guessed', odd.every((row) => row.categoryKey === 'level:unknown' && row.categoryLabel === '等级未知'), JSON.stringify(odd.map((row) => row.categoryKey)))
|
|
|
+
|
|
|
+ // B3 工单按状态字典 0–7;认不出的码落 state:unknown。
|
|
|
+ const orders = workOrderRows([
|
|
|
+ { workOrderCode: 'WO-1', stateCode: 0 },
|
|
|
+ { workOrderCode: 'WO-2', stateCode: 5 },
|
|
|
+ { workOrderCode: 'WO-3', stateCode: 99 },
|
|
|
+ { workOrderCode: 'WO-4' },
|
|
|
+ ])
|
|
|
+ check(
|
|
|
+ 'B3-work-order-state-categories',
|
|
|
+ orders[0].categoryKey === 'state:0' && orders[0].categoryLabel === '待派单' && orders[1].categoryLabel === '已结案' && orders[2].categoryKey === 'state:unknown' && orders[3].categoryKey === 'state:unknown',
|
|
|
+ JSON.stringify(orders.map((row) => row.categoryKey)),
|
|
|
+ )
|
|
|
+ check('B3b-order-chips-order', WORK_ORDER_CATEGORY_ORDER.filter((key) => key !== 'state:unknown').length === 8, '工单分类次序 = 字典 0–7(单源 workOrder.js,不另造)')
|
|
|
+}
|
|
|
+
|
|
|
+// ================================================================ C. 展开详情:四件事始终保留
|
|
|
+
|
|
|
+{
|
|
|
+ // C1 最小事件(只有编码):数据时间/状态/来源/关联设备四行**都在**,缺值给占位符(变异 M3 的靶)。
|
|
|
+ const rows = eventDetailRows(eventRows([{ code: 'W1' }])[0])
|
|
|
+ const labels = rows.map((row) => row.label)
|
|
|
+ check(
|
|
|
+ 'C1-mandatory-detail-rows',
|
|
|
+ ['数据时间', '状态', '来源', '关联设备'].every((label) => labels.includes(label)),
|
|
|
+ `实际:${labels.join('/')}`,
|
|
|
+ )
|
|
|
+ check('C1b-missing-values-are-placeholder', rows.filter((row) => ['数据时间', '状态', '来源', '关联设备'].includes(row.label)).every((row) => row.value === DETAIL_PLACEHOLDER), '缺值一律占位符,不画空白')
|
|
|
+
|
|
|
+ // C2 有值时带出真实内容;可选行(等级/量值/位置)没值就不出现。
|
|
|
+ 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])
|
|
|
+ const byLabel = Object.fromEntries(full.map((row) => [row.label, row.value]))
|
|
|
+ check('C2-full-detail-values', byLabel['量值'] === '18 mg/L' && byLabel['位置'] === '井下一层' && byLabel['关联设备'] === '压力计-1', JSON.stringify(byLabel))
|
|
|
+ const sparse = eventDetailRows(eventRows([{ code: 'W1', name: 'a' }])[0])
|
|
|
+ check('C2b-optional-rows-omitted-when-empty', !sparse.some((row) => row.label === '位置' || row.label === '量值'), JSON.stringify(sparse.map((row) => row.label)))
|
|
|
+
|
|
|
+ // C3 工单展开:四件事 + 计划/实际结案;关联对象那句三态文案仍来自 domain(票 21 口径)。
|
|
|
+ const order = workOrderRows([{ workOrderCode: 'WO-1', stateCode: 3, associatedWithCurrentEvent: true, createTime: '2026-10-01T08:00:00+08:00', orderTypeName: '故障维修' }])[0]
|
|
|
+ const detail = Object.fromEntries(workOrderDetailRows(order).map((row) => [row.label, row.value]))
|
|
|
+ check(
|
|
|
+ 'C3-work-order-detail-rows',
|
|
|
+ detail['数据时间'] === '2026-10-01T08:00:00+08:00' && detail['状态'] === '处理中' && detail['来源'] === '故障维修' && detail['关联对象'] === '已关联当前事件' && detail['实际结案'] === DETAIL_PLACEHOLDER,
|
|
|
+ JSON.stringify(detail),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ================================================================ D. 点位设备计数 KPI(不画虚假零值)
|
|
|
+
|
|
|
+{
|
|
|
+ const rawComplete = {
|
|
|
+ monitoringPointCode: 'MP-1',
|
|
|
+ monitoringPointName: '测试点',
|
|
|
+ pointStatus: 'ALARM',
|
|
|
+ deviceStatistics: { totalDeviceCount: 5, alarmDeviceCount: 1, warningDeviceCount: 2, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 1 },
|
|
|
+ devices: [],
|
|
|
+ }
|
|
|
+ // D1 统计整段缺失 → null(KPI 条不出现),而不是五个 0(变异 M2 的靶)。
|
|
|
+ const degraded = toMonitoringPointDetail({ monitoringPointCode: 'MP-2', monitoringPointName: '缺统计点', devices: [] })
|
|
|
+ check('D1-missing-statistics-hides-kpi', pointDeviceCountsView(degraded) === null, '没给统计时回 null——画一排 0 就是虚假零值')
|
|
|
+
|
|
|
+ // D2 完整统计 → 五态计数;0 是真实值照画;色键来自设备词表(报警 lv1/预警 lv2/工单 lv3/正常 lv4/离线 point-offline)。
|
|
|
+ const view = pointDeviceCountsView(toMonitoringPointDetail(rawComplete))
|
|
|
+ const byKey = Object.fromEntries(view.map((row) => [row.key, row]))
|
|
|
+ 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}`)))
|
|
|
+ check('D2b-zero-is-real', byKey.WORK_ORDER.count === 0 && byKey.WORK_ORDER.label === '工单处置', '确实 0 台照画 0(与"没给统计"是两件事)')
|
|
|
+ check('D2c-device-table-tones', byKey.ALARM.tone === 'lv1' && byKey.OFFLINE.tone === 'point-offline', JSON.stringify(view.map((row) => `${row.key}:${row.tone}`)))
|
|
|
+}
|
|
|
+
|
|
|
+// ================================================================ F. 标签页计划(返工第 1 条)
|
|
|
+
|
|
|
+{
|
|
|
+ // F1 页签清单恰为五个且顺序/默认页符合维护者点名的形态;图片不在页签里(并入档案页)。
|
|
|
+ check(
|
|
|
+ 'F1-tab-plan-shape',
|
|
|
+ EQUIPMENT_TABS.map((tab) => tab.name).join(',') === 'archive,metrics,warnings,alarms,workOrders' &&
|
|
|
+ DEFAULT_EQUIPMENT_TAB === 'archive' &&
|
|
|
+ EQUIPMENT_TABS[0].sections.join(',') === 'archive,latestImage',
|
|
|
+ EQUIPMENT_TABS.map((tab) => `${tab.name}[${tab.sections.join('+')}]`).join(' '),
|
|
|
+ )
|
|
|
+ // F2 渲染计划仍是六块(t11 D1/D9 的独立性不能被标签页吃掉);latestImage 标了归属。
|
|
|
+ check(
|
|
|
+ 'F2-six-panels-still-independently-planned',
|
|
|
+ EQUIPMENT_SECTION_PANELS.length === 6 &&
|
|
|
+ EQUIPMENT_SECTION_PANELS.find((panel) => panel.name === 'latestImage').mergedInto === 'archive',
|
|
|
+ '标签页只改呈现归属,六块分区的数据与成败一个都不合并',
|
|
|
+ )
|
|
|
+ // F3 页签取面板:档案页拿到两块且保持渲染计划次序;未知页签回空数组(不猜)。
|
|
|
+ const fakePanels = EQUIPMENT_SECTION_PANELS.map((panel) => ({ ...panel, section: { state: 'success', data: [] } }))
|
|
|
+ check(
|
|
|
+ 'F3-panels-for-tab',
|
|
|
+ panelsForTab(fakePanels, 'archive').map((panel) => panel.name).join(',') === 'archive,latestImage' &&
|
|
|
+ panelsForTab(fakePanels, 'metrics').length === 1 &&
|
|
|
+ panelsForTab(fakePanels, 'nope').length === 0,
|
|
|
+ panelsForTab(fakePanels, 'archive').map((panel) => panel.name).join(','),
|
|
|
+ )
|
|
|
+ // F4 页签计数只给列表型页;档案/指标回空串(不是 0 —— 0 会被读成"档案有 0 条")。
|
|
|
+ const listPanels = [
|
|
|
+ { name: 'warnings', kind: 'events', section: { state: 'success', data: [1, 2, 3] } },
|
|
|
+ { name: 'alarms', kind: 'events', section: { state: 'success', data: [1] } },
|
|
|
+ { name: 'workOrders', kind: 'orders', section: { state: 'success', data: [] } },
|
|
|
+ { name: 'archive', kind: 'archive', section: { state: 'success', data: {} } },
|
|
|
+ ]
|
|
|
+ check(
|
|
|
+ 'F4-tab-counts-only-for-lists',
|
|
|
+ tabCount(listPanels, 'warnings') === '3' && tabCount(listPanels, 'workOrders') === '0' && tabCount(listPanels, 'archive') === '',
|
|
|
+ `预警=${tabCount(listPanels, 'warnings')} 工单=${tabCount(listPanels, 'workOrders')} 档案="${tabCount(listPanels, 'archive')}"`,
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ================================================================ G. 分页边界(返工第 1 条:每页最多 10 项)
|
|
|
+
|
|
|
+{
|
|
|
+ const rows = eventRows(Array.from({ length: 23 }, (_, index) => ({ code: `W${index}`, name: `n${index}`, level: 1 })))
|
|
|
+
|
|
|
+ // G1 每页上限 10;23 条 → 3 页。
|
|
|
+ const p1 = pagedRows(rows, 1)
|
|
|
+ check('G1-page-size-ten', HISTORY_PAGE_SIZE === 10 && p1.rows.length === 10 && p1.pageCount === 3, `第1页 ${p1.rows.length} 条 / ${p1.pageCount} 页`)
|
|
|
+ // G2 末页取余数;区间读数给"第 21–23 条"这种可核对的口径。
|
|
|
+ const p3 = pagedRows(rows, 3)
|
|
|
+ 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 }))
|
|
|
+ // G3 越界页码夹回(第 99 页 → 第 3 页),不返回空表冒充"没数据"。
|
|
|
+ check('G3-out-of-range-clamped', pagedRows(rows, 99).page === 3 && pagedRows(rows, 0).page === 1 && pagedRows(rows, -5).page === 1, '越界与脏页码都夹回合法页')
|
|
|
+ // G4 空表:0 条也是 1 页(页码控件由调用方按 pageCount>1 决定是否出现)。
|
|
|
+ const empty = pagedRows([], 1)
|
|
|
+ check('G4-empty-is-one-page', empty.total === 0 && empty.pageCount === 1 && empty.from === 0 && !empty.hasPrev && !empty.hasNext, JSON.stringify(empty))
|
|
|
+ // G5 不排序、不改序(切片保序 = 后端次序,票 10/11 立场)。
|
|
|
+ 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(','))
|
|
|
+}
|
|
|
+
|
|
|
+// ================================================================ H. 状态图例对齐(返工第 2 条)
|
|
|
+
|
|
|
+{
|
|
|
+ // H1 图标登记是**单源接缝**:legendVisuals 的每一行都经 legendIconFor 取值(顶部状态栏与详情共用)。
|
|
|
+ const visuals = legendVisuals()
|
|
|
+ check(
|
|
|
+ 'H1-icon-registry-single-source',
|
|
|
+ visuals.every((visual) => visual.icon === legendIconFor(visual.key)) && visuals.every((visual) => visual.icon !== ''),
|
|
|
+ `${visuals.length} 行词条的图标都来自 legendIconFor(顶部状态栏同源)`,
|
|
|
+ )
|
|
|
+ // H2 点位状态视图带 icon,且 tone/shape/label 与顶部图例词条逐字一致。
|
|
|
+ const alarm = statusView('ALARM')
|
|
|
+ const legendAlarm = visuals.find((visual) => visual.key === 'ALARM')
|
|
|
+ check(
|
|
|
+ 'H2-point-status-matches-top-legend',
|
|
|
+ alarm.icon === legendIconFor('ALARM') && alarm.tone === legendAlarm.tone && alarm.shape === legendAlarm.shape && alarm.label === legendAlarm.label,
|
|
|
+ `报警:${alarm.tone}/${alarm.shape}/${alarm.label}/${alarm.icon}`,
|
|
|
+ )
|
|
|
+ // H3 设备状态视图带 icon(借登记、不借点位状态语义 —— 票 44 边界仍成立)。
|
|
|
+ const deviceAlarm = deviceStatusView('ALARM')
|
|
|
+ check(
|
|
|
+ 'H3-device-status-icon-no-table-bleed',
|
|
|
+ deviceAlarm.icon === legendIconFor('ALARM') && deviceAlarm.tone === 'lv1' && deviceAlarm.shape === 'triangle' && deviceAlarm.key === 'ALARM',
|
|
|
+ `设备报警:${deviceAlarm.tone}/${deviceAlarm.shape}/${deviceAlarm.icon}`,
|
|
|
+ )
|
|
|
+ // H4 认不出的状态:图标落 UNKNOWN 登记,**不猜成某个真实态的图标**。
|
|
|
+ check('H4-unknown-status-icon-not-guessed', deviceStatusView('SOMETHING_NEW').icon === legendIconFor('UNKNOWN') && statusView('MAINTENANCE').icon === legendIconFor('UNKNOWN'), '未发布状态的图标来自 UNKNOWN 词条')
|
|
|
+ // H5 空/脏键回空串(渲染层据此跳过,不画错图标)。
|
|
|
+ check('H5-blank-key-no-icon', legendIconFor('') === '' && legendIconFor(undefined) === '' && legendIconFor('toString') === '', '认不出的键回空串(继承键不算登记)')
|
|
|
+}
|
|
|
+
|
|
|
+// ================================================================ E. 静态守卫(消费侧形状与边界)
|
|
|
+
|
|
|
+{
|
|
|
+ const sections = read('components/EquipmentDetailSections.vue')
|
|
|
+ const card = read('components/PointDetailCard.vue')
|
|
|
+ const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
|
+ const sectionsCode = stripComments(sections)
|
|
|
+ const cardCode = stripComments(card)
|
|
|
+
|
|
|
+ // E1 折叠默认收起:条目是 `<details data-history-item>` 且**没有** open 属性(默认展开=没折叠)。
|
|
|
+ check('E1-history-items-collapsed-by-default', /<details[\s\S]*?data-history-item/.test(sectionsCode) && !/<details[^>]*\sopen(\s|>)/.test(sectionsCode), '历史条目用 details 且不带 open——默认折叠是票 46 范围第 3 条')
|
|
|
+ // E2 每条可展开详情(data-history-detail 两块都有:events 与 orders)。
|
|
|
+ check('E2-expandable-detail-present', (sectionsCode.match(/data-history-detail/g) || []).length === 2, '预警/报警与工单条目各有一条展开详情')
|
|
|
+ // E3 分类绑定齐全:chip 键、按下态、切换入口、过滤行(工单)与分页行(预警/报警)。
|
|
|
+ check(
|
|
|
+ 'E3-category-bindings-complete',
|
|
|
+ /:data-category-chip="`\$\{panel\.name\}:\$\{chip\.key \|\| 'all'\}`"/.test(sectionsCode) &&
|
|
|
+ /:aria-pressed="chip\.key === activeCategory\(panel\.name\)"/.test(sectionsCode) &&
|
|
|
+ /@click="toggleCategory\(panel\.name, chip\.key\)"/.test(sectionsCode) &&
|
|
|
+ /v-for="\(event, index\) in pagedOf\(panel\)\.rows"/.test(sectionsCode) &&
|
|
|
+ /v-for="\(order, index\) in visibleRows\(panel\)"/.test(sectionsCode),
|
|
|
+ 'chip 键/按下态/点击 + 预警报警走分页行 + 工单走过滤行(守卫按绑定判,不按字样判)',
|
|
|
+ )
|
|
|
+ // E3b 分页控件绑定齐全(返工第 1 条:每页最多 10 项;单页不画控件由 pageCount>1 门控)。
|
|
|
+ check(
|
|
|
+ 'E3b-pager-bindings-complete',
|
|
|
+ /v-if="pagedOf\(panel\)\.pageCount > 1"/.test(sectionsCode) &&
|
|
|
+ /:disabled="!pagedOf\(panel\)\.hasPrev"/.test(sectionsCode) &&
|
|
|
+ /:disabled="!pagedOf\(panel\)\.hasNext"/.test(sectionsCode) &&
|
|
|
+ /@click="turnPage\(panel, -1\)"/.test(sectionsCode) &&
|
|
|
+ /@click="turnPage\(panel, 1\)"/.test(sectionsCode) &&
|
|
|
+ /data-page-state/.test(sectionsCode),
|
|
|
+ '上一页/下一页按 hasPrev/hasNext 置灰 + 页码读数 + 单页不出现控件',
|
|
|
+ )
|
|
|
+ // E3c 标签页绑定齐全(返工第 1 条:弃用长滚动改切换;默认页与换设备复位都要有)。
|
|
|
+ check(
|
|
|
+ 'E3c-tabs-bindings-complete',
|
|
|
+ /role="tablist"/.test(sectionsCode) &&
|
|
|
+ /:aria-selected="tab\.name === activeTab"/.test(sectionsCode) &&
|
|
|
+ /v-for="panel in activePanels"/.test(sectionsCode) &&
|
|
|
+ /const activeTab = ref\(DEFAULT_EQUIPMENT_TAB\)/.test(sectionsCode) &&
|
|
|
+ /activeTab\.value = DEFAULT_EQUIPMENT_TAB/.test(sectionsCode),
|
|
|
+ '页签可访问语义 + 只渲染当前页 + 默认档案页 + 换设备复位',
|
|
|
+ )
|
|
|
+ // E4 换设备清分类条件(A 的分类不得挂到 B 上)。
|
|
|
+ check('E4-device-change-resets-categories', /watch\(\s*\(\) => props\.deviceCode,[\s\S]{0,200}categories\.value = \{\}/.test(sectionsCode), 'deviceCode 变化 → categories 归空')
|
|
|
+ // E5 分类是视图本地状态:不进 store、不发请求(只读边界的另一半:组件里零 fetch/零接口路径)。
|
|
|
+ check('E5-local-state-no-requests', !/\bfetch\s*\(|XMLHttpRequest|\/equipments\/|\/monitoring-points\//.test(sectionsCode + cardCode) && /const categories = ref\(\{\}\)/.test(sectionsCode), '分类状态在组件本地;组件不拼路径不取数')
|
|
|
+ // E6 前端不重排:过滤/归组都不改后端行次序。
|
|
|
+ check('E6-no-sorting-in-components', !/\.sort\(/.test(sectionsCode) && !/\.sort\(/.test(cardCode), '组件零 .sort((次序归后端与 domain 单源)')
|
|
|
+ // E7 零后端原因码/裸字段上屏(沿用票 11 的清单,新增的 chip/详情绑定不许漏词)。
|
|
|
+ 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))
|
|
|
+ check('E7-no-backend-reason-codes-in-ui', leakedCodes.length === 0, leakedCodes.join(','))
|
|
|
+ const leakedFields = /deviceModel|deviceSpec|metricCode|warningNo|alarmId|workOrderCode|aggregateState|stateReason|deviceStatistics|statusReasons/.test(cardCode)
|
|
|
+ check('E7b-card-has-no-raw-fields', !leakedFields, '点位卡只读领域结论(pointDeviceCountsView),不认后端裸字段')
|
|
|
+ // E8 点位卡 KPI 与既有钩子:v-if 挂 null 判据、编码仍是 dd、设备数仍是 b(t41 的形状)。
|
|
|
+ check(
|
|
|
+ 'E8-kpi-and-pinned-hooks',
|
|
|
+ /v-if="deviceCountsView"/.test(cardCode) && /data-point-kpis/.test(cardCode) &&
|
|
|
+ /<dd class="point-detail__mono" data-point-code-value>/.test(cardCode) &&
|
|
|
+ /<b class="point-detail__kpi-value point-detail__num" data-point-device-count>/.test(cardCode),
|
|
|
+ 'KPI 条按 null 收口;data-point-code-value(<dd>)/data-point-device-count(<b>) 形状保留',
|
|
|
+ )
|
|
|
+ // E9 词表单源:domain 的次序表来自 workOrder.js/LEVEL 词表,不抄第二张名单。
|
|
|
+ const viewCode = read('domain/equipmentDetailView.js')
|
|
|
+ check(
|
|
|
+ 'E9-vocabulary-single-source',
|
|
|
+ /WORK_ORDER_CATEGORY_ORDER = \[\.\.\.WORK_ORDER_STATES\.map/.test(viewCode) && /import \{ eventLevelTag, shortEventTime \} from '\.\/eventList\.js'/.test(viewCode),
|
|
|
+ '工单次序展开自字典、等级词复用票 13 单源(另抄名单=将来必漂移)',
|
|
|
+ )
|
|
|
+ // E10 状态图例对齐(返工第 2 条):详情里**每个** StatusTag 都绑定了 icon(按绑定判)。
|
|
|
+ // 只查"文件里出现过 icon"会被 import 或注释满足(本仓栽过多次)。
|
|
|
+ const cardTags = cardCode.match(/<StatusTag[\s\S]*?\/>/g) || []
|
|
|
+ check(
|
|
|
+ 'E10-all-detail-status-tags-carry-icon',
|
|
|
+ cardTags.length >= 3 && cardTags.every((tag) => /:icon="/.test(tag)),
|
|
|
+ `详情卡内 ${cardTags.length} 个状态标签全部绑定 icon(与一张图顶部状态栏同一套视觉)`,
|
|
|
+ )
|
|
|
+ // E10b 图标登记的单源在 pointStatus(顶部状态栏与详情共用一个函数,不各存一份表)。
|
|
|
+ // 判据:登记只在 pointStatus 一处定义;MapCanvas 经 legendVisuals 消费(不自建图标表)。
|
|
|
+ const pointStatus = read('domain/pointStatus.js')
|
|
|
+ const mapCanvas = read('components/MapCanvas.vue')
|
|
|
+ const flowCode = read('domain/detailFlow.js')
|
|
|
+ check(
|
|
|
+ 'E10b-icon-registry-lives-in-one-place',
|
|
|
+ /export function legendIconFor/.test(pointStatus) &&
|
|
|
+ /icon: legendIconFor\(visual\.key\)/.test(pointStatus) &&
|
|
|
+ /import \{[^}]*legendIconFor[^}]*\} from '\.\/pointStatus\.js'/.test(flowCode) &&
|
|
|
+ (pointStatus.match(/statusAlarm/g) || []).length === 1 &&
|
|
|
+ /legendVisuals\(\)/.test(mapCanvas) &&
|
|
|
+ /icon: visual\.icon \|\| ''/.test(mapCanvas) &&
|
|
|
+ !/statusAlarm/.test(mapCanvas),
|
|
|
+ '图标登记只在 pointStatus 一处(statusAlarm 字样在该文件只出现 1 次);顶部状态栏与详情都经它取值',
|
|
|
+ )
|
|
|
+
|
|
|
+ // E11 组件 import 的名字**必须真是 domain 的导出**(按真实导出判,不按"字样出现过"判)。
|
|
|
+ // 为什么要这条:一次把 `EVENT_CATEGORY_ORDER` 误写成 `EQUIPMENT_CATEGORY_ORDER`,
|
|
|
+ // 文本守卫全绿(字样都在文件里),只有 t41 那种**真的加载 .vue** 的 SSR 闸门才炸。
|
|
|
+ // 这里不靠浏览器、也不解析 .vue:直接拿 domain 模块的**命名空间**当判据,
|
|
|
+ // 再对上一份"本票应当 import 的名字清单"(清单缺一即红,见 E11b)。
|
|
|
+ const REQUIRED_SECTION_IMPORTS = [
|
|
|
+ 'DEFAULT_EQUIPMENT_TAB',
|
|
|
+ 'EQUIPMENT_TABS',
|
|
|
+ 'EVENT_CATEGORY_ORDER',
|
|
|
+ 'WORK_ORDER_CATEGORY_ORDER',
|
|
|
+ 'archiveRows',
|
|
|
+ 'categoryChips',
|
|
|
+ 'categoryNote',
|
|
|
+ 'emptyListText',
|
|
|
+ 'equipmentPanels',
|
|
|
+ 'eventDetailRows',
|
|
|
+ 'eventRows',
|
|
|
+ 'filterByCategory',
|
|
|
+ 'imageView',
|
|
|
+ 'metricRows',
|
|
|
+ 'metricsSourceText',
|
|
|
+ 'pagedRows',
|
|
|
+ 'panelsForTab',
|
|
|
+ 'tabCount',
|
|
|
+ 'timelineSource',
|
|
|
+ 'workOrderDetailRows',
|
|
|
+ 'workOrderRows',
|
|
|
+ ]
|
|
|
+ const missingExports = REQUIRED_SECTION_IMPORTS.filter((name) => !(name in equipmentDetailViewModule))
|
|
|
+ check(
|
|
|
+ 'E11-imported-names-are-real-exports',
|
|
|
+ REQUIRED_SECTION_IMPORTS.length >= 10 && missingExports.length === 0,
|
|
|
+ missingExports.length ? `domain 里没有这些导出:${missingExports.join(', ')}` : `${REQUIRED_SECTION_IMPORTS.length} 个 import 名全部命中 domain 真实导出`,
|
|
|
+ )
|
|
|
+ // E11b 清单与源码**双向**核对:组件 import 块里的名字必须与清单完全一致
|
|
|
+ // (多一个没登记的名字、或清单里的名字没被 import,都判红 —— 单向判等于没判)。
|
|
|
+ // `[^}]*` 而不是 `[\s\S]*?`:后者会从文件里**第一个** `import {` 起跨(vue 那个),
|
|
|
+ // 把整段 import 头都当成名字清单(实测就是这么假红的)。
|
|
|
+ const importBlock = (sections.match(/import \{([^}]*)\} from '\.\.\/domain\/equipmentDetailView\.js'/) || [])[1] || ''
|
|
|
+ const actualImports = importBlock.split(',').map((piece) => piece.trim().split(/\s+as\s+/)[0]).filter(Boolean)
|
|
|
+ const notInList = actualImports.filter((name) => !REQUIRED_SECTION_IMPORTS.includes(name))
|
|
|
+ const notImported = REQUIRED_SECTION_IMPORTS.filter((name) => !actualImports.includes(name))
|
|
|
+ check(
|
|
|
+ 'E11b-import-list-matches-source-both-ways',
|
|
|
+ notInList.length === 0 && notImported.length === 0,
|
|
|
+ `源码多出的名字:${notInList.join(',') || '无'};清单里未 import 的:${notImported.join(',') || '无'}`,
|
|
|
+ )
|
|
|
+ // E11c 点位卡的 domain import 同样按真实导出判。
|
|
|
+ const CARD_IMPORTS = { detailFlow: ['DETAIL_LEVEL', 'deviceCards', 'deviceCardOf', 'pointDeviceCountsView', 'pointSummary', 'deviceStatusView'], equipmentDetailView: ['equipmentDetailMatches', 'aggregateUnavailable'] }
|
|
|
+ const cardMissing = Object.entries(CARD_IMPORTS).flatMap(([module, names]) =>
|
|
|
+ names.filter((name) => !(name in (module === 'detailFlow' ? detailFlowModule : equipmentDetailViewModule))),
|
|
|
+ )
|
|
|
+ check(
|
|
|
+ 'E11c-card-imports-are-real-exports',
|
|
|
+ cardMissing.length === 0,
|
|
|
+ cardMissing.length ? `点位卡 import 了不存在的导出:${cardMissing.join(', ')}` : '点位卡两处 domain import 全部命中真实导出',
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+console.log('')
|
|
|
+console.log(`T46 详情卡片化/分类查询/历史折叠:${passed}/${passed + failed} 项通过。`)
|
|
|
+if (failed) {
|
|
|
+ console.log('未通过项见上方 FAIL。')
|
|
|
+ process.exit(1)
|
|
|
+}
|
|
|
+assert.equal(1, 1)
|
|
|
+console.log('T46 全部通过(浏览器走查按项目规则未自动执行)。')
|