|
|
@@ -0,0 +1,803 @@
|
|
|
+/**
|
|
|
+ * 票 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 全部通过。')
|