|
|
@@ -1,225 +1,227 @@
|
|
|
-/**
|
|
|
- * 呈现对拍(P11)· 多页签版:把**重构前的页面**与**Vue 迁移后的组件**放在同一份数据上比一遍。
|
|
|
- *
|
|
|
- * 口径(实逮决定):
|
|
|
- * · `/detail/v2` 是浏览器里现渲的单页(HTML 只有壳 + 内联 app.js/charts.js + 语言包,无 window.__DATA__)
|
|
|
- * ⇒ 基线必须**让旧代码真跑一遍**:jsdom 加载现网页面,并在 **beforeParse** 装 fetch 桩转发到真实网关
|
|
|
- * (桩晚装一句,旧页面就 `fetch is not defined`,基线全空)。
|
|
|
- * · 页签由 hash 决定(`#tab=energy`)⇒ 基线按同一 hash 加载,两边渲染同页签。
|
|
|
- * · Vue 侧用**同一份数据**(fetch 桩记录下的 /api/fleet 响应)经 SSR 渲成 HTML。
|
|
|
- * · 比对:
|
|
|
- * ① 文本序列(`#root` 下所有文本节点,按 DOM 顺序、空白归一)—— 结构 + 文案一致;
|
|
|
- * ② 表格单元格文本(逐表、逐行);
|
|
|
- * ③ **图表数据**:旧页面内联 SVG 里的数字 vs Vue 侧 ECharts option 里 `__data__` 的数字
|
|
|
- * (视觉实现不同是用户令「引入 ECharts」的结果,**数据必须同源**)。
|
|
|
- *
|
|
|
- * 用法:node tools/render_parity.mjs [gateway] [tab1] [tab2] ...
|
|
|
- * 退出码:0 全一致 · 9 有不一致 · 2 基准页取不到。
|
|
|
- */
|
|
|
-import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
-import { dirname, resolve } from 'node:path'
|
|
|
-import { JSDOM, VirtualConsole } from 'jsdom'
|
|
|
-
|
|
|
-const HERE = dirname(fileURLToPath(import.meta.url))
|
|
|
-const GATEWAY = process.argv[2] || 'http://127.0.0.1:28084'
|
|
|
-const TABS = process.argv.slice(3).length ? process.argv.slice(3) : ['overview', 'energy', 'component']
|
|
|
-
|
|
|
-/** 让 jsdom 里的相对地址 fetch 打到真实网关;顺手记录 `/api/fleet` 的原始响应。 */
|
|
|
-function installFetch(window, state) {
|
|
|
- window.fetch = async (input, init) => {
|
|
|
- const raw = typeof input === 'string' ? input : (input && input.url) || String(input)
|
|
|
- const url = raw.startsWith('http') ? raw : GATEWAY + (raw.startsWith('/') ? raw : '/' + raw)
|
|
|
- const resp = await fetch(url, init)
|
|
|
- const body = await resp.text()
|
|
|
- if (url.includes('/api/fleet')) {
|
|
|
- try { state.fleet = JSON.parse(body); state.fleetUrl = url } catch { /* 非 JSON */ }
|
|
|
- }
|
|
|
- return {
|
|
|
- ok: resp.ok, status: resp.status,
|
|
|
- headers: { get: (k) => resp.headers.get(k) },
|
|
|
- json: async () => JSON.parse(body), text: async () => body,
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-const norm = (s) => String(s).replace(/\s+/g, ' ').trim()
|
|
|
-
|
|
|
-/** 文本节点序列(跳过画布/SVG —— 图形由"图表数据"那一项比)。 */
|
|
|
-function textsIn(root) {
|
|
|
- const out = []
|
|
|
- const walk = (n) => {
|
|
|
- for (const c of n.childNodes) {
|
|
|
- if (c.nodeType === 3) {
|
|
|
- const s = norm(c.textContent)
|
|
|
- if (s) out.push(s)
|
|
|
- } else if (c.nodeType === 1) {
|
|
|
- const tag = c.tagName.toLowerCase()
|
|
|
- if (tag === 'canvas' || tag === 'svg' || tag === 'script' || tag === 'style') continue
|
|
|
- walk(c)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- walk(root)
|
|
|
- return out
|
|
|
-}
|
|
|
-
|
|
|
-/** 表格:每张表的行×单元格文本。 */
|
|
|
-function tablesIn(root) {
|
|
|
- return Array.from(root.querySelectorAll('table')).map((tb) =>
|
|
|
- Array.from(tb.querySelectorAll('tr')).map((tr) =>
|
|
|
- Array.from(tr.querySelectorAll('th,td')).map((c) => norm(c.textContent))))
|
|
|
-}
|
|
|
-
|
|
|
-/** 旧页面图表里的数字(内联 SVG 文本里的数值)。 */
|
|
|
-function svgNumbers(root) {
|
|
|
- const out = []
|
|
|
- for (const t of Array.from(root.querySelectorAll('svg text'))) {
|
|
|
- for (const m of norm(t.textContent).matchAll(/-?\d[\d,]*(?:\.\d+)?/g)) out.push(m[0].replace(/,/g, ''))
|
|
|
- }
|
|
|
- return out
|
|
|
-}
|
|
|
-
|
|
|
-/** Vue 侧:ECharts option 的 `__data__` 里的数字(数据级对拍)。 */
|
|
|
-function chartNumbers(root) {
|
|
|
- const out = []
|
|
|
- for (const el of Array.from(root.querySelectorAll('[data-chart]'))) {
|
|
|
- let opt = null
|
|
|
- try { opt = JSON.parse(el.getAttribute('data-chart') || 'null') } catch { /* ignore */ }
|
|
|
- const d = opt && opt.__data__
|
|
|
- if (!d) continue
|
|
|
- const walk = (x) => {
|
|
|
- if (typeof x === 'number') { if (Number.isFinite(x)) out.push(String(Math.round(x))); return }
|
|
|
- if (Array.isArray(x)) { x.forEach(walk); return }
|
|
|
- if (x && typeof x === 'object') { Object.values(x).forEach(walk) }
|
|
|
- }
|
|
|
- walk(d)
|
|
|
- }
|
|
|
- return out
|
|
|
-}
|
|
|
-
|
|
|
-async function baseline(tab) {
|
|
|
- const html = await (await fetch(GATEWAY + '/detail/v2')).text()
|
|
|
- const state = {}
|
|
|
- const dom = new JSDOM(html, {
|
|
|
- url: GATEWAY + `/detail/v2#tab=${tab}`,
|
|
|
- runScripts: 'dangerously', pretendToBeVisual: true,
|
|
|
- virtualConsole: new VirtualConsole(),
|
|
|
- beforeParse: (window) => installFetch(window, state),
|
|
|
- })
|
|
|
- const doc = dom.window.document
|
|
|
- // ★实逮:`#root` 初始就带一个 `.panel`(出错提示占位)——若拿 `.panel` 当"渲染好了"的判据,
|
|
|
- // 会在 app.js 取数之前就跳出循环,基线全空。判据改用 render() 一定产出的 `.page-head`,
|
|
|
- // 并且要求 fetch 桩已经记下 `/api/fleet` 的响应。
|
|
|
- for (let i = 0; i < 120; i++) {
|
|
|
- const root = doc.getElementById('root')
|
|
|
- if (state.fleet && root && root.querySelector('.page-head')) break
|
|
|
- await new Promise((r) => setTimeout(r, 500))
|
|
|
- }
|
|
|
- return { doc, data: state.fleet, root: doc.getElementById('root') }
|
|
|
-}
|
|
|
-
|
|
|
-async function vueAnchors(data, tab) {
|
|
|
- const entry = pathToFileURL(resolve(HERE, '../target/ssr/entry-server.js')).href
|
|
|
- const html = await (await import(entry)).renderTab(data, tab)
|
|
|
- const dom = new JSDOM(`<div id="wrap">${html}</div>`)
|
|
|
- const wrap = dom.window.document.getElementById('wrap')
|
|
|
- // ★对齐口径:基线取的是 `#root`(页签内容本身,页签导航在 #root 之外的 #tabs)。
|
|
|
- // Vue 的 Workbench 把导航也渲进来了 ⇒ 这里先把导航摘掉再比;导航文案单独比(见 tabsLabel)。
|
|
|
- const nav = wrap.querySelector('.wb-tabs, #tabs, nav')
|
|
|
- const tabsLabel = nav ? Array.from(nav.querySelectorAll('button')).map((b) => norm(b.textContent.replace(/迁移中/, ''))) : []
|
|
|
- if (nav) nav.remove()
|
|
|
- return { wrap, tabsLabel }
|
|
|
-}
|
|
|
-
|
|
|
-/** 深比较(表格用):返回第一个差异的说明。 */
|
|
|
-function cmpDeep(a, b, label) {
|
|
|
- const A = JSON.stringify(a), B = JSON.stringify(b)
|
|
|
- if (A === B) return ''
|
|
|
- if (a.length !== b.length) return `${label}: 张数/行数不同 ${a.length} ≠ ${b.length}`
|
|
|
- for (let i = 0; i < a.length; i++) {
|
|
|
- if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) {
|
|
|
- const ra = a[i] || [], rb = b[i] || []
|
|
|
- if (ra.length !== rb.length) return `${label}: 第 ${i + 1} 张表行数 ${ra.length} ≠ ${rb.length}`
|
|
|
- for (let r = 0; r < ra.length; r++) {
|
|
|
- if (JSON.stringify(ra[r]) !== JSON.stringify(rb[r])) {
|
|
|
- return `${label}: 第 ${i + 1} 张表第 ${r + 1} 行不同 —— "${String(ra[r]).slice(0, 60)}" ≠ "${String(rb[r]).slice(0, 60)}"`
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- return label + ': 有差异'
|
|
|
-}
|
|
|
-
|
|
|
-function cmpList(a, b, label) {
|
|
|
- if (a.length !== b.length) {
|
|
|
- const [short, long, side] = a.length < b.length ? [a, b, 'Vue 多'] : [b, a, '基准多']
|
|
|
- const extra = long.slice(short.length, short.length + 6).map((s) => `"${String(s).slice(0, 60)}"`)
|
|
|
- return `${label}: 条数 ${a.length} ≠ ${b.length};${side} ${long.length - short.length} 项,例如 ${extra.join(' / ')}`
|
|
|
- }
|
|
|
- for (let i = 0; i < a.length; i++) {
|
|
|
- if (a[i] !== b[i]) return `${label}[${i}]: "${String(a[i]).slice(0, 70)}" ≠ "${String(b[i]).slice(0, 70)}"`
|
|
|
- }
|
|
|
- return ''
|
|
|
-}
|
|
|
-
|
|
|
-function cmpSorted(a, b, label) {
|
|
|
- const A = [...a].sort(), B = [...b].sort()
|
|
|
- if (A.length !== B.length) return `${label}: 条数 ${A.length} ≠ ${B.length}`
|
|
|
- for (let i = 0; i < A.length; i++) if (A[i] !== B[i]) return `${label}: 值集合不同(首个差异 ${A[i]} ≠ ${B[i]})`
|
|
|
- return ''
|
|
|
-}
|
|
|
-
|
|
|
-let bad = 0
|
|
|
-const DUMP = process.env.PARITY_DUMP === '1'
|
|
|
-for (const tab of TABS) {
|
|
|
- const base = await baseline(tab)
|
|
|
- if (!base.data) {
|
|
|
- console.log(`[X] ${tab}: 取不到 /api/fleet 数据(服务没起?)`)
|
|
|
- bad++
|
|
|
- continue
|
|
|
- }
|
|
|
- const vroot = await vueAnchors(base.data, tab)
|
|
|
- if (DUMP) {
|
|
|
- const bt = textsIn(base.root), vt = textsIn(vroot.wrap)
|
|
|
- console.log(`\n== ${tab} 文本对照(基准 ${bt.length} / Vue ${vt.length})==`)
|
|
|
- for (let i = 0; i < Math.max(bt.length, vt.length); i++) {
|
|
|
- const same = bt[i] === vt[i]
|
|
|
- console.log(` ${String(i).padStart(3)} ${same ? ' ' : '≠ '}基:${String(bt[i] ?? '—').slice(0, 60).padEnd(62)} Vue:${String(vt[i] ?? '—').slice(0, 60)}`)
|
|
|
- }
|
|
|
- console.log(` 图数字 基: ${svgNumbers(base.root).join(',')}`)
|
|
|
- console.log(` 图数字 Vue: ${chartNumbers(vroot.wrap).join(',')}`)
|
|
|
- console.log(` 末尾 基: ${bt.slice(-5).map((s) => `"${s.slice(0, 40)}"`).join(' | ')}`)
|
|
|
- console.log(` 末尾 Vue: ${vt.slice(-5).map((s) => `"${s.slice(0, 40)}"`).join(' | ')}`)
|
|
|
- console.log(` 表 基: ${JSON.stringify(tablesIn(base.root)).slice(0, 400)}`)
|
|
|
- console.log(` 表 Vue: ${JSON.stringify(tablesIn(vroot.wrap)).slice(0, 400)}`)
|
|
|
- const bt1 = tablesIn(base.root)[1], vt1 = tablesIn(vroot.wrap)[1]
|
|
|
- if (bt1 || vt1) {
|
|
|
- console.log(` 第2张表 基 ${(bt1 || []).length} 行 / Vue ${(vt1 || []).length} 行`)
|
|
|
- for (let i = 0; i < Math.max((bt1 || []).length, (vt1 || []).length); i++) {
|
|
|
- const A = JSON.stringify(bt1?.[i] ?? null), B = JSON.stringify(vt1?.[i] ?? null)
|
|
|
- if (A !== B) console.log(` 行${i} 基:${String(A).slice(0, 130)}\n Vue:${String(B).slice(0, 130)}`)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- const baseTabs = Array.from(base.doc.querySelectorAll('#tabs button')).map((b) => norm(b.textContent))
|
|
|
- const checks = [
|
|
|
- cmpList(textsIn(base.root), textsIn(vroot.wrap), '文本序列'),
|
|
|
- cmpDeep(tablesIn(base.root), tablesIn(vroot.wrap), '表格'),
|
|
|
- cmpList(baseTabs, vroot.tabsLabel, '页签'),
|
|
|
- ]
|
|
|
- const svgN = svgNumbers(base.root)
|
|
|
- const echN = chartNumbers(vroot.wrap)
|
|
|
- // 图表:老页面没有 SVG(或该页签本就无图)时不比;两边都有才比数据集合
|
|
|
- if (svgN.length && echN.length) checks.push(cmpSorted(svgN, echN, '图表数据(数字集合)'))
|
|
|
- const fails = checks.filter(Boolean)
|
|
|
- const extra = `文本 ${textsIn(base.root).length} 项 · 表 ${tablesIn(base.root).length} 张 · 图数字 ${svgN.length}/${echN.length}`
|
|
|
- if (fails.length) {
|
|
|
- bad++
|
|
|
- console.log(`[X] ${tab} —— ${extra}`)
|
|
|
- for (const f of fails) console.log(' ' + f)
|
|
|
- } else {
|
|
|
- console.log(`[OK] ${tab} —— ${extra}(呈现一致)`)
|
|
|
- }
|
|
|
-}
|
|
|
-console.log(`\n结论: ${bad === 0 ? `页签 ${TABS.join('/')} 与重构前呈现一致` : `${bad} 个页签不一致(见上)`}`)
|
|
|
-process.exit(bad === 0 ? 0 : 9)
|
|
|
+/**
|
|
|
+ * 呈现对拍(P11)· 多页签版:把**重构前的页面**与**Vue 迁移后的组件**放在同一份数据上比一遍。
|
|
|
+ *
|
|
|
+ * 口径(实逮决定):
|
|
|
+ * · `/detail/v2` 是浏览器里现渲的单页(HTML 只有壳 + 内联 app.js/charts.js + 语言包,无 window.__DATA__)
|
|
|
+ * ⇒ 基线必须**让旧代码真跑一遍**:jsdom 加载现网页面,并在 **beforeParse** 装 fetch 桩转发到真实网关
|
|
|
+ * (桩晚装一句,旧页面就 `fetch is not defined`,基线全空)。
|
|
|
+ * · 页签由 hash 决定(`#tab=energy`)⇒ 基线按同一 hash 加载,两边渲染同页签。
|
|
|
+ * · Vue 侧用**同一份数据**(fetch 桩记录下的 /api/fleet 响应)经 SSR 渲成 HTML。
|
|
|
+ * · 比对:
|
|
|
+ * ① 文本序列(`#root` 下所有文本节点,按 DOM 顺序、空白归一)—— 结构 + 文案一致;
|
|
|
+ * ② 表格单元格文本(逐表、逐行);
|
|
|
+ * ③ **图表数据**:旧页面内联 SVG 里的数字 vs Vue 侧 ECharts option 里 `__data__` 的数字
|
|
|
+ * (视觉实现不同是用户令「引入 ECharts」的结果,**数据必须同源**)。
|
|
|
+ *
|
|
|
+ * 用法:node tools/render_parity.mjs [gateway] [tab1] [tab2] ...
|
|
|
+ * 退出码:0 全一致 · 9 有不一致 · 2 基准页取不到。
|
|
|
+ */
|
|
|
+import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
+import { dirname, resolve } from 'node:path'
|
|
|
+import { JSDOM, VirtualConsole } from 'jsdom'
|
|
|
+
|
|
|
+const HERE = dirname(fileURLToPath(import.meta.url))
|
|
|
+const GATEWAY = process.argv[2] || 'http://127.0.0.1:28084'
|
|
|
+const TABS = process.argv.slice(3).length ? process.argv.slice(3) : ['overview', 'energy', 'component']
|
|
|
+
|
|
|
+/** 让 jsdom 里的相对地址 fetch 打到真实网关;顺手记录 `/api/fleet` 的原始响应。 */
|
|
|
+function installFetch(window, state) {
|
|
|
+ window.fetch = async (input, init) => {
|
|
|
+ const raw = typeof input === 'string' ? input : (input && input.url) || String(input)
|
|
|
+ const url = raw.startsWith('http') ? raw : GATEWAY + (raw.startsWith('/') ? raw : '/' + raw)
|
|
|
+ const resp = await fetch(url, init)
|
|
|
+ const body = await resp.text()
|
|
|
+ if (url.includes('/api/fleet')) {
|
|
|
+ try { state.fleet = JSON.parse(body); state.fleetUrl = url } catch { /* 非 JSON */ }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ ok: resp.ok, status: resp.status,
|
|
|
+ headers: { get: (k) => resp.headers.get(k) },
|
|
|
+ json: async () => JSON.parse(body), text: async () => body,
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const norm = (s) => String(s).replace(/\s+/g, ' ').trim()
|
|
|
+
|
|
|
+/** 文本节点序列(跳过画布/SVG —— 图形由"图表数据"那一项比)。 */
|
|
|
+function textsIn(root) {
|
|
|
+ const out = []
|
|
|
+ const walk = (n) => {
|
|
|
+ for (const c of n.childNodes) {
|
|
|
+ if (c.nodeType === 3) {
|
|
|
+ const s = norm(c.textContent)
|
|
|
+ if (s) out.push(s)
|
|
|
+ } else if (c.nodeType === 1) {
|
|
|
+ const tag = c.tagName.toLowerCase()
|
|
|
+ if (tag === 'canvas' || tag === 'svg' || tag === 'script' || tag === 'style') continue
|
|
|
+ walk(c)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ walk(root)
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+/** 表格:每张表的行×单元格文本。 */
|
|
|
+function tablesIn(root) {
|
|
|
+ return Array.from(root.querySelectorAll('table')).map((tb) =>
|
|
|
+ Array.from(tb.querySelectorAll('tr')).map((tr) =>
|
|
|
+ Array.from(tr.querySelectorAll('th,td')).map((c) => norm(c.textContent))))
|
|
|
+}
|
|
|
+
|
|
|
+/** 旧页面图表里的数字(内联 SVG 文本里的数值)。 */
|
|
|
+function svgNumbers(root) {
|
|
|
+ const out = []
|
|
|
+ for (const t of Array.from(root.querySelectorAll('svg text'))) {
|
|
|
+ for (const m of norm(t.textContent).matchAll(/-?\d[\d,]*(?:\.\d+)?/g)) out.push(m[0].replace(/,/g, ''))
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+/** Vue 侧:ECharts option 的 `__data__` 里的数字(数据级对拍)。 */
|
|
|
+function chartNumbers(root) {
|
|
|
+ const out = []
|
|
|
+ for (const el of Array.from(root.querySelectorAll('[data-chart]'))) {
|
|
|
+ let opt = null
|
|
|
+ try { opt = JSON.parse(el.getAttribute('data-chart') || 'null') } catch { /* ignore */ }
|
|
|
+ const d = opt && opt.__data__
|
|
|
+ if (!d) continue
|
|
|
+ const walk = (x) => {
|
|
|
+ if (typeof x === 'number') { if (Number.isFinite(x)) out.push(String(Math.round(x))); return }
|
|
|
+ if (Array.isArray(x)) { x.forEach(walk); return }
|
|
|
+ if (x && typeof x === 'object') { Object.values(x).forEach(walk) }
|
|
|
+ }
|
|
|
+ walk(d)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+async function baseline(tab) {
|
|
|
+ const html = await (await fetch(GATEWAY + '/detail/v2')).text()
|
|
|
+ const state = {}
|
|
|
+ const dom = new JSDOM(html, {
|
|
|
+ url: GATEWAY + `/detail/v2#tab=${tab}`,
|
|
|
+ runScripts: 'dangerously', pretendToBeVisual: true,
|
|
|
+ virtualConsole: new VirtualConsole(),
|
|
|
+ beforeParse: (window) => installFetch(window, state),
|
|
|
+ })
|
|
|
+ const doc = dom.window.document
|
|
|
+ // ★实逮:`#root` 初始就带一个 `.panel`(出错提示占位)——若拿 `.panel` 当"渲染好了"的判据,
|
|
|
+ // 会在 app.js 取数之前就跳出循环,基线全空。判据改用 render() 一定产出的 `.page-head`,
|
|
|
+ // 并且要求 fetch 桩已经记下 `/api/fleet` 的响应。
|
|
|
+ for (let i = 0; i < 120; i++) {
|
|
|
+ const root = doc.getElementById('root')
|
|
|
+ if (state.fleet && root && root.querySelector('.page-head')) break
|
|
|
+ await new Promise((r) => setTimeout(r, 500))
|
|
|
+ }
|
|
|
+ return { doc, data: state.fleet, root: doc.getElementById('root') }
|
|
|
+}
|
|
|
+
|
|
|
+async function vueAnchors(data, tab) {
|
|
|
+ const entry = pathToFileURL(resolve(HERE, '../target/ssr/entry-server.js')).href
|
|
|
+ const html = await (await import(entry)).renderTab(data, tab)
|
|
|
+ const dom = new JSDOM(`<div id="wrap">${html}</div>`)
|
|
|
+ const wrap = dom.window.document.getElementById('wrap')
|
|
|
+ // ★对齐口径:基线取的是 `#root`(页签内容本身,页签导航在 #root 之外的 #tabs)。
|
|
|
+ // Vue 的 Workbench 把导航也渲进来了 ⇒ 这里先把导航摘掉再比;导航文案单独比(见 tabsLabel)。
|
|
|
+ const nav = wrap.querySelector('.wb-tabs, #tabs, nav')
|
|
|
+ const tabsLabel = nav ? Array.from(nav.querySelectorAll('button')).map((b) => norm(b.textContent.replace(/迁移中/, ''))) : []
|
|
|
+ if (nav) nav.remove()
|
|
|
+ return { wrap, tabsLabel }
|
|
|
+}
|
|
|
+
|
|
|
+/** 深比较(表格用):返回第一个差异的说明。 */
|
|
|
+function cmpDeep(a, b, label) {
|
|
|
+ const A = JSON.stringify(a), B = JSON.stringify(b)
|
|
|
+ if (A === B) return ''
|
|
|
+ if (a.length !== b.length) return `${label}: 张数/行数不同 ${a.length} ≠ ${b.length}`
|
|
|
+ for (let i = 0; i < a.length; i++) {
|
|
|
+ if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) {
|
|
|
+ const ra = a[i] || [], rb = b[i] || []
|
|
|
+ if (ra.length !== rb.length) return `${label}: 第 ${i + 1} 张表行数 ${ra.length} ≠ ${rb.length}`
|
|
|
+ for (let r = 0; r < ra.length; r++) {
|
|
|
+ if (JSON.stringify(ra[r]) !== JSON.stringify(rb[r])) {
|
|
|
+ return `${label}: 第 ${i + 1} 张表第 ${r + 1} 行不同 —— "${String(ra[r]).slice(0, 60)}" ≠ "${String(rb[r]).slice(0, 60)}"`
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return label + ': 有差异'
|
|
|
+}
|
|
|
+
|
|
|
+function cmpList(a, b, label) {
|
|
|
+ if (a.length !== b.length) {
|
|
|
+ const [short, long, side] = a.length < b.length ? [a, b, 'Vue 多'] : [b, a, '基准多']
|
|
|
+ const extra = long.slice(short.length, short.length + 6).map((s) => `"${String(s).slice(0, 60)}"`)
|
|
|
+ return `${label}: 条数 ${a.length} ≠ ${b.length};${side} ${long.length - short.length} 项,例如 ${extra.join(' / ')}`
|
|
|
+ }
|
|
|
+ for (let i = 0; i < a.length; i++) {
|
|
|
+ if (a[i] !== b[i]) return `${label}[${i}]: "${String(a[i]).slice(0, 70)}" ≠ "${String(b[i]).slice(0, 70)}"`
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+/** 图数据:**子集**规则 —— 旧图把数据与坐标刻度都写成 SVG 文本,ECharts 的刻度在画布上拿不到,
|
|
|
+ * 所以只要求"Vue 画的每个数都在旧图数字里出现"(抓数据画错/画多;刻度差异不算)。 */
|
|
|
+function cmpSubset(vueNums, baseNums, label) {
|
|
|
+ const pool = new Set(baseNums)
|
|
|
+ const missing = vueNums.filter((v) => !pool.has(v))
|
|
|
+ if (missing.length) return `${label}: Vue 有 ${missing.length} 个数字不在旧图里,例如 ${missing.slice(0, 6).join(',')}`
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+let bad = 0
|
|
|
+const DUMP = process.env.PARITY_DUMP === '1'
|
|
|
+for (const tab of TABS) {
|
|
|
+ const base = await baseline(tab)
|
|
|
+ if (!base.data) {
|
|
|
+ console.log(`[X] ${tab}: 取不到 /api/fleet 数据(服务没起?)`)
|
|
|
+ bad++
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const vroot = await vueAnchors(base.data, tab)
|
|
|
+ if (DUMP) {
|
|
|
+ const bt = textsIn(base.root), vt = textsIn(vroot.wrap)
|
|
|
+ console.log(`\n== ${tab} 文本对照(基准 ${bt.length} / Vue ${vt.length})==`)
|
|
|
+ for (let i = 0; i < Math.max(bt.length, vt.length); i++) {
|
|
|
+ const same = bt[i] === vt[i]
|
|
|
+ console.log(` ${String(i).padStart(3)} ${same ? ' ' : '≠ '}基:${String(bt[i] ?? '—').slice(0, 60).padEnd(62)} Vue:${String(vt[i] ?? '—').slice(0, 60)}`)
|
|
|
+ }
|
|
|
+ console.log(` 图数字 基: ${svgNumbers(base.root).join(',')}`)
|
|
|
+ console.log(` 图数字 Vue: ${chartNumbers(vroot.wrap).join(',')}`)
|
|
|
+ console.log(` 末尾 基: ${bt.slice(-5).map((s) => `"${s.slice(0, 40)}"`).join(' | ')}`)
|
|
|
+ console.log(` 末尾 Vue: ${vt.slice(-5).map((s) => `"${s.slice(0, 40)}"`).join(' | ')}`)
|
|
|
+ console.log(` 表 基: ${JSON.stringify(tablesIn(base.root)).slice(0, 400)}`)
|
|
|
+ console.log(` 表 Vue: ${JSON.stringify(tablesIn(vroot.wrap)).slice(0, 400)}`)
|
|
|
+ const bt1 = tablesIn(base.root)[1], vt1 = tablesIn(vroot.wrap)[1]
|
|
|
+ if (bt1 || vt1) {
|
|
|
+ console.log(` 第2张表 基 ${(bt1 || []).length} 行 / Vue ${(vt1 || []).length} 行`)
|
|
|
+ for (let i = 0; i < Math.max((bt1 || []).length, (vt1 || []).length); i++) {
|
|
|
+ const A = JSON.stringify(bt1?.[i] ?? null), B = JSON.stringify(vt1?.[i] ?? null)
|
|
|
+ if (A !== B) console.log(` 行${i} 基:${String(A).slice(0, 130)}\n Vue:${String(B).slice(0, 130)}`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const baseTabs = Array.from(base.doc.querySelectorAll('#tabs button')).map((b) => norm(b.textContent))
|
|
|
+ const checks = [
|
|
|
+ cmpList(textsIn(base.root), textsIn(vroot.wrap), '文本序列'),
|
|
|
+ cmpDeep(tablesIn(base.root), tablesIn(vroot.wrap), '表格'),
|
|
|
+ cmpList(baseTabs, vroot.tabsLabel, '页签'),
|
|
|
+ ]
|
|
|
+ const svgN = svgNumbers(base.root)
|
|
|
+ const echN = chartNumbers(vroot.wrap)
|
|
|
+ // 图表:老页面没有 SVG(或该页签本就无图)时不比;两边都有才比数据集合
|
|
|
+ if (svgN.length && echN.length) checks.push(cmpSubset(echN, svgN, '图表数据(Vue ⊆ 旧图)'))
|
|
|
+ const fails = checks.filter(Boolean)
|
|
|
+ const extra = `文本 ${textsIn(base.root).length} 项 · 表 ${tablesIn(base.root).length} 张 · 图数字 ${svgN.length}/${echN.length}`
|
|
|
+ if (fails.length) {
|
|
|
+ bad++
|
|
|
+ console.log(`[X] ${tab} —— ${extra}`)
|
|
|
+ for (const f of fails) console.log(' ' + f)
|
|
|
+ } else {
|
|
|
+ console.log(`[OK] ${tab} —— ${extra}(呈现一致)`)
|
|
|
+ }
|
|
|
+}
|
|
|
+console.log(`\n结论: ${bad === 0 ? `页签 ${TABS.join('/')} 与重构前呈现一致` : `${bad} 个页签不一致(见上)`}`)
|
|
|
+process.exit(bad === 0 ? 0 : 9)
|