| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- /**
- * 票 59 离线闸门:统一分页批次一 —— 共享分页基建(pagination.js + PagerNav.vue)与设备详情三清单。
- *
- * 五组断言(都不启浏览器、不联网,遵循 t46 成例):
- * P. 单源:PAGE_SIZE=10;equipmentDetailView 的 pagedRows/HISTORY_PAGE_SIZE 是**兼容再导出**
- * (同一函数引用),组件里不再有两份翻页算术。
- * W. 页码窗口纯函数:≤7 全列;>7 收缩「1 … c−1 c c+1 … Y」恒含首末与省略号;非法输入不抛错。
- * J. 跳页夹回纯函数:非数字/0/超 pageCount 一律夹回 1..pageCount,不报错(规范「跳页」节)。
- * S. PagerNav 静态守卫:一页不渲染、到边禁用保占位、aria-current="page"、
- * 跳页 aria-label/placeholder、**唯一 @page 出口**(不新增第二套页码状态)、断言面 data-* 齐全。
- * C. 消费方守卫:EquipmentDetailSections 三清单(预警/报警/工单)都走 pagedOf 窗口行 +
- * 共享 PagerNav;组件零第二份翻页算术;既有 data-pager/data-page-prev/next/state 面迁移保留。
- * K. SFC 真实编译:两份 .vue 用 @vue/compiler-sfc 编译过(t41 教训:拼错的绑定与坏语法只有加载层判得出)。
- *
- * 守卫有牙(变异见 verify/t59-mutations.mjs):
- * 窗口全列阈值收缩/省略号丢失/夹回失效/单页画控件/第二套出口/aria 丢失/组件重写算术/兼容导出改名 → 对应断言转红。
- *
- * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t59-shared-pager.mjs
- */
- import assert from 'node:assert/strict'
- import { readFileSync } from 'node:fs'
- import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
- import { PAGE_ELLIPSIS, PAGE_SIZE, clampPage, pageWindow, pagedRows as pagedRowsCore } from '../../../src/modules/cockpit/domain/pagination.js'
- import { HISTORY_PAGE_SIZE, pagedRows as pagedRowsCompat } from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
- const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
- const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
- let passed = 0
- let failed = 0
- function check(name, condition, message) {
- if (condition) {
- passed += 1
- console.log(`PASS ${name}`)
- } else {
- failed += 1
- console.error(`FAIL ${name}: ${message}`)
- }
- }
- // ================================================================ P. 单源与兼容导出
- {
- // P1 页大小单源 = 10(条文 1);旧名 HISTORY_PAGE_SIZE 与 PAGE_SIZE 同值。
- check('P1-page-size-ten', PAGE_SIZE === 10 && HISTORY_PAGE_SIZE === PAGE_SIZE && PAGE_ELLIPSIS === '…', `PAGE_SIZE=${PAGE_SIZE} HISTORY=${HISTORY_PAGE_SIZE}`)
- // P2 兼容是**同一函数引用**,不是复制粘贴的第二份实现(条文 11 的判据形状)。
- check('P2-compat-export-is-same-function', pagedRowsCompat === pagedRowsCore, 'equipmentDetailView.pagedRows 必须是 pagination.pagedRows 的再导出(同一引用)')
- // P3 旧模块里不许再留有本地实现体(复制粘贴升格=将来必漂移)。
- const viewCode = read('domain/equipmentDetailView.js')
- check('P3-no-second-implementation', !/function pagedRows\(/.test(viewCode) && /from '\.\/pagination\.js'/.test(viewCode), 'equipmentDetailView 只再导出,不再自己实现 pagedRows')
- // P4 脏输入不抛错(非数组、NaN 页码、非法页大小全部兜底)。
- const dirty = pagedRowsCore(null, NaN)
- check('P4-dirty-input-safe', dirty.page === 1 && dirty.pageCount === 1 && dirty.rows.length === 0, JSON.stringify(dirty))
- // P5 越界页码夹回最后一页(条文 6:数据变少导致的越界由分页函数夹回,不画空表)。
- const many = pagedRowsCore(Array.from({ length: 23 }, (_, index) => ({ code: `W${index}` })), 99)
- check('P5-out-of-range-clamps-to-last', many.page === 3 && many.rows.length === 3 && many.pageCount === 3, JSON.stringify({ page: many.page, n: many.rows.length }))
- }
- // ================================================================ W. 页码窗口(条文 2)
- {
- // W1 少于 7 页全列,不带省略号。
- check('W1-five-pages-full', pageWindow(3, 5).join(',') === '1,2,3,4,5', pageWindow(3, 5).join(','))
- // W2 恰好 7 页仍全列(阈值边界)。
- check('W2-seven-pages-still-full', pageWindow(4, 7).join(',') === '1,2,3,4,5,6,7', pageWindow(4, 7).join(','))
- // W3 8 页起步即收缩:恒含首末 + 当前±1 + 省略号。
- check('W3-eight-pages-shrinks', pageWindow(5, 8).join(',') === '1,…,4,5,6,…,8', pageWindow(5, 8).join(','))
- // W4 中段窗口:10 页看第 5 页 →「1 … 4 5 6 … 10」。
- check('W4-middle-window', pageWindow(5, 10).join(',') === '1,…,4,5,6,…,10', pageWindow(5, 10).join(','))
- // W5 贴左:当前页邻到首页时左侧不出现假省略号(1,2,3 … 10)。
- check('W5-left-edge-no-fake-ellipsis', pageWindow(2, 10).join(',') === '1,2,3,…,10', pageWindow(2, 10).join(','))
- // W6 贴右:对称(1 … 8,9,10)。
- check('W6-right-edge', pageWindow(9, 10).join(',') === '1,…,8,9,10', pageWindow(9, 10).join(','))
- // W7 恒含首末页(条文 2「恒含首末」),任意当前页成立。
- const alwaysFirstLast = [1, 3, 5, 8, 15].every((c) => {
- const items = pageWindow(c, 20)
- return items[0] === 1 && items[items.length - 1] === 20 && items.includes(Math.min(c, 20))
- })
- check('W7-first-last-current-always', alwaysFirstLast, '每个窗口都要含首页、末页与当前页')
- // W8 数字升序且无重复(省略号除外)——渲染层据此直接出按钮。
- const nums = pageWindow(6, 12).filter((item) => item !== PAGE_ELLIPSIS)
- check('W8-ascending-unique', nums.every((n, i) => i === 0 || n > nums[i - 1]), nums.join(','))
- // W9 非法输入不抛错:脏页码/脏总数/超界页码都回合法窗口。
- let threw = false
- try {
- const zero = pageWindow(0, 0)
- const junk = pageWindow('x', undefined)
- const over = pageWindow(999, 10)
- threw = !(zero.length === 1 && junk.length === 1 && over[over.length - 1] === 10 && over.includes(10))
- } catch {
- threw = true
- }
- check('W9-illegal-input-no-throw', !threw, '脏输入必须按最小安全形状回,不许抛')
- }
- // ================================================================ J. 跳页夹回(规范「跳页」节)
- {
- // J1 合法页照收。
- check('J1-valid-page', clampPage(5, 10) === 5 && clampPage('7', 10) === 7, `${clampPage(5, 10)}/${clampPage('7', 10)}`)
- // J2 0/负数夹到 1,超 pageCount 夹到 pageCount(不报错、不越界)。
- check('J2-clamp-to-range', clampPage(0, 10) === 1 && clampPage(-4, 10) === 1 && clampPage(99, 10) === 10 && clampPage('100', 3) === 3, `${clampPage(0, 10)}/${clampPage(99, 10)}`)
- // J3 非数字回 1,**绝不回 NaN**(NaN 会污染页码状态并把控件画成「第 NaN 页」)。
- check('J3-non-numeric-clamps-to-one', clampPage('abc', 10) === 1 && clampPage('', 10) === 1 && clampPage(null, 10) === 1 && clampPage(undefined, 10) === 1, `abc→${clampPage('abc', 10)}`)
- // J4 小数截断、空白容错(输入框里粘进来的值)。
- check('J4-truncate-and-trim', clampPage(2.7, 10) === 2 && clampPage(' 4 ', 10) === 4, `${clampPage(2.7, 10)}/${clampPage(' 4 ', 10)}`)
- // J5 pageCount 本身脏时兜底为 1(宁可不跳,不跳进非法页)。
- check('J5-dirty-pagecount-safe', clampPage(5, 0) === 1 && clampPage(5, NaN) === 1, `pageCount=0→${clampPage(5, 0)}`)
- }
- // ================================================================ S. PagerNav 静态守卫
- {
- const pager = stripComments(read('components/PagerNav.vue'))
- // S1 总页数 ≤1 整块不渲染(条文 4 + 跳页节第 4 条),门控在 visible 计算属性。
- check(
- 'S1-one-page-renders-nothing',
- /const visible = computed\(\(\) => props\.pageCount > 1\)/.test(pager) && /<div v-if="visible"[^>]*data-pager/.test(pager),
- 'pageCount>1 是唯一渲染门;整块(含跳页)由 v-if 收口',
- )
- // S2 唯一翻页出口:defineEmits 只声明 page;别处不许再 emit 第二个事件(不新增第二套页码状态)。
- check(
- 'S2-single-page-outlet',
- /defineEmits\(\['page'\]\)/.test(pager) && !/emit\((?!'page')/.test(pager) && pager.match(/emit\('page'/g).length === 1,
- '只允许一个 emit("page") 出口(前后页/页码/跳页共用)',
- )
- // S3 到边禁用保占位(票 46 既有语义:disabled 而不是消失)。
- check(
- 'S3-edge-disabled-with-placeholder',
- /:disabled="page <= 1" data-page-prev/.test(pager) && /:disabled="page >= pageCount" data-page-next/.test(pager),
- '上一页按 page<=1、下一页按 page>=pageCount 置灰,按钮恒在',
- )
- // S4 无障碍:role=group + 「X分页」aria-label(条文 9);当前页 aria-current="page"(不可点由表达式守卫)。
- check(
- 'S4-aria-group-current',
- /role="group" :aria-label="`\$\{listName\}分页`"/.test(pager) && /:aria-current="item === page \? 'page' : undefined"/.test(pager) && /@click="item !== page && go\(item\)"/.test(pager),
- 'pager 有 group 语义与列表名;当前页 aria-current 且点了不跳',
- )
- // S5 跳页控件:数字输入框 + 跳转按钮 + Enter;aria-label 与 placeholder=当前页(条文逐字)。
- check(
- 'S5-jump-controls',
- /data-page-jump\b/.test(pager) && /data-page-jump-input/.test(pager) && /aria-label="跳转到指定页"/.test(pager) && /:placeholder="String\(page\)"/.test(pager) && /@keydown\.enter\.prevent="jump"/.test(pager) && /data-page-jump-btn/.test(pager),
- '输入框/跳转按钮/Enter 三入口 + aria-label + placeholder=当前页',
- )
- // S6 跳页走夹回函数与同一出口;同值不 emit(不发请求、不清列表)。
- check(
- 'S6-jump-goes-through-clamp-and-same-outlet',
- /const target = clampPage\(jumpValue\.value, props\.pageCount\)/.test(pager) && /if \(target === props\.page\) return/.test(pager) && /go\(target\)/.test(pager),
- '跳页先 clampPage 再 go(),与当前页同值什么都不做',
- )
- // S7 计数行「第 X/Y 页 · 共 N 条」,total 是 prop(条文 7:不由当前页反推)。
- check(
- 'S7-count-row-wording',
- /data-page-state>第 \{\{ page \}\}\/\{\{ pageCount \}\} 页 · 共 \{\{ total \}\} 条/.test(pager),
- '计数行措辞与票 46 一致;总数从 props 来,不做 页×大小 反推',
- )
- // S8 组件是受控的:页码只从 props 进,本地只有跳页输入缓冲;零请求零裸算术。
- check(
- 'S8-controlled-no-local-page-no-fetch',
- !/\bfetch\(|XMLHttpRequest/.test(pager) && !/Math\.(ceil|trunc|floor)/.test(pager) && !/= ref\(1\)/.test(pager) && /const jumpValue = ref\(''\)/.test(pager),
- 'PagerNav 不存第二套页码状态、不算窗口、不发请求',
- )
- // S9 断言面迁移完整:票 46 的 data-pager/data-page-prev/next/state 逐字保留,新面是追加不是替换。
- check(
- 'S9-assertion-hooks-present',
- ['data-pager', 'data-page-prev', 'data-page-next', 'data-page-state', 'data-page-jump', 'data-page-jump-input', 'data-page-number'].every((hook) => new RegExp(hook).test(pager)),
- '旧面保留 + 跳页新面就位(探针/测试按这些取数)',
- )
- }
- // ================================================================ C. 消费方:设备详情三清单
- {
- const sections = stripComments(read('components/EquipmentDetailSections.vue'))
- // C1 三清单同一控件两处接线(events 模板覆盖预警+报警两分区;orders 独立一处)。
- const pagerUses = sections.match(/<PagerNav\b/g) || []
- check('C1-shared-pager-wired-into-both-blocks', pagerUses.length === 2 && /import PagerNav from '\.\/PagerNav\.vue'/.test(sections), `PagerNav 接线 ${pagerUses.length} 处(events 覆盖预警/报警 + orders)`)
- // C2 唯一翻页出口接线:三处都只接 @page → gotoPage。
- const pageHandlers = sections.match(/@page="\(target\) => gotoPage\(panel, target\)"/g) || []
- check('C2-single-handler', pageHandlers.length === 2 && /function gotoPage\(panel, target\)/.test(sections), `@page→gotoPage 接线 ${pageHandlers.length} 处`)
- // C3 工单接入分页(票 59 的新要求):窗口行渲染 + 计数/空态都读 pagedOf。
- check(
- 'C3-orders-paginated',
- /v-for="\(order, index\) in pagedOf\(panel\)\.rows"/.test(sections) && /v-if="!pagedOf\(panel\)\.total"[\s\S]{0,160}换一个分类或点「全部」[\s\S]{0,260}emptyList\('工单'\)/.test(sections),
- '工单走窗口行;空态文案区分"分类下没有"与"真没有"',
- )
- // C4 组件内零第二份翻页算术(条文 11:窗口计算只在 pagination.js 一份)。
- check(
- 'C4-no-local-pagination-arithmetic',
- !/Math\.(ceil|trunc|floor)/.test(sections) && !/\.slice\(/.test(sections) && !/equipment-pager__btn/.test(sections) && !/hasPrev|hasNext/.test(sections),
- '组件不出现 ceil/trunc/slice/内联 pager/hasPrev-hasNext 判断(窗口与禁用态都归共享层)',
- )
- // C5 复位规则(条文 5/6):分类变化回第 1 页;换设备全复位;gotoPage 不做夹取(夹回归 pagedRows)。
- check(
- 'C5-reset-rules-intact',
- /toggleCategory[\s\S]{0,260}\[panelName\]: 1/.test(sections) && /watch\(\s*\(\) => props\.deviceCode,[\s\S]{0,200}pages\.value = \{\}/.test(sections) && !/Math\.(min|max)/.test((sections.match(/function gotoPage[\s\S]*?\n\}/) || [''])[0]),
- '换分类/换设备都复位;翻页出口只存状态,夹回由 domain 单源做',
- )
- // C6 分页读的是**过滤后**集合(先筛后翻,条文 5)。
- check('C6-filter-before-paginate', /return pagedRows\(visibleRows\(panel\), pages\.value\[panel\.name\] \|\| 1\)/.test(sections), 'pagedRows 输入必须是分类过滤后的行')
- // C7 pagedOf 是单一委托(一条路径给三清单;不再按 kind 分叉出兜底形状)。
- check('C7-pagedof-is-single-delegate', /function pagedOf\(panel\) \{\s*return pagedRows\(visibleRows\(panel\), pages\.value\[panel\.name\] \|\| 1\)/.test(sections), 'pagedOf 只有 pagedRows 一条出口(分叉兜底=第二份形状)')
- }
- // ================================================================ K. SFC 真实编译(t41 教训)
- {
- const compileSfc = (label, rel) => {
- let ok = false
- let why = ''
- try {
- const source = read(rel)
- const { descriptor, errors } = parse(source, { filename: rel })
- assert.deepEqual(errors, [], `${rel} parse`)
- const script = compileScript(descriptor, { id: 't59' })
- const template = compileTemplate({
- source: descriptor.template.content,
- filename: rel,
- id: 't59',
- compilerOptions: { bindingMetadata: script.bindings },
- })
- assert.deepEqual(template.errors, [], `${rel} template`)
- ok = true
- } catch (error) {
- why = error.message.split('\n')[0]
- }
- check(`K1-${label}-compiles`, ok, why || `${rel} 编译应无错误`)
- }
- compileSfc('pagernav', 'components/PagerNav.vue')
- compileSfc('sections', 'components/EquipmentDetailSections.vue')
- }
- console.log('')
- console.log(`T59 共享分页基建与设备详情三清单:${passed}/${passed + failed} 项通过。`)
- if (failed) {
- console.log('未通过项见上方 FAIL。')
- process.exit(1)
- }
- assert.equal(1, 1)
- console.log('T59 全部通过(浏览器走查按项目规则未自动执行)。')
|