import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' import { resolveBrowserProof, screenshotProofStatus } from './model-editor-report-evidence.mjs' // This tool renders evidence. It neither exercises the application nor reads credentials. const toolDir = path.dirname(fileURLToPath(import.meta.url)) const reportDir = path.resolve(toolDir, '../reports/model-editor-closure-20260906') const workspace = path.resolve(toolDir, '../../..') fs.mkdirSync(path.join(reportDir, 'data'), { recursive: true }) const baseline = path.join(workspace, '.codex-tmp/model-closure-20260906/inventory-main.md') if (fs.existsSync(baseline)) fs.copyFileSync(baseline, path.join(reportDir, 'data/inventory-main.md')) const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) const arr = value => Array.isArray(value) ? value : [] const encode = value => value.replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/') const clean = value => typeof value === 'string' ? value.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]').replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g, '[已隐藏]') : Array.isArray(value) ? value.map(clean) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).filter(([key]) => !/^(password|secret|accessToken|refreshToken|token|authorization|cookie|uploadTicket|credentials)$/i.test(key)).map(([key, child]) => [key, clean(child)])) : value const missing = [], malformed = [] function read(name, required = false) { const file = path.join(reportDir, name) if (!fs.existsSync(file)) { if (required) missing.push(name); return null } try { return clean(JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))) } catch (error) { malformed.push({ file: name, error: error.message }); return null } } function state(value) { if (value === true) return 'PASS' if (value === false) return 'FAIL' const text = String(value ?? '').toUpperCase() if (['PASS', 'PASSED', 'SUCCESS', 'SUCCEEDED', 'EXPECTED'].includes(text)) return 'PASS' if (['FAIL', 'FAILED', 'ERROR', 'UNEXPECTED', 'TIMEDOUT', 'INTERRUPTED'].includes(text)) return 'FAIL' if (['FLAKY', 'WARN', 'WARNING'].includes(text)) return 'WARN' if (['SKIP', 'SKIPPED'].includes(text)) return 'SKIP' return 'PENDING' } const labels = { PASS: '已验证', FAIL: '失败', WARN: '需复核', PENDING: '待验证', SKIP: '未执行' } const badge = (value, label) => `${esc(label || labels[value] || value)}` const link = (file, label = file) => fs.existsSync(path.resolve(reportDir, file)) ? `${esc(label)}` : `${esc(label)} · 待提供` const pretty = value => `
${esc(JSON.stringify(value, null, 2))}
` const sourceFiles = { main: '../../../unreal_tran/src/main.js', timeline: '../../../unreal_tran/src/timeline.js', blueprint: '../../../unreal_tran/src/blueprint.js', curve: '../../../unreal_tran/src/model-curve-editor.js', state: '../../../unreal_tran/src/model-editor-state.js', api: '../../../unreal_tran/src/model-content-api.js', io: '../../../unreal_tran/src/editor-project-io.js', host: '../../../unreal_tran_web/src/views/content/LegacyModelEditorView.vue', bridge: '../../../unreal_tran_web/src/features/editors/model/legacy-iframe-permissions.ts' } // Implementation state and verification state are separate. No row is verified by source review alone. // [id, name, source locator, implementation, boundary, fix, planned acceptance] const groups = [ ['工程与数据', [ ['project.new', '新建工程', 'host:createNewProject', '本轮修复', '宿主创建新项目 ID 并跳转;新建不是清空并覆盖当前工程。', '新建复用正式 create API;创建失败或取消保留原工程。', '新建前后 ID 不同,原工程内容/版本不被覆盖;取消不发创建请求。'], ['project.import-glb', '导入装备 GLB', 'main:importModelFile', '已有实现', '只支持单文件 GLB;上传→登记→解析→最终保存。', '延续导入 busy/失败阻断及稳定资产校验。', '选择轻量 GLB,真实模型可见;刷新仍加载同一资产。'], ['project.import-json', '导入工程 JSON', 'main:importProjectFile', '本轮修复', '导入到当前工程,不创建副本;受控引用必须属于当前可用资产。JSON 不含 GLB 字节。', '导入前 materialize 稳定资产引用。', '同项目导出后再导入,材质/动画/环境不丢;无效或跨项目缺失引用报错。'], ['project.save', '手动保存 / Ctrl+S', 'main:saveProject', '已有实现', '同一工程保存内容和当前视口封面;成功以 API 响应为准。', '保留队列、版本保护和封面失败回退。', '保存真实 PUT 成功,刷新字段不丢;重复点击不交错。'], ['project.autosave', '30 秒自动保存', 'main:bindShellEvents', '已有实现', '自动保存工程内容;不反复截图更新封面,恢复稿待确认期间暂停。', '导入/封面保存期间阻断重入。', '修改后等待自动保存并刷新;忙碌时不提交半成品。'], ['project.name', '修改工程名', 'main:projectNameInput', '已有实现', '输入→失焦/Enter→保存;当前模型工程名。', '无本轮专门改动。', '改名保存后列表和重新打开一致。'], ['project.export-json', '另存项目 JSON', 'main:saveProjectAs', '已有实现', '下载工程 JSON;不是另建服务端项目,也不内嵌 GLB 文件。', '稳定资产引用保留,临时 blob 地址清除。', '下载文件可解析,包含轨道/蓝图/自定义字段,配套资源可重新解析。'], ['project.undo', '撤销 / Ctrl+Z', 'main:class HistoryStack', '本轮修复', '最多 60 个历史快照;不是数据库版本回滚。', '异步互斥,失败不移动索引,托管 GLB 快照重新解析。', '属性/曲线修改撤销;连续点击不并发;下载失败索引与修改保留。'], ['project.redo', '重做 / Ctrl+Y', 'main:class HistoryStack', '本轮修复', '撤销后重做;产生新编辑时清掉旧 redo 分支。', '与撤销共用异步恢复保护。', '重做恢复相同字段及姿态,不回放无效分支。'], ['project.recovery', '异常恢复副本', 'main:reconcileApiRecovery', '本轮修复', '同版本恢复稿需用户确认;旧版本隔离/下载,不静默覆盖服务器。', '恢复稿重新解析受控资源;指纹忽略对象键顺序,避免已保存页面刷新误生恢复稿。', '同版本恢复可见;旧版本保留/下载;主动保存前不自动 PUT。'], ['project.conflict', '保存/发布版本冲突', 'api:queueSave', '已有实现', '409 阻断;恢复稿保留。', '发布封面队列同样遵守冲突保护。', '定向模拟 409,无后续发布请求,不误报成功。'], ['project.permissions', '查看/编辑/新增/发布权限', 'bridge:installLegacyModelPermissions', '本轮修复', '按 content.model.* 权限,不按 teacher/admin 身份推断写权限;只读仍能搜索、选择原生动画和键盘激活预览。', '新建接真实 create 能力;只发布可发布已保存版本;修复查看控件键盘事件误拦截。', '只读查看操作可用且无内容写请求;不可编辑/保存;publisher 不上传/PUT,正常发布已有版本。'], ['project.leave', '离开/切角色未保存确认', 'host:confirmLeave', '已有实现', '取消留在当前页;保存失败不得离开;上传/保存/发布忙时保留状态。', '新建也复用现有未保存确认。', '取消、放弃、保存失败与 busy 分别验证,没有误清 dirty。'], ]], ['资源库与场景树', [ ['resource.catalog', '资源库浏览 / 选择装备', 'main:renderAssets', '本轮修复', '切换装备可能应用其基准时间轴/蓝图;不是把第二个主装备追加进当前模型。', '修复单节点选择器误用导致启动报错;忙碌期间禁止切换资源。', '完整资源库 DOM 初始化、卡片事件、空目录启动正常;切换实际几何可见,保存后资源一致。'], ['resource.search', '资源搜索', 'main:applyAssetSearch', '已有实现', '按资源名称/关键词过滤现有目录,不搜索服务器全站。', '无本轮专门改动。', '关键字命中、空结果与清空恢复。'], ['environment.builtin', '内置车间 / 野外 / 装备车库', 'main:resetEnvironmentPlaceholder', '已有实现', '车间/野外为程序化环境;车库为随包 GLB。', '保留真实 GLB 和占位环境来源区别。', '三种切换与重新打开可重现相应环境。'], ['environment.external', '导入外部车间 / 野外 GLB', 'main:loadEnvironmentSceneFile', '本轮修复', '一个环境根;受控 MODEL_FILE 资产 metadata.usage=environment,与主装备独立。', '上传、最终保存、JSON 与刷新恢复稳定引用;失败阻断半提交。', '小 GLB 上传后主装备不被替换;环境刷新仍可见且资产引用一致。'], ['resource.loading', '模型加载进度 / 失败 / 重试', 'host:installLegacyModelIframeLoadingScope', '已有实现', '局部加载状态;错误短提示,中性失败状态可重试。', '复用前轮加载范围与错误消息修复。', '资源失败不无限转圈;重试恢复;无凭据泄露。'], ['tree.browse', '场景树节点与计数', 'main:buildSceneTree', '现有边界', '树行上限 220;统计节点总数不等于当前展开行数。', '无本轮容量重构。', '模型/环境节点可达,计数说明不当作全部节点已经逐项验收。'], ['tree.expand', '节点展开 / 折叠', 'main:buildSceneTree', '已有实现', '当前界面浏览状态;无须持久化展开布局。', '无本轮专门改动。', '展开和折叠子级不修改模型对象。'], ['tree.search', '场景树搜索', 'main:buildSceneTree', '已有实现', '搜索当前载入对象,不新增/删除对象。', '无本轮专门改动。', '搜索节点后选中正确对象,清空恢复树。'], ['tree.visibility', '显示 / 隐藏对象', 'main:buildSceneTree', '本轮修复', '对象 visible 保存;隐藏祖先的子对象不接受射线拾取。', '拾取与高亮统一检查祖先可见性。', '隐藏父级后不能点到子级,保存重开仍隐藏。'], ['selection.scope', '整体 / 部件级选择', 'main:setSelectionScope', '已有实现', '整体作用主装备根;部件作用实际节点。', '保留当前选择层级合同。', '点击可见 mesh,整体/部件切换选择目标不同。'], ['selection.sync', '树 / 视口 / 属性选择联动', 'main:selectObject', '本轮修复', '显示实际对象并更新辅助框;环境也是可编辑对象。', '关闭轮廓后重选不恢复辅助框。', '三处名称/对象 ID 一致,重选后轮廓状态保持。'], ]], ['三维视图与操作', [ ['view.orbit', '鼠标旋转 / 平移 / 缩放视角', 'main:OrbitControls', '已有实现', '当前三维相机;不是模型变换。', '无本轮专门改动。', '鼠标操作改变镜头但不改变对象局部坐标。'], ['view.focus', '聚焦选中 / F', 'main:fitCamera', '已有实现', '按当前对象包围盒定位相机。', '无本轮专门改动。', '小模型/部件聚焦后仍可见,不被裁剪。'], ['view.move', '移动工具 / W', 'main:setTransformMode', '本轮修复', '拖拽编辑对象局部位置,保存后重开一致。', '动画姿态下更新基值只扣实际偏移。', '移动后保存、刷新及动画再播放不漂移。'], ['view.rotate', '旋转工具 / E', 'main:setTransformMode', '本轮修复', '对象旋转;与当前时间轴旋转叠加需要一致。', '统一动画基值处理。', '旋转后保存/撤销/重做姿态一致。'], ['view.scale', '缩放工具 / R', 'main:setTransformMode', '已有实现', '对象缩放持久化;时间轴没有缩放轨道 UI。', '无新增缩放动画能力。', '对象缩放保存和重开正确。'], ['view.grid', '网格开关', 'main:toggleGrid', '已有实现', '临时视图选项,不要求写入工程。', '非持久化不计保存缺陷。', '开关只影响辅助网格,不改变模型。'], ['view.pbr', 'PBR 光照开关', 'main:handleView', '已有实现', '当前照明预览选项;不是批量材质编辑。', '非持久化不计保存缺陷。', '开关改变显示,物体材质字段不被覆盖。'], ['view.wireframe', '线框开关', 'main:handleView', '已有实现', '当前视图选项;不承诺保存到工程。', '非持久化不计保存缺陷。', '线框开关恢复正常着色。'], ['view.outline', '选中轮廓开关', 'main:handleView', '本轮修复', '当前会话偏好,进出预览后保持;不要求跨重载存储。', '重选时不重新泄露轮廓。', '关闭→重选→预览→退出,轮廓仍关闭。'], ['view.perspective', '透视视角按钮', 'main:handleView', '现有边界', '重置透视观察角度;当前没有正交视角菜单。', '不把临时视图状态当作保存缺陷。', '点击后相机和聚焦目标正常。'], ['view.snapshot', '下载视口截图', 'main:downloadSnapshot', '已有实现', '当前 canvas PNG,可包含编辑辅助元素;保存封面是独立截图流程。', '无本轮专门改动。', 'PNG 下载可打开,内容与当前视口对应。'], ['view.panels', '底部页签 / Tab / 面板高度', 'main:switchBottomMode', '已有实现', '时间轴、蓝图、曲线、日志切换;拖分割条只调整当前工作区。', '曲线入口接真实数据。', '各页签显示正确内容,调整高度后画布正确 resize。'], ['view.preview', '进入 / 退出运行预览', 'main:togglePreview', '本轮修复', '真实当前三维场景;隐藏辅助工具,退出不丢编辑状态。', '进入显式派发 auto;退出停止运行并恢复轮廓策略。', '进入/退出与 Esc,三维仍可见,无辅助框泄漏。'], ['view.preview-step', '预览播放 / 前后导航', 'main:bindShellEvents', '现有边界', '播放运行蓝图;前后按钮当前按时间轴 ±2 秒,不是按业务工序跳转。', '无新增业务工序导航。', '播放触发真实流程;前后跳时与时间轴一致。'], ]], ['对象属性与扩展标记', [ ['property.name', '对象名称', 'main:bindInspectorEvents', '已有实现', '修改当前节点名,标识保持稳定。', '无本轮专门改动。', '树和属性同步,保存重开名称保留。'], ['property.identity', '对象标识 / 状态说明', 'main:renderInspector', '本轮修复', '标识只读;可见性含祖先状态,阴影取实际castShadow;编辑能力由宿主权限注入。', '不再固定显示“可见/投射阴影”,补宿主可编辑状态钩子。', '标识不可编辑;选中对象、隐藏状态、阴影及只读权限标签一致。'], ['property.position', '位置 XYZ 数值', 'main:bindInspectorEvents', '本轮修复', '局部坐标;动画当前帧下编辑也能生成正确基值。', '中性姿态与实际应用偏移统一。', '三个轴编辑、保存重开、动画回放一致。'], ['property.rotation', '旋转 XYZ 数值', 'main:bindInspectorEvents', '本轮修复', '输入角度转弧度存储,显示角度。', '多轨旋转基值不重复扣除。', '度数显示与存储/播放的弧度一致。'], ['property.scale', '缩放 XYZ 数值', 'main:bindInspectorEvents', '已有实现', '对象当前缩放,不添加新的缩放轨道。', '无本轮专门改动。', '非均匀缩放可保存和重开。'], ['property.color', '材质基础色', 'main:bindInspectorEvents', '现有边界', '只编辑当前对象第一个材质槽;共享材质可能影响共享该实例的网格。', '界面明确材质槽 1/N。', '颜色修改、保存重开对应当前槽;不宣称所有槽可编辑。'], ['property.metalness', '材质金属度', 'main:bindInspectorEvents', '现有边界', '当前首槽 PBR 参数 0~1。', '标明材质槽范围。', '拖动参数有效,保存重开值一致。'], ['property.roughness', '材质粗糙度', 'main:bindInspectorEvents', '现有边界', '当前首槽 PBR 参数 0~1。', '标明材质槽范围。', '参数与当前槽持久化一致。'], ['property.opacity', '材质透明度', 'main:bindInspectorEvents', '现有边界', '当前首槽;联动 transparent/depthWrite。', '保留实际透明度保存字段。', '透明效果与重开结果一致。'], ['property.code', '自定义资源编码', 'main:bindInspectorEvents', '本轮修复', '对象 customProperties.resourceCode,不是服务器资产主键。', '输入真正回写、保存、恢复及撤销。', '编辑、清空、重选、JSON 往返、刷新均一致。'], ['property.status', '自定义运行状态', 'main:bindInspectorEvents', '本轮修复', 'NORMAL/SERVICE 标签,不会发起真实设备控制。', '真正写回对象,首次变更可撤销。', '两状态切换及刷新保留。'], ['property.binding', '流程绑定说明', 'main:renderInspector', '现有边界', '只读显示模板/时间轴名称,不是教学任务的设备或物联网绑定。', '无本轮业务绑定改造。', '只读,不将其当作任务绑定操作验收。'], ['component.inspect', '组件页内置能力说明', 'main:renderComponents', '现有边界', '变换、场景节点、渲染等派生说明,没有通用组件启停系统。', '区分内置说明与自定义标记。', '显示与对象类型一致,不虚报可执行组件配置。'], ['component.tag', '添加扩展标记', 'main:renderComponents', '本轮澄清', '只保存名称标签;没有脚本、启停、运行时行为。', '从“添加组件”改为“扩展标记,仅保存标签”。', '新增标签保存/重开;不得声称已启用新行为。'], ]], ['时间轴与曲线', [ ['timeline.play', '播放 / 暂停 / Space', 'timeline:play()', '已有实现', '播放当前编辑时间轴;原生 GLB 动画单独控制。', '停止清理与时间同步由本轮补强。', '播放进度/真实对象姿态变化,暂停停在当前帧。'], ['timeline.stop', '停止', 'timeline:stop()', '已有实现', '停止并回到起始时间,按起始帧应用动画。', '无本轮语义扩展。', '按钮停止后不再自行前进。'], ['timeline.loop', '循环开关', 'timeline:#bindEvents', '本轮修复', '循环设置随 timeline 保存;不循环到达终点只完成一次。', '循环变更进入历史/dirty。', '开关、保存重开、到末尾/循环边界正确。'], ['timeline.key-nav', '上一 / 下一关键帧', 'timeline:#onClick', '已有实现', '按时间轴关键帧时间定位。', '无本轮专门改动。', '多个轨道时跳到正确相邻关键时刻。'], ['timeline.seek', '拖播放头 / 点击刻度', 'timeline:#onPointerDown', '本轮修复', '实时求值当前帧;键盘左右移动一帧,Shift 移动一秒。', '拖动补发 timechange,解绑指针监听。', '时间码、曲线和视口姿态同步。'], ['timeline.add-track', '添加轨道', 'timeline:addTrack(', '已有实现', '当前主装备节点的变换轨道。', '无新增轨道类型。', '增加后可绑定节点并保存。'], ['timeline.remove-track', '删除轨道', 'timeline:removeTrack(', '本轮修复', '删除后不再保留该轨道施加的旧姿态。', '宿主恢复此前作用的属性基值。', '删除即时复原,再保存/重开一致。'], ['timeline.target', '修改轨道目标节点', 'timeline:updateSelectedTrack', '本轮修复', '用稳定对象标识绑定,不靠同名第一项。', '重绑清理旧目标的应用姿态。', '从 A 改为 B,A 复原,B 播放。'], ['timeline.property', '选择位置 / 旋转属性', 'timeline:updateSelectedTrack', '已有实现', '位置 XYZ 向量、旋转 X/Y/Z;没有缩放轨道 UI。', '保留当前实际能力。', '属性切换后关键帧类型和播放一致。'], ['timeline.mute', '轨道静音', 'timeline:#onClick', '本轮修复', '仅忽略当前轨道,不关闭其他轨道。', '不再停留旧姿态或保存时重复扣值。', '静音/恢复后实际姿态与重开一致。'], ['timeline.record', '记录当前姿态关键帧', 'main:addKeyframeFromSelection', '本轮修复', '记录相对对象基值的当前值。', '与多轨实际应用/保存采用同一基值。', '操作对象后记录,回放命中相同姿态。'], ['timeline.add-key', '双击新增关键帧', 'timeline:#onDoubleClick', '已有实现', '在轨道对应时刻创建/更新关键帧。', '时间碰撞保留当前编辑帧。', '新增后节点可选,时间/数值可保存。'], ['timeline.delete-key', '删除关键帧 / Delete', 'timeline:deleteSelected', '已有实现', '删除选中的关键帧;输入框编辑时不截获删除键。', '删除和监听清理回归。', '删除后曲线和播放变化,撤销可恢复。'], ['timeline.drag-key', '关键帧拖动 / Alt 取消吸附', 'timeline:#onPointerDown', '本轮修复', '默认 1/30 秒吸附;重叠时合并,保留正在编辑帧。', '拖动时间同步,碰撞处理确定。', '拖动/重叠/Alt 行为和保存后时间正确。'], ['timeline.zoom', '时间轴缩放', 'timeline:setZoom', '已有实现', '显示缩放随 timeline 记录;不改变时长/关键帧数值。', '无本轮专门改动。', '缩放不改变动作内容。'], ['timeline.multitrack', '多轨组合与保存基值', 'state:applyEditorTimelinePose', '本轮修复', '不同属性组合;同对象同属性最后有效轨道生效。', '求值、手工编辑基值、序列化一致。', '两轨同属性、静音、删轨、重绑、重开无漂移。'], ['timeline.baseline', '载入装备基准动作', 'main:loadBaselineAnimation', '现有边界', '匹配部件后直接替换当前时间轴/蓝图;当前没有单独确认框,不是任意维修流程自动生成。', '保持当前装备限定;无匹配只提示,不改工程。', '匹配时载入/保存/重开;无匹配时保留原数据并给出提示。'], ['timeline.native', 'GLB 原生动画选择 / 播放', 'main:selectNativeAnimation', '已有实现', '真实 GLB 自带片段;保存选择索引,不是蓝图 clip 字段。', '无片段时禁用。', '狐狸实际动画播放/暂停,选择保存后重开一致。'], ['timeline.curve', '曲线选择轨道 / 关键帧', 'curve:class ModelCurveEditor', '本轮修复', '曲线与时间轴共用一份轨道和关键帧数据。', '移除固定 SVG 假数据。', '实际轨道名和点数匹配时间轴,点击同步当前帧。'], ['curve.time', '曲线编辑时间', 'curve:change(input)', '本轮修复', '更新实际 key.time,约束在时间轴时长内。', '调用 timeline.updateKeyframe 并保持编辑目标。', '改时间后重排、撤销、JSON 往返一致。'], ['curve.value', '曲线编辑标量 / XYZ 数值', 'curve:change(input)', '本轮修复', '修改实际关键帧值,向量编辑保留其他分量。', '无效空值拒绝提交。', '标量和向量都驱动真实姿态并保存。'], ['curve.easing', '曲线插值', 'curve:change(input)', '本轮修复', '支持线性、平滑、缓入、缓出、阶梯;没有 Bezier 切线 UI。', '阶梯插值与已有下游时间轴求值一致。', '修改插值改变中间帧,重开保留。'], ['curve.axis', '曲线 XYZ 分量显示', 'curve:change(input)', '已有实现', '改变图上显示分量,不改另外两轴的数据。', '当前实际向量轨道才出现。', '切分量显示不同曲线,数值不被改写。'], ]], ['蓝图与交互反馈', [ ['blueprint.palette', '节点库搜索 / 拖入节点', 'blueprint:_renderPalette', '已有实现', '当前七类节点:触发、时间轴、等待、高亮、提示、条件、完成。', '不新增任意脚本节点。', '搜索、拖入后节点类型和参数可保存。'], ['blueprint.add', '添加节点按钮', 'blueprint:_bind()', '已有实现', '工具栏默认添加提示节点;其他类型来自节点库。', '无本轮专门改动。', '新增进入画布、历史与 JSON。'], ['blueprint.parameters', '节点标题 / 参数编辑', 'blueprint:_renderInspector', '已有实现', '按节点类型提供数字、文字、枚举、复选框。', '保留数值边界与真实数据写回。', '改参数与标题后重开保留。'], ['blueprint.move', '节点拖动 / 多选框选', 'blueprint:_onViewportPointerDown', '已有实现', '节点布局随蓝图 JSON 保存。', '无本轮专门改动。', '多选移动位置正确,撤销恢复。'], ['blueprint.connect', '连接 / 替换 / 删除执行线', 'blueprint:connect(', '本轮修复', '单输出重连替换;允许多个来源汇聚同一输入。', '不再清掉同一输入的其他合法来源。', 'true/false 汇聚保留两条,替换输出只影响该线。'], ['blueprint.delete', '删除节点 / 连线', 'blueprint:deleteSelection', '已有实现', '删除节点一并清理相关边。', '复制/剪切使用同一图数据合同。', '无悬空边;撤销恢复完整图。'], ['blueprint.copy', '复制 / 剪切 / 粘贴', 'blueprint:copySelection', '本轮修复', 'Ctrl/Cmd+C/X/V;复制选区内部边,不复制指向外部的边。', '新节点 ID 和内部连线同步生成,阻止快捷键冒泡。', '副本内部连接正确,不误删外部对象。'], ['blueprint.canvas', '平移 / 缩放 / 适应画布', 'blueprint:frameAll', '已有实现', '蓝图画布浏览;不是三维相机。', '无本轮专门改动。', '滚轮、Space 拖动和适应可见全部节点。'], ['blueprint.run', '运行 / F5', 'blueprint:async run(', '本轮修复', '在当前模型编辑器驱动真实场景;有 200 步循环上限。', '条件与等待流程回归。', '执行高亮、动作、反馈;循环图可终止不挂死。'], ['blueprint.stop', '停止运行', 'blueprint:stop(', '本轮修复', '停止中断挂起等待,旧运行不覆盖新运行状态。', 'AbortSignal 贯穿宿主动作与高亮清理。', '等待/动画中停止及时退出,无迟到完成提示。'], ['blueprint.reset', '重置执行', 'blueprint:resetExecution', '已有实现', '复位执行状态、时间轴和高亮;保留蓝图编辑内容。', '高亮恢复原材质。', '运行后重置,进度归零且节点图不丢。'], ['blueprint.click', '点击触发目标', 'blueprint:async trigger(', '本轮修复', '匹配事件和目标 ID/名称/祖先别名;空目标可通配。', '不再点击 B 触发 A。', '两个不同目标分别点击,只运行匹配分支。'], ['blueprint.double-click', '双击触发目标', 'main:pickObject', '本轮修复', '双击独立派发 double-click,与单击区分。', '增加真实视口双击派发并抑制待发单击。', '双击只触发目标的双击分支。'], ['blueprint.auto', '自动触发', 'main:togglePreview', '本轮修复', '仅进入预览显式派发 auto,不把每次单击当 auto。', '事件匹配与进入预览联动。', '进入预览触发一次,普通点击不重放自动分支。'], ['blueprint.timeline', '播放时间轴节点', 'main:playTimelineForBlueprint', '本轮修复', '播放当前唯一时间轴,动作名称仅描述;支持起始时间、速度、等待。', '起始末帧不反跳零,终止可取消。', '起始时间和速度实际生效,等待与不等待分支正确。'], ['blueprint.wait', '等待节点', 'blueprint:NODE_TYPES', '已有实现', '指定毫秒等待;可被停止取消。', '异步停止及时结束。', '等待期间停止不会继续执行后续节点。'], ['blueprint.highlight', '高亮节点 / 呼吸效果', 'main:highlightPart', '本轮修复', '准确命中可见对象;同名歧义要求标识;没有可高亮材质则报错。', '拒绝错误整机回退,真实呼吸效果,保存忽略临时高亮。', '目标正确、找不到报错、结束恢复、保存不永久染色。'], ['blueprint.message', '操作提示节点', 'main:showTeachingMessage', '现有边界', '显示标题/文本,可点击关闭或约 1.8/2.5 秒自动继续;不是强制人员确认。', '不将其当作业务审核/签名。', '文本出现、关闭和停止不留弹层。'], ['blueprint.condition', '条件判断节点', 'main:evaluateBlueprintCondition', '本轮修复', '当前运行变量、嵌套字段、truthy/布尔/数字比较。', 'false 没有出口不走 true,字符串布尔正确解析。', '真假分支分别验证,缺出口不误执行。'], ['blueprint.finish', '流程完成节点', 'main:initEditors', '已有实现', '本次预览结果/进度提示,不写教学任务成绩。', '保持本次运行反馈边界。', '成功/完成提示与实际执行结果一致。'], ]], ['发布、交付与运行信息', [ ['publish.preflight', '生成 / 发布检查面板', 'main:togglePublishPanel', '现有边界', '本地主要检查资源引用存在及轨道/节点数量,不执行完整蓝图可达性、终止性或业务校验;服务端发布校验最终生效。', '发布目标与离线交付能力明确。', '可打开关闭;不完整工程不能凭界面绿标绕过服务端。'], ['publish.api', '发布到场景资源库', 'main:publishToSceneLibrary', '本轮修复', '先保存当前工程再发布固定版本;纯 publisher 发布已保存版本。', '整个保存/发布队列互斥,失败不发后续发布。', '发布版本/模型资产/列表状态一致,保存失败不发布。'], ['publish.cover', '发布时当前三维截图封面', 'main:publishToSceneLibrary', '本轮修复', 'JPEG 当前视口;截图或上传失败可保留旧封面并明确提示。', '与手动保存共用封面链路,409 阻断。', '真实封面图对应当前模型;上传失败提示一次,旧图保留。'], ['publish.html', '导出步骤演示 HTML', 'main:exportStandaloneHtml', '本轮澄清', '工程数据、静态截图和步骤演示;不包含 GLB、不执行真实三维交互。', '生成面板和按钮移除完整离线运行的误导。', '下载可打开、步骤导航可用,醒目标明不含三维模型。'], ['publish.downstream', '下游读取模型固定版本', 'api:publishDocument', '现有边界', '已发布模型作为资源引用;下游自动执行模型 timeline/blueprint 尚无统一运行桥接。', '本轮不将资源加载成功等同动作自动运行成功。', '固定引用/可见性单独测;自动动作执行列为待确认范围。'], ['feedback.log', '运行日志', 'main:log(message', '已有实现', '本次内存运行日志,最多 160 条;不是服务端审计日志。', '错误信息转义,保留清晰运行结果。', '选中/运行/保存产生相应日志,不要求刷新保留。'], ['feedback.stats', '节点 / 三角面 / FPS 等状态', 'main:updateStats', '现有边界', '节点与三角面统计、当前 FPS;内存为估算值,不是精确浏览器内存剖析。', '无本轮性能仪表重构。', '载入模型后统计有合理变化,状态不作为性能验收数据。'], ['feedback.toast', '成功 / 错误短消息', 'main:toast(message', '已有实现', '瞬时反馈;保存/发布成功以接口完成为准。', '封面失败不伪报全部成功。', '失败出现一次明确提示,自动消失,不形成重复常驻错误条。'], ]], ] const sourceCache = new Map() function locate(locator) { const colon = locator.indexOf(':'), key = locator.slice(0, colon), needle = locator.slice(colon + 1), file = sourceFiles[key] if (!sourceCache.has(file)) sourceCache.set(file, fs.existsSync(path.resolve(reportDir, file)) ? fs.readFileSync(path.resolve(reportDir, file), 'utf8') : '') const lines = sourceCache.get(file).split(/\r?\n/) const name = needle.replace(/^async /, '').replace(/\(.*/, '') const declaration = new RegExp(`^\\s*(?:export\\s+)?(?:async\\s+)?(?:function\\s+)?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`) const declared = lines.findIndex(line => declaration.test(line)) const index = declared >= 0 ? declared : lines.findIndex(line => line.includes(needle)) return { file, line: index < 0 ? null : index + 1, symbol: needle } } const catalog = groups.flatMap(([group, rows]) => rows.map(([id, name, locator, implementation, boundary, fix, acceptance]) => ({ id, group, name, source: locate(locator), implementation, boundary, fix, acceptance }))) fs.writeFileSync(path.join(reportDir, 'feature-catalog.json'), JSON.stringify({ schemaVersion: 1, features: catalog }, null, 2)) const results = read('results.json', true), validation = read('validation.json', true), cleanup = read('cleanup.json'), artifactChecks = read('artifact-checks.json'), runHistory = read('run-history.json'), finalCorrections = read('data/final-corrections.json') const proofFiles = fs.existsSync(path.join(reportDir, 'evidence')) ? fs.readdirSync(path.join(reportDir, 'evidence')).filter(name => name.endsWith('.json')).sort().map(name => `evidence/${name}`) : [] const proofData = proofFiles.map(file => ({ file, data: read(file) })) function e2eRows(suites, parents = []) { return arr(suites).flatMap(suite => [ ...arr(suite.specs).flatMap(spec => arr(spec.tests).map(item => { const last = arr(item.results).at(-1) return { title: [...parents, spec.title].filter(Boolean).join(' · '), rawTitle: spec.title, status: item.status === 'unexpected' ? 'FAIL' : item.status === 'flaky' ? 'WARN' : state(last?.status), startTime: last?.startTime, duration: last?.duration, attachments: arr(last?.attachments).map(a => ({ name: a.name, path: a.path, contentType: a.contentType })), errors: arr(last?.errors).map(error => error.message || String(error)) } })), ...e2eRows(suite.suites, suite.file ? parents : [...parents, suite.title].filter(Boolean)), ]) } const browserRows = e2eRows(results?.suites) const proofs = [] function collect(value, file, inherited = {}) { if (!value || typeof value !== 'object') return const ownStatus = state(value.status ?? value.result ?? value.overallStatus), status = ownStatus === 'PENDING' ? inherited.status : ownStatus const testTitle = value.testTitle || value.test || inherited.testTitle const recordedAt = value.recordedAt || value.time || inherited.recordedAt const kind = value.kind || value.type || inherited.kind || 'evidence' const ids = [...arr(value.featureIds), ...arr(value.features), ...(typeof value.featureId === 'string' ? [value.featureId] : [])].filter(item => typeof item === 'string') if (ids.length) proofs.push({ ids, file, status: status || 'PENDING', testTitle, recordedAt, kind, title: value.title || value.name || testTitle || file, screenshots: arr(value.screenshots), details: value.scope || value.note || value.details || '' }) for (const [key, child] of Object.entries(value)) if (child && typeof child === 'object' && !['features', 'featureIds', 'screenshots', 'attachments'].includes(key)) { if (Array.isArray(child)) child.forEach(item => collect(item, file, { status, testTitle, recordedAt, kind })) else collect(child, file, { status, testTitle, recordedAt, kind }) } } proofData.forEach(({ file, data }) => collect(data, file)) collect(validation?.coverage, 'validation.json') for (let index = 0; index < proofs.length; index++) proofs[index] = resolveBrowserProof(proofs[index], browserRows) const coverage = catalog.map(feature => { const evidence = proofs.filter(item => item.ids.includes(feature.id)), statuses = evidence.map(item => item.status) const status = statuses.includes('FAIL') ? 'FAIL' : statuses.includes('WARN') ? 'WARN' : statuses.includes('PASS') ? 'PASS' : 'PENDING' return { ...feature, verification: status, evidence } }) 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 browserCount = count(browserRows), featureCount = count(coverage.map(row => ({ status: row.verification }))) // A runtime result:false can be the expected result of an unmatched trigger. // Only explicit check outcomes / failed commands are failures, not arbitrary booleans in snapshots. const anyFailure = value => value && typeof value === 'object' && (state(value.overallStatus ?? value.status ?? (typeof value.result === 'string' ? value.result : undefined)) === '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 failed = browserCount.fail || featureCount.fail || arr(results?.errors).length || Number(results?.stats?.unexpected || 0) || anyFailure(validation) || anyFailure(cleanup) || anyFailure(artifactChecks) || malformed.length || proofData.some(item => anyFailure(item.data)) const finalRun = validation?.e2eComplete === true || validation?.finalRun === true || validation?.e2e?.complete === true const runVerified = results && validation && browserCount.total > 0 && browserCount.pending === 0 && finalRun && state(validation.overallStatus ?? validation.status) === 'PASS' const finalArtifactsVerified = artifactChecks?.finalArtifactScan === true && state(artifactChecks.status ?? artifactChecks.secretScan) === 'PASS' && ['results.json', 'validation.json', 'cleanup.json', 'playwright/index.html'].every(file => { const target = path.join(reportDir, file) return fs.existsSync(target) && artifactChecks.finalInputSha256?.[file] === crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex') }) const overall = failed ? 'FAIL' : runVerified && featureCount.pending === 0 && finalArtifactsVerified ? 'PASS' : 'PENDING' const overallTitle = failed ? '存在失败,当前验收未通过' : overall === 'PASS' ? '已列功能的记录验证通过' : runVerified && featureCount.pending === 0 ? '功能验收已通过,最终交付物扫描待完成' : runVerified ? '已执行验收通过,部分功能尚未逐项验证' : '实现已整理,最终验收证据待完成' fs.writeFileSync(path.join(reportDir, 'coverage.json'), JSON.stringify({ generatedAt: new Date().toISOString(), overall, featureCount, browserCount, finalRun, finalArtifactsVerified, features: coverage }, null, 2)) const screenshotDir = path.join(reportDir, 'screenshots') const screenshots = fs.existsSync(screenshotDir) ? fs.readdirSync(screenshotDir).filter(name => /\.(png|jpe?g|webp)$/i.test(name)).sort() : [] const captions = read('screenshot-captions.json') || {} const requirements = [ ['部件级选择与操作', '整体与部件区分;树、视口和属性联动;操作后可保存重开。', 'selection.scope / selection.sync / property.*'], ['动作流程', '时间轴关键帧、曲线插值、蓝图顺序和真假分支有实际执行结果。', 'timeline.* / curve.* / blueprint.*'], ['交互反馈', '点击/双击/自动事件、高亮、文字提示、停止和错误反馈与对象匹配。', 'blueprint.click / double-click / auto / highlight / message'], ['结果追踪', '模型版本、封面、引用和本次运行结果有证据;明确运行日志不是教学成绩或审计。', 'publish.* / feedback.* / project.conflict'], ] const decisions = [ { id: 'D1', title: '真正的组件运行系统', current: '“组件”页目前是内置能力说明;新增项只保存扩展标记名称,没有启停、参数执行或生命周期。', behavior: '用户新增一种行为组件,配置参数后能在当前模型和约定下游环境执行;停用后立即停止,重开后按保存状态恢复。', scope: '先定义有限组件目录、参数 schema、权限与版本;实现初始化/更新/销毁及错误隔离。不要直接开放任意脚本。', acceptance: '每种批准组件可添加、编辑、启停、删除、保存重开;重复载入不重复绑定事件;失败组件不阻断其他对象,运行结果可定位。' }, { id: 'D2', title: '完整三维离线运行包', current: '现有 HTML 是嵌入静态截图与工程数据的步骤演示,可离线查看;没有 GLB 字节和完整三维运行时。', behavior: '断网电脑打开交付包,真实模型/材质/原生动画/编辑时间轴与交互节点可以运行,且不依赖服务端 blob URL。', scope: '明确交付形式(目录/ZIP或单文件)、体积约束、资产授权和三维依赖;打包全部受控资产、运行时和相对资源清单。', acceptance: '清缓存断网运行;模型和环境完整可见,动作/分支/高亮可交互;资源缺失明确报错;包内清单/哈希与发布版本一致。' }, { id: 'D3', title: '下游场景 / 训练自动执行模型动画与蓝图', current: '发布模型可供下游按固定版本引用;不同下游已有局部动画能力,但没有统一的模型 timeline/blueprint 自动执行桥接。加载成功不等于动作已自动执行。', behavior: '在场景或训练放入模型实例后,按明确触发策略启动该发布版本的时间轴/蓝图;多个实例互不干扰。', scope: '统一实例目标命名空间、事件总线、生命周期和停止规则;明确自动播放、用户触发与训练步骤触发优先级,以及错误/完成结果回传。', acceptance: '两个同模型实例分别触发不串台;锁定发布版本;停止/切步骤/卸载无迟到动作;真实对象姿态、分支和结果回传均有 E2E 证据。' }, ] const table = (heads, rows) => `
${heads.map(head => ``).join('')}${rows.map(row => `${row.map(cell => ``).join('')}`).join('')}
${esc(head)}
${cell}
` const sourceLink = source => link(source.file, `${path.basename(source.file)}${source.line ? `:${source.line}` : ''} · ${source.symbol}`) const evidenceLinks = ['results.json', 'run-history.json', 'validation.json', 'cleanup.json', 'artifact-checks.json', 'coverage.json', 'feature-catalog.json', 'coverage-plan.md', 'data/inventory-main.md', 'data/unit-coverage-plan.json', 'data/final-corrections.json', 'data/kingbase-validation-probe-result.json', 'data/visual-qa.json', 'screenshot-captions.json', 'screenshot-verification.json', ...proofFiles] const fileHashes = new Map() const fileHash = file => { if (!file || !fs.existsSync(file)) return null if (!fileHashes.has(file)) fileHashes.set(file, crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')) return fileHashes.get(file) } function attachmentFile(attachment) { const normalized = String(attachment.path || '').replaceAll('\\', '/') const marker = normalized.lastIndexOf('/test-results/') // Remap Playwright absolute paths to this checked-out report directory. const candidate = path.resolve(reportDir, marker >= 0 ? normalized.slice(marker + 1) : normalized) const relative = path.relative(reportDir, candidate) return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? candidate : null } const screenshotEvidence = [] const gallery = screenshots.map((filename, index) => { const base = filename.replace(/\.[^.]+$/, ''), caption = captions[filename] || captions[base] const title = typeof caption === 'string' ? caption : caption?.title || base.replace(/^\d+[a-z]?[-_]?/, '').replaceAll('-', ' ') const detail = caption?.description || caption?.detail || '过程画面;只有关联断言通过时才作为相应功能的验证证据。' const associated = browserRows.flatMap(row => row.attachments.filter(attachment => attachment.name === base || attachment.name === filename).map(attachment => ({ title: row.title, status: row.status, hashMatches: fileHash(attachmentFile(attachment)) === fileHash(path.join(screenshotDir, filename)), }))) const verification = screenshotProofStatus(associated) screenshotEvidence.push({ file: `screenshots/${filename}`, title, status: verification, associations: associated }) const note = verification === 'PASS' ? '本轮附件一致' : verification === 'FAIL' ? '本轮用例失败' : verification === 'WARN' ? '用例需复核' : associated.length ? '图片与本轮附件待核对' : '过程截图' return `
${esc(title)}
${String(index + 1).padStart(2, '0')}${esc(title)}${badge(verification, note)}

