t65b-work-order-paging.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. /**
  2. * 票 65 补口闸门:报警模式的「当前工单列表」**服务端翻页**真的生效。
  3. *
  4. * 为什么单独一份:维护者真机反馈「分页没生效,点不到第 2 页」。根因是
  5. * `WarningResponsePane.onListPage()` 早先只认 `historyAnalysis`,工单列表画出了翻页控件、
  6. * 点了却什么都没发生(事件被 `return` 掉,页码根本没进 store)。
  7. *
  8. * 这类缺陷**领域层闸门抓不到**:`workOrderListBlock` 的 `serverPage` 是对的、
  9. * `PanelBlocks` 的 emit 也是对的,断在「组件 → store」这一跳。所以这里起 pinia + 真实
  10. * store + 真实 mock transport,从「点翻页」一路断到「分区换页」。
  11. *
  12. * 数据用**闸门内现场注入**(12 张工单,每页 5 张 → 3 页),不扩 `contractMocks` 的静态样例
  13. * (票 64 裁定:静态样例的扩面会污染其它断言,现场注入是允许的)。
  14. *
  15. * 用法:node .scratch/cockpit-real-data-wayfinding/verify/t65b-work-order-paging.mjs
  16. */
  17. import assert from 'node:assert/strict'
  18. import { readFileSync } from 'node:fs'
  19. import { registerHooks } from 'node:module'
  20. import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
  21. const SRC = '../../../src/modules/cockpit/'
  22. const readSrc = (rel) => readFileSync(new URL(SRC + rel, import.meta.url), 'utf8')
  23. registerHooks({
  24. resolve(specifier, context, nextResolve) {
  25. const resolved = nextResolve(specifier, context)
  26. const parent = context.parentURL || ''
  27. if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) {
  28. return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true }
  29. }
  30. return resolved
  31. },
  32. load(url, context, nextLoad) {
  33. if (url.endsWith('.json')) {
  34. return { format: 'module', shortCircuit: true, source: `export default ${readFileSync(new URL(url), 'utf8')}` }
  35. }
  36. if (url.endsWith('.css')) return { format: 'module', shortCircuit: true, source: 'export default {}' }
  37. const isClient = url.endsWith('.vue?client')
  38. if (url.endsWith('.vue') || isClient) {
  39. const path = isClient ? url.slice(0, -'?client'.length) : url
  40. const source = readFileSync(new URL(path), 'utf8')
  41. const { descriptor } = parse(source, { filename: path })
  42. const script = compileScript(descriptor, { id: 't65b', genDefaultAs: '__sfc__' })
  43. const template = compileTemplate({
  44. source: descriptor.template.content,
  45. filename: path,
  46. id: 't65b',
  47. ssr: !isClient,
  48. compilerOptions: { bindingMetadata: script.bindings },
  49. })
  50. assert.deepEqual(template.errors, [], `${path} template`)
  51. const tail = isClient
  52. ? '__sfc__.render = render; export default __sfc__;'
  53. : '__sfc__.ssrRender = ssrRender; export default __sfc__;'
  54. return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` }
  55. }
  56. return nextLoad(url, context)
  57. },
  58. })
  59. let passed = 0
  60. let failed = 0
  61. function check(name, condition, detail = '') {
  62. if (condition) {
  63. passed += 1
  64. console.log(`PASS ${name}`)
  65. } else {
  66. failed += 1
  67. console.error(`FAIL ${name}: ${detail}`)
  68. }
  69. }
  70. const { createPinia, setActivePinia } = await import('pinia')
  71. setActivePinia(createPinia())
  72. const { registerMockTransport, resetMockTransportsForTest } = await import(`${SRC}services/cockpitApi.js`)
  73. const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`)
  74. const { builtinConfig } = await import(`${SRC}services/cockpitConfig.js`)
  75. const { useEventStore } = await import(`${SRC}stores/eventStore.js`)
  76. const { responseEfficiencyPanel } = await import(`${SRC}domain/warningResponsePanels.js`)
  77. // ---- 现场注入:12 张工单,每页 5 张 → 3 页;服务端真的按 page 切窗口 ----
  78. const TOTAL = 12
  79. const ORDERS = Array.from({ length: TOTAL }, (_, index) => ({
  80. workOrderCode: `WO-${String(index + 1).padStart(3, '0')}`,
  81. deviceCode: '21119740',
  82. orderLevel: 2,
  83. orderDesc: `注入工单 ${index + 1}`,
  84. stateCode: index % 8,
  85. stateName: '待派单',
  86. createTime: '2026-10-14T13:43:50+08:00',
  87. updateTime: '2026-10-14T13:43:50+08:00',
  88. planFinishTime: null,
  89. realFinishTime: null,
  90. eventAssociation: null,
  91. associatedWithCurrentEvent: false,
  92. }))
  93. const requests = []
  94. function queryOf(path) {
  95. const query = {}
  96. const search = String(path || '').split('?')[1] || ''
  97. for (const [key, value] of new URLSearchParams(search).entries()) query[key] = value
  98. return query
  99. }
  100. async function workOrderListPayload(query) {
  101. const page = Math.max(1, Number(query.page) || 1)
  102. const pageSize = Math.max(1, Number(query.pageSize) || 5)
  103. const totalPages = Math.max(1, Math.ceil(TOTAL / pageSize))
  104. // 模拟后端把越界页码夹回最后一页(与真接口 `loadAll` 的行为一致)
  105. const safePage = Math.min(page, totalPages)
  106. requests.push({ page, pageSize, startTime: query.startTime || '', endTime: query.endTime || '' })
  107. // 第 2 页刻意慢回:P7「快速连点」要能观察到迟到响应(否则两条都按请求顺序回来,变异抓不住)
  108. if (safePage === 2) await new Promise((resolve) => setTimeout(resolve, 40))
  109. return {
  110. requestId: 'injected',
  111. apiVersion: 'v1',
  112. readOnly: true,
  113. state: 'READY',
  114. stateReason: 'READY',
  115. stateDictionary: [],
  116. workOrders: ORDERS.slice((safePage - 1) * pageSize, safePage * pageSize),
  117. pagination: { page: safePage, pageSize, total: TOTAL, totalPages },
  118. }
  119. }
  120. resetMockTransportsForTest()
  121. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  122. // 覆盖 '/work-orders':本组要的是「多页」,静态样例只有 4 张(1 页)。
  123. registerMockTransport('/work-orders', async (context) => workOrderListPayload(queryOf(context.path)))
  124. const store = useEventStore()
  125. store.configure({ config: builtinConfig })
  126. // ---- P1 首屏:第 1 页、5 张、共 12 张 / 3 页 ----
  127. await store.loadPanels({ mode: 'ALARM', range: '7d', industry: '' })
  128. const first = store.sections.workOrderList
  129. check(
  130. 'P1 首屏工单列表是第 1 页(5 张 / 共 12 张 / 3 页),翻页控件该出现',
  131. first.loaded === true &&
  132. first.data.orders.length === 5 &&
  133. first.data.pagination.page === 1 &&
  134. first.data.pagination.total === 12 &&
  135. first.data.pagination.totalPages === 3 &&
  136. store.workOrderPage === 1,
  137. `orders=${first.data && first.data.orders.length} pagination=${JSON.stringify(first.data && first.data.pagination)}`,
  138. )
  139. // ---- P2 点「下一页」→ 真的换到第 2 页 ----
  140. const othersBefore = {
  141. currentEvents: store.sections.currentEvents,
  142. historyAnalysis: store.sections.historyAnalysis,
  143. responseEfficiency: store.sections.responseEfficiency,
  144. eventAnalysis: store.sections.eventAnalysis,
  145. }
  146. requests.length = 0
  147. await store.setWorkOrderPage(2)
  148. const second = store.sections.workOrderList
  149. check(
  150. 'P2 翻到第 2 页:请求带 page=2、分区换成 6–10 号工单',
  151. requests.length === 1 &&
  152. requests[0].page === 2 &&
  153. second.data.orders.length === 5 &&
  154. second.data.orders[0].code === 'WO-006' &&
  155. second.data.pagination.page === 2 &&
  156. store.workOrderPage === 2,
  157. `requests=${JSON.stringify(requests)} firstRow=${second.data && second.data.orders[0] && second.data.orders[0].code}`,
  158. )
  159. check(
  160. 'P3 翻工单页只重取工单分区(其它四块引用未变,不被牵连重取)',
  161. store.sections.currentEvents === othersBefore.currentEvents &&
  162. store.sections.historyAnalysis === othersBefore.historyAnalysis &&
  163. store.sections.responseEfficiency === othersBefore.responseEfficiency &&
  164. store.sections.eventAnalysis === othersBefore.eventAnalysis,
  165. '翻工单页把其它分区也换掉了',
  166. )
  167. // ---- P4 重复点同一页:不重复请求 ----
  168. requests.length = 0
  169. await store.setWorkOrderPage(2)
  170. check('P4 重复点同一页不重复请求(幂等)', requests.length === 0, `requests=${requests.length}`)
  171. // ---- P5 越界页码:按后端回显夹回最后一页 ----
  172. requests.length = 0
  173. await store.setWorkOrderPage(9)
  174. const clamped = store.sections.workOrderList
  175. check(
  176. 'P5 越界页码夹回最后一页(第 3 页,2 张),页码状态与回显一致',
  177. clamped.data.pagination.page === 3 &&
  178. clamped.data.orders.length === 2 &&
  179. store.workOrderPage === 3,
  180. `page=${clamped.data && clamped.data.pagination.page} rows=${clamped.data && clamped.data.orders.length} storePage=${store.workOrderPage}`,
  181. )
  182. // ---- P6 条件变化:页码回第 1 页(条文 5)----
  183. await store.loadPanels({ mode: 'ALARM', range: '30d', industry: '' })
  184. check(
  185. 'P6 条件变化(切范围)后工单页码回第 1 页',
  186. store.workOrderPage === 1 && store.sections.workOrderList.data.pagination.page === 1,
  187. `storePage=${store.workOrderPage}`,
  188. )
  189. // ---- P7 迟到响应丢弃(独立令牌:快速连点第 2、3 页,只认最后一页)----
  190. requests.length = 0
  191. const slow = store.setWorkOrderPage(2)
  192. const fast = store.setWorkOrderPage(3)
  193. await Promise.all([slow, fast])
  194. check(
  195. 'P7 快速连点只认最后一页(迟到响应丢弃,独立令牌)',
  196. store.sections.workOrderList.data.pagination.page === 3 && store.workOrderPage === 3,
  197. `page=${store.sections.workOrderList.data.pagination.page} storePage=${store.workOrderPage}`,
  198. )
  199. // ---- P9–P11 时间窗(2026-10-22 维护者反馈:列表没按时间筛)----
  200. //
  201. // 面板按近 7/30/90 天切换时,列表必须与上方「工单状态分布」统计同一批工单。
  202. // 断言的是**请求真的带上了当前窗口**(不是「界面看起来对」)。
  203. {
  204. requests.length = 0
  205. await store.loadPanels({ mode: 'ALARM', range: '7d', industry: '' })
  206. const seven = requests[0] || {}
  207. const active7d = store.range
  208. check(
  209. 'P9 首屏工单列表请求带当前窗口(7 天的 startTime/endTime 都非空)',
  210. active7d === '7d' && Boolean(seven.startTime) && Boolean(seven.endTime) &&
  211. // 7 天窗口:两端相差应约等于 7 天(允许整点取整带来的 1 小时内偏差)
  212. Math.abs((Date.parse(seven.endTime) - Date.parse(seven.startTime)) - 7 * 86400000) <= 3600000,
  213. 'start=' + seven.startTime + ' end=' + seven.endTime,
  214. )
  215. const window7d = { start: seven.startTime, end: seven.endTime }
  216. // 翻页必须沿用同一窗口(少了它第 2 页会悄悄变回全库口径)
  217. requests.length = 0
  218. await store.setWorkOrderPage(2)
  219. const page2 = requests[0] || {}
  220. check(
  221. 'P10 翻页沿用同一时间窗(第 2 页的 startTime/endTime 与首屏完全一致)',
  222. page2.startTime === window7d.start && page2.endTime === window7d.end,
  223. 'p2 start=' + page2.startTime + ' end=' + page2.endTime + ' vs 首屏 ' + window7d.start + '/' + window7d.end,
  224. )
  225. // 切范围必须换窗口并回第 1 页
  226. requests.length = 0
  227. await store.loadPanels({ mode: 'ALARM', range: '30d', industry: '' })
  228. const thirty = requests[0] || {}
  229. check(
  230. 'P11 切到 30 天后请求换成 30 天窗口(且回第 1 页)',
  231. Boolean(thirty.startTime) && thirty.startTime !== window7d.start &&
  232. Math.abs((Date.parse(thirty.endTime) - Date.parse(thirty.startTime)) - 30 * 86400000) <= 3600000 &&
  233. thirty.page === 1,
  234. 'start=' + thirty.startTime + ' end=' + thirty.endTime + ' page=' + thirty.page,
  235. )
  236. }
  237. // ---- P8 预警模式不画工单列表,但也不因缺它而报错 ----
  238. const warningView = responseEfficiencyPanel(
  239. { family: 'WARNING', totalEvents: 3, linkedWorkOrderCount: 0, missingWorkOrderCount: 3, levelDistribution: [], eventTrend: [] },
  240. { state: 'success', loaded: true, data: {} },
  241. { mode: 'WARNING', workOrderList: store.sections.workOrderList },
  242. )
  243. check(
  244. 'P8 预警模式仍不出现工单列表(翻页接线不影响两族分流)',
  245. !warningView.blocks.some((block) => block.kind === 'list' && block.label === '当前工单列表'),
  246. `blocks=${warningView.blocks.map((b) => b.label).join('/')}`,
  247. )
  248. // ---- S 组:接线(这一跳正是当初断掉的地方)----
  249. const paneSource = readSrc('components/WarningResponsePane.vue')
  250. check(
  251. 'S1 面板把报警效率的翻页事件交给 store(不再只认 historyAnalysis)',
  252. /panelId === 'responseEfficiency'[\s\S]{0,120}setWorkOrderPage/.test(paneSource),
  253. 'onListPage 没有 responseEfficiency 分支 ⇒ 点了翻页没反应(本次缺陷)',
  254. )
  255. check(
  256. 'S2 store 暴露 setWorkOrderPage(组件才调得到)',
  257. /setWorkOrderPage,/.test(readSrc('stores/eventStore.js')),
  258. 'store 返回表里没有 setWorkOrderPage',
  259. )
  260. check(
  261. 'S3 工单页码用独立令牌(翻工单页不作废四块面板的在途响应)',
  262. /let workOrderToken = 0/.test(readSrc('stores/eventStore.js')) &&
  263. !/async function setWorkOrderPage[\s\S]{0,400}token \+= 1/.test(readSrc('stores/eventStore.js')),
  264. 'setWorkOrderPage 用了全局面板令牌',
  265. )
  266. console.log('')
  267. console.log(`T65B 工单列表翻页:${passed}/${passed + failed} 项通过。`)
  268. if (failed) {
  269. console.log('未通过项见上方 FAIL。')
  270. process.exit(1)
  271. }
  272. console.log('T65B 全部通过(浏览器走查按 AGENTS.md 未自动执行)。')