| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291 |
- /**
- * 票 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 未自动执行)。')
|