You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

214 lines
35 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import crypto from 'node:crypto'
  4. import { fileURLToPath } from 'node:url'
  5. import { sceneGroups, sceneSources, unmountedWorkspace } from './scene-editor-feature-catalog.mjs'
  6. import { resolveBrowserProof, screenshotProofStatus } from './model-editor-report-evidence.mjs'
  7. import { matchesSceneScreenshotAttachment, isSceneUnitProof } from './scene-editor-report-evidence.mjs'
  8. // Report generation only: no login, credential files, application requests or tests.
  9. const here = path.dirname(fileURLToPath(import.meta.url))
  10. const report = path.resolve(here, '../reports/scene-editor-closure-20260906')
  11. fs.mkdirSync(report, { recursive: true })
  12. const arr = value => Array.isArray(value) ? value : []
  13. const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
  14. const encode = value => value.replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/')
  15. const missing = [], malformed = []
  16. function read(file, required = false) {
  17. const target = path.resolve(report, file)
  18. if (!fs.existsSync(target)) { if (required) missing.push(file); return null }
  19. try { return JSON.parse(fs.readFileSync(target, 'utf8').replace(/^\uFEFF/, '')) }
  20. catch { malformed.push(file); return null }
  21. }
  22. function state(value) {
  23. if (value === true) return 'PASS'
  24. if (value === false) return 'FAIL'
  25. const normalized = String(value ?? '').toUpperCase()
  26. if (['PASS', 'PASSED', 'SUCCESS'].includes(normalized)) return 'PASS'
  27. if (['FAIL', 'FAILED', 'TIMEDOUT', 'INTERRUPTED', 'ERROR', 'UNEXPECTED'].includes(normalized)) return 'FAIL'
  28. if (['WARN', 'FLAKY'].includes(normalized)) return 'WARN'
  29. return 'PENDING'
  30. }
  31. const labels = { PASS: '已验证', FAIL: '失败', WARN: '需复核', PENDING: '待验证' }
  32. const badge = (status, label) => `<span class="badge ${status.toLowerCase()}">${esc(label || labels[status])}</span>`
  33. const link = (file, label = file) => fs.existsSync(path.resolve(report, file))
  34. ? `<a href="${encode(file)}">${esc(label)}</a>` : `<span class="muted">${esc(label)}(待提供)</span>`
  35. const sourceCache = new Map()
  36. function locate(locator) {
  37. const colon = locator.indexOf(':'), key = locator.slice(0, colon), symbol = locator.slice(colon + 1)
  38. const file = sceneSources[key]
  39. if (!file) return { file: '', symbol, line: null }
  40. if (!sourceCache.has(file)) {
  41. const target = path.resolve(report, file)
  42. sourceCache.set(file, fs.existsSync(target) ? fs.readFileSync(target, 'utf8').split(/\r?\n/) : [])
  43. }
  44. const index = sourceCache.get(file).findIndex(line => line.includes(symbol))
  45. return { file, symbol, line: index < 0 ? null : index + 1 }
  46. }
  47. const fixes = read('fixes.json') || { items: [] }
  48. const catalog = sceneGroups.flatMap(([group, rows]) => rows.map(([id, name, locator, boundary, acceptance]) => {
  49. const related = arr(fixes.items).filter(item => arr(item.featureIds).includes(id))
  50. return { id, group, name, source: locate(locator), implementation: related.length ? '本轮修复记录已提供,仍需验证' : '源码路径已审查,待本轮实测', boundary, acceptance,
  51. fix: related.length ? related.map(item => item.summary || item.title).join(';') : '尚未关联本轮修复记录', fixIds: related.map(item => item.id) }
  52. }))
  53. const allIds = new Set(catalog.map(row => row.id))
  54. if (allIds.size !== catalog.length) throw new Error('Duplicate scene feature IDs')
  55. const results = read('results.json', true), validation = read('validation.json', true), cleanup = read('cleanup.json'), artifacts = read('artifact-checks.json')
  56. const baseline = read('data/baseline-findings.json') || { items: [] }, decisions = read('decisions.json') || { items: [] }
  57. const captions = read('screenshot-captions.json') || {}
  58. function browserRows(suites, parents = []) {
  59. return arr(suites).flatMap(suite => [
  60. ...arr(suite.specs).flatMap(spec => arr(spec.tests).map(test => {
  61. const last = arr(test.results).at(-1)
  62. return { title: [...parents, spec.title].filter(Boolean).join(' · '), rawTitle: spec.title,
  63. status: test.status === 'unexpected' ? 'FAIL' : test.status === 'flaky' ? 'WARN' : state(last?.status),
  64. startTime: last?.startTime, duration: last?.duration,
  65. attachments: arr(last?.attachments).map(item => ({ name: item.name, path: item.path, contentType: item.contentType })),
  66. // Never paste request/response bodies or full errors into the HTML.
  67. errorCount: arr(last?.errors).length,
  68. }
  69. })), ...browserRows(suite.suites, suite.file ? parents : [...parents, suite.title].filter(Boolean)),
  70. ])
  71. }
  72. const tests = browserRows(results?.suites)
  73. const files = fs.existsSync(path.join(report, 'evidence')) ? fs.readdirSync(path.join(report, 'evidence')).filter(file => file.endsWith('.json')).sort().map(file => `evidence/${file}`) : []
  74. const documents = files.map(file => ({ file, data: read(file) }))
  75. const rawProofs = []
  76. function collect(value, file, parent = {}) {
  77. if (!value || typeof value !== 'object') return
  78. const ownState = state(value.status ?? value.overallStatus ?? (typeof value.result === 'string' ? value.result : undefined))
  79. const metadata = {
  80. status: ownState === 'PENDING' ? parent.status || 'PENDING' : ownState,
  81. testTitle: value.testTitle || parent.testTitle,
  82. recordedAt: value.recordedAt || value.time || parent.recordedAt,
  83. kind: value.kind || parent.kind || '',
  84. runId: value.runId || parent.runId,
  85. }
  86. const ids = [...arr(value.featureIds), ...arr(value.features), ...(value.featureId ? [value.featureId] : [])].filter(id => typeof id === 'string')
  87. if (ids.length) rawProofs.push({ ...metadata, ids, file, title: value.title || value.name || metadata.testTitle || file, details: value.scope || value.note || '', screenshots: arr(value.screenshots) })
  88. for (const [key, child] of Object.entries(value)) if (child && typeof child === 'object' && !['featureIds', 'features', 'screenshots', 'attachments'].includes(key)) {
  89. if (Array.isArray(child)) child.forEach(item => collect(item, file, metadata))
  90. else collect(child, file, metadata)
  91. }
  92. }
  93. documents.forEach(item => collect(item.data, item.file))
  94. collect(validation?.coverage, 'validation.json')
  95. const unknownIds = [...new Set(rawProofs.flatMap(proof => proof.ids).filter(id => !allIds.has(id)))]
  96. const proofs = rawProofs.map(proof => {
  97. if (proof.testTitle && !isSceneUnitProof(proof)) return resolveBrowserProof(proof, tests)
  98. // Independent API/unit proof is admissible only in the confirmed current run,
  99. // or under validation.coverage. A stale unlinked PASS file is not sufficient.
  100. const validKind = ['api', 'unit', 'contract', 'integration', 'manual-visual'].includes(String(proof.kind).toLowerCase())
  101. const associated = proof.file === 'validation.json' || (validation?.runId && proof.runId === validation.runId)
  102. if (!validKind || !associated) return { ...proof, status: proof.status === 'FAIL' ? 'FAIL' : 'PENDING', verificationNote: '缺少当前轮 testTitle 或 runId/验证类型关联;旧证据不计通过。' }
  103. return proof
  104. })
  105. const coverage = catalog.map(feature => {
  106. const evidence = proofs.filter(proof => proof.ids.includes(feature.id)), values = evidence.map(proof => proof.status)
  107. const verification = values.includes('FAIL') ? 'FAIL' : values.includes('WARN') ? 'WARN' : values.includes('PASS') ? 'PASS' : 'PENDING'
  108. const implementation = verification === 'PASS' ? feature.fixIds.length ? '本轮修复已完成验证' : '已有实现已完成验证'
  109. : verification === 'FAIL' ? '当前验证失败,尚未完成验收'
  110. : verification === 'WARN' ? '当前证据需复核,尚未完成验收' : feature.implementation
  111. return { ...feature, implementation, verification, evidence }
  112. })
  113. fs.writeFileSync(path.join(report, 'feature-catalog.json'), JSON.stringify(coverage.map(({ evidence, ...feature }) => feature), null, 2))
  114. const count = rows => ({ total: rows.length, pass: rows.filter(row => row.status === 'PASS').length, fail: rows.filter(row => row.status === 'FAIL').length, pending: rows.filter(row => !['PASS', 'FAIL'].includes(row.status)).length })
  115. const featureCount = count(coverage.map(row => ({ status: row.verification }))), e2eCount = count(tests)
  116. const hasBrowserProof = row => row.evidence.some(proof => proof.status === 'PASS' && proof.testTitle && !isSceneUnitProof(proof))
  117. const featureKinds = {
  118. browser: coverage.filter(row => row.verification === 'PASS' && hasBrowserProof(row)).length,
  119. focused: coverage.filter(row => row.verification === 'PASS' && !hasBrowserProof(row) && row.evidence.some(proof => proof.status === 'PASS' && isSceneUnitProof(proof))).length,
  120. }
  121. const namedValidationChecks = [
  122. ['frontend', 'Web 构建与既有模型产物校验', '构建与类型检查'],
  123. ['backend', 'Tran 后端完整构建与测试', '后端构建与测试'],
  124. ['runtime', '场景运行时、资源、关闭守卫与封面合同', 'Node 合同与单元'],
  125. ['client', '场景内容 API 客户端合同', 'Node 合同与单元'],
  126. ['reportTools', '报告证据与附件匹配', '报告工具'],
  127. ].filter(([key]) => validation?.[key]).map(([key, name, kind]) => ({ name, kind, ...validation[key] }))
  128. const validationChecks = (arr(validation?.checks).length ? validation.checks : namedValidationChecks).map(check => {
  129. const descriptor = `${check.name || ''} ${check.command || ''}`
  130. const kind = check.kind || (/node\s+--test/.test(descriptor) ? /报告/.test(descriptor) ? '报告工具' : 'Node 合同与单元'
  131. : /\bmvn\b|后端|Tran|Auth/.test(descriptor) ? '后端构建与测试'
  132. : Number.isFinite(check.tests) ? '专项验证' : '构建与类型检查')
  133. return { ...check, kind, status: state(check.status) }
  134. })
  135. const validationTotals = [...new Set(validationChecks.map(check => check.kind))].map(kind => {
  136. const checks = validationChecks.filter(check => check.kind === kind)
  137. return { kind, checks: checks.length, passedChecks: checks.filter(check => check.status === 'PASS').length,
  138. tests: checks.reduce((sum, check) => sum + (Number.isFinite(check.tests) ? check.tests : 0), 0),
  139. passedTests: checks.reduce((sum, check) => sum + (check.status === 'PASS' && Number.isFinite(check.tests) ? check.tests : 0), 0) }
  140. })
  141. const cleanupProjects = arr(cleanup?.projects), cleanupOriginals = arr(cleanup?.originals)
  142. const wasRemoved = item => item.removed === true || item.alreadyAbsent === true || item.absent === true || item.status === 404 || item.statusCode === 404
  143. const cleanupCount = {
  144. projects: cleanupProjects.length,
  145. projectsRemoved: cleanupProjects.filter(wasRemoved).length,
  146. originals: cleanupOriginals.length,
  147. originalsUnchanged: cleanupOriginals.filter(item => item.unchanged === true).length,
  148. users: arr(cleanup?.users).length,
  149. usersRemoved: arr(cleanup?.users).filter(wasRemoved).length,
  150. roles: arr(cleanup?.roles).length,
  151. rolesRemoved: arr(cleanup?.roles).filter(wasRemoved).length,
  152. }
  153. const digest = file => fs.existsSync(file) ? crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null
  154. function attachmentPath(item) {
  155. const normalized = String(item.path || '').replaceAll('\\', '/')
  156. if (!normalized) return null
  157. const marker = '/test-results/'
  158. const start = normalized.lastIndexOf(marker)
  159. if (start >= 0) return path.join(report, 'test-results', normalized.slice(start + marker.length))
  160. return path.isAbsolute(item.path) ? item.path : path.resolve(report, item.path)
  161. }
  162. const imageDir = path.join(report, 'screenshots')
  163. const images = fs.existsSync(imageDir) ? fs.readdirSync(imageDir).filter(file => /\.(png|jpg|jpeg|webp)$/i.test(file)).sort() : []
  164. const imageProof = images.map(file => {
  165. const hash = digest(path.join(imageDir, file))
  166. const matches = tests.flatMap(test => test.attachments.filter(item => matchesSceneScreenshotAttachment(file, item))
  167. .map(item => ({ testTitle: test.title, status: test.status, hashMatches: digest(attachmentPath(item) || '') === hash })))
  168. return { file, status: screenshotProofStatus(matches), captionPresent: Boolean(captions[file] || captions[file.replace(/\.[^.]+$/, '')]), associations: matches }
  169. })
  170. fs.writeFileSync(path.join(report, 'screenshot-verification.json'), JSON.stringify(imageProof, null, 2))
  171. const anyFailure = value => value && typeof value === 'object' && (state(value.status ?? value.overallStatus) === 'FAIL' || (Number.isInteger(value.exitCode) && value.exitCode !== 0) || Object.values(value).some(child => child && typeof child === 'object' && (Array.isArray(child) ? child.some(anyFailure) : anyFailure(child))))
  172. const finalRun = validation?.finalRun === true || validation?.e2eComplete === true || validation?.e2e?.complete === true
  173. const requiredFinalFiles = ['results.json', 'validation.json', 'cleanup.json', 'playwright/index.html']
  174. const finalArtifactsVerified = artifacts?.finalArtifactScan === true && state(artifacts.status) === 'PASS'
  175. && requiredFinalFiles.every(file => artifacts.finalInputSha256?.[file] && artifacts.finalInputSha256[file] === digest(path.join(report, file)))
  176. const failed = e2eCount.fail || featureCount.fail || malformed.length || unknownIds.length || arr(results?.errors).length || Number(results?.stats?.unexpected || 0)
  177. || anyFailure(validation) || anyFailure(cleanup) || anyFailure(artifacts) || documents.some(item => anyFailure(item.data)) || imageProof.some(item => item.status === 'FAIL')
  178. const finished = finalRun && state(validation?.overallStatus ?? validation?.status) === 'PASS' && e2eCount.total > 0 && !e2eCount.pending
  179. && !featureCount.pending && finalArtifactsVerified && images.length > 0 && imageProof.every(item => item.status === 'PASS' && item.captionPresent)
  180. const overall = failed ? 'FAIL' : finished ? 'PASS' : 'PENDING'
  181. const snapshot = { generatedAt: new Date().toISOString(), overall, finalRun, finalArtifactsVerified, featureCount, featureKinds, e2eCount, validationTotals, cleanupCount, unknownIds, missing, malformed, features: coverage }
  182. fs.writeFileSync(path.join(report, 'coverage.json'), JSON.stringify(snapshot, null, 2))
  183. const sourceLink = source => link(source.file, `${path.basename(source.file)}${source.line ? `:${source.line}` : ''} · ${source.symbol}`)
  184. const table = (heads, rows) => `<div class="scroll"><table><thead><tr>${heads.map(head => `<th>${esc(head)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`
  185. const validationSummary = validationTotals.length ? validationTotals.map(item => `${item.kind}:${item.tests ? `${item.passedTests} / ${item.tests} 项测试` : `${item.passedChecks} / ${item.checks} 项检查`}`).join(';') : '构建与单测统计待最终 validation.json 提供'
  186. const cleanupSummary = cleanup ? `数据清理${labels[state(cleanup.status)]};临时项目 ${cleanupCount.projectsRemoved} / ${cleanupCount.projects}、账号 ${cleanupCount.usersRemoved} / ${cleanupCount.users}、角色 ${cleanupCount.rolesRemoved} / ${cleanupCount.roles} 已清理;原件创作内容、版本、资产与依赖 ${cleanupCount.originalsUnchanged} / ${cleanupCount.originals} 保持不变` : '数据清理记录待提供'
  187. const cleanupOriginalTable = cleanupOriginals.length ? table(['原始数据 / 版本', '创作数据比对范围', '引用数量:测试前 → 清理后', '更新时间:测试前 → 清理后', '完整记录'], cleanupOriginals.map(item => [
  188. `${esc(item.name || item.id)}<small class="muted"> · 版本 ${esc(item.version ?? '未记录')}</small>`,
  189. `${badge(item.unchanged === true ? 'PASS' : item.unchanged === false ? 'FAIL' : 'PENDING', item.unchanged === true ? '创作数据保持不变' : '待核验')}<p>${esc(item.scope || '以清理证据声明的字段范围为准')}</p>`,
  190. `${esc(item.before?.usageCount ?? '未记录')} → ${esc(item.after?.usageCount ?? '未记录')}${item.referenceCountRestored === true ? `<p>${badge('PASS', '引用数量已恢复')}</p>` : ''}`,
  191. `${esc(item.before?.updateTime ?? '未记录')} → ${esc(item.after?.updateTime ?? '未记录')}`,
  192. item.fullRecordUnchanged === true ? '完整记录相同' : item.fullRecordUnchanged === false ? '存在派生字段差异;详见前后统计' : '未声明整条记录完全一致',
  193. ])) : ''
  194. const historicalRuns = [['首轮', validation?.priorFullRun || validation?.firstRun], ['第二轮', validation?.secondFullRun]].filter(([, run]) => run)
  195. const firstRunNotice = historicalRuns.map(([label, run]) => `<p class="muted">${esc(label)}记录:${esc(run.passed ?? run.pass ?? run.passedTests ?? '未记录')} 项通过,${esc(run.fail ?? run.failed ?? run.testLocatorFailure ?? run.testLocatorFailures ?? '未记录')} 项失败。${esc(run.reason || '')}${run.additionalScreenshotWait ? '后续补充截图等待,确保列表加载遮罩消失后取证。' : ''}此轮记录保留在验证文件中,不计入下列最终整轮结果。</p>`).join('')
  196. const notice = overall === 'FAIL' ? '存在失败,当前验收未通过' : overall === 'PASS' ? '当前列明功能已完成记录验证' : '全功能清单已建立,最终验收待完成'
  197. const links = ['feature-catalog.json', 'coverage.json', 'coverage-plan.md', 'data/coverage-test-plan.json', 'data/inventory-scene.md', 'data/baseline-findings.json', 'decisions.json', 'fixes.json', 'results.json', 'validation.json', 'cleanup.json', 'artifact-checks.json', 'screenshot-verification.json', 'screenshot-captions.json', 'covers/fox.jpg', 'playwright/index.html', ...files]
  198. const html = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>场景编辑器 · 全功能闭环审查</title><style>
  199. :root{--ink:#173443;--muted:#607782;--line:#dbe5e9;--accent:#087e83;--paper:#f2f6f7}*{box-sizing:border-box}body{margin:0;background:var(--paper);font:15px/1.65 "Microsoft YaHei","Segoe UI",sans-serif;color:var(--ink)}a{color:#087786;text-underline-offset:3px}header{padding:54px max(5vw,24px);background:linear-gradient(120deg,#102f44,#07594f);color:#fff}header small{letter-spacing:3px;color:#9cced1}h1{font-size:42px;line-height:1.3;margin:17px 0}header p{max-width:850px;color:#d2e5e8}nav{display:flex;flex-wrap:wrap;gap:22px;background:#fff;border-bottom:1px solid var(--line);padding:15px max(5vw,24px);position:sticky;top:0;z-index:5}nav a{text-decoration:none;color:#3c5c69;font-weight:600}main{max-width:1510px;margin:auto;padding:28px 30px 55px}section{scroll-margin-top:85px;margin-bottom:38px}h2{font-size:26px;margin:8px 0 17px}h3{font-size:19px;margin:0 0 13px}.eyebrow{color:#6c8690;font-size:12px;letter-spacing:2px}.panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:23px;margin:17px 0}.notice{border-left:4px solid #c69220;padding:19px 24px;background:#fff5d9;border-radius:9px}.notice.fail{background:#fff0ef;border-color:#cb574e}.notice.pass{background:#e7f7ed;border-color:#23845a}.notice strong{font-size:22px}.notice p{margin:8px 0 0}.badge{display:inline-block;white-space:nowrap;font:600 12px/1.4 "Microsoft YaHei",sans-serif;border-radius:20px;padding:5px 9px;background:#edf1f3;color:#667883}.badge.pass{background:#ddf3e8;color:#186d4e}.badge.fail{background:#f9e2df;color:#a63832}.badge.warn,.badge.pending{background:#fff1ce;color:#926614}.stats,.flow{display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin:22px 0}.stats>div{background:#fff;border:1px solid var(--line);padding:20px;border-radius:10px}.stats b{font-size:30px;display:block}.stats small,.muted{color:var(--muted)}.flow{grid-template-columns:repeat(5,1fr)}.flow article{padding:19px;background:#fff;border:1px solid var(--line);border-radius:10px}.flow b{color:var(--accent)}.flow p{font-size:13px}.twocol{display:grid;grid-template-columns:1fr 1fr;gap:18px}.scroll{overflow:auto;border:1px solid var(--line);border-radius:9px}table{border-collapse:collapse;width:100%;font-size:13px;text-align:left}th{background:#eaf0f3;padding:12px;white-space:nowrap}td{border-top:1px solid var(--line);padding:13px;vertical-align:top;min-width:130px}tbody tr:nth-child(even){background:#f9fbfc}.matrix td:first-child{min-width:220px}.matrix td:nth-child(3),.matrix td:nth-child(4){min-width:240px}.matrix code,.matrix small{display:block;font-size:11px;overflow-wrap:anywhere;margin-top:6px;color:var(--muted)}.matrix strong{display:block}.matrix-group{margin-top:25px}.filters{display:flex;gap:12px;flex-wrap:wrap;margin:17px 0}.filters input,.filters select{padding:9px 12px;border:1px solid #cad9df;border-radius:7px;background:white;font:inherit}.filters input{flex:1;min-width:260px}.gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}figure{margin:0;border:1px solid var(--line);border-radius:11px;overflow:hidden;background:white}figure img{width:100%;aspect-ratio:16/9;object-fit:contain;background:#dce7e9;display:block}figcaption{padding:18px;font-size:13px}figcaption p{margin:10px 0}.links{display:flex;flex-wrap:wrap;gap:12px 24px}.decision{border-top:4px solid #c79231}.decision dt{font-weight:bold;margin-top:12px}.decision dd{margin:4px 0;color:var(--muted)}details summary{cursor:pointer;color:#147586}.hidden{display:none!important}footer{border-top:1px solid var(--line);padding-top:20px;font-size:12px;color:var(--muted)}dialog{border:0;border-radius:10px;padding:0;background:#102a3a;color:white;width:min(96vw,1900px);max-width:none}dialog::backdrop{background:#04131de6}dialog img{display:block;width:100%;max-height:87vh;object-fit:contain}.viewer-bar{display:flex;justify-content:space-between;gap:12px;padding:13px 18px}.viewer-bar a{color:#aae0e7}.viewer-bar button{background:transparent;border:1px solid #799ca8;border-radius:6px;color:white;padding:5px 12px;margin-left:14px}@media(max-width:1050px){.flow,.gallery{grid-template-columns:repeat(2,1fr)}.stats{grid-template-columns:repeat(2,1fr)}.twocol{grid-template-columns:1fr}}@media(max-width:620px){main{padding:20px 12px}h1{font-size:30px}.flow,.gallery{grid-template-columns:1fr}nav{gap:10px;font-size:12px}}@media print{nav,.filters{display:none}.scroll{overflow:visible}header{padding:25px}.gallery{grid-template-columns:1fr 1fr}}
  200. </style></head><body><header><small>SCENE AUTHORING · CLOSURE REVIEW</small><h1>场景编辑器<br>全功能闭环审查与验收</h1><p>资料 → 产品原型 → 设计 → 开发 → 测试。先核准实际入口与全部可达操作,再以真实模型、保存版本、错误恢复和截图证明闭环;每项实现与验证分别记录。</p></header><nav><a href="#overview">验收概况</a><a href="#materials">01 资料</a><a href="#prototype">02 原型</a><a href="#design">03 设计</a><a href="#development">04 开发</a><a href="#testing">05 测试</a><a href="#boundaries">待确认范围</a><a href="#screenshots">截图</a></nav><main>
  201. <section id="overview"><div class="notice ${overall.toLowerCase()}"><strong>${esc(notice)}</strong><p>${badge(overall)} · ${esc(new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }))}(北京时间)</p>${missing.length ? `<p>待提供:${missing.map(esc).join('、')}。</p>` : ''}${malformed.length || unknownIds.length ? `<p>证据结构问题:${esc([...malformed, ...unknownIds].join('、'))}。</p>` : ''}</div><div class="stats"><div><span>默认入口功能</span><b>${catalog.length}</b><small>未挂载 Vue 工作区不纳入分母</small></div><div><span>逐项证据匹配</span><b>${featureCount.pass} / ${featureCount.total}</b><small>失败 ${featureCount.fail} · 待验证 ${featureCount.pending}</small></div><div><span>当前真实 E2E</span><b>${e2eCount.pass} / ${e2eCount.total}</b><small>构建、API 和单测不计入 E2E</small></div><div><span>原始截图</span><b>${images.length}</b><small>需图注与本轮附件哈希一致</small></div></div><p class="muted">${featureKinds.browser} 项功能由本轮浏览器证据验证,${featureKinds.focused} 项由聚焦单元 / 合同证据验证;未验证 ${featureCount.pending} 项。</p><p class="muted">${esc(validationSummary)}。</p><p class="muted">${esc(cleanupSummary)}。</p></section>
  202. <section id="materials"><div class="eyebrow">01 / 资料</div><h2>参考资料中的通用交互要求</h2><div class="panel"><p>沿用前一轮已核对的两份 PDF(共 11 页)。资料中的部件选择、操作步骤、交互反馈和结果追踪,在本轮映射为场景对象摆放、部件能力、稳定资源引用、保存发布及后续训练选用。</p><p>${link('../../../../文档/维修训练内容设计.pdf', '维修训练内容设计.pdf')} · ${link('../../../../文档/电器控制系统故障维修脚本.pdf', '电器控制系统故障维修脚本.pdf')} · ${link('../scene-live-20260905/index.html', '前轮场景实测报告(历史参考)')}</p><p>前轮曾把对象树/接口成功误认为狐狸可见,已撤回旧完整通过结论并补视觉验证。本轮要求截图中实际辨认模型,不能仅凭树节点、依赖字段或成功提示判断加载通过。</p></div></section>
  203. <section id="prototype"><div class="eyebrow">02 / 产品原型</div><h2>以当前路由挂载的场景编辑器为准</h2><div class="flow"><article><b>场景列表</b><p>ContentProjectsView 的 SCENE 入口。</p></article><article><b>真实路由</b><p>/content/scenes/:id/edit。</p></article><article><b>Vue 宿主</b><p>LegacyScenePageView 管理权限、项目、恢复和 FIFO。</p></article><article><b>原型运行时</b><p>demo-src/scene-editor.js 真实 DOM + Three.js。</p></article><article><b>内容 API</b><p>项目版本、封面、受控资产与发布依赖。</p></article></div><div class="panel"><p>${sourceLink(locate('router:场景制作:列表走真实内容制作 API'))} · ${sourceLink(locate('host:async function mountLegacy'))} · ${sourceLink(locate('storage:export function legacySceneRuntimeContext'))}</p><p>SceneEditorView → SceneEditorWorkspace 的正式实现仍在源码中,但当前场景路由未挂载。它的额外控件列在“待确认范围”中,不能作为本轮默认入口的已实现/已验证功能。</p></div></section>
  204. <section id="design"><div class="eyebrow">03 / 设计</div><h2>先约定保存、权限与实例边界</h2><div class="twocol"><article class="panel"><h3>数据闭环</h3><p>edit3dv4.scene 文档保存对象变换、语义、模型部件覆盖和 rendering;模型以 SCENE_MODEL 固定项目/版本依赖,场景自导入 GLB 使用本项目资产编码。blob 是临时渲染地址。</p><p>手动保存同时生成当前视口 JPEG 封面;截图失败保留旧封面并提示,409 不覆盖。800ms 本地恢复缓冲不等于自动写服务器。</p></article><article class="panel"><h3>权限与生命周期</h3><p>content.scene 控制页面,create/update/delete/publish 控制实际动作。发布只有权限者可以发布已存版本;编辑后发布必须先完成受 update 保护的保存。</p><p>切标签保留编辑状态并暂停渲染;真正离开/关闭/切角色确认未存修改。资源上传、保存和发布均须完整结束或明确阻止离开。</p></article></div><div class="panel"><h3>基线风险记录</h3><p class="muted">以下是本轮修复前源码发现,不能代替实际失败复现。已修复时须关联 fixes.json 与新测试,历史风险描述保留追溯。</p>${table(['风险', '基线观察', '受影响功能'], arr(baseline.items).map(item => [esc(item.title), esc(item.observation), arr(item.featureIds).map(esc).join('、')]))}</div></section>
  205. <section id="development"><div class="eyebrow">04 / 开发</div><h2>默认入口全功能覆盖矩阵</h2><p>${link('feature-catalog.json', '稳定功能 ID')} · ${link('coverage.json', '逐项证据')} · ${link('coverage-plan.md', '测试分层约定')}。每项分别说明实现状态、闭环边界与实际证据;各模板与资源单独列明。</p><div class="filters"><input id="search" type="search" placeholder="搜索功能、ID、边界、验收要求"><select id="state" aria-label="验证状态"><option value="all">全部状态</option><option value="PENDING">待验证</option><option value="PASS">已验证</option><option value="FAIL">失败</option><option value="WARN">需复核</option></select><span id="visible">${catalog.length} 项</span></div>${sceneGroups.map(([group]) => `<article class="matrix-group" data-group><h3>${esc(group)}</h3><div class="scroll"><table class="matrix"><thead><tr><th>功能 / 执行路径</th><th>实现 / 对应修复</th><th>闭环边界</th><th>证据与验收要求</th></tr></thead><tbody>${coverage.filter(row => row.group === group).map(row => `<tr data-state="${row.verification}"><td><strong>${esc(row.name)}</strong><code>${esc(row.id)}</code><small>${sourceLink(row.source)}</small></td><td>${esc(row.implementation)}<small>${esc(row.fix)}</small></td><td>${esc(row.boundary)}</td><td>${badge(row.verification)}${row.evidence.length ? row.evidence.map(proof => `<small>${link(proof.file, proof.title)} · ${esc(proof.kind)}${proof.verificationNote ? ` · ${esc(proof.verificationNote)}` : ''}</small>`).join('') : '<small>尚无本轮逐项证据</small>'}<details><summary>验收要求</summary><p>${esc(row.acceptance)}</p>${row.evidence.filter(proof => proof.details).map(proof => `<p>${esc(proof.details)}</p>`).join('')}</details></td></tr>`).join('')}</tbody></table></div></article>`).join('')}</section>
  206. <section id="testing"><div class="eyebrow">05 / 测试</div><h2>真实浏览器、API、单测分别记录</h2><article class="panel"><h3>最终整轮 E2E</h3>${firstRunNotice}${results ? table(['用例', '结果', '耗时 / 证据'], tests.map(test => [esc(test.title), badge(test.status), `${test.duration == null ? '未记录' : `${(test.duration / 1000).toFixed(1)} 秒`} · ${test.attachments.length} 个附件${test.errorCount ? ` · ${test.errorCount} 条失败信息,详见安全处理后的原始报告` : ''}`])) : '<div class="notice">尚无 results.json,未执行项目不计通过。</div>'}<p>${link('results.json', 'Playwright JSON')} · ${link('playwright/index.html', 'Playwright HTML')}。同名旧用例或定向重跑不会覆盖当前完整轮失败。</p></article><article class="panel"><h3>构建、后端与聚焦单元测试</h3>${validationChecks.length ? table(['分类 / 检查', '结果', '实际数量', '执行命令 / 说明'], validationChecks.map(check => [esc(`${check.kind} · ${check.name || '未命名检查'}`), badge(check.status), Number.isFinite(check.tests) ? `${esc(check.tests)} 项测试${Number.isFinite(check.failures) ? ` · ${esc(check.failures)} 失败` : ''}` : '独立检查,不计入测试总数', `${esc(check.command || '未记录命令')}${check.warning ? `<small class="muted"> · ${esc(check.warning)}</small>` : ''}${check.evidence ? `<p>${link(check.evidence, '原始证据')}</p>` : ''}`])) : '<p class="muted">最终构建和测试统计待提供。</p>'}<p>${esc(validationSummary)}。上述数量分别来自 validation.json,不计入 E2E 数。</p></article><article class="panel"><h3>数据保留与清理</h3><p>${esc(cleanupSummary)}。</p>${cleanup?.note ? `<p class="muted">${esc(cleanup.note)}</p>` : ''}${cleanupOriginalTable}<p>${link('cleanup.json', '完整清理与保留证据')} · ${link('artifact-checks.json', '最终交付物扫描')}。</p></article><article class="panel"><h3>逐项证据索引</h3>${files.length ? table(['证据文件', '关联类型 / 结果'], documents.map(item => [link(item.file), esc([...new Set(proofs.filter(proof => proof.file === item.file).map(proof => `${proof.kind}: ${proof.status}`))].join(';') || '未声明 featureIds,未计入矩阵')])) : '<p class="muted">本轮逐项证据待提供。</p>'}<p>${link('validation.json', '构建与最终验证记录')} · ${link('screenshot-verification.json', '截图附件匹配结果')}。</p><p>最终整轮确认:${finalRun ? '已完成' : '待提供'};最终交付物哈希扫描:${finalArtifactsVerified ? '通过' : '待完成'}。</p></article></section>
  207. <section id="boundaries"><div class="eyebrow">范围与产品确认</div><h2>较大调整单列,不纳入本轮通过数</h2><div class="panel"><h3>未挂载正式工作区的能力差异</h3>${table(['能力组', '当前边界'], unmountedWorkspace.map(row => row.map(esc)))}<p>${link(sceneSources.formal, 'SceneEditorWorkspace.vue 源码参考')}。没有可达 UI 的功能不臆造测试、不自动迁入默认入口。</p></div>${arr(decisions.items).map(item => `<article class="panel decision"><span class="eyebrow">${esc(item.id)}</span><h3>${esc(item.title)}</h3><dl><dt>当前行为</dt><dd>${esc(item.current)}</dd><dt>建议确认的范围</dt><dd>${esc(item.scope)}</dd><dt>可观察验收</dt><dd>${esc(item.acceptance)}</dd></dl></article>`).join('')}</section>
  208. <section id="screenshots"><div class="eyebrow">实际截图</div><h2>${images.length} 张原图 · 点击放大</h2><p class="muted">失败注入、加载遮罩、环境全景和实际模型可见证据分别写明。故意移动/缩放部件的测试姿态不能误判为加载丢失。</p><div class="gallery">${images.length ? images.map((file, index) => { const caption = captions[file] || captions[file.replace(/\.[^.]+$/, '')] || {}; const verify = imageProof.find(item => item.file === file); return `<figure><a class="shot" href="${encode(`screenshots/${file}`)}" data-caption="${esc(caption.title || file)}"><img src="${encode(`screenshots/${file}`)}" alt="${esc(caption.title || file)}" loading="lazy"></a><figcaption><strong>${index + 1}. ${esc(caption.title || file)}</strong> ${badge(verify.status)}<p>${esc(caption.description || '图注尚未提供;不推断画面已验收。')}</p><small class="muted">${esc(file)}</small></figcaption></figure>` }).join('') : '<div class="notice">尚未提供截图,不生成或补画测试画面。</div>'}</div></section><section><div class="panel links">${links.map(file => link(file)).join('')}</div></section><footer>生成命令:node tools/build-scene-editor-closure-report.mjs。仅处理此场景报告;原模型报告不改动。没有当前轮关联证据的功能保持 PENDING。</footer></main><dialog id="viewer" aria-label="截图放大"><div class="viewer-bar"><span id="caption"></span><div><a id="original" target="_blank" rel="noopener">原图</a><button id="close" type="button">关闭 ×</button></div></div><img alt=""></dialog><script>
  209. const search=document.getElementById('search'),state=document.getElementById('state');function filter(){let total=0;document.querySelectorAll('[data-state]').forEach(row=>{const show=(state.value==='all'||state.value===row.dataset.state)&&row.textContent.toLowerCase().includes(search.value.toLowerCase());row.classList.toggle('hidden',!show);if(show)total++});document.querySelectorAll('[data-group]').forEach(group=>group.classList.toggle('hidden',!group.querySelector('[data-state]:not(.hidden)')));document.getElementById('visible').textContent=total+' 项'}search.addEventListener('input',filter);state.addEventListener('change',filter);const viewer=document.getElementById('viewer');document.querySelectorAll('.shot').forEach(anchor=>anchor.addEventListener('click',event=>{event.preventDefault();viewer.querySelector('img').src=anchor.href;viewer.querySelector('img').alt=anchor.dataset.caption;document.getElementById('caption').textContent=anchor.dataset.caption;document.getElementById('original').href=anchor.href;viewer.showModal()}));document.getElementById('close').addEventListener('click',()=>viewer.close());viewer.addEventListener('click',event=>{if(event.target===viewer)viewer.close()});
  210. </script></body></html>`
  211. fs.writeFileSync(path.join(report, 'index.html'), html)
  212. console.log(JSON.stringify({ report: path.join(report, 'index.html'), overall, featureCount, e2eCount, screenshots: images.length, unresolvedSources: catalog.filter(row => !row.source.line).map(row => row.id), unknownIds, missing, malformed }, null, 2))