| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803 |
- /**
- * 票 61 闸门:预警处置事件清单「服务端页码分页」(当前事件表 + 历史事件单窗/多窗)。
- *
- * 覆盖验收四条 + 真实契约落地:
- * A 当前事件表:请求带 page/pageSize=10、翻页只重取本分区、迟到页响应被 token 作废、
- * mode/范围变化回第 1 页、定时刷新不复位、越界夹回、打开的详情 refreshNotice 保留
- * B 历史事件单窗:服务端分页(paging.mode='server'),翻页发 page 请求、迟到页作废
- * C 历史事件多窗:无全局页,回退已加载合并集合分页 + 页脚如实口径(窗口数/已加载)
- * F mock 同构:contractMocks 的 current/history 吃 pageSize=10 并演示多页;形状与
- * /events/current 的 EventItem 同构;事件行总数与 pagination.total 同源
- * S 静态守卫:单源(PagerNav/翻页算术/serverPageView)、文案纪律、无 12 条帽
- * R 真实渲染(真实 SFC 编译 + 自定义渲染器真实 patch):两种分页形态的控件、计数行、
- * 一页不画控件、服务端翻页 emit list-page、多窗口径说明在控件不画时也出现
- *
- * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t61-event-server-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'
- import { createRenderer, h, nextTick } from '@vue/runtime-core'
- import { createPinia, setActivePinia } from 'pinia'
- const SRC = new URL('../../../src/modules/cockpit/', import.meta.url).href
- const repoRoot = new URL('../../../', import.meta.url)
- const read = (relative) => readFileSync(new URL(relative, repoRoot), 'utf8')
- /** 去掉注释再查文案(注释会解释被禁的东西,直接对全文正则会拿假阳性换通过率)。 */
- const stripComments = (text) =>
- String(text)
- .replace(/\/\*[\s\S]*?\*\//g, '')
- .replace(/(^|[^:])\/\/.*$/gm, '$1')
- .replace(/<!--[\s\S]*?-->/g, '')
- if (typeof globalThis.document === 'undefined') globalThis.document = { activeElement: null }
- const {
- registerMockTransport,
- resetMockTransportsForTest,
- findMockTransport,
- } = await import(`${SRC}services/cockpitApi.js`)
- const { registerCockpitMocks } = await import(`${SRC}mocks/contractMocks.js`)
- const { useEventStore } = await import(`${SRC}stores/eventStore.js`)
- const { fetchCurrentEvents } = await import(`${SRC}services/eventApi.js`)
- const { fetchEventHistory } = await import(`${SRC}services/warningResponseApi.js`)
- const { toCurrentEvents, toEventItem } = await import(`${SRC}domain/eventList.js`)
- const { SECTION_STATE, section } = await import(`${SRC}domain/sectionState.js`)
- const { PAGE_SIZE, serverPageView, pagedRows } = await import(`${SRC}domain/pagination.js`)
- const { panelView } = await import(`${SRC}domain/panelViews.js`)
- const { historyAnalysisPanel } = await import(`${SRC}domain/warningResponsePanels.js`)
- 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 pinia = createPinia()
- setActivePinia(pinia)
- const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
- const REQUESTS = []
- /** 用「录制包装器」包住 contractMocks 的内建处理器:既能断言查询串,又吃到真实样例。 */
- function installMock({ current = null, history = null } = {}) {
- requcedClear()
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const builtinCurrent = findMockTransport('/events/current')
- const builtinHistory = findMockTransport('/events/history-analysis')
- registerMockTransport('/events/current', async (context) => {
- REQUESTS.push(context.path)
- return current ? current(context, builtinCurrent) : builtinCurrent(context)
- })
- registerMockTransport('/events/history-analysis', async (context) => {
- REQUESTS.push(context.path)
- return history ? history(context, builtinHistory) : builtinHistory(context)
- })
- }
- function requcedClear() { REQUESTS.length = 0 }
- function pathsMatching(prefix) { return REQUESTS.filter((path) => String(path).startsWith(prefix)) }
- function queryOf(path) { return new URLSearchParams(String(path).split('?')[1] || '') }
- const flush = async () => {
- await nextTick()
- await new Promise((resolve) => setTimeout(resolve, 5))
- await nextTick()
- }
- // ---------------------------------------------------------------- 造载荷(与后端响应类同形)
- function eventItem(no, { level = 1, eventTime = '2026-10-21T10:00:00+08:00', deviceCode = `EQ-${no}` } = {}) {
- return {
- eventNo: no,
- eventName: `事件${no}`,
- eventType: 'GAS_CONCENTRATION',
- level,
- levelColor: { 1: 'RED', 2: 'YELLOW', 3: 'BLUE', 4: 'GREEN' }[level] || 'UNKNOWN',
- status: 'ACTIVE',
- eventTime,
- updatedTime: null,
- value: 1.2,
- unit: '%LEL',
- deviceCode,
- deviceName: `设备${no}`,
- monitoringPointCode: 'MP-001',
- monitoringPointName: '样例点位',
- location: '城区A',
- industry: 'GAS',
- longitude: null,
- latitude: null,
- coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
- thresholdMinValue: null,
- thresholdMaxValue: null,
- workOrderSummary: null,
- }
- }
- function currentEnvelope({ mode = 'WARNING', items = [], page = 1, pageSize = PAGE_SIZE, total = items.length, totalPages = null } = {}) {
- const pages = totalPages === null ? (total ? Math.ceil(total / pageSize) : 0) : totalPages
- return {
- requestId: 't61-current', apiVersion: 'v1', readOnly: true, displayName: mode === 'ALARM' ? '当前报警' : '当前预警',
- timezone: 'Asia/Shanghai', mode, updatedAt: '2026-10-21T10:00:00+08:00',
- filters: { mode, level: null, industry: null, status: null },
- pagination: { page, pageSize, total, totalPages: pages, hasMore: page * pageSize < total },
- mapFilter: { mode, level: null, industry: null, status: null, deviceCodes: [] },
- sections: {
- events: {
- state: items.length ? 'SUCCESS' : 'EMPTY',
- reason: items.length ? 'SUCCESS' : 'NO_EVENTS',
- message: null,
- data: items.length ? items : null,
- },
- workOrders: { state: 'EMPTY', reason: 'NO_WORK_ORDERS', message: null, data: null },
- },
- }
- }
- function historyEnvelope({ mode = 'WARNING', events = [], page = 1, pageSize = PAGE_SIZE, total = events.length, startTime = '', endTime = '' } = {}) {
- return {
- requestId: 't61-history', apiVersion: 'v1', readOnly: true, displayName: '历史分析',
- timezone: 'Asia/Shanghai', eventFamily: mode, mode,
- range: { type: 'CUSTOM', startTime, endTime },
- updatedAt: '2026-10-21T10:00:00+08:00',
- filters: { eventFamily: mode, level: null, industry: null },
- summary: { eventCount: total, handledCount: 0, handlingEfficiency: { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_ELIGIBLE_WORK_ORDERS' } },
- pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0, hasMore: page * pageSize < total },
- events,
- // 有事件就给一个非空桶:`isEmptyBy` 按 `rows.length===0` 判空(后端真实形状同理)。
- rows: events.length ? [{ timeBucket: startTime, eventCount: events.length, handledCount: 0 }] : [],
- }
- }
- const pagesOf = (items, page, pageSize = PAGE_SIZE) => {
- const from = Math.min((page - 1) * pageSize, items.length)
- return items.slice(from, from + pageSize)
- }
- const WARNINGS_14 = Array.from({ length: PAGE_SIZE + 4 }, (_, index) => eventItem(`WRN-${1001 + index}`, { level: (index % 4) + 1 }))
- const HISTORY_14 = Array.from({ length: PAGE_SIZE + 4 }, (_, index) => eventItem(`HW-${1001 + index}`, { level: (index % 4) + 1 }))
- const rangeWindow = (days) => {
- const end = new Date('2026-10-21T10:00:00+08:00')
- const start = new Date(end.getTime() - days * 86400000)
- return { startTime: start.toISOString(), endTime: end.toISOString() }
- }
- const store = useEventStore()
- store.reset()
- store.configure({ config: mockConfig })
- // ================================================================ A. 当前事件表:服务端页码
- {
- installMock()
- store.reset()
- store.configure({ config: mockConfig })
- await store.loadPanels({ mode: 'WARNING', range: '7d' })
- const currentPaths = pathsMatching('/events/current')
- const historyPaths = pathsMatching('/events/history-analysis')
- check(
- 'A1-initial-request-carries-page-and-page-size',
- currentPaths.length === 1 &&
- queryOf(currentPaths[0]).get('page') === '1' &&
- // 契约字面量 10(规范条文 1);刻意不引用常量,否则改常量会把判据一起改掉(等价变异)。
- queryOf(currentPaths[0]).get('pageSize') === '10',
- `当前事件首取:${currentPaths[0]}`,
- )
- check(
- 'A2-first-page-view-and-count',
- store.view.rows.length === PAGE_SIZE &&
- store.view.pagination.page === 1 &&
- store.view.pagination.pageCount === 2 &&
- store.view.total === PAGE_SIZE + 4 &&
- historyPaths.length === 1,
- `行=${store.view.rows.length} 页=${store.view.pagination.page}/${store.view.pagination.pageCount} 总=${store.view.total};历史请求 ${historyPaths.length} 个(单窗)`,
- )
- // 翻页:只重取当前事件分区(历史请求数不变),查询串 page=2。
- requcedClear()
- await store.setCurrentPage(2)
- const pageTwoPaths = pathsMatching('/events/current')
- check(
- 'A3-page-flip-requests-page-two-only',
- pageTwoPaths.length === 1 &&
- queryOf(pageTwoPaths[0]).get('page') === '2' &&
- queryOf(pageTwoPaths[0]).get('pageSize') === String(PAGE_SIZE) &&
- pathsMatching('/events/history-analysis').length === 0 &&
- store.currentPage === 2 &&
- store.view.rows.length === PAGE_SIZE + 4 - PAGE_SIZE &&
- store.view.pagination.page === 2,
- `翻页请求 ${pageTwoPaths[0]};行=${store.view.rows.length};历史分区零重取`,
- )
- // mode 切换 → 回第 1 页并重取(切换入口走 loadPanels)。
- requcedClear()
- await store.setMode('ALARM')
- check(
- 'A4-mode-change-resets-to-first-page',
- store.currentPage === 1 &&
- pathsMatching('/events/current').length === 1 &&
- queryOf(pathsMatching('/events/current')[0]).get('page') === '1',
- `切换后页码=${store.currentPage} 请求=${pathsMatching('/events/current')[0]}`,
- )
- // 定时刷新(无参 loadPanels)不复位页码:先翻到第 2 页,再刷新。
- await store.setCurrentPage(2)
- requcedClear()
- await store.loadPanels()
- check(
- 'A5-refresh-does-not-reset-page',
- store.currentPage === 2 &&
- pathsMatching('/events/current').length === 1 &&
- queryOf(pathsMatching('/events/current')[0]).get('page') === '2',
- `刷新后页码=${store.currentPage} 请求=${pathsMatching('/events/current')[0]}`,
- )
- // 打开的详情:翻到不含该事件的一页 → refreshNotice 如实标注;翻回 → 恢复。
- store.reset()
- store.configure({ config: mockConfig })
- installMock()
- await store.loadPanels({ mode: 'WARNING', range: '7d' })
- const firstRow = store.view.rows[0]
- store.openEvent(firstRow)
- await store.setCurrentPage(2)
- const noticeOnPageTwo = store.selectedEvent && store.selectedEvent.refreshNotice
- await store.setCurrentPage(1)
- const restored = store.selectedEvent
- check(
- 'A6-open-detail-refresh-notice-preserved',
- !!noticeOnPageTwo &&
- /当前列表未返回该事件/.test(noticeOnPageTwo) &&
- restored.code === firstRow.code &&
- !restored.refreshNotice,
- `第 2 页通知「${noticeOnPageTwo}」→ 回第 1 页恢复(notice=${restored.refreshNotice || '空'})`,
- )
- }
- // ---- A7 迟到页响应被 token 作废(翻页竞态) ----
- {
- installMock()
- let releaseSlow = null
- let slowServed = false
- const marker = (name) => [{ ...eventItem(`WRN-${name}-1`), eventName: name }]
- registerMockTransport('/events/current', async ({ path }) => {
- REQUESTS.push(path)
- const page = Number(queryOf(path).get('page') || 1)
- if (page === 2 && !slowServed) {
- slowServed = true
- await new Promise((resolve) => {
- releaseSlow = resolve
- })
- return currentEnvelope({ items: marker('SLOW'), page: 2, total: PAGE_SIZE + 4 })
- }
- // 守卫若失效会有第二次 page=2(夹回重取)——立即回一个可辨认的载荷,让断言干净地红。
- if (page === 2) return currentEnvelope({ items: marker('SLOW-CLAMP'), page: 2, total: PAGE_SIZE + 4 })
- if (page === 3) return currentEnvelope({ items: marker('FAST'), page: 3, total: PAGE_SIZE * 3 })
- return currentEnvelope({ items: pagesOf(WARNINGS_14, 1), page: 1, total: PAGE_SIZE + 4 })
- })
- store.reset()
- store.configure({ config: mockConfig })
- await store.loadPanels({ mode: 'WARNING', range: '7d' })
- const slow = store.setCurrentPage(2)
- await new Promise((resolve) => setTimeout(resolve, 0))
- const fast = store.setCurrentPage(3)
- await fast
- const afterFast = { page: store.currentPage, rows: store.view.rows.length, name: store.view.rows[0] && store.view.rows[0].name }
- releaseSlow()
- await slow
- await new Promise((resolve) => setTimeout(resolve, 0))
- check(
- 'A7-late-page-response-discarded-by-token',
- afterFast.page === 3 &&
- afterFast.rows === 1 &&
- afterFast.name === 'FAST' &&
- store.currentPage === 3 &&
- store.view.rows.length === 1 &&
- store.view.rows[0].name === 'FAST',
- `快响应落定(page3/FAST) 后迟到的 page2 被丢弃(现状 page=${store.currentPage} name=${store.view.rows[0] && store.view.rows[0].name})`,
- )
- }
- // ---- A8 越界页码夹回最后一页(数据变少) ----
- {
- installMock()
- registerMockTransport('/events/current', async ({ path }) => {
- REQUESTS.push(path)
- const page = Number(queryOf(path).get('page') || 1)
- // 后端如实回显请求页与真实 totalPages=1(数据只剩一页)。
- return currentEnvelope({ items: page === 1 ? pagesOf(WARNINGS_14.slice(0, 4), 1) : [], page, total: 4, totalPages: 1 })
- })
- store.reset()
- store.configure({ config: mockConfig })
- await store.loadPanels({ mode: 'WARNING', range: '7d' })
- requcedClear()
- await store.setCurrentPage(2)
- const pages = pathsMatching('/events/current').map((path) => queryOf(path).get('page'))
- check(
- 'A8-out-of-range-page-clamps-to-last',
- store.currentPage === 1 &&
- store.view.pagination.pageCount === 1 &&
- pages.join(',') === '2,1',
- `请求页码序列 ${pages.join('→')};夹回后 page=${store.currentPage}`,
- )
- }
- // ================================================================ B. 历史事件单窗:服务端分页
- {
- installMock()
- store.reset()
- store.configure({ config: mockConfig })
- await store.loadPanels({ mode: 'WARNING', range: '7d' })
- const historyPaths = pathsMatching('/events/history-analysis')
- const historyData = store.sections.historyAnalysis.data
- check(
- 'B1-single-window-uses-server-paging',
- historyPaths.length === 1 &&
- queryOf(historyPaths[0]).get('page') === '1' &&
- // 契约字面量 10(同上:判据不跟着常量走)。
- queryOf(historyPaths[0]).get('pageSize') === '10' &&
- historyData.paging.mode === 'server' &&
- historyData.paging.page === 1 &&
- historyData.paging.pageCount === 2 &&
- historyData.paging.total === PAGE_SIZE + 4 &&
- historyData.events.length === PAGE_SIZE,
- `单窗历史:${historyPaths[0]};paging=${JSON.stringify(historyData.paging)};行=${historyData.events.length}`,
- )
- requcedClear()
- await store.setHistoryPage(2)
- const pageTwo = store.sections.historyAnalysis.data
- check(
- 'B2-history-page-flip-requests-page-two',
- pathsMatching('/events/history-analysis').length === 1 &&
- queryOf(pathsMatching('/events/history-analysis')[0]).get('page') === '2' &&
- queryOf(pathsMatching('/events/history-analysis')[0]).get('pageSize') === String(PAGE_SIZE) &&
- pathsMatching('/events/current').length === 0 &&
- store.historyPage === 2 &&
- pageTwo.paging.page === 2 &&
- pageTwo.events.length === 4,
- `历史第 2 页:${pathsMatching('/events/history-analysis')[0]};行=${pageTwo.events.length}`,
- )
- // 迟到历史页响应被 token 作废:慢 page2 在途时改范围(→ 多窗合并),迟到结果不落盘。
- installMock()
- let releaseHistory = null
- registerMockTransport('/events/history-analysis', async ({ path }) => {
- REQUESTS.push(path)
- const params = queryOf(path)
- if (params.get('page') === '2') {
- await new Promise((resolve) => {
- releaseHistory = resolve
- })
- return historyEnvelope({ events: [eventItem('HW-LATE')], page: 2, total: PAGE_SIZE + 4, startTime: params.get('startTime'), endTime: params.get('endTime') })
- }
- 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') })
- })
- store.reset()
- store.configure({ config: mockConfig })
- await store.loadPanels({ mode: 'WARNING', range: '7d' })
- const slowHistory = store.setHistoryPage(2)
- await new Promise((resolve) => setTimeout(resolve, 0))
- const ranged = store.setRange('30d')
- await ranged
- const mergedMode = store.sections.historyAnalysis.data.paging.mode
- releaseHistory()
- await slowHistory
- await new Promise((resolve) => setTimeout(resolve, 0))
- check(
- 'B3-late-history-page-discarded-after-range-change',
- mergedMode === 'merged' && store.sections.historyAnalysis.data.paging.mode === 'merged' && store.historyPage === 1,
- `改范围后 paging.mode=${store.sections.historyAnalysis.data.paging.mode}(历史页码=${store.historyPage},迟到单窗页未落盘)`,
- )
- }
- // ================================================================ C. 历史事件多窗:无全局页 → 合并集合分页 + 如实页脚
- {
- installMock()
- store.reset()
- store.configure({ config: mockConfig })
- await store.loadPanels({ mode: 'WARNING', range: '30d' })
- const historyPaths = pathsMatching('/events/history-analysis')
- const data = store.sections.historyAnalysis.data
- const view = panelView('historyAnalysis', data, store.sections.historyAnalysis)
- const block = view.blocks.find((item) => item.kind === 'list')
- check(
- 'C1-multi-window-fetches-each-window-fully',
- historyPaths.length === 2 &&
- historyPaths.every((path) => queryOf(path).get('pageSize') === '1000' && queryOf(path).get('page') === '1') &&
- new Set(historyPaths.map((path) => queryOf(path).get('startTime'))).size === 2,
- `多窗请求 ${historyPaths.length} 个,窗口起点 ${historyPaths.map((path) => queryOf(path).get('startTime')).join(' / ')}`,
- )
- check(
- 'C2-multi-window-paging-is-merged-collection',
- data.paging.mode === 'merged' &&
- data.paging.windowCount === 2 &&
- data.paging.loaded === PAGE_SIZE + 4 &&
- data.paging.total === PAGE_SIZE + 4 &&
- !('pagination' in data) === false ? data.pagination === null : true,
- `paging=${JSON.stringify(data.paging)}`,
- )
- check(
- 'C3-merged-block-declares-frontend-paging-with-honest-footer',
- block.paginate === true &&
- !block.serverPage &&
- block.rows.length === PAGE_SIZE + 4 &&
- /已合并 2 个时间窗 · 已加载 14 条/.test(String(block.pageNote || '')),
- `块 rows=${block.rows.length} pageNote「${block.pageNote}」`,
- )
- const secondPageRows = pagedRows(block.rows, 2)
- check(
- 'C4-merged-collection-pages-by-ten',
- secondPageRows.pageCount === 2 && secondPageRows.rows.length === 4 && secondPageRows.page === 2,
- `合并集合第 2 页 ${secondPageRows.rows.length} 行 / 共 ${secondPageRows.pageCount} 页`,
- )
- check(
- 'C5-merged-page-scope-changes-with-range',
- block.pageScope === `WARNING|30d`,
- `pageScope=${block.pageScope}(范围变化即复位页码的触发键)`,
- )
- // 90 天范围:窗口数由 `historyWindows` 按 30 天切分得出(90 天含一个毫秒级尾窗,
- // 现网口径就是 4 个窗——判据只看「如实报出实际窗数」,不写死 3)。
- await store.setRange('90d')
- const data90 = store.sections.historyAnalysis.data
- const block90 = panelView('historyAnalysis', data90, store.sections.historyAnalysis).blocks.find((item) => item.kind === 'list')
- const windows90 = data90.paging.windowCount
- check(
- 'C6-ninety-day-range-reports-actual-window-count',
- data90.paging.mode === 'merged' &&
- windows90 >= 3 &&
- new RegExp(`已合并 ${windows90} 个时间窗`).test(String(block90.pageNote || '')) &&
- block90.pageScope === 'WARNING|90d',
- `90 天 paging=${JSON.stringify(data90.paging)} pageNote「${block90.pageNote}」`,
- )
- }
- // ================================================================ F. mock 同构(contractMocks)
- {
- resetMockTransportsForTest()
- registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, resetMockTransportsForTest } })
- const rawCurrent = await findMockTransport('/events/current')({ path: '/events/current?mode=WARNING&page=1&pageSize=10' })
- const rawCurrentPageTwo = await findMockTransport('/events/current')({ path: '/events/current?mode=WARNING&page=2&pageSize=10' })
- const rawHistory = await findMockTransport('/events/history-analysis')({ path: '/events/history-analysis?eventFamily=WARNING&page=1&pageSize=10' })
- const rawHistoryPageTwo = await findMockTransport('/events/history-analysis')({ path: '/events/history-analysis?eventFamily=WARNING&page=2&pageSize=10' })
- check(
- 'F1-mock-current-events-page-size-ten',
- rawCurrent.sections.events.data.length === PAGE_SIZE &&
- rawCurrentPageTwo.sections.events.data.length === 4 &&
- rawCurrent.pagination.pageSize === PAGE_SIZE &&
- rawCurrent.pagination.total === PAGE_SIZE + 4,
- `当前事件样例 page1=${rawCurrent.sections.events.data.length} page2=${rawCurrentPageTwo.sections.events.data.length} total=${rawCurrent.pagination.total}`,
- )
- check(
- 'F2-mock-history-server-pages',
- rawHistory.events.length === PAGE_SIZE &&
- rawHistoryPageTwo.events.length === 4 &&
- rawHistory.pagination.pageSize === PAGE_SIZE &&
- rawHistory.pagination.total === PAGE_SIZE + 4,
- `历史样例 page1=${rawHistory.events.length} page2=${rawHistoryPageTwo.events.length} total=${rawHistory.pagination.total}`,
- )
- check(
- 'F3-mock-history-event-shape-isomorphic-to-current',
- JSON.stringify(Object.keys(rawHistory.events[0]).sort()) === JSON.stringify(Object.keys(rawCurrent.sections.events.data[0]).sort()),
- '历史逐事件行与 events/current 的 EventItem 字段集合一致(mock 同构)',
- )
- check(
- 'F4-mock-history-total-equals-summary',
- rawHistory.pagination.total === rawHistory.summary.eventCount,
- `pagination.total=${rawHistory.pagination.total} summary.eventCount=${rawHistory.summary.eventCount}(同一份总数)`,
- )
- // 真实服务链路:单窗 pageSize=10 → 两页;30 天 → 两窗合并。
- const window7 = rangeWindow(7)
- const window30 = rangeWindow(30)
- const single = await fetchEventHistory({ mode: 'WARNING', rangeType: 'LAST_7_DAYS', ...window7, page: 1, pageSize: PAGE_SIZE, config: mockConfig })
- const singlePageTwo = await fetchEventHistory({ mode: 'WARNING', rangeType: 'LAST_7_DAYS', ...window7, page: 2, pageSize: PAGE_SIZE, config: mockConfig })
- const merged = await fetchEventHistory({ mode: 'WARNING', rangeType: 'LAST_30_DAYS', ...window30, page: 1, pageSize: PAGE_SIZE, config: mockConfig })
- check(
- 'F5-service-composes-server-and-merged-paging',
- single.data.paging.mode === 'server' &&
- single.data.paging.pageCount === 2 &&
- singlePageTwo.data.paging.page === 2 &&
- singlePageTwo.data.events.length === 4 &&
- merged.data.paging.mode === 'merged' &&
- merged.data.paging.windowCount === 2 &&
- merged.data.paging.loaded === PAGE_SIZE + 4,
- `单窗 paging=${JSON.stringify(single.data.paging)};多窗 paging=${JSON.stringify(merged.data.paging)}`,
- )
- }
- // ================================================================ S. 静态守卫(单源/文案/无 12 条帽)
- {
- const pane = read('src/modules/cockpit/components/EventListPane.vue')
- const blocks = read('src/modules/cockpit/components/PanelBlocks.vue')
- const storeSource = read('src/modules/cockpit/stores/eventStore.js')
- const historyDomain = read('src/modules/cockpit/domain/warningResponsePanels.js')
- const historyService = read('src/modules/cockpit/services/warningResponseApi.js')
- const pager = read('src/modules/cockpit/components/PagerNav.vue')
- check(
- 'S1-event-pane-uses-shared-pager-and-view-pagination',
- /import PagerNav from '\.\/PagerNav\.vue'/.test(pane) &&
- /view\.pagination\.pageCount > 1/.test(pane) &&
- /:page="view\.pagination\.page"/.test(pane) &&
- /@page="\(target\) => eventStore\.setCurrentPage\(target\)"/.test(pane),
- '当前事件表接共享 PagerNav,页码/总页读视图(不自己算)',
- )
- check(
- 'S2-no-12-row-cap-left',
- !/data-event-limited/.test(pane) &&
- !/仅展示最新/.test(pane) &&
- !/latestEventRows/.test(historyDomain) &&
- !/slice\(0, 12\)/.test(historyDomain),
- '撤下「仅展示最新 12 条」帽(当前事件表与历史事件目录层都没有 12 条截断)',
- )
- check(
- 'S3-store-owns-server-page-state-and-token',
- /const currentPage = ref\(1\)/.test(storeSource) &&
- /const historyPage = ref\(1\)/.test(storeSource) &&
- /async function setCurrentPage/.test(storeSource) &&
- /async function setHistoryPage/.test(storeSource) &&
- /pageSize: PAGE_SIZE/.test(storeSource) &&
- /mine !== token/.test(storeSource),
- '页码是 store 视图状态;翻页只重取本分区;迟到页由 token 作废',
- )
- check(
- 'S4-history-domain-declares-two-paging-forms',
- /serverPage: \{ page: paging\.page, pageCount: paging\.pageCount, total: paging\.total \}/.test(historyDomain) &&
- /pageNote: `已合并 \$\{paging\.windowCount \|\| 0\} 个时间窗 · 已加载 \$\{paging\.loaded \|\| 0\} 条`/.test(historyDomain) &&
- /paging\.mode === 'server'/.test(historyDomain),
- '历史事件块按 data.paging 声明服务端页码/合并集合两种形态',
- )
- check(
- 'S5-service-pages-single-window-and-merges-multi-window',
- /pageSize: singleWindow \? PAGE_SIZE : HISTORY_WINDOW_MAX_PAGE_SIZE/.test(historyService) &&
- /mode: 'server'/.test(historyService) &&
- /mode: 'merged'/.test(historyService) &&
- /pagination: singleWindow \?/.test(historyService),
- '服务端:单窗 page/pageSize=10、多窗每窗 1000 上限取全再合并;多窗不冒充全局 pagination',
- )
- check(
- 'S6-count-row-comes-from-single-pager-source',
- /第 \{\{ page \}\}\/\{\{ pageCount \}\} 页 · 共 \{\{ total \}\} 条/.test(pager) &&
- !/第 \{\{/.test(pane) &&
- !/共 \{\{/.test(blocks),
- '计数行只在 PagerNav 一份;组件不另写「第 X/Y 页 · 共 N 条」',
- )
- check(
- 'S7-no-backend-param-names-in-ui-copy',
- !/pageSize/.test(stripComments(pane)) && !/pageSize/.test(stripComments(blocks)) && !/>page=/.test(stripComments(pane)),
- '界面文案不暴露 page/pageSize 参数名(条文 10)',
- )
- check(
- 'S8-page-arithmetic-single-source',
- /export function serverPageView/.test(read('src/modules/cockpit/domain/pagination.js')) &&
- /export function pagedRows/.test(read('src/modules/cockpit/domain/pagination.js')) &&
- !/Math\.(ceil|trunc)\(/.test(pane) &&
- !/Math\.(ceil|trunc)\(/.test(blocks),
- '翻页算术只在 domain/pagination.js;组件零 Math.ceil/trunc',
- )
- }
- // ================================================================ R. 真实渲染(SFC 编译 + 自定义渲染器)
- 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) {
- 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: 't61', genDefaultAs: '__sfc__' })
- const template = compileTemplate({
- source: descriptor.template.content,
- filename: path,
- id: 't61',
- 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)
- },
- })
- const { default: ClientEventListPane } = await import(`${SRC}components/EventListPane.vue?client`)
- const { default: ClientPanelBlocks } = await import(`${SRC}components/PanelBlocks.vue?client`)
- const nodeOps = {
- createElement: (tag) => ({ tag, props: {}, children: [], parent: null, text: '', style: {}, addEventListener() {}, removeEventListener() {} }),
- createText: (text) => ({ tag: '#text', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
- createComment: (text) => ({ tag: '#comment', text, children: [], parent: null, props: {}, style: {}, addEventListener() {}, removeEventListener() {} }),
- setText: (node, text) => { node.text = text },
- setElementText: (node, text) => { node.text = text; node.children = [] },
- insert: (child, parent, anchor) => {
- if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child)
- child.parent = parent
- if (!parent) return
- if (anchor) { const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, child) }
- else parent.children.push(child)
- },
- remove: (child) => { if (child.parent) child.parent.children = child.parent.children.filter((c) => c !== child); child.parent = null },
- parentNode: (node) => node.parent,
- nextSibling: (node) => (node.parent ? node.parent.children[node.parent.children.indexOf(node) + 1] || null : null),
- querySelector: () => null,
- setScopeId: () => {},
- patchProp: (el, key, _prev, next) => { el.props[key] = next },
- }
- const findAll = (node, predicate, acc = []) => {
- if (!node) return acc
- if (predicate(node)) acc.push(node)
- for (const child of node.children || []) findAll(child, predicate, acc)
- return acc
- }
- const findNode = (node, predicate) => {
- if (!node) return null
- if (predicate(node)) return node
- for (const child of node.children || []) {
- const hit = findNode(child, predicate)
- if (hit) return hit
- }
- return null
- }
- const textOf = (node) => {
- if (!node) return ''
- const own = node.tag === '#text' ? node.text : typeof node.text === 'string' ? node.text : ''
- return own + (node.children || []).map(textOf).join('')
- }
- const byAttr = (name) => (node) => !!node.props && name in node.props
- const mount = (render) => {
- const app = createRenderer(nodeOps).createApp({ render })
- app.config.warnHandler = () => {}
- app.use(pinia)
- const container = nodeOps.createElement('#root')
- app.mount(container)
- return { container, unmount: () => app.unmount() }
- }
- const click = async (node) => {
- assert.ok(node && typeof node.props.onClick === 'function', '节点不可点')
- node.props.onClick(new Proxy({}, { get: () => undefined }))
- await flush()
- }
- // ---- R1/R2 当前事件表:一页有控件、翻页走 store 请求 ----
- {
- installMock()
- store.reset()
- store.configure({ config: mockConfig })
- await store.loadPanels({ mode: 'WARNING', range: '7d' })
- const { container, unmount } = mount(() => h(ClientEventListPane))
- const rows = findAll(container, byAttr('data-event-row'))
- const pagerWrap = findNode(container, byAttr('data-event-pager'))
- const state = textOf(findNode(container, byAttr('data-page-state')))
- check(
- 'R1-current-pane-renders-page-and-count-row',
- rows.length === PAGE_SIZE && !!pagerWrap && /第 1\/2 页 · 共 14 条/.test(state),
- `行=${rows.length} 计数行「${state}」`,
- )
- check(
- 'R2-current-pane-removed-12-cap-note',
- findAll(container, byAttr('data-event-limited')).length === 0,
- '「仅展示最新 12 条」节点已不存在',
- )
- await click(findNode(pagerWrap, byAttr('data-page-next')))
- const rowsAfter = findAll(container, byAttr('data-event-row'))
- const stateAfter = textOf(findNode(container, byAttr('data-page-state')))
- check(
- 'R3-clicking-next-requests-second-page',
- rowsAfter.length === 4 && /第 2\/2 页/.test(stateAfter) && store.currentPage === 2,
- `点下一页后行=${rowsAfter.length} 计数行「${stateAfter}」`,
- )
- unmount()
- }
- // ---- R4 一页不画控件(服务端分页) ----
- {
- store.reset()
- store.configure({ config: mockConfig })
- installMock()
- registerMockTransport('/events/current', async ({ path }) => {
- REQUESTS.push(path)
- return currentEnvelope({ items: pagesOf(WARNINGS_14.slice(0, 4), 1), page: 1, total: 4 })
- })
- await store.loadPanels({ mode: 'WARNING', range: '7d' })
- const { container, unmount } = mount(() => h(ClientEventListPane))
- check(
- 'R4-single-page-draws-no-pager',
- findAll(container, byAttr('data-event-row')).length === 4 && findAll(container, byAttr('data-event-pager')).length === 0,
- '一页(4 条)时整块 pager 不渲染(条文 4)',
- )
- unmount()
- }
- // ---- R5/R6 历史事件:服务端页码块(翻页 emit 给 store)+多窗合并块(页脚如实口径) ----
- {
- installMock()
- store.reset()
- store.configure({ config: mockConfig })
- await store.loadPanels({ mode: 'WARNING', range: '7d' })
- const serverView = panelView('historyAnalysis', store.sections.historyAnalysis.data, store.sections.historyAnalysis)
- const emitted = []
- const serverApp = mount(() => h(ClientPanelBlocks, { view: serverView, onListPage: (payload) => emitted.push(payload) }))
- const serverPager = findNode(serverApp.container, byAttr('data-pager'))
- const serverState = textOf(findNode(serverApp.container, byAttr('data-page-state')))
- check(
- 'R5-history-server-block-renders-shared-pager',
- !!serverPager &&
- /第 1\/2 页 · 共 14 条/.test(serverState) &&
- findAll(serverApp.container, byAttr('data-item')).length === PAGE_SIZE,
- `服务端分页块:计数行「${serverState}」、行=${findAll(serverApp.container, byAttr('data-item')).length}`,
- )
- await click(findNode(serverPager, byAttr('data-page-next')))
- check(
- 'R6-history-server-paging-asks-store-for-next-page',
- emitted.length === 1 && emitted[0].page === 2 && /历史事件/.test(String(emitted[0].label || '')),
- `点下一页 emit ${JSON.stringify(emitted[0])}(块内不自己切窗口)`,
- )
- serverApp.unmount()
- await store.setRange('30d')
- const mergedView = panelView('historyAnalysis', store.sections.historyAnalysis.data, store.sections.historyAnalysis)
- const mergedEmitted = []
- const mergedApp = mount(() => h(ClientPanelBlocks, { view: mergedView, onListPage: (payload) => mergedEmitted.push(payload) }))
- const note = findNode(mergedApp.container, byAttr('data-list-page-note'))
- const mergedRowsFirst = findAll(mergedApp.container, byAttr('data-item'))
- const mergedStateFirst = textOf(findNode(mergedApp.container, byAttr('data-page-state')))
- check(
- 'R7-merged-block-renders-honest-footer-and-first-page',
- mergedRowsFirst.length === PAGE_SIZE &&
- /第 1\/2 页 · 共 14 条/.test(mergedStateFirst) &&
- /已合并 2 个时间窗 · 已加载 14 条/.test(textOf(note)),
- `合并块:行=${mergedRowsFirst.length} 计数行「${mergedStateFirst}」页脚「${textOf(note)}」`,
- )
- await click(findNode(mergedApp.container, (node) => String(node.props && node.props['data-page-number']) === '2'))
- const mergedRowsSecond = findAll(mergedApp.container, byAttr('data-item'))
- const mergedStateSecond = textOf(findNode(mergedApp.container, byAttr('data-page-state')))
- check(
- 'R8-merged-block-pages-loaded-collection-locally',
- mergedRowsSecond.length === 4 && /第 2\/2 页/.test(mergedStateSecond) && mergedEmitted.length === 0,
- `合并块第 2 页行=${mergedRowsSecond.length}、零 list-page emit(窗口在渲染层)`,
- )
- mergedApp.unmount()
- // 合并集合一页不到:控件不画,但「已合并 N 个时间窗 · 已加载 M 条」的口径说明仍要在。
- const fourEvents = HISTORY_14.slice(0, 4).map((raw) => toEventItem(raw))
- const onePageView = historyAnalysisPanel(
- { mode: 'WARNING', range: { key: '30d', type: 'LAST_30_DAYS', start: '', end: '' }, trend: [], events: fourEvents, paging: { mode: 'merged', windowCount: 2, loaded: 4, total: 4 } },
- { ...section(SECTION_STATE.SUCCESS, { data: null }), loaded: true },
- )
- const onePageApp = mount(() => h(ClientPanelBlocks, { view: onePageView }))
- check(
- 'R9-merged-note-survives-without-pager',
- findAll(onePageApp.container, byAttr('data-pager')).length === 0 &&
- /已合并 2 个时间窗 · 已加载 4 条/.test(textOf(findNode(onePageApp.container, byAttr('data-list-page-note')))),
- '一页不到时不画控件,但合并口径说明仍出现(口径不是控件)',
- )
- onePageApp.unmount()
- }
- // ================================================================ 结果
- console.log('')
- console.log(`T61 事件清单服务端分页闸门:${passed}/${passed + failed} 项通过。`)
- if (failed) {
- console.error(`未通过 ${failed} 项。`)
- process.exit(1)
- }
- console.log('T61 全部通过。')
|