${esc(detail)}

${esc(filename)}
` }).join('') fs.writeFileSync(path.join(reportDir, 'screenshot-verification.json'), JSON.stringify({ time: new Date().toISOString(), scope: '截图与当前 results.json 的附件逐字节 SHA256 一致性;同名预跑或重拍不自动计本轮通过。', screenshots: screenshotEvidence }, null, 2)) const statusCountCards = `
功能清单${catalog.length}全部可达动作逐项整理
逐项证据${featureCount.pass} / ${featureCount.total}${featureCount.pending} 项仍待验证
浏览器 E2E${browserCount.pass} / ${browserCount.total}${browserCount.fail} 失败 · ${finalRun ? '最终轮已标记' : '最终轮未确认'}
截图 / 待确认${screenshots.length} / 3截图按实际文件读取
` const document = `装备模型编辑器 · 全功能闭环验收
MODEL AUTHORING · CLOSURE REVIEW · 2026.09.06

装备模型编辑器
全功能闭环审查与验收

沿资料 → 产品原型 → 设计 → 开发 → 测试核查每一个可达动作。用狐狸、牛奶卡车等轻量模型验证通用编排能力,保留现有业务流程与版本数据。

${esc(overallTitle)}

${badge(overall)} · 报告生成:${esc(new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }))}(北京时间)。源码已接通、合同测试和真实浏览器验证分别记录;没有断言关联的功能仍为待验证。

