t61-event-server-paging.mjs 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803
  1. /**
  2. * 票 61 闸门:预警处置事件清单「服务端页码分页」(当前事件表 + 历史事件单窗/多窗)。
  3. *
  4. * 覆盖验收四条 + 真实契约落地:
  5. * A 当前事件表:请求带 page/pageSize=10、翻页只重取本分区、迟到页响应被 token 作废、
  6. * mode/范围变化回第 1 页、定时刷新不复位、越界夹回、打开的详情 refreshNotice 保留
  7. * B 历史事件单窗:服务端分页(paging.mode='server'),翻页发 page 请求、迟到页作废
  8. * C 历史事件多窗:无全局页,回退已加载合并集合分页 + 页脚如实口径(窗口数/已加载)
  9. * F mock 同构:contractMocks 的 current/history 吃 pageSize=10 并演示多页;形状与
  10. * /events/current 的 EventItem 同构;事件行总数与 pagination.total 同源
  11. * S 静态守卫:单源(PagerNav/翻页算术/serverPageView)、文案纪律、无 12 条帽
  12. * R 真实渲染(真实 SFC 编译 + 自定义渲染器真实 patch):两种分页形态的控件、计数行、
  13. * 一页不画控件、服务端翻页 emit list-page、多窗口径说明在控件不画时也出现
  14. *
  15. * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t61-event-server-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. import { createRenderer, h, nextTick } from '@vue/runtime-core'
  22. import { createPinia, setActivePinia } from 'pinia'
  23. const SRC = new URL('../../../src/modules/cockpit/', import.meta.url).href
  24. const repoRoot = new URL('../../../', import.meta.url)
  25. const read = (relative) => readFileSync(new URL(relative, repoRoot), 'utf8')
  26. /** 去掉注释再查文案(注释会解释被禁的东西,直接对全文正则会拿假阳性换通过率)。 */
  27. const stripComments = (text) =>
  28. String(text)
  29. .replace(/\/\*[\s\S]*?\*\//g, '')
  30. .replace(/(^|[^:])\/\/.*$/gm, '$1')
  31. .replace(/<!--[\s\S]*?-->/g, '')
  32. if (typeof globalThis.document === 'undefined') globalThis.document = { activeElement: null }
  33. const {
  34. registerMockTransport,
  35. resetMockTransportsForTest,
  36. findMockTransport,
  37. } = await import(`${SRC}services/cockpitApi.js`)
  38. const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`)
  39. const { useEventStore } = await import(`${SRC}stores/eventStore.js`)
  40. const { fetchCurrentEvents } = await import(`${SRC}services/eventApi.js`)
  41. const { fetchEventHistory } = await import(`${SRC}services/warningResponseApi.js`)
  42. const { toCurrentEvents, toEventItem } = await import(`${SRC}domain/eventList.js`)
  43. const { SECTION_STATE, section } = await import(`${SRC}domain/sectionState.js`)
  44. const { PAGE_SIZE, serverPageView, pagedRows } = await import(`${SRC}domain/pagination.js`)
  45. const { panelView } = await import(`${SRC}domain/panelViews.js`)
  46. const { historyAnalysisPanel } = await import(`${SRC}domain/warningResponsePanels.js`)
  47. let passed = 0
  48. let failed = 0
  49. function check(name, condition, detail = '') {
  50. if (condition) { passed += 1; console.log(`PASS ${name}`) }
  51. else { failed += 1; console.error(`FAIL ${name}: ${detail}`) }
  52. }
  53. const pinia = createPinia()
  54. setActivePinia(pinia)
  55. const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
  56. const REQUESTS = []
  57. /** 用「录制包装器」包住 contractMocks 的内建处理器:既能断言查询串,又吃到真实样例。 */
  58. function installMock({ current = null, history = null } = {}) {
  59. requcedClear()
  60. resetMockTransportsForTest()
  61. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  62. const builtinCurrent = findMockTransport('/events/current')
  63. const builtinHistory = findMockTransport('/events/history-analysis')
  64. registerMockTransport('/events/current', async (context) => {
  65. REQUESTS.push(context.path)
  66. return current ? current(context, builtinCurrent) : builtinCurrent(context)
  67. })
  68. registerMockTransport('/events/history-analysis', async (context) => {
  69. REQUESTS.push(context.path)
  70. return history ? history(context, builtinHistory) : builtinHistory(context)
  71. })
  72. }
  73. function requcedClear() { REQUESTS.length = 0 }
  74. function pathsMatching(prefix) { return REQUESTS.filter((path) => String(path).startsWith(prefix)) }
  75. function queryOf(path) { return new URLSearchParams(String(path).split('?')[1] || '') }
  76. const flush = async () => {
  77. await nextTick()
  78. await new Promise((resolve) => setTimeout(resolve, 5))
  79. await nextTick()
  80. }
  81. // ---------------------------------------------------------------- 造载荷(与后端响应类同形)
  82. function eventItem(no, { level = 1, eventTime = '2026-10-21T10:00:00+08:00', deviceCode = `EQ-${no}` } = {}) {
  83. return {
  84. eventNo: no,
  85. eventName: `事件${no}`,
  86. eventType: 'GAS_CONCENTRATION',
  87. level,
  88. levelColor: { 1: 'RED', 2: 'YELLOW', 3: 'BLUE', 4: 'GREEN' }[level] || 'UNKNOWN',
  89. status: 'ACTIVE',
  90. eventTime,
  91. updatedTime: null,
  92. value: 1.2,
  93. unit: '%LEL',
  94. deviceCode,
  95. deviceName: `设备${no}`,
  96. monitoringPointCode: 'MP-001',
  97. monitoringPointName: '样例点位',
  98. location: '城区A',
  99. industry: 'GAS',
  100. longitude: null,
  101. latitude: null,
  102. coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
  103. thresholdMinValue: null,
  104. thresholdMaxValue: null,
  105. workOrderSummary: null,
  106. }
  107. }
  108. function currentEnvelope({ mode = 'WARNING', items = [], page = 1, pageSize = PAGE_SIZE, total = items.length, totalPages = null } = {}) {
  109. const pages = totalPages === null ? (total ? Math.ceil(total / pageSize) : 0) : totalPages
  110. return {
  111. requestId: 't61-current', apiVersion: 'v1', readOnly: true, displayName: mode === 'ALARM' ? '当前报警' : '当前预警',
  112. timezone: 'Asia/Shanghai', mode, updatedAt: '2026-10-21T10:00:00+08:00',
  113. filters: { mode, level: null, industry: null, status: null },
  114. pagination: { page, pageSize, total, totalPages: pages, hasMore: page * pageSize < total },
  115. mapFilter: { mode, level: null, industry: null, status: null, deviceCodes: [] },
  116. sections: {
  117. events: {
  118. state: items.length ? 'SUCCESS' : 'EMPTY',
  119. reason: items.length ? 'SUCCESS' : 'NO_EVENTS',
  120. message: null,
  121. data: items.length ? items : null,
  122. },
  123. workOrders: { state: 'EMPTY', reason: 'NO_WORK_ORDERS', message: null, data: null },
  124. },
  125. }
  126. }
  127. function historyEnvelope({ mode = 'WARNING', events = [], page = 1, pageSize = PAGE_SIZE, total = events.length, startTime = '', endTime = '' } = {}) {
  128. return {
  129. requestId: 't61-history', apiVersion: 'v1', readOnly: true, displayName: '历史分析',
  130. timezone: 'Asia/Shanghai', eventFamily: mode, mode,
  131. range: { type: 'CUSTOM', startTime, endTime },
  132. updatedAt: '2026-10-21T10:00:00+08:00',
  133. filters: { eventFamily: mode, level: null, industry: null },
  134. summary: { eventCount: total, handledCount: 0, handlingEfficiency: { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_ELIGIBLE_WORK_ORDERS' } },
  135. pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0, hasMore: page * pageSize < total },
  136. events,
  137. // 有事件就给一个非空桶:`isEmptyBy` 按 `rows.length===0` 判空(后端真实形状同理)。
  138. rows: events.length ? [{ timeBucket: startTime, eventCount: events.length, handledCount: 0 }] : [],
  139. }
  140. }
  141. const pagesOf = (items, page, pageSize = PAGE_SIZE) => {
  142. const from = Math.min((page - 1) * pageSize, items.length)
  143. return items.slice(from, from + pageSize)
  144. }
  145. const WARNINGS_14 = Array.from({ length: PAGE_SIZE + 4 }, (_, index) => eventItem(`WRN-${1001 + index}`, { level: (index % 4) + 1 }))
  146. const HISTORY_14 = Array.from({ length: PAGE_SIZE + 4 }, (_, index) => eventItem(`HW-${1001 + index}`, { level: (index % 4) + 1 }))
  147. const rangeWindow = (days) => {
  148. const end = new Date('2026-10-21T10:00:00+08:00')
  149. const start = new Date(end.getTime() - days * 86400000)
  150. return { startTime: start.toISOString(), endTime: end.toISOString() }
  151. }
  152. const store = useEventStore()
  153. store.reset()
  154. store.configure({ config: mockConfig })
  155. // ================================================================ A. 当前事件表:服务端页码
  156. {
  157. installMock()
  158. store.reset()
  159. store.configure({ config: mockConfig })
  160. await store.loadPanels({ mode: 'WARNING', range: '7d' })
  161. const currentPaths = pathsMatching('/events/current')
  162. const historyPaths = pathsMatching('/events/history-analysis')
  163. check(
  164. 'A1-initial-request-carries-page-and-page-size',
  165. currentPaths.length === 1 &&
  166. queryOf(currentPaths[0]).get('page') === '1' &&
  167. // 契约字面量 10(规范条文 1);刻意不引用常量,否则改常量会把判据一起改掉(等价变异)。
  168. queryOf(currentPaths[0]).get('pageSize') === '10',
  169. `当前事件首取:${currentPaths[0]}`,
  170. )
  171. check(
  172. 'A2-first-page-view-and-count',
  173. store.view.rows.length === PAGE_SIZE &&
  174. store.view.pagination.page === 1 &&
  175. store.view.pagination.pageCount === 2 &&
  176. store.view.total === PAGE_SIZE + 4 &&
  177. historyPaths.length === 1,
  178. `行=${store.view.rows.length} 页=${store.view.pagination.page}/${store.view.pagination.pageCount} 总=${store.view.total};历史请求 ${historyPaths.length} 个(单窗)`,
  179. )
  180. // 翻页:只重取当前事件分区(历史请求数不变),查询串 page=2。
  181. requcedClear()
  182. await store.setCurrentPage(2)
  183. const pageTwoPaths = pathsMatching('/events/current')
  184. check(
  185. 'A3-page-flip-requests-page-two-only',
  186. pageTwoPaths.length === 1 &&
  187. queryOf(pageTwoPaths[0]).get('page') === '2' &&
  188. queryOf(pageTwoPaths[0]).get('pageSize') === String(PAGE_SIZE) &&
  189. pathsMatching('/events/history-analysis').length === 0 &&
  190. store.currentPage === 2 &&
  191. store.view.rows.length === PAGE_SIZE + 4 - PAGE_SIZE &&
  192. store.view.pagination.page === 2,
  193. `翻页请求 ${pageTwoPaths[0]};行=${store.view.rows.length};历史分区零重取`,
  194. )
  195. // mode 切换 → 回第 1 页并重取(切换入口走 loadPanels)。
  196. requcedClear()
  197. await store.setMode('ALARM')
  198. check(
  199. 'A4-mode-change-resets-to-first-page',
  200. store.currentPage === 1 &&
  201. pathsMatching('/events/current').length === 1 &&
  202. queryOf(pathsMatching('/events/current')[0]).get('page') === '1',
  203. `切换后页码=${store.currentPage} 请求=${pathsMatching('/events/current')[0]}`,
  204. )
  205. // 定时刷新(无参 loadPanels)不复位页码:先翻到第 2 页,再刷新。
  206. await store.setCurrentPage(2)
  207. requcedClear()
  208. await store.loadPanels()
  209. check(
  210. 'A5-refresh-does-not-reset-page',
  211. store.currentPage === 2 &&
  212. pathsMatching('/events/current').length === 1 &&
  213. queryOf(pathsMatching('/events/current')[0]).get('page') === '2',
  214. `刷新后页码=${store.currentPage} 请求=${pathsMatching('/events/current')[0]}`,
  215. )
  216. // 打开的详情:翻到不含该事件的一页 → refreshNotice 如实标注;翻回 → 恢复。
  217. store.reset()
  218. store.configure({ config: mockConfig })
  219. installMock()
  220. await store.loadPanels({ mode: 'WARNING', range: '7d' })
  221. const firstRow = store.view.rows[0]
  222. store.openEvent(firstRow)
  223. await store.setCurrentPage(2)
  224. const noticeOnPageTwo = store.selectedEvent && store.selectedEvent.refreshNotice
  225. await store.setCurrentPage(1)
  226. const restored = store.selectedEvent
  227. check(
  228. 'A6-open-detail-refresh-notice-preserved',
  229. !!noticeOnPageTwo &&
  230. /当前列表未返回该事件/.test(noticeOnPageTwo) &&
  231. restored.code === firstRow.code &&
  232. !restored.refreshNotice,
  233. `第 2 页通知「${noticeOnPageTwo}」→ 回第 1 页恢复(notice=${restored.refreshNotice || '空'})`,
  234. )
  235. }
  236. // ---- A7 迟到页响应被 token 作废(翻页竞态) ----
  237. {
  238. installMock()
  239. let releaseSlow = null
  240. let slowServed = false
  241. const marker = (name) => [{ ...eventItem(`WRN-${name}-1`), eventName: name }]
  242. registerMockTransport('/events/current', async ({ path }) => {
  243. REQUESTS.push(path)
  244. const page = Number(queryOf(path).get('page') || 1)
  245. if (page === 2 && !slowServed) {
  246. slowServed = true
  247. await new Promise((resolve) => {
  248. releaseSlow = resolve
  249. })
  250. return currentEnvelope({ items: marker('SLOW'), page: 2, total: PAGE_SIZE + 4 })
  251. }
  252. // 守卫若失效会有第二次 page=2(夹回重取)——立即回一个可辨认的载荷,让断言干净地红。
  253. if (page === 2) return currentEnvelope({ items: marker('SLOW-CLAMP'), page: 2, total: PAGE_SIZE + 4 })
  254. if (page === 3) return currentEnvelope({ items: marker('FAST'), page: 3, total: PAGE_SIZE * 3 })
  255. return currentEnvelope({ items: pagesOf(WARNINGS_14, 1), page: 1, total: PAGE_SIZE + 4 })
  256. })
  257. store.reset()
  258. store.configure({ config: mockConfig })
  259. await store.loadPanels({ mode: 'WARNING', range: '7d' })
  260. const slow = store.setCurrentPage(2)
  261. await new Promise((resolve) => setTimeout(resolve, 0))
  262. const fast = store.setCurrentPage(3)
  263. await fast
  264. const afterFast = { page: store.currentPage, rows: store.view.rows.length, name: store.view.rows[0] && store.view.rows[0].name }
  265. releaseSlow()
  266. await slow
  267. await new Promise((resolve) => setTimeout(resolve, 0))
  268. check(
  269. 'A7-late-page-response-discarded-by-token',
  270. afterFast.page === 3 &&
  271. afterFast.rows === 1 &&
  272. afterFast.name === 'FAST' &&
  273. store.currentPage === 3 &&
  274. store.view.rows.length === 1 &&
  275. store.view.rows[0].name === 'FAST',
  276. `快响应落定(page3/FAST) 后迟到的 page2 被丢弃(现状 page=${store.currentPage} name=${store.view.rows[0] && store.view.rows[0].name})`,
  277. )
  278. }
  279. // ---- A8 越界页码夹回最后一页(数据变少) ----
  280. {
  281. installMock()
  282. registerMockTransport('/events/current', async ({ path }) => {
  283. REQUESTS.push(path)
  284. const page = Number(queryOf(path).get('page') || 1)
  285. // 后端如实回显请求页与真实 totalPages=1(数据只剩一页)。
  286. return currentEnvelope({ items: page === 1 ? pagesOf(WARNINGS_14.slice(0, 4), 1) : [], page, total: 4, totalPages: 1 })
  287. })
  288. store.reset()
  289. store.configure({ config: mockConfig })
  290. await store.loadPanels({ mode: 'WARNING', range: '7d' })
  291. requcedClear()
  292. await store.setCurrentPage(2)
  293. const pages = pathsMatching('/events/current').map((path) => queryOf(path).get('page'))
  294. check(
  295. 'A8-out-of-range-page-clamps-to-last',
  296. store.currentPage === 1 &&
  297. store.view.pagination.pageCount === 1 &&
  298. pages.join(',') === '2,1',
  299. `请求页码序列 ${pages.join('→')};夹回后 page=${store.currentPage}`,
  300. )
  301. }
  302. // ================================================================ B. 历史事件单窗:服务端分页
  303. {
  304. installMock()
  305. store.reset()
  306. store.configure({ config: mockConfig })
  307. await store.loadPanels({ mode: 'WARNING', range: '7d' })
  308. const historyPaths = pathsMatching('/events/history-analysis')
  309. const historyData = store.sections.historyAnalysis.data
  310. check(
  311. 'B1-single-window-uses-server-paging',
  312. historyPaths.length === 1 &&
  313. queryOf(historyPaths[0]).get('page') === '1' &&
  314. // 契约字面量 10(同上:判据不跟着常量走)。
  315. queryOf(historyPaths[0]).get('pageSize') === '10' &&
  316. historyData.paging.mode === 'server' &&
  317. historyData.paging.page === 1 &&
  318. historyData.paging.pageCount === 2 &&
  319. historyData.paging.total === PAGE_SIZE + 4 &&
  320. historyData.events.length === PAGE_SIZE,
  321. `单窗历史:${historyPaths[0]};paging=${JSON.stringify(historyData.paging)};行=${historyData.events.length}`,
  322. )
  323. requcedClear()
  324. await store.setHistoryPage(2)
  325. const pageTwo = store.sections.historyAnalysis.data
  326. check(
  327. 'B2-history-page-flip-requests-page-two',
  328. pathsMatching('/events/history-analysis').length === 1 &&
  329. queryOf(pathsMatching('/events/history-analysis')[0]).get('page') === '2' &&
  330. queryOf(pathsMatching('/events/history-analysis')[0]).get('pageSize') === String(PAGE_SIZE) &&
  331. pathsMatching('/events/current').length === 0 &&
  332. store.historyPage === 2 &&
  333. pageTwo.paging.page === 2 &&
  334. pageTwo.events.length === 4,
  335. `历史第 2 页:${pathsMatching('/events/history-analysis')[0]};行=${pageTwo.events.length}`,
  336. )
  337. // 迟到历史页响应被 token 作废:慢 page2 在途时改范围(→ 多窗合并),迟到结果不落盘。
  338. installMock()
  339. let releaseHistory = null
  340. registerMockTransport('/events/history-analysis', async ({ path }) => {
  341. REQUESTS.push(path)
  342. const params = queryOf(path)
  343. if (params.get('page') === '2') {
  344. await new Promise((resolve) => {
  345. releaseHistory = resolve
  346. })
  347. return historyEnvelope({ events: [eventItem('HW-LATE')], page: 2, total: PAGE_SIZE + 4, startTime: params.get('startTime'), endTime: params.get('endTime') })
  348. }
  349. return historyEnvelope({ events: pagesOf(HISTORY_14, Number(params.get('page') || 1)), page: Number(params.get('page') || 1), total: PAGE_SIZE + 4, startTime: params.get('startTime'), endTime: params.get('endTime') })
  350. })
  351. store.reset()
  352. store.configure({ config: mockConfig })
  353. await store.loadPanels({ mode: 'WARNING', range: '7d' })
  354. const slowHistory = store.setHistoryPage(2)
  355. await new Promise((resolve) => setTimeout(resolve, 0))
  356. const ranged = store.setRange('30d')
  357. await ranged
  358. const mergedMode = store.sections.historyAnalysis.data.paging.mode
  359. releaseHistory()
  360. await slowHistory
  361. await new Promise((resolve) => setTimeout(resolve, 0))
  362. check(
  363. 'B3-late-history-page-discarded-after-range-change',
  364. mergedMode === 'merged' && store.sections.historyAnalysis.data.paging.mode === 'merged' && store.historyPage === 1,
  365. `改范围后 paging.mode=${store.sections.historyAnalysis.data.paging.mode}(历史页码=${store.historyPage},迟到单窗页未落盘)`,
  366. )
  367. }
  368. // ================================================================ C. 历史事件多窗:无全局页 → 合并集合分页 + 如实页脚
  369. {
  370. installMock()
  371. store.reset()
  372. store.configure({ config: mockConfig })
  373. await store.loadPanels({ mode: 'WARNING', range: '30d' })
  374. const historyPaths = pathsMatching('/events/history-analysis')
  375. const data = store.sections.historyAnalysis.data
  376. const view = panelView('historyAnalysis', data, store.sections.historyAnalysis)
  377. const block = view.blocks.find((item) => item.kind === 'list')
  378. check(
  379. 'C1-multi-window-fetches-each-window-fully',
  380. historyPaths.length === 2 &&
  381. historyPaths.every((path) => queryOf(path).get('pageSize') === '1000' && queryOf(path).get('page') === '1') &&
  382. new Set(historyPaths.map((path) => queryOf(path).get('startTime'))).size === 2,
  383. `多窗请求 ${historyPaths.length} 个,窗口起点 ${historyPaths.map((path) => queryOf(path).get('startTime')).join(' / ')}`,
  384. )
  385. check(
  386. 'C2-multi-window-paging-is-merged-collection',
  387. data.paging.mode === 'merged' &&
  388. data.paging.windowCount === 2 &&
  389. data.paging.loaded === PAGE_SIZE + 4 &&
  390. data.paging.total === PAGE_SIZE + 4 &&
  391. !('pagination' in data) === false ? data.pagination === null : true,
  392. `paging=${JSON.stringify(data.paging)}`,
  393. )
  394. check(
  395. 'C3-merged-block-declares-frontend-paging-with-honest-footer',
  396. block.paginate === true &&
  397. !block.serverPage &&
  398. block.rows.length === PAGE_SIZE + 4 &&
  399. /已合并 2 个时间窗 · 已加载 14 条/.test(String(block.pageNote || '')),
  400. `块 rows=${block.rows.length} pageNote「${block.pageNote}」`,
  401. )
  402. const secondPageRows = pagedRows(block.rows, 2)
  403. check(
  404. 'C4-merged-collection-pages-by-ten',
  405. secondPageRows.pageCount === 2 && secondPageRows.rows.length === 4 && secondPageRows.page === 2,
  406. `合并集合第 2 页 ${secondPageRows.rows.length} 行 / 共 ${secondPageRows.pageCount} 页`,
  407. )
  408. check(
  409. 'C5-merged-page-scope-changes-with-range',
  410. block.pageScope === `WARNING|30d`,
  411. `pageScope=${block.pageScope}(范围变化即复位页码的触发键)`,
  412. )
  413. // 90 天范围:窗口数由 `historyWindows` 按 30 天切分得出(90 天含一个毫秒级尾窗,
  414. // 现网口径就是 4 个窗——判据只看「如实报出实际窗数」,不写死 3)。
  415. await store.setRange('90d')
  416. const data90 = store.sections.historyAnalysis.data
  417. const block90 = panelView('historyAnalysis', data90, store.sections.historyAnalysis).blocks.find((item) => item.kind === 'list')
  418. const windows90 = data90.paging.windowCount
  419. check(
  420. 'C6-ninety-day-range-reports-actual-window-count',
  421. data90.paging.mode === 'merged' &&
  422. windows90 >= 3 &&
  423. new RegExp(`已合并 ${windows90} 个时间窗`).test(String(block90.pageNote || '')) &&
  424. block90.pageScope === 'WARNING|90d',
  425. `90 天 paging=${JSON.stringify(data90.paging)} pageNote「${block90.pageNote}」`,
  426. )
  427. }
  428. // ================================================================ F. mock 同构(contractMocks)
  429. {
  430. resetMockTransportsForTest()
  431. registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
  432. const rawCurrent = await findMockTransport('/events/current')({ path: '/events/current?mode=WARNING&page=1&pageSize=10' })
  433. const rawCurrentPageTwo = await findMockTransport('/events/current')({ path: '/events/current?mode=WARNING&page=2&pageSize=10' })
  434. const rawHistory = await findMockTransport('/events/history-analysis')({ path: '/events/history-analysis?eventFamily=WARNING&page=1&pageSize=10' })
  435. const rawHistoryPageTwo = await findMockTransport('/events/history-analysis')({ path: '/events/history-analysis?eventFamily=WARNING&page=2&pageSize=10' })
  436. check(
  437. 'F1-mock-current-events-page-size-ten',
  438. rawCurrent.sections.events.data.length === PAGE_SIZE &&
  439. rawCurrentPageTwo.sections.events.data.length === 4 &&
  440. rawCurrent.pagination.pageSize === PAGE_SIZE &&
  441. rawCurrent.pagination.total === PAGE_SIZE + 4,
  442. `当前事件样例 page1=${rawCurrent.sections.events.data.length} page2=${rawCurrentPageTwo.sections.events.data.length} total=${rawCurrent.pagination.total}`,
  443. )
  444. check(
  445. 'F2-mock-history-server-pages',
  446. rawHistory.events.length === PAGE_SIZE &&
  447. rawHistoryPageTwo.events.length === 4 &&
  448. rawHistory.pagination.pageSize === PAGE_SIZE &&
  449. rawHistory.pagination.total === PAGE_SIZE + 4,
  450. `历史样例 page1=${rawHistory.events.length} page2=${rawHistoryPageTwo.events.length} total=${rawHistory.pagination.total}`,
  451. )
  452. check(
  453. 'F3-mock-history-event-shape-isomorphic-to-current',
  454. JSON.stringify(Object.keys(rawHistory.events[0]).sort()) === JSON.stringify(Object.keys(rawCurrent.sections.events.data[0]).sort()),
  455. '历史逐事件行与 events/current 的 EventItem 字段集合一致(mock 同构)',
  456. )
  457. check(
  458. 'F4-mock-history-total-equals-summary',
  459. rawHistory.pagination.total === rawHistory.summary.eventCount,
  460. `pagination.total=${rawHistory.pagination.total} summary.eventCount=${rawHistory.summary.eventCount}(同一份总数)`,
  461. )
  462. // 真实服务链路:单窗 pageSize=10 → 两页;30 天 → 两窗合并。
  463. const window7 = rangeWindow(7)
  464. const window30 = rangeWindow(30)
  465. const single = await fetchEventHistory({ mode: 'WARNING', rangeType: 'LAST_7_DAYS', ...window7, page: 1, pageSize: PAGE_SIZE, config: mockConfig })
  466. const singlePageTwo = await fetchEventHistory({ mode: 'WARNING', rangeType: 'LAST_7_DAYS', ...window7, page: 2, pageSize: PAGE_SIZE, config: mockConfig })
  467. const merged = await fetchEventHistory({ mode: 'WARNING', rangeType: 'LAST_30_DAYS', ...window30, page: 1, pageSize: PAGE_SIZE, config: mockConfig })
  468. check(
  469. 'F5-service-composes-server-and-merged-paging',
  470. single.data.paging.mode === 'server' &&
  471. single.data.paging.pageCount === 2 &&
  472. singlePageTwo.data.paging.page === 2 &&
  473. singlePageTwo.data.events.length === 4 &&
  474. merged.data.paging.mode === 'merged' &&
  475. merged.data.paging.windowCount === 2 &&
  476. merged.data.paging.loaded === PAGE_SIZE + 4,
  477. `单窗 paging=${JSON.stringify(single.data.paging)};多窗 paging=${JSON.stringify(merged.data.paging)}`,
  478. )
  479. }
  480. // ================================================================ S. 静态守卫(单源/文案/无 12 条帽)
  481. {
  482. const pane = read('src/modules/cockpit/components/EventListPane.vue')
  483. const blocks = read('src/modules/cockpit/components/PanelBlocks.vue')
  484. const storeSource = read('src/modules/cockpit/stores/eventStore.js')
  485. const historyDomain = read('src/modules/cockpit/domain/warningResponsePanels.js')
  486. const historyService = read('src/modules/cockpit/services/warningResponseApi.js')
  487. const pager = read('src/modules/cockpit/components/PagerNav.vue')
  488. check(
  489. 'S1-event-pane-uses-shared-pager-and-view-pagination',
  490. /import PagerNav from '\.\/PagerNav\.vue'/.test(pane) &&
  491. /view\.pagination\.pageCount > 1/.test(pane) &&
  492. /:page="view\.pagination\.page"/.test(pane) &&
  493. /@page="\(target\) => eventStore\.setCurrentPage\(target\)"/.test(pane),
  494. '当前事件表接共享 PagerNav,页码/总页读视图(不自己算)',
  495. )
  496. check(
  497. 'S2-no-12-row-cap-left',
  498. !/data-event-limited/.test(pane) &&
  499. !/仅展示最新/.test(pane) &&
  500. !/latestEventRows/.test(historyDomain) &&
  501. !/slice\(0, 12\)/.test(historyDomain),
  502. '撤下「仅展示最新 12 条」帽(当前事件表与历史事件目录层都没有 12 条截断)',
  503. )
  504. check(
  505. 'S3-store-owns-server-page-state-and-token',
  506. /const currentPage = ref\(1\)/.test(storeSource) &&
  507. /const historyPage = ref\(1\)/.test(storeSource) &&
  508. /async function setCurrentPage/.test(storeSource) &&
  509. /async function setHistoryPage/.test(storeSource) &&
  510. /pageSize: PAGE_SIZE/.test(storeSource) &&
  511. /mine !== token/.test(storeSource),
  512. '页码是 store 视图状态;翻页只重取本分区;迟到页由 token 作废',
  513. )
  514. check(
  515. 'S4-history-domain-declares-two-paging-forms',
  516. /serverPage: \{ page: paging\.page, pageCount: paging\.pageCount, total: paging\.total \}/.test(historyDomain) &&
  517. /pageNote: `已合并 \$\{paging\.windowCount \|\| 0\} 个时间窗 · 已加载 \$\{paging\.loaded \|\| 0\} 条`/.test(historyDomain) &&
  518. /paging\.mode === 'server'/.test(historyDomain),
  519. '历史事件块按 data.paging 声明服务端页码/合并集合两种形态',
  520. )
  521. check(
  522. 'S5-service-pages-single-window-and-merges-multi-window',
  523. /pageSize: singleWindow \? PAGE_SIZE : HISTORY_WINDOW_MAX_PAGE_SIZE/.test(historyService) &&
  524. /mode: 'server'/.test(historyService) &&
  525. /mode: 'merged'/.test(historyService) &&
  526. /pagination: singleWindow \?/.test(historyService),
  527. '服务端:单窗 page/pageSize=10、多窗每窗 1000 上限取全再合并;多窗不冒充全局 pagination',
  528. )
  529. check(
  530. 'S6-count-row-comes-from-single-pager-source',
  531. /第 \{\{ page \}\}\/\{\{ pageCount \}\} 页 · 共 \{\{ total \}\} 条/.test(pager) &&
  532. !/第 \{\{/.test(pane) &&
  533. !/共 \{\{/.test(blocks),
  534. '计数行只在 PagerNav 一份;组件不另写「第 X/Y 页 · 共 N 条」',
  535. )
  536. check(
  537. 'S7-no-backend-param-names-in-ui-copy',
  538. !/pageSize/.test(stripComments(pane)) && !/pageSize/.test(stripComments(blocks)) && !/>page=/.test(stripComments(pane)),
  539. '界面文案不暴露 page/pageSize 参数名(条文 10)',
  540. )
  541. check(
  542. 'S8-page-arithmetic-single-source',
  543. /export function serverPageView/.test(read('src/modules/cockpit/domain/pagination.js')) &&
  544. /export function pagedRows/.test(read('src/modules/cockpit/domain/pagination.js')) &&
  545. !/Math\.(ceil|trunc)\(/.test(pane) &&
  546. !/Math\.(ceil|trunc)\(/.test(blocks),
  547. '翻页算术只在 domain/pagination.js;组件零 Math.ceil/trunc',
  548. )
  549. }
  550. // ================================================================ R. 真实渲染(SFC 编译 + 自定义渲染器)
  551. registerHooks({
  552. resolve(specifier, context, nextResolve) {
  553. const resolved = nextResolve(specifier, context)
  554. const parent = context.parentURL || ''
  555. if (parent.endsWith('.vue?client') && resolved.url.endsWith('.vue')) {
  556. return { url: `${resolved.url}?client`, format: 'module', shortCircuit: true }
  557. }
  558. return resolved
  559. },
  560. load(url, context, nextLoad) {
  561. const isClient = url.endsWith('.vue?client')
  562. if (url.endsWith('.vue') || isClient) {
  563. const path = isClient ? url.slice(0, -'?client'.length) : url
  564. const source = readFileSync(new URL(path), 'utf8')
  565. const { descriptor } = parse(source, { filename: path })
  566. const script = compileScript(descriptor, { id: 't61', genDefaultAs: '__sfc__' })
  567. const template = compileTemplate({
  568. source: descriptor.template.content,
  569. filename: path,
  570. id: 't61',
  571. ssr: !isClient,
  572. compilerOptions: { bindingMetadata: script.bindings },
  573. })
  574. assert.deepEqual(template.errors, [], `${path} template`)
  575. const tail = isClient ? '__sfc__.render = render; export default __sfc__;' : '__sfc__.ssrRender = ssrRender; export default __sfc__;'
  576. return { format: 'module', shortCircuit: true, source: `${script.content}\n${template.code}\n${tail}` }
  577. }
  578. return nextLoad(url, context)
  579. },
  580. })
  581. const { default: ClientEventListPane } = await import(`${SRC}components/EventListPane.vue?client`)
  582. const { default: ClientPanelBlocks } = await import(`${SRC}components/PanelBlocks.vue?client`)
  583. const nodeOps = {
  584. createElement: (tag) => ({ tag, props: {}, children: [], parent: null, text: '', style: {}, addEventListener() {}, removeEventListener() {} }),
  585. createText: (text) => ({ tag: '#text', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
  586. createComment: (text) => ({ tag: '#comment', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
  587. setText: (node, text) => { node.text = text },
  588. setElementText: (node, text) => { node.text = text; node.children = [] },
  589. insert: (child, parent, anchor) => {
  590. if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child)
  591. child.parent = parent
  592. if (!parent) return
  593. if (anchor) { const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, child) }
  594. else parent.children.push(child)
  595. },
  596. remove: (child) => { if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child); child.parent = null },
  597. parentNode: (node) => node.parent,
  598. nextSibling: (node) => (node.parent ? node.parent.children[node.parent.children.indexOf(node) + 1] || null : null),
  599. querySelector: () => null,
  600. setScopeId: () => {},
  601. patchProp: (el, key, _prev, next) => { el.props[key] = next },
  602. }
  603. const findAll = (node, predicate, acc = []) => {
  604. if (!node) return acc
  605. if (predicate(node)) acc.push(node)
  606. for (const child of node.children || []) findAll(child, predicate, acc)
  607. return acc
  608. }
  609. const findNode = (node, predicate) => {
  610. if (!node) return null
  611. if (predicate(node)) return node
  612. for (const child of node.children || []) {
  613. const hit = findNode(child, predicate)
  614. if (hit) return hit
  615. }
  616. return null
  617. }
  618. const textOf = (node) => {
  619. if (!node) return ''
  620. const own = node.tag === '#text' ? node.text : typeof node.text === 'string' ? node.text : ''
  621. return own + (node.children || []).map(textOf).join('')
  622. }
  623. const byAttr = (name) => (node) => !!node.props && name in node.props
  624. const mount = (render) => {
  625. const app = createRenderer(nodeOps).createApp({ render })
  626. app.config.warnHandler = () => {}
  627. app.use(pinia)
  628. const container = nodeOps.createElement('#root')
  629. app.mount(container)
  630. return { container, unmount: () => app.unmount() }
  631. }
  632. const click = async (node) => {
  633. assert.ok(node && typeof node.props.onClick === 'function', '节点不可点')
  634. node.props.onClick(new Proxy({}, { get: () => undefined }))
  635. await flush()
  636. }
  637. // ---- R1/R2 当前事件表:一页有控件、翻页走 store 请求 ----
  638. {
  639. installMock()
  640. store.reset()
  641. store.configure({ config: mockConfig })
  642. await store.loadPanels({ mode: 'WARNING', range: '7d' })
  643. const { container, unmount } = mount(() => h(ClientEventListPane))
  644. const rows = findAll(container, byAttr('data-event-row'))
  645. const pagerWrap = findNode(container, byAttr('data-event-pager'))
  646. const state = textOf(findNode(container, byAttr('data-page-state')))
  647. check(
  648. 'R1-current-pane-renders-page-and-count-row',
  649. rows.length === PAGE_SIZE && !!pagerWrap && /第 1\/2 页 · 共 14 条/.test(state),
  650. `行=${rows.length} 计数行「${state}」`,
  651. )
  652. check(
  653. 'R2-current-pane-removed-12-cap-note',
  654. findAll(container, byAttr('data-event-limited')).length === 0,
  655. '「仅展示最新 12 条」节点已不存在',
  656. )
  657. await click(findNode(pagerWrap, byAttr('data-page-next')))
  658. const rowsAfter = findAll(container, byAttr('data-event-row'))
  659. const stateAfter = textOf(findNode(container, byAttr('data-page-state')))
  660. check(
  661. 'R3-clicking-next-requests-second-page',
  662. rowsAfter.length === 4 && /第 2\/2 页/.test(stateAfter) && store.currentPage === 2,
  663. `点下一页后行=${rowsAfter.length} 计数行「${stateAfter}」`,
  664. )
  665. unmount()
  666. }
  667. // ---- R4 一页不画控件(服务端分页) ----
  668. {
  669. store.reset()
  670. store.configure({ config: mockConfig })
  671. installMock()
  672. registerMockTransport('/events/current', async ({ path }) => {
  673. REQUESTS.push(path)
  674. return currentEnvelope({ items: pagesOf(WARNINGS_14.slice(0, 4), 1), page: 1, total: 4 })
  675. })
  676. await store.loadPanels({ mode: 'WARNING', range: '7d' })
  677. const { container, unmount } = mount(() => h(ClientEventListPane))
  678. check(
  679. 'R4-single-page-draws-no-pager',
  680. findAll(container, byAttr('data-event-row')).length === 4 && findAll(container, byAttr('data-event-pager')).length === 0,
  681. '一页(4 条)时整块 pager 不渲染(条文 4)',
  682. )
  683. unmount()
  684. }
  685. // ---- R5/R6 历史事件:服务端页码块(翻页 emit 给 store)+多窗合并块(页脚如实口径) ----
  686. {
  687. installMock()
  688. store.reset()
  689. store.configure({ config: mockConfig })
  690. await store.loadPanels({ mode: 'WARNING', range: '7d' })
  691. const serverView = panelView('historyAnalysis', store.sections.historyAnalysis.data, store.sections.historyAnalysis)
  692. const emitted = []
  693. const serverApp = mount(() => h(ClientPanelBlocks, { view: serverView, onListPage: (payload) => emitted.push(payload) }))
  694. const serverPager = findNode(serverApp.container, byAttr('data-pager'))
  695. const serverState = textOf(findNode(serverApp.container, byAttr('data-page-state')))
  696. check(
  697. 'R5-history-server-block-renders-shared-pager',
  698. !!serverPager &&
  699. /第 1\/2 页 · 共 14 条/.test(serverState) &&
  700. findAll(serverApp.container, byAttr('data-item')).length === PAGE_SIZE,
  701. `服务端分页块:计数行「${serverState}」、行=${findAll(serverApp.container, byAttr('data-item')).length}`,
  702. )
  703. await click(findNode(serverPager, byAttr('data-page-next')))
  704. check(
  705. 'R6-history-server-paging-asks-store-for-next-page',
  706. emitted.length === 1 && emitted[0].page === 2 && /历史事件/.test(String(emitted[0].label || '')),
  707. `点下一页 emit ${JSON.stringify(emitted[0])}(块内不自己切窗口)`,
  708. )
  709. serverApp.unmount()
  710. await store.setRange('30d')
  711. const mergedView = panelView('historyAnalysis', store.sections.historyAnalysis.data, store.sections.historyAnalysis)
  712. const mergedEmitted = []
  713. const mergedApp = mount(() => h(ClientPanelBlocks, { view: mergedView, onListPage: (payload) => mergedEmitted.push(payload) }))
  714. const note = findNode(mergedApp.container, byAttr('data-list-page-note'))
  715. const mergedRowsFirst = findAll(mergedApp.container, byAttr('data-item'))
  716. const mergedStateFirst = textOf(findNode(mergedApp.container, byAttr('data-page-state')))
  717. check(
  718. 'R7-merged-block-renders-honest-footer-and-first-page',
  719. mergedRowsFirst.length === PAGE_SIZE &&
  720. /第 1\/2 页 · 共 14 条/.test(mergedStateFirst) &&
  721. /已合并 2 个时间窗 · 已加载 14 条/.test(textOf(note)),
  722. `合并块:行=${mergedRowsFirst.length} 计数行「${mergedStateFirst}」页脚「${textOf(note)}」`,
  723. )
  724. await click(findNode(mergedApp.container, (node) => String(node.props && node.props['data-page-number']) === '2'))
  725. const mergedRowsSecond = findAll(mergedApp.container, byAttr('data-item'))
  726. const mergedStateSecond = textOf(findNode(mergedApp.container, byAttr('data-page-state')))
  727. check(
  728. 'R8-merged-block-pages-loaded-collection-locally',
  729. mergedRowsSecond.length === 4 && /第 2\/2 页/.test(mergedStateSecond) && mergedEmitted.length === 0,
  730. `合并块第 2 页行=${mergedRowsSecond.length}、零 list-page emit(窗口在渲染层)`,
  731. )
  732. mergedApp.unmount()
  733. // 合并集合一页不到:控件不画,但「已合并 N 个时间窗 · 已加载 M 条」的口径说明仍要在。
  734. const fourEvents = HISTORY_14.slice(0, 4).map((raw) => toEventItem(raw))
  735. const onePageView = historyAnalysisPanel(
  736. { mode: 'WARNING', range: { key: '30d', type: 'LAST_30_DAYS', start: '', end: '' }, trend: [], events: fourEvents, paging: { mode: 'merged', windowCount: 2, loaded: 4, total: 4 } },
  737. { ...section(SECTION_STATE.SUCCESS, { data: null }), loaded: true },
  738. )
  739. const onePageApp = mount(() => h(ClientPanelBlocks, { view: onePageView }))
  740. check(
  741. 'R9-merged-note-survives-without-pager',
  742. findAll(onePageApp.container, byAttr('data-pager')).length === 0 &&
  743. /已合并 2 个时间窗 · 已加载 4 条/.test(textOf(findNode(onePageApp.container, byAttr('data-list-page-note')))),
  744. '一页不到时不画控件,但合并口径说明仍出现(口径不是控件)',
  745. )
  746. onePageApp.unmount()
  747. }
  748. // ================================================================ 结果
  749. console.log('')
  750. console.log(`T61 事件清单服务端分页闸门:${passed}/${passed + failed} 项通过。`)
  751. if (failed) {
  752. console.error(`未通过 ${failed} 项。`)
  753. process.exit(1)
  754. }
  755. console.log('T61 全部通过。')