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) => `
`
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 = `场景编辑器 · 全功能闭环审查 验收概况 01 资料 02 原型 03 设计 04 开发 05 测试 待确认范围 截图
${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)} `).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} 张原图 · 点击放大 失败注入、加载遮罩、环境全景和实际模型可见证据分别写明。故意移动/缩放部件的测试姿态不能误判为加载丢失。
${images.length ? images.map((file, index) => { const caption = captions[file] || captions[file.replace(/\.[^.]+$/, '')] || {}; const verify = imageProof.find(item => item.file === file); return `
${index + 1}. ${esc(caption.title || file)} ${badge(verify.status)}${esc(caption.description || '图注尚未提供;不推断画面已验收。')}
${esc(file)} ` }).join('') : '
尚未提供截图,不生成或补画测试画面。
'}
${links.map(file => link(file)).join('')}
生成命令:node tools/build-scene-editor-closure-report.mjs。仅处理此场景报告;原模型报告不改动。没有当前轮关联证据的功能保持 PENDING。 `
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))