struct_diff.mjs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. // P20 结构对拍:把 v2.9.2 原文页(本地经典页,jsdom 真跑)与 Vue SSR 输出逐页签比「结构骨架」
  2. // = #root 下每个元素的 `tag.class…`(忽略 svg/canvas/script/style 与文本)
  3. // 用法:node tools/struct_diff.mjs [gateway] [tab…]
  4. import { readFileSync } from 'node:fs'
  5. import { fileURLToPath, pathToFileURL } from 'node:url'
  6. import { dirname, resolve } from 'node:path'
  7. import { JSDOM, VirtualConsole } from 'jsdom'
  8. const HERE = dirname(fileURLToPath(import.meta.url))
  9. const GATEWAY = process.argv[2] || 'http://106.120.102.238:28084'
  10. const TABS = process.argv.slice(3).length ? process.argv.slice(3)
  11. : ['overview', 'energy', 'component', 'fault', 'vibration', 'generation', 'decision', 'assistant', 'report', 'system']
  12. const CLASSIC = resolve(HERE, '../../../release/web/detail_v2.html')
  13. const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim()
  14. function skeletonIn(root) {
  15. const out = []
  16. const walk = (el) => {
  17. for (const c of Array.from(el.children)) {
  18. const tag = c.tagName.toLowerCase()
  19. if (tag === 'canvas' || tag === 'svg' || tag === 'script' || tag === 'style') continue
  20. // ★P23:Vue 的 SVG 图外壳(旧页直接内联 SVG,无包裹)⇒ 本层视为透明,不参与骨架
  21. if (tag === 'div' && (c.getAttribute('class') || '').includes('legacy-chart')) { walk(c); continue }
  22. // ★P27:`v-unwrap` 宿主在运行时被解包(SSR 不执行指令)⇒ 判据等价解包
  23. if ((c.getAttribute('class') || '').includes('unwrap-host')) { walk(c); continue }
  24. const cls = (c.getAttribute('class') || '').split(/\s+/).filter(Boolean).sort().join('.')
  25. out.push(tag + (cls ? '.' + cls : ''))
  26. walk(c)
  27. }
  28. }
  29. walk(root)
  30. return out
  31. }
  32. const API_KEEP = ['/api/maint_survey', '/api/facts', '/api/ont_chain', '/api/ask_models',
  33. '/api/ask_status', '/api/vibcms', '/api/turbine', '/api/problem', '/api/channel']
  34. function installFetch(window, state) {
  35. state.apis = state.apis || {}
  36. state.cache = state.cache || new Map()
  37. window.fetch = async (input, init) => {
  38. const raw = typeof input === 'string' ? input : (input && input.url) || String(input)
  39. const url = raw.startsWith('http') ? raw : GATEWAY + (raw.startsWith('/') ? raw : '/' + raw)
  40. if (state.cache.has(url)) { // ★P36 同轮同 URL 复用
  41. const c = state.cache.get(url)
  42. return { ok: true, status: 200, headers: new Map(), text: async () => c, json: async () => JSON.parse(c) }
  43. }
  44. const r = await fetch(url, init)
  45. const body = await r.text()
  46. if (r.ok) state.cache.set(url, body)
  47. let j = null
  48. try { j = JSON.parse(body) } catch { /* ignore */ }
  49. const path = url.slice(GATEWAY.length).split('?')[0]
  50. if (path === '/api/fleet' && !state.fleet) state.fleet = j
  51. if (path === '/api/vibcms' && !state.cms) state.cms = j
  52. if (API_KEEP.includes(path) && !state.apis[path]) state.apis[path] = j
  53. return { ok: r.ok, status: r.status, headers: r.headers, text: async () => body, json: async () => j }
  54. }
  55. }
  56. /** ★P36 预热:等网关把 fleet/curves 算定(避免基线页落到 pending/骨架)。 */
  57. async function warmup(tries = 24, gapMs = 4000) {
  58. let fleet = null
  59. for (let k = 0; k < tries; k++) {
  60. try {
  61. const r = await fetch(GATEWAY + '/api/fleet')
  62. if (r.ok) {
  63. const j = await r.json()
  64. const settled = !j.pending && !j.m9_pending && !j.fleet_pending
  65. if (settled) { fleet = j; break }
  66. }
  67. } catch { /* 继续等 */ }
  68. await new Promise((x) => setTimeout(x, gapMs))
  69. }
  70. return fleet
  71. }
  72. /** generation 前置:把 /api/curves 追到出图(与呈现对拍同口径)。 */
  73. async function awaitCurves(tries = 20, gapMs = 3000) {
  74. for (let k = 0; k < tries; k++) {
  75. const r = await fetch(GATEWAY + '/api/curves')
  76. if (r.ok) {
  77. const j = await r.json()
  78. if (!j.building && (j.figs || []).length) return j
  79. }
  80. await new Promise((r2) => setTimeout(r2, gapMs))
  81. }
  82. throw new Error('等 /api/curves 出图超时')
  83. }
  84. /** ★P63:`tab:sub` 形式 —— 基线点一下子视图 pill,Vue 侧把 sub 传给 renderTab。 */
  85. function parseTab(x) { const [tab, sub] = String(x).split(':'); return { tab, sub: sub || null } }
  86. async function baseline(tab, sub) {
  87. const state = {}
  88. if (!state.warmed) { await warmup(); state.warmed = true }
  89. if (tab === 'generation') { state.curves = await awaitCurves(); console.log(' [i] curves figs ' + (state.curves.figs || []).length) }
  90. const html = readFileSync(CLASSIC, 'utf8')
  91. const dom = new JSDOM(html, {
  92. url: GATEWAY + `/detail/v2#tab=${tab}`,
  93. runScripts: 'dangerously', pretendToBeVisual: true,
  94. virtualConsole: new VirtualConsole(),
  95. beforeParse: (w) => installFetch(w, state),
  96. })
  97. const doc = dom.window.document
  98. for (let i = 0; i < 120; i++) {
  99. const root = doc.getElementById('root')
  100. if (root && root.querySelector('.page-head')) break
  101. await new Promise((r) => setTimeout(r, 500))
  102. }
  103. // ★P36 等 DOM 稳定:连续 3 次子元素数一致才认为渲染定稿
  104. let last = -1, same = 0
  105. for (let i = 0; i < 60; i++) {
  106. const root = doc.getElementById('root')
  107. const n = root ? root.querySelectorAll('*').length : 0
  108. if (n === last) { if (++same >= 3) break } else { same = 0 }
  109. last = n
  110. await new Promise((r) => setTimeout(r, 700))
  111. }
  112. if (sub) {
  113. const pill = doc.querySelector(`#root [data-fsub="${sub}"]`)
  114. if (pill) { pill.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); await new Promise((r) => setTimeout(r, sub === 'cms' ? 4000 : 1200)) }
  115. }
  116. return { root: doc.getElementById('root'), data: state.fleet, cms: state.cms ?? null,
  117. curves: state.curves ?? null, apis: state.apis ?? {} }
  118. }
  119. async function vueSkel(tab, sub, data, cms, curves, apis) {
  120. const entry = pathToFileURL(resolve(HERE, '../target/ssr/entry-server.js')).href
  121. const html = await (await import(entry)).renderTab(data, tab, sub, cms, curves, apis)
  122. const dom = new JSDOM(`<div id="wrap">${html}</div>`)
  123. const wrap = dom.window.document.getElementById('wrap')
  124. const nav = wrap.querySelector('.wb-tabs')
  125. if (nav) nav.remove()
  126. return skeletonIn(wrap)
  127. }
  128. let bad = 0
  129. for (const tab of TABS) {
  130. const { tab: TAB, sub: SUB } = parseTab(tab)
  131. const { root, data, cms, curves, apis } = await baseline(TAB, SUB)
  132. if (!root) { console.log(`[X] ${tab}: 基线未渲染`); bad++; continue }
  133. const a = skeletonIn(root)
  134. const b = await vueSkel(TAB, SUB, data, cms, curves, apis)
  135. if (a.length === b.length && a.every((x, i) => x === b[i])) {
  136. console.log(`[OK] ${tab} 结构一致(${a.length} 个元素)`)
  137. } else {
  138. bad++
  139. const diffs = []
  140. for (let i = 0; i < Math.max(a.length, b.length) && diffs.length < 4; i++) {
  141. if (a[i] !== b[i]) diffs.push(`#${i} 基准=${a[i] || '∅'} · Vue=${b[i] || '∅'}`)
  142. }
  143. console.log(`[X] ${tab} 元素数 基准 ${a.length} · Vue ${b.length}`)
  144. for (const d of diffs) console.log(` ${d}`)
  145. }
  146. }
  147. console.log(`\n结构对拍结论: ${TABS.length - bad}/${TABS.length} 页签结构一致`)
  148. process.exit(bad ? 9 : 0)