${missing.length ? `

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

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

证据读取失败:${esc(JSON.stringify(malformed))}

` : ''}
${statusCountCards}

“已实现/本轮修复”是源码状态;绿色“已验证”要求独立证据。过程截图与测试总数不能自动把 ${catalog.length} 个功能全部变成通过。

从参考资料提取通用能力

原始两份 PDF 共 11 页已由主代理完整渲染并视觉阅读。原始诉求集中在部件选择、操作步骤、交互反馈和结果追踪。本轮以狐狸、牛奶卡车等轻量模型验证部件选择、流程编排、交互反馈和结果保存。

${link('../../../../文档/维修训练内容设计.pdf', '维修训练内容设计.pdf')} · ${link('../../../../文档/电器控制系统故障维修脚本.pdf', '电器控制系统故障维修脚本.pdf')}。报告仅链接原文,不复制全文或整页图片。

${table(['资料能力', '本轮可观察结果', '矩阵追踪'], requirements.map(row => row.map(esc)))}

确认用户实际使用的执行入口

内容模型列表

选中正式项目,进入模型编辑路由。

Vue 宿主

传 projectId / api=1,注入权限与未保存确认。

同源 iframe

承载 legacy-model-editor/editor.html。

真实原型源码

main.js + timeline.js + blueprint.js,经 stage 构建。

