import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' import { sceneGroups, sceneSources, unmountedWorkspace } from './scene-editor-feature-catalog.mjs' import { resolveBrowserProof, screenshotProofStatus } from './model-editor-report-evidence.mjs' import { matchesSceneScreenshotAttachment, isSceneUnitProof } from './scene-editor-report-evidence.mjs' // Report generation only: no login, credential files, application requests or tests. const here = path.dirname(fileURLToPath(import.meta.url)) const report = path.resolve(here, '../reports/scene-editor-closure-20260906') fs.mkdirSync(report, { recursive: true }) const arr = value => Array.isArray(value) ? value : [] const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) const encode = value => value.replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/') const missing = [], malformed = [] function read(file, required = false) { const target = path.resolve(report, file) if (!fs.existsSync(target)) { if (required) missing.push(file); return null } try { return JSON.parse(fs.readFileSync(target, 'utf8').replace(/^\uFEFF/, '')) } catch { malformed.push(file); return null } } function state(value) { if (value === true) return 'PASS' if (value === false) return 'FAIL' const normalized = String(value ?? '').toUpperCase() if (['PASS', 'PASSED', 'SUCCESS'].includes(normalized)) return 'PASS' if (['FAIL', 'FAILED', 'TIMEDOUT', 'INTERRUPTED', 'ERROR', 'UNEXPECTED'].includes(normalized)) return 'FAIL' if (['WARN', 'FLAKY'].includes(normalized)) return 'WARN' return 'PENDING' } const labels = { PASS: '已验证', FAIL: '失败', WARN: '需复核', PENDING: '待验证' } const badge = (status, label) => `${esc(label || labels[status])}` const link = (file, label = file) => fs.existsSync(path.resolve(report, file)) ? `${esc(label)}` : `${esc(label)}(待提供)` const sourceCache = new Map() function locate(locator) { const colon = locator.indexOf(':'), key = locator.slice(0, colon), symbol = locator.slice(colon + 1) const file = sceneSources[key] if (!file) return { file: '', symbol, line: null } if (!sourceCache.has(file)) { const target = path.resolve(report, file) sourceCache.set(file, fs.existsSync(target) ? fs.readFileSync(target, 'utf8').split(/\r?\n/) : []) } const index = sourceCache.get(file).findIndex(line => line.includes(symbol)) return { file, symbol, line: index < 0 ? null : index + 1 } } const fixes = read('fixes.json') || { items: [] } const catalog = sceneGroups.flatMap(([group, rows]) => rows.map(([id, name, locator, boundary, acceptance]) => { const related = arr(fixes.items).filter(item => arr(item.featureIds).includes(id)) return { id, group, name, source: locate(locator), implementation: related.length ? '本轮修复记录已提供,仍需验证' : '源码路径已审查,待本轮实测', boundary, acceptance, fix: related.length ? related.map(item => item.summary || item.title).join(';') : '尚未关联本轮修复记录', fixIds: related.map(item => item.id) } })) const allIds = new Set(catalog.map(row => row.id)) if (allIds.size !== catalog.length) throw new Error('Duplicate scene feature IDs') const results = read('results.json', true), validation = read('validation.json', true), cleanup = read('cleanup.json'), artifacts = read('artifact-checks.json') const baseline = read('data/baseline-findings.json') || { items: [] }, decisions = read('decisions.json') || { items: [] } const captions = read('screenshot-captions.json') || {} function browserRows(suites, parents = []) { return arr(suites).flatMap(suite => [ ...arr(suite.specs).flatMap(spec => arr(spec.tests).map(test => { const last = arr(test.results).at(-1) return { title: [...parents, spec.title].filter(Boolean).join(' · '), rawTitle: spec.title, status: test.status === 'unexpected' ? 'FAIL' : test.status === 'flaky' ? 'WARN' : state(last?.status), startTime: last?.startTime, duration: last?.duration, attachments: arr(last?.attachments).map(item => ({ name: item.name, path: item.path, contentType: item.contentType })), // Never paste request/response bodies or full errors into the HTML. errorCount: arr(last?.errors).length, } })), ...browserRows(suite.suites, suite.file ? parents : [...parents, suite.title].filter(Boolean)), ]) } const tests = browserRows(results?.suites) const files = fs.existsSync(path.join(report, 'evidence')) ? fs.readdirSync(path.join(report, 'evidence')).filter(file => file.endsWith('.json')).sort().map(file => `evidence/${file}`) : [] const documents = files.map(file => ({ file, data: read(file) })) const rawProofs = [] function collect(value, file, parent = {}) { if (!value || typeof value !== 'object') return const ownState = state(value.status ?? value.overallStatus ?? (typeof value.result === 'string' ? value.result : undefined)) const metadata = { status: ownState === 'PENDING' ? parent.status || 'PENDING' : ownState, testTitle: value.testTitle || parent.testTitle, recordedAt: value.recordedAt || value.time || parent.recordedAt, kind: value.kind || parent.kind || '', runId: value.runId || parent.runId, } const ids = [...arr(value.featureIds), ...arr(value.features), ...(value.featureId ? [value.featureId] : [])].filter(id => typeof id === 'string') 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) }) for (const [key, child] of Object.entries(value)) if (child && typeof child === 'object' && !['featureIds', 'features', 'screenshots', 'attachments'].includes(key)) { if (Array.isArray(child)) child.forEach(item => collect(item, file, metadata)) else collect(child, file, metadata) } } documents.forEach(item => collect(item.data, item.file)) collect(validation?.coverage, 'validation.json') const unknownIds = [...new Set(rawProofs.flatMap(proof => proof.ids).filter(id => !allIds.has(id)))] const proofs = rawProofs.map(proof => { if (proof.testTitle && !isSceneUnitProof(proof)) return resolveBrowserProof(proof, tests) // Independent API/unit proof is admissible only in the confirmed current run, // or under validation.coverage. A stale unlinked PASS file is not sufficient. const validKind = ['api', 'unit', 'contract', 'integration', 'manual-visual'].includes(String(proof.kind).toLowerCase()) const associated = proof.file === 'validation.json' || (validation?.runId && proof.runId === validation.runId) if (!validKind || !associated) return { ...proof, status: proof.status === 'FAIL' ? 'FAIL' : 'PENDING', verificationNote: '缺少当前轮 testTitle 或 runId/验证类型关联;旧证据不计通过。' } return proof }) const coverage = catalog.map(feature => { const evidence = proofs.filter(proof => proof.ids.includes(feature.id)), values = evidence.map(proof => proof.status) const verification = values.includes('FAIL') ? 'FAIL' : values.includes('WARN') ? 'WARN' : values.includes('PASS') ? 'PASS' : 'PENDING' const implementation = verification === 'PASS' ? feature.fixIds.length ? '本轮修复已完成验证' : '已有实现已完成验证' : verification === 'FAIL' ? '当前验证失败,尚未完成验收' : verification === 'WARN' ? '当前证据需复核,尚未完成验收' : feature.implementation return { ...feature, implementation, verification, evidence } }) fs.writeFileSync(path.join(report, 'feature-catalog.json'), JSON.stringify(coverage.map(({ evidence, ...feature }) => feature), null, 2)) 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 }) const featureCount = count(coverage.map(row => ({ status: row.verification }))), e2eCount = count(tests) const hasBrowserProof = row => row.evidence.some(proof => proof.status === 'PASS' && proof.testTitle && !isSceneUnitProof(proof)) const featureKinds = { browser: coverage.filter(row => row.verification === 'PASS' && hasBrowserProof(row)).length, focused: coverage.filter(row => row.verification === 'PASS' && !hasBrowserProof(row) && row.evidence.some(proof => proof.status === 'PASS' && isSceneUnitProof(proof))).length, } const namedValidationChecks = [ ['frontend', 'Web 构建与既有模型产物校验', '构建与类型检查'], ['backend', 'Tran 后端完整构建与测试', '后端构建与测试'], ['runtime', '场景运行时、资源、关闭守卫与封面合同', 'Node 合同与单元'], ['client', '场景内容 API 客户端合同', 'Node 合同与单元'], ['reportTools', '报告证据与附件匹配', '报告工具'], ].filter(([key]) => validation?.[key]).map(([key, name, kind]) => ({ name, kind, ...validation[key] })) const validationChecks = (arr(validation?.checks).length ? validation.checks : namedValidationChecks).map(check => { const descriptor = `${check.name || ''} ${check.command || ''}` const kind = check.kind || (/node\s+--test/.test(descriptor) ? /报告/.test(descriptor) ? '报告工具' : 'Node 合同与单元' : /\bmvn\b|后端|Tran|Auth/.test(descriptor) ? '后端构建与测试' : Number.isFinite(check.tests) ? '专项验证' : '构建与类型检查') return { ...check, kind, status: state(check.status) } }) const validationTotals = [...new Set(validationChecks.map(check => check.kind))].map(kind => { const checks = validationChecks.filter(check => check.kind === kind) return { kind, checks: checks.length, passedChecks: checks.filter(check => check.status === 'PASS').length, tests: checks.reduce((sum, check) => sum + (Number.isFinite(check.tests) ? check.tests : 0), 0), passedTests: checks.reduce((sum, check) => sum + (check.status === 'PASS' && Number.isFinite(check.tests) ? check.tests : 0), 0) } }) const cleanupProjects = arr(cleanup?.projects), cleanupOriginals = arr(cleanup?.originals) const wasRemoved = item => item.removed === true || item.alreadyAbsent === true || item.absent === true || item.status === 404 || item.statusCode === 404 const cleanupCount = { projects: cleanupProjects.length, projectsRemoved: cleanupProjects.filter(wasRemoved).length, originals: cleanupOriginals.length, originalsUnchanged: cleanupOriginals.filter(item => item.unchanged === true).length, users: arr(cleanup?.users).length, usersRemoved: arr(cleanup?.users).filter(wasRemoved).length, roles: arr(cleanup?.roles).length, rolesRemoved: arr(cleanup?.roles).filter(wasRemoved).length, } const digest = file => fs.existsSync(file) ? crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null function attachmentPath(item) { const normalized = String(item.path || '').replaceAll('\\', '/') if (!normalized) return null const marker = '/test-results/' const start = normalized.lastIndexOf(marker) if (start >= 0) return path.join(report, 'test-results', normalized.slice(start + marker.length)) return path.isAbsolute(item.path) ? item.path : path.resolve(report, item.path) } const imageDir = path.join(report, 'screenshots') const images = fs.existsSync(imageDir) ? fs.readdirSync(imageDir).filter(file => /\.(png|jpg|jpeg|webp)$/i.test(file)).sort() : [] const imageProof = images.map(file => { const hash = digest(path.join(imageDir, file)) const matches = tests.flatMap(test => test.attachments.filter(item => matchesSceneScreenshotAttachment(file, item)) .map(item => ({ testTitle: test.title, status: test.status, hashMatches: digest(attachmentPath(item) || '') === hash }))) return { file, status: screenshotProofStatus(matches), captionPresent: Boolean(captions[file] || captions[file.replace(/\.[^.]+$/, '')]), associations: matches } }) fs.writeFileSync(path.join(report, 'screenshot-verification.json'), JSON.stringify(imageProof, null, 2)) 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)))) const finalRun = validation?.finalRun === true || validation?.e2eComplete === true || validation?.e2e?.complete === true const requiredFinalFiles = ['results.json', 'validation.json', 'cleanup.json', 'playwright/index.html'] const finalArtifactsVerified = artifacts?.finalArtifactScan === true && state(artifacts.status) === 'PASS' && requiredFinalFiles.every(file => artifacts.finalInputSha256?.[file] && artifacts.finalInputSha256[file] === digest(path.join(report, file))) const failed = e2eCount.fail || featureCount.fail || malformed.length || unknownIds.length || arr(results?.errors).length || Number(results?.stats?.unexpected || 0) || anyFailure(validation) || anyFailure(cleanup) || anyFailure(artifacts) || documents.some(item => anyFailure(item.data)) || imageProof.some(item => item.status === 'FAIL') const finished = finalRun && state(validation?.overallStatus ?? validation?.status) === 'PASS' && e2eCount.total > 0 && !e2eCount.pending && !featureCount.pending && finalArtifactsVerified && images.length > 0 && imageProof.every(item => item.status === 'PASS' && item.captionPresent) const overall = failed ? 'FAIL' : finished ? 'PASS' : 'PENDING' const snapshot = { generatedAt: new Date().toISOString(), overall, finalRun, finalArtifactsVerified, featureCount, featureKinds, e2eCount, validationTotals, cleanupCount, unknownIds, missing, malformed, features: coverage } fs.writeFileSync(path.join(report, 'coverage.json'), JSON.stringify(snapshot, null, 2)) const sourceLink = source => link(source.file, `${path.basename(source.file)}${source.line ? `:${source.line}` : ''} · ${source.symbol}`) const table = (heads, rows) => `
${heads.map(head => ``).join('')}${rows.map(row => `${row.map(cell => ``).join('')}`).join('')}
${esc(head)}
${cell}
` const validationSummary = validationTotals.length ? validationTotals.map(item => `${item.kind}:${item.tests ? `${item.passedTests} / ${item.tests} 项测试` : `${item.passedChecks} / ${item.checks} 项检查`}`).join(';') : '构建与单测统计待最终 validation.json 提供' const cleanupSummary = cleanup ? `数据清理${labels[state(cleanup.status)]};临时项目 ${cleanupCount.projectsRemoved} / ${cleanupCount.projects}、账号 ${cleanupCount.usersRemoved} / ${cleanupCount.users}、角色 ${cleanupCount.rolesRemoved} / ${cleanupCount.roles} 已清理;原件创作内容、版本、资产与依赖 ${cleanupCount.originalsUnchanged} / ${cleanupCount.originals} 保持不变` : '数据清理记录待提供' const cleanupOriginalTable = cleanupOriginals.length ? table(['原始数据 / 版本', '创作数据比对范围', '引用数量:测试前 → 清理后', '更新时间:测试前 → 清理后', '完整记录'], cleanupOriginals.map(item => [ `${esc(item.name || item.id)} · 版本 ${esc(item.version ?? '未记录')}`, `${badge(item.unchanged === true ? 'PASS' : item.unchanged === false ? 'FAIL' : 'PENDING', item.unchanged === true ? '创作数据保持不变' : '待核验')}

${esc(item.scope || '以清理证据声明的字段范围为准')}

`, `${esc(item.before?.usageCount ?? '未记录')} → ${esc(item.after?.usageCount ?? '未记录')}${item.referenceCountRestored === true ? `

${badge('PASS', '引用数量已恢复')}

` : ''}`, `${esc(item.before?.updateTime ?? '未记录')} → ${esc(item.after?.updateTime ?? '未记录')}`, item.fullRecordUnchanged === true ? '完整记录相同' : item.fullRecordUnchanged === false ? '存在派生字段差异;详见前后统计' : '未声明整条记录完全一致', ])) : '' const historicalRuns = [['首轮', validation?.priorFullRun || validation?.firstRun], ['第二轮', validation?.secondFullRun]].filter(([, run]) => run) const firstRunNotice = historicalRuns.map(([label, run]) => `

${esc(label)}记录:${esc(run.passed ?? run.pass ?? run.passedTests ?? '未记录')} 项通过,${esc(run.fail ?? run.failed ?? run.testLocatorFailure ?? run.testLocatorFailures ?? '未记录')} 项失败。${esc(run.reason || '')}${run.additionalScreenshotWait ? '后续补充截图等待,确保列表加载遮罩消失后取证。' : ''}此轮记录保留在验证文件中,不计入下列最终整轮结果。

`).join('') const notice = overall === 'FAIL' ? '存在失败,当前验收未通过' : overall === 'PASS' ? '当前列明功能已完成记录验证' : '全功能清单已建立,最终验收待完成' 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] const html = `场景编辑器 · 全功能闭环审查
SCENE AUTHORING · CLOSURE REVIEW

场景编辑器
全功能闭环审查与验收

资料 → 产品原型 → 设计 → 开发 → 测试。先核准实际入口与全部可达操作,再以真实模型、保存版本、错误恢复和截图证明闭环;每项实现与验证分别记录。

${esc(notice)}

${badge(overall)} · ${esc(new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }))}(北京时间)

${missing.length ? `

待提供:${missing.map(esc).join('、')}。

` : ''}${malformed.length || unknownIds.length ? `

证据结构问题:${esc([...malformed, ...unknownIds].join('、'))}。

` : ''}
默认入口功能${catalog.length}未挂载 Vue 工作区不纳入分母
逐项证据匹配${featureCount.pass} / ${featureCount.total}失败 ${featureCount.fail} · 待验证 ${featureCount.pending}
当前真实 E2E${e2eCount.pass} / ${e2eCount.total}构建、API 和单测不计入 E2E
原始截图${images.length}需图注与本轮附件哈希一致

${featureKinds.browser} 项功能由本轮浏览器证据验证,${featureKinds.focused} 项由聚焦单元 / 合同证据验证;未验证 ${featureCount.pending} 项。

${esc(validationSummary)}。

${esc(cleanupSummary)}。

01 / 资料

参考资料中的通用交互要求

沿用前一轮已核对的两份 PDF(共 11 页)。资料中的部件选择、操作步骤、交互反馈和结果追踪,在本轮映射为场景对象摆放、部件能力、稳定资源引用、保存发布及后续训练选用。

${link('../../../../文档/维修训练内容设计.pdf', '维修训练内容设计.pdf')} · ${link('../../../../文档/电器控制系统故障维修脚本.pdf', '电器控制系统故障维修脚本.pdf')} · ${link('../scene-live-20260905/index.html', '前轮场景实测报告(历史参考)')}

前轮曾把对象树/接口成功误认为狐狸可见,已撤回旧完整通过结论并补视觉验证。本轮要求截图中实际辨认模型,不能仅凭树节点、依赖字段或成功提示判断加载通过。

02 / 产品原型

以当前路由挂载的场景编辑器为准

场景列表

ContentProjectsView 的 SCENE 入口。

真实路由

/content/scenes/:id/edit。

Vue 宿主

LegacyScenePageView 管理权限、项目、恢复和 FIFO。

原型运行时

demo-src/scene-editor.js 真实 DOM + Three.js。

内容 API

项目版本、封面、受控资产与发布依赖。

${sourceLink(locate('router:场景制作:列表走真实内容制作 API'))} · ${sourceLink(locate('host:async function mountLegacy'))} · ${sourceLink(locate('storage:export function legacySceneRuntimeContext'))}

SceneEditorView → SceneEditorWorkspace 的正式实现仍在源码中,但当前场景路由未挂载。它的额外控件列在“待确认范围”中,不能作为本轮默认入口的已实现/已验证功能。

03 / 设计

先约定保存、权限与实例边界

数据闭环

edit3dv4.scene 文档保存对象变换、语义、模型部件覆盖和 rendering;模型以 SCENE_MODEL 固定项目/版本依赖,场景自导入 GLB 使用本项目资产编码。blob 是临时渲染地址。

手动保存同时生成当前视口 JPEG 封面;截图失败保留旧封面并提示,409 不覆盖。800ms 本地恢复缓冲不等于自动写服务器。

权限与生命周期

content.scene 控制页面,create/update/delete/publish 控制实际动作。发布只有权限者可以发布已存版本;编辑后发布必须先完成受 update 保护的保存。

切标签保留编辑状态并暂停渲染;真正离开/关闭/切角色确认未存修改。资源上传、保存和发布均须完整结束或明确阻止离开。

基线风险记录

以下是本轮修复前源码发现,不能代替实际失败复现。已修复时须关联 fixes.json 与新测试,历史风险描述保留追溯。

${table(['风险', '基线观察', '受影响功能'], arr(baseline.items).map(item => [esc(item.title), esc(item.observation), arr(item.featureIds).map(esc).join('、')]))}
04 / 开发

默认入口全功能覆盖矩阵

${link('feature-catalog.json', '稳定功能 ID')} · ${link('coverage.json', '逐项证据')} · ${link('coverage-plan.md', '测试分层约定')}。每项分别说明实现状态、闭环边界与实际证据;各模板与资源单独列明。

${catalog.length} 项
${sceneGroups.map(([group]) => `

${esc(group)}

${coverage.filter(row => row.group === group).map(row => ``).join('')}
功能 / 执行路径实现 / 对应修复闭环边界证据与验收要求
${esc(row.name)}${esc(row.id)}${sourceLink(row.source)}${esc(row.implementation)}${esc(row.fix)}${esc(row.boundary)}${badge(row.verification)}${row.evidence.length ? row.evidence.map(proof => `${link(proof.file, proof.title)} · ${esc(proof.kind)}${proof.verificationNote ? ` · ${esc(proof.verificationNote)}` : ''}`).join('') : '尚无本轮逐项证据'}
验收要求

${esc(row.acceptance)}

${row.evidence.filter(proof => proof.details).map(proof => `

${esc(proof.details)}

`).join('')}
`).join('')}
05 / 测试

真实浏览器、API、单测分别记录

最终整轮 E2E

${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} 条失败信息,详见安全处理后的原始报告` : ''}`])) : '
尚无 results.json,未执行项目不计通过。
'}

${link('results.json', 'Playwright JSON')} · ${link('playwright/index.html', 'Playwright HTML')}。同名旧用例或定向重跑不会覆盖当前完整轮失败。

构建、后端与聚焦单元测试

${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 ? ` · ${esc(check.warning)}` : ''}${check.evidence ? `

${link(check.evidence, '原始证据')}

` : ''}`])) : '

最终构建和测试统计待提供。

'}

${esc(validationSummary)}。上述数量分别来自 validation.json,不计入 E2E 数。

数据保留与清理

${esc(cleanupSummary)}。

${cleanup?.note ? `

${esc(cleanup.note)}

` : ''}${cleanupOriginalTable}

${link('cleanup.json', '完整清理与保留证据')} · ${link('artifact-checks.json', '最终交付物扫描')}。

逐项证据索引

${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,未计入矩阵')])) : '

本轮逐项证据待提供。

'}

${link('validation.json', '构建与最终验证记录')} · ${link('screenshot-verification.json', '截图附件匹配结果')}。

最终整轮确认:${finalRun ? '已完成' : '待提供'};最终交付物哈希扫描:${finalArtifactsVerified ? '通过' : '待完成'}。

范围与产品确认

较大调整单列,不纳入本轮通过数

未挂载正式工作区的能力差异

${table(['能力组', '当前边界'], unmountedWorkspace.map(row => row.map(esc)))}

${link(sceneSources.formal, 'SceneEditorWorkspace.vue 源码参考')}。没有可达 UI 的功能不臆造测试、不自动迁入默认入口。

${arr(decisions.items).map(item => `
${esc(item.id)}

${esc(item.title)}

当前行为
${esc(item.current)}
建议确认的范围
${esc(item.scope)}
可观察验收
${esc(item.acceptance)}
`).join('')}
实际截图

${images.length} 张原图 · 点击放大

失败注入、加载遮罩、环境全景和实际模型可见证据分别写明。故意移动/缩放部件的测试姿态不能误判为加载丢失。

原图
` fs.writeFileSync(path.join(report, 'index.html'), html) 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))