/** * 票 65 补口闸门:报警模式的「当前工单列表」**服务端翻页**真的生效。 * * 为什么单独一份:维护者真机反馈「分页没生效,点不到第 2 页」。根因是 * `WarningResponsePane.onListPage()` 早先只认 `historyAnalysis`,工单列表画出了翻页控件、 * 点了却什么都没发生(事件被 `return` 掉,页码根本没进 store)。 * * 这类缺陷**领域层闸门抓不到**:`workOrderListBlock` 的 `serverPage` 是对的、 * `PanelBlocks` 的 emit 也是对的,断在「组件 → store」这一跳。所以这里起 pinia + 真实 * store + 真实 mock transport,从「点翻页」一路断到「分区换页」。 * * 数据用**闸门内现场注入**(12 张工单,每页 5 张 → 3 页),不扩 `contractMocks` 的静态样例 * (票 64 裁定:静态样例的扩面会污染其它断言,现场注入是允许的)。 * * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t65b-work-order-paging.mjs */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { registerHooks } from 'node:module' import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' const SRC = '../../../src/modules/cockpit/' const readSrc = (rel) => readFileSync(new URL(SRC + rel, import.meta.url), 'utf8') registerHooks({ resolve(specifier, context, nextResolve) { const resolved = nextResolve(specifier, context) const parent = context.parentURL || '' if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) { return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true } } return resolved }, load(url, context, nextLoad) { if (url.endsWith('.json')) { return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` } } if (url.endsWith('.css')) return { format: 'module', shortCircuit: true, source: 'export default {}' } const isClient = url.endsWith('.vue?client') if (url.endsWith('.vue') || isClient) { const path = isClient ? url.slice(0, -'?client'.length) : url const source = readFileSync(new URL(path), 'utf8') const { descriptor } = parse(source, { filename: path }) const script = compileScript(descriptor, { id: 't65b', genDefaultAs: '__sfc__' }) const template = compileTemplate({ source: descriptor.template.content, filename: path, id: 't65b', ssr: !isClient, compilerOptions: { bindingMetadata: script.bindings }, }) assert.deepEqual(template.errors, [], `${path} template`) const tail = isClient ? '__sfc__.render = render; export default __sfc__;' : '__sfc__.ssrRender = ssrRender; export default __sfc__;' return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` } } return nextLoad(url, context) }, }) let passed = 0 let failed = 0 function check(name, condition, detail = '') { if (condition) { passed += 1 console.log(`PASS ${name}`) } else { failed += 1 console.error(`FAIL ${name}: ${detail}`) } } const { createPinia, setActivePinia } = await import('pinia') setActivePinia(createPinia()) const { registerMockTransport, resetMockTransportsForTest } = await import(`${SRC}services/cockpitApi.js`) const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`) const { builtinConfig } = await import(`${SRC}services/cockpitConfig.js`) const { useEventStore } = await import(`${SRC}stores/eventStore.js`) const { responseEfficiencyPanel } = await import(`${SRC}domain/warningResponsePanels.js`) // ---- 现场注入:12 张工单,每页 5 张 → 3 页;服务端真的按 page 切窗口 ---- const TOTAL = 12 const ORDERS = Array.from({ length: TOTAL }, (_, index) => ({ workOrderCode: `WO-${String(index + 1).padStart(3, '0')}`, deviceCode: '21119740', orderLevel: 2, orderDesc: `注入工单 ${index + 1}`, stateCode: index % 8, stateName: '待派单', createTime: '2026-10-14T13:43:50+08:00', updateTime: '2026-10-14T13:43:50+08:00', planFinishTime: null, realFinishTime: null, eventAssociation: null, associatedWithCurrentEvent: false, })) const requests = [] function queryOf(path) { const query = {} const search = String(path || '').split('?')[1] || '' for (const [key, value] of new URLSearchParams(search).entries()) query[key] = value return query } async function workOrderListPayload(query) { const page = Math.max(1, Number(query.page) || 1) const pageSize = Math.max(1, Number(query.pageSize) || 5) const totalPages = Math.max(1, Math.ceil(TOTAL / pageSize)) // 模拟后端把越界页码夹回最后一页(与真接口 `loadAll` 的行为一致) const safePage = Math.min(page, totalPages) requests.push({ page, pageSize, startTime: query.startTime || '', endTime: query.endTime || '' }) // 第 2 页刻意慢回:P7「快速连点」要能观察到迟到响应(否则两条都按请求顺序回来,变异抓不住) if (safePage === 2) await new Promise((resolve) => setTimeout(resolve, 40)) return { requestId: 'injected', apiVersion: 'v1', readOnly: true, state: 'READY', stateReason: 'READY', stateDictionary: [], workOrders: ORDERS.slice((safePage - 1) * pageSize, safePage * pageSize), pagination: { page: safePage, pageSize, total: TOTAL, totalPages }, } } resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } }) // 覆盖 '/work-orders':本组要的是「多页」,静态样例只有 4 张(1 页)。 registerMockTransport('/work-orders', async (context) => workOrderListPayload(queryOf(context.path))) const store = useEventStore() store.configure({ config: builtinConfig }) // ---- P1 首屏:第 1 页、5 张、共 12 张 / 3 页 ---- await store.loadPanels({ mode: 'ALARM', range: '7d', industry: '' }) const first = store.sections.workOrderList check( 'P1 首屏工单列表是第 1 页(5 张 / 共 12 张 / 3 页),翻页控件该出现', first.loaded === true && first.data.orders.length === 5 && first.data.pagination.page === 1 && first.data.pagination.total === 12 && first.data.pagination.totalPages === 3 && store.workOrderPage === 1, `orders=${first.data && first.data.orders.length} pagination=${JSON.stringify(first.data && first.data.pagination)}`, ) // ---- P2 点「下一页」→ 真的换到第 2 页 ---- const othersBefore = { currentEvents: store.sections.currentEvents, historyAnalysis: store.sections.historyAnalysis, responseEfficiency: store.sections.responseEfficiency, eventAnalysis: store.sections.eventAnalysis, } requests.length = 0 await store.setWorkOrderPage(2) const second = store.sections.workOrderList check( 'P2 翻到第 2 页:请求带 page=2、分区换成 6–10 号工单', requests.length === 1 && requests[0].page === 2 && second.data.orders.length === 5 && second.data.orders[0].code === 'WO-006' && second.data.pagination.page === 2 && store.workOrderPage === 2, `requests=${JSON.stringify(requests)} firstRow=${second.data && second.data.orders[0] && second.data.orders[0].code}`, ) check( 'P3 翻工单页只重取工单分区(其它四块引用未变,不被牵连重取)', store.sections.currentEvents === othersBefore.currentEvents && store.sections.historyAnalysis === othersBefore.historyAnalysis && store.sections.responseEfficiency === othersBefore.responseEfficiency && store.sections.eventAnalysis === othersBefore.eventAnalysis, '翻工单页把其它分区也换掉了', ) // ---- P4 重复点同一页:不重复请求 ---- requests.length = 0 await store.setWorkOrderPage(2) check('P4 重复点同一页不重复请求(幂等)', requests.length === 0, `requests=${requests.length}`) // ---- P5 越界页码:按后端回显夹回最后一页 ---- requests.length = 0 await store.setWorkOrderPage(9) const clamped = store.sections.workOrderList check( 'P5 越界页码夹回最后一页(第 3 页,2 张),页码状态与回显一致', clamped.data.pagination.page === 3 && clamped.data.orders.length === 2 && store.workOrderPage === 3, `page=${clamped.data && clamped.data.pagination.page} rows=${clamped.data && clamped.data.orders.length} storePage=${store.workOrderPage}`, ) // ---- P6 条件变化:页码回第 1 页(条文 5)---- await store.loadPanels({ mode: 'ALARM', range: '30d', industry: '' }) check( 'P6 条件变化(切范围)后工单页码回第 1 页', store.workOrderPage === 1 && store.sections.workOrderList.data.pagination.page === 1, `storePage=${store.workOrderPage}`, ) // ---- P7 迟到响应丢弃(独立令牌:快速连点第 2、3 页,只认最后一页)---- requests.length = 0 const slow = store.setWorkOrderPage(2) const fast = store.setWorkOrderPage(3) await Promise.all([slow, fast]) check( 'P7 快速连点只认最后一页(迟到响应丢弃,独立令牌)', store.sections.workOrderList.data.pagination.page === 3 && store.workOrderPage === 3, `page=${store.sections.workOrderList.data.pagination.page} storePage=${store.workOrderPage}`, ) // ---- P9–P11 时间窗(2026-10-22 维护者反馈:列表没按时间筛)---- // // 面板按近 7/30/90 天切换时,列表必须与上方「工单状态分布」统计同一批工单。 // 断言的是**请求真的带上了当前窗口**(不是「界面看起来对」)。 { requests.length = 0 await store.loadPanels({ mode: 'ALARM', range: '7d', industry: '' }) const seven = requests[0] || {} const active7d = store.range check( 'P9 首屏工单列表请求带当前窗口(7 天的 startTime/endTime 都非空)', active7d === '7d' && Boolean(seven.startTime) && Boolean(seven.endTime) && // 7 天窗口:两端相差应约等于 7 天(允许整点取整带来的 1 小时内偏差) Math.abs((Date.parse(seven.endTime) - Date.parse(seven.startTime)) - 7 * 86400000) <= 3600000, 'start=' + seven.startTime + ' end=' + seven.endTime, ) const window7d = { start: seven.startTime, end: seven.endTime } // 翻页必须沿用同一窗口(少了它第 2 页会悄悄变回全库口径) requests.length = 0 await store.setWorkOrderPage(2) const page2 = requests[0] || {} check( 'P10 翻页沿用同一时间窗(第 2 页的 startTime/endTime 与首屏完全一致)', page2.startTime === window7d.start && page2.endTime === window7d.end, 'p2 start=' + page2.startTime + ' end=' + page2.endTime + ' vs 首屏 ' + window7d.start + '/' + window7d.end, ) // 切范围必须换窗口并回第 1 页 requests.length = 0 await store.loadPanels({ mode: 'ALARM', range: '30d', industry: '' }) const thirty = requests[0] || {} check( 'P11 切到 30 天后请求换成 30 天窗口(且回第 1 页)', Boolean(thirty.startTime) && thirty.startTime !== window7d.start && Math.abs((Date.parse(thirty.endTime) - Date.parse(thirty.startTime)) - 30 * 86400000) <= 3600000 && thirty.page === 1, 'start=' + thirty.startTime + ' end=' + thirty.endTime + ' page=' + thirty.page, ) } // ---- P8 预警模式不画工单列表,但也不因缺它而报错 ---- const warningView = responseEfficiencyPanel( { family: 'WARNING', totalEvents: 3, linkedWorkOrderCount: 0, missingWorkOrderCount: 3, levelDistribution: [], eventTrend: [] }, { state: 'success', loaded: true, data: {} }, { mode: 'WARNING', workOrderList: store.sections.workOrderList }, ) check( 'P8 预警模式仍不出现工单列表(翻页接线不影响两族分流)', !warningView.blocks.some((block) => block.kind === 'list' && block.label === '当前工单列表'), `blocks=${warningView.blocks.map((b) => b.label).join('/')}`, ) // ---- S 组:接线(这一跳正是当初断掉的地方)---- const paneSource = readSrc('components/WarningResponsePane.vue') check( 'S1 面板把报警效率的翻页事件交给 store(不再只认 historyAnalysis)', /panelId === 'responseEfficiency'[\s\S]{0,120}setWorkOrderPage/.test(paneSource), 'onListPage 没有 responseEfficiency 分支 ⇒ 点了翻页没反应(本次缺陷)', ) check( 'S2 store 暴露 setWorkOrderPage(组件才调得到)', /setWorkOrderPage,/.test(readSrc('stores/eventStore.js')), 'store 返回表里没有 setWorkOrderPage', ) check( 'S3 工单页码用独立令牌(翻工单页不作废四块面板的在途响应)', /let workOrderToken = 0/.test(readSrc('stores/eventStore.js')) && !/async function setWorkOrderPage[\s\S]{0,400}token \+= 1/.test(readSrc('stores/eventStore.js')), 'setWorkOrderPage 用了全局面板令牌', ) console.log('') console.log(`T65B 工单列表翻页:${passed}/${passed + failed} 项通过。`) if (failed) { console.log('未通过项见上方 FAIL。') process.exit(1) } console.log('T65B 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')