内容 API

受控资产、项目版本、保存和发布。

当前默认入口由 ${link('../../../unreal_tran_web/src/config/content.ts', 'content.ts')} 和 ${link(sourceFiles.host, 'LegacyModelEditorView.vue')} 确定,${link('../../../unreal_tran_web/scripts/stage-legacy-model-editor.mjs', 'stage-legacy-model-editor.mjs')} 将真实原型源码打入静态资源。备用 DigitalTwinEditor.js 不是本轮默认入口。

覆盖顶部工具、场景树/资源库、三维视口、对象属性/组件说明、时间轴、曲线、蓝图、发布面板、运行日志和快捷键。代码中保留但没有可达按钮的“训练场景 JSON 导入”不计为用户可达功能。

明确保存、运行与验证的边界

工程数据闭环

对象变换/材质/自定义字段、时间轴/蓝图和环境稳定引用必须能保存、重开、撤销及 JSON 往返。临时 blob 只用于渲染,服务端资产标识用于持久化。

导入、截图、保存与发布期间保留互斥和乐观锁;失败不得用成功消息掩盖,也不得发布半绑定资源。

界面与业务范围

网格、线框、PBR、透视和展开布局属于当前视图选项,未跨刷新保存不作为缺陷。运行日志属于本次运行记录,不等同审计和教学成绩。

