/** * 票 46 离线闸门:监测点与设备详情卡片化、分类查询与历史折叠。 * * 四组断言(都不启浏览器、不联网): * A. 分类查询纯函数:chips 首项恒为「全部」且计数真实;0 计数的词表项不出现; * 词表外的分类不静默丢;过滤不改源次序、认不出的键回空数组(不"筛了个寂寞")。 * B. 行视图分类键:事件按等级(词表单源 eventList/eventLevelTag)、工单按状态字典 0–7 * (单源 workOrder.js);认不出的落 unknown 桶,不并进真实语义。 * C. 展开详情四件事:数据时间/状态/来源/关联对象**始终保留**(缺值给占位符,不画空白)。 * D. 点位设备计数 KPI:统计整段缺失 → null(KPI 条不出现,**不画一排 0**); * 计数 0 是真实值照画;色键走设备词表不借点位表。 * E. 静态守卫:折叠默认收起(`
` 无 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(//g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') const sectionsCode = stripComments(sections) const cardCode = stripComments(card) // E1 折叠默认收起:条目是 `
` 且**没有** open 属性(默认展开=没折叠)。 check('E1-history-items-collapsed-by-default', /]*\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) && /
/.test(cardCode) && //.test(cardCode), 'KPI 条按 null 收口;data-point-code-value(
)/data-point-device-count() 形状保留', ) // 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(//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 全部通过(浏览器走查按项目规则未自动执行)。')