/** * 票 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(//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 全部通过。')