现有组件新增是名称标记;现有离线 HTML 是步骤演示;下游自动执行模型动作须另行确认。

全功能覆盖矩阵

每行列出实际执行入口、实现状态、边界、对应修复及独立验证要求。${link('feature-catalog.json', '静态功能清单')} · ${link('coverage.json', '带证据的矩阵 JSON')} · ${link('data/inventory-main.md', '修复前基线与实施记录')}。

${catalog.length} 项
${finalCorrections ? `
${arr(finalCorrections.items).map(item => `

${esc(item.title)}

${esc(item.problem)}

${esc(item.change)}

${esc(item.validation)}

${link(item.source, '源码')}${item.secondSource ? ` · ${link(item.secondSource, '另一模块源码')}` : ''}${item.testSource ? ` · ${link(item.testSource, '聚焦测试')}` : ''}${item.evidence ? ` · ${link(item.evidence, '验证证据')}` : ''}
`).join('')}
` : ''} ${groups.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.boundary)}${esc(row.fix)}${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.kind)}:${esc(proof.details)}

`).join('')}
`).join('')}

执行结果与证据分层

浏览器 E2E

${results ? `

${browserCount.pass}/${browserCount.total} 项通过 · ${badge(browserCount.fail ? 'FAIL' : browserCount.pending || !browserCount.total ? 'PENDING' : 'PASS')} · ${link('results.json', 'Playwright 原始结果')}

${table(['用例', '结果', '耗时 / 失败原因'], browserRows.map(row => [esc(row.title), badge(row.status), `${row.duration == null ? '未记录' : `${(row.duration / 1000).toFixed(1)} 秒`}${row.errors.length ? `
失败信息${pretty(row.errors)}
` : ''} `]))}` : '
尚未提供 results.json,不计任何 E2E 通过。
'}

通过浏览器定向注入的 409/503 等异常仅验证失败处理,不代表开发服务真实发生该故障。UI 点击、脚本调用运行时和纯 API 检查按各用例证据说明区分。

${runHistory ? `

保留首轮失败与定向重跑事实

${esc(runHistory.scope)} ${link('run-history.json', '安全迭代记录')}

${table(['历史轮次', '原始结果', '解释范围'], arr(runHistory.runs).map(run => [esc(run.name), `${Number(run.passed)}/${Number(run.tests)} 通过 · ${badge(state(run.outcome), state(run.outcome) === 'PASS' ? '该轮通过' : '该轮失败')}`, esc(run.note || '保留原始失败,不以随后同名证据覆盖。')]))}

${esc(runHistory.investigation)}

${esc(runHistory.finalAcceptance)}

` : ''}

真实 API / 运行时断言

逐份读取 evidence/*.json。文件存在本身不代表通过;每份证据须注明断言结果和覆盖的 featureIds。

${proofData.length ? proofData.map(({ file, data }) => `
${link(file)} ${badge(anyFailure(data) ? 'FAIL' : state(data?.status ?? data?.data?.status ?? data?.result))}${pretty(data)}
`).join('') : '

尚无接口或运行时证据文件。

'}

构建、聚焦单测与版本检查

${validation ? `

${link('validation.json', '完整验证记录')}。单测/合同测试不并入真实 E2E 数量。

${pretty(validation)}` : '
validation.json 待提供。已由子任务运行的聚焦测试需最终记录确认;报告不硬编码历史通过数。
'}

测试数据保留与清理

${cleanup ? `

${link('cleanup.json', '清理结果')}

${pretty(cleanup)}` : '

清理或保留结果待记录;不声明测试数据已全部清理。原始业务工程与其发布版本应以最终证据核对。

'}

交付物凭据与附件检查

${artifactChecks ? `

${badge(finalArtifactsVerified ? 'PASS' : anyFailure(artifactChecks) ? 'FAIL' : 'PENDING', finalArtifactsVerified ? '最终扫描通过' : artifactChecks.phase === 'preliminary' ? '初查完成,待最终扫描' : '待核验')} · ${link('artifact-checks.json', '脱敏扫描结果')}

${esc(artifactChecks.scope)}

扫描 ${Number(artifactChecks.textArtifactsScanned || 0)} 份文本、${Number(artifactChecks.pngMetadataFilesScanned || 0)} 张图片的文本元数据及 ${arr(artifactChecks.archives).length} 个归档;图片正文不以文本扫描代替视觉 QA。

检查范围、历史常量排除与限制${pretty(artifactChecks)}
` : '
交付物扫描结果待提供;不声明报告已完成最终凭据检查。
'}

较大调整的具体范围

以下能力超出现有小范围修复。本轮保留入口并明确现状;确认范围后才能据此另行实现和验收。

${decisions.map(item => `
${item.id}

${esc(item.title)}

当前行为
${esc(item.current)}
拟实现的可观察行为
${esc(item.behavior)}
建议确认的实施范围
${esc(item.scope)}
验收条件
${esc(item.acceptance)}
`).join('')}

${screenshots.length} 张截图 · 点击放大

截图来自实际目录;首次加载、过程画面和最终结果按图注说明,不把空场景或加载遮罩当作模型可见证据。

证据与复现入口

原图
` fs.writeFileSync(path.join(reportDir, 'index.html'), document, 'utf8') console.log(JSON.stringify({ report: path.join(reportDir, 'index.html'), overall, features: featureCount, e2e: browserCount, finalRun, screenshots: screenshots.length, evidenceFiles: proofFiles.length, missing, malformed }, null, 2))