- api/prescreen.py:transition_improvement 注入 _knowledge_publisher, release 委托 knowledge_cards.publish → component_registry.release; 按 proposed_revision_ref 以「组件id@版本」精确解析草稿(不取"最新草稿", 否则会出现"审核的是 A、发布的是 B");客户端自报 released_ref 一律 422; 非 knowledge 目标 409 诚实拒绝;发布与候选状态同一事务,失败保留旧版。 - TrajectoryPanel:新增会议结果录入(追加式更正走 supersedes_id; 未匹配单列且不计入分母)。 - ImprovementPanel(新增):偏差→候选→两道评测门→独立审核→发布; 请求体不含版本号,无自动上线,不自批。 - 界面并入「轨迹复盘」,不新增第四个导航入口(AC-53)。 - 守卫:新增 scripts/test_improvement_panel.cjs(verify.py 13→14 项); test_module_shell_ui.cjs 的「只读」断言改为分别锁死「回放只读」与「结果只可追加」。 - 测试:新增 backend/test_improvement_release.py(AC-57 端到端 + 三条反例); test_improvements.py 补「自报版本号被拒」。 - 文档:17 第四批交付记录、15 §7 交接、03 §9.4 发布事务边界。 验证:scripts/verify.py 14/14 PASS(backend-unit 572 项),证据 test-evidence/20261001-013517-3e15bda/;scripts/xingbang_batch4_smoke.py 对真实服务器实跑通过(发布指针实测移到新版本)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
152 lines
9.9 KiB
JavaScript
152 lines
9.9 KiB
JavaScript
/**
|
||
* 纠偏第三批 · 模块壳与三入口源码守卫(BR-50;AC-53/54/55)。
|
||
*
|
||
* 这是**源码断言**,不是浏览器端到端测试(docs/v2/06-validation.md §2)。
|
||
* 它锁住四类可机械核对的约束:
|
||
*
|
||
* 1. **深色导航+浅色工作区**(AC-53):导航取原型 token,画布保持浅色。
|
||
* 2. **三个模块各有独立入口**(BR-50):AI 预审 / 历史经验加工 / 轨迹复盘,
|
||
* 各自有路由、各自渲染**复用**的组件——不是把面板塞进旧页当页签。
|
||
* 3. **防重复页面**(AC-55):已合并的旧页必须仍是重定向;不得复活。
|
||
* 4. **权限与数据隔离**(AC-55):导航按角色裁剪;模块以 ?project= 为上下文,
|
||
* 不把项目 id 硬编码,也不在前端自造授权。
|
||
*/
|
||
const assert = require("node:assert/strict");
|
||
const fs = require("node:fs");
|
||
const path = require("node:path");
|
||
|
||
const root = path.resolve(__dirname, "..");
|
||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||
const exists = (file) => fs.existsSync(path.join(root, file));
|
||
|
||
const navigation = read("src/components/xingbang/navigation.css");
|
||
const nav = read("src/components/xingbang/AgentNav.tsx");
|
||
const home = read("src/app/page.tsx");
|
||
|
||
// ---------------------------------------------------------------- 1. 深色导航
|
||
assert.match(navigation, /\.xb-agent-nav\{[^}]*background:#102B27/, "主导航应使用原型的深色 nav.bg #102B27");
|
||
assert.match(navigation, /\.xb-agent-nav a\.selected\{[^}]*background:#244D41/, "选中项应使用 nav.active #244D41");
|
||
assert.match(nav, /智审工作台/, "品牌区应显示「智审工作台」(原型文案)");
|
||
|
||
// 深色底的文字对比度**算出来**,而不是在文档里声称"应该够清楚"(G-6 机械约束优先)。
|
||
// 阈值取 WCAG AA 正文 4.5:1;四组配色都要过。
|
||
function luminance(hex) {
|
||
const channel = (value) => {
|
||
const c = value / 255;
|
||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||
};
|
||
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||
}
|
||
function contrast(foreground, background) {
|
||
const [a, b] = [luminance(foreground), luminance(background)];
|
||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||
}
|
||
const NAV_BG = "#102B27", NAV_ACTIVE = "#244D41";
|
||
for (const [label, fg, bg] of [
|
||
["未选中导航项", "#DCE9E3", NAV_BG],
|
||
["选中导航项", "#FFFFFF", NAV_ACTIVE],
|
||
["次要文字(muted)", "#B1C6BE", NAV_BG],
|
||
["品牌标题", "#F3F7F5", NAV_BG],
|
||
]) {
|
||
assert.ok(contrast(fg, bg) >= 4.5, `深色导航「${label}」对比度应 ≥ 4.5:1,实测 ${contrast(fg, bg).toFixed(2)}:1`);
|
||
}
|
||
assert.match(nav, /业务工作区/, "导航应保留「业务工作区」分组标签");
|
||
// 导航是深色,身份选择器在导航内必须用深色变体,否则白底白字
|
||
assert.match(nav, /<IdentitySelector[^>]*dark/, "导航内的身份选择器应使用深色变体");
|
||
// 画布仍是浅色:不要把整页刷黑
|
||
assert.match(read("src/app/data-access/workspace.css"), /\.ra-app\{min-height:100vh;background:#f5f7f9/, "工作区画布应保持浅色");
|
||
|
||
// ---------------------------------------------------------------- 2. 三个独立入口
|
||
const MODULES = [
|
||
{key: "prescreen", href: "/prescreen-workbench", label: "AI 预审", component: "PrescreenPanel"},
|
||
{key: "experience", href: "/experience-center", label: "历史经验加工", component: "ExperienceCenter"},
|
||
{key: "trajectory", href: "/trajectory-center", label: "轨迹复盘", component: "TrajectoryPanel"},
|
||
];
|
||
for (const item of MODULES) {
|
||
assert.ok(exists(`src/app${item.href}/page.tsx`), `${item.label} 应有独立路由 ${item.href}`);
|
||
assert.ok(nav.includes(`"${item.href}"`), `导航应提供 ${item.label} 入口`);
|
||
assert.ok(nav.includes(item.label), `导航入口文案应为「${item.label}」`);
|
||
const page = read(`src/app${item.href}/page.tsx`);
|
||
// 复用组件而不是在路由里再写一份实现:import 自 @/components
|
||
assert.match(page, /from "@\/components\/xingbang\//, `${item.label} 路由应复用既有组件`);
|
||
assert.match(page, /AgentNav/, `${item.label} 路由应复用深色导航`);
|
||
// 数据隔离:项目上下文来自 URL,不硬编码项目 id
|
||
assert.match(page, /new URLSearchParams\(location\.search\)\.get\("project"\)/, `${item.label} 的项目上下文应来自 ?project=`);
|
||
assert.ok(!/DEMO-[ABC]/.test(page), `${item.label} 不得硬编码演示项目 id`);
|
||
}
|
||
// 首页必须呈现三个入口(AC-53:不能只保留旧版落地页布局)
|
||
for (const item of MODULES) {
|
||
assert.ok(home.includes(item.href), `业务首页应提供 ${item.label} 入口卡片`);
|
||
}
|
||
assert.match(home, /MODULES/, "首页应以模块清单驱动入口卡片,而不是写死一段静态文案");
|
||
// 旧版落地页内容保留为独立入口,而不是被删掉
|
||
assert.ok(exists("src/app/assistant/page.tsx"), "原落地页内容应保留在 /assistant");
|
||
assert.match(read("src/app/assistant/page.tsx"), /BusinessAssistant/, "/assistant 应仍渲染业务助手");
|
||
|
||
// ---------------------------------------------------------------- 3. 防重复页面(AC-55)
|
||
// 已合并的旧页必须仍是重定向,不得被重新实现成独立页面。
|
||
for (const merged of ["trajectory", "review-workbench", "report-workbench", "collateral-workbench"]) {
|
||
assert.match(read(`src/app/${merged}/page.tsx`), /redirect\(/,
|
||
`已合并页 src/app/${merged} 必须保持重定向,不得复活`);
|
||
}
|
||
// 不得把预审面板搬到与既有禁名冲突的目录(那会把旧的 S1/T09 结构重新引入)
|
||
for (const banned of ["prescreen", "sandbox", "simulation", "import-batch"]) {
|
||
assert.ok(!exists(`src/app/${banned}`), `不得新建与既有面板重名的路由 src/app/${banned}`);
|
||
}
|
||
// 同一个面板组件被两个宿主复用时,必须是同一份实现(import 复用),不是复制粘贴的第二份
|
||
const hosts = ["src/app/xingbang-workbench/page.tsx", "src/app/prescreen-workbench/page.tsx"];
|
||
for (const host of hosts) {
|
||
assert.match(read(host), /import PrescreenPanel from "@\/components\/xingbang\/PrescreenPanel"/,
|
||
`${host} 应复用同一个 PrescreenPanel 组件`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 4. 权限(AC-55)
|
||
assert.match(nav, /canConfigure/, "导航应按角色裁剪配置治理入口");
|
||
assert.match(nav, /request\.state|identity\?\.role/, "导航的角色判据应来自服务端身份而不是硬编码");
|
||
assert.ok(!/localStorage\.setItem\("xingbang-demo-role"/.test(nav), "导航不得自行设定演示身份");
|
||
|
||
// ---------------------------------------------------------------- 5. 轨迹复盘的可操作与诚实性(AC-54)
|
||
const trajectory = read("src/components/xingbang/TrajectoryPanel.tsx");
|
||
assert.match(trajectory, /failed/, "轨迹面板必须能显示失败步骤(失败保留)");
|
||
assert.match(trajectory, /不填 0|未记录/, "成本缺失时必须留空,不得填 0");
|
||
assert.ok(!/cost\s*[:=]\s*0\b/.test(trajectory), "不得把缺失成本写成 0");
|
||
// 纠偏第四批把"会议结果录入"并入了本面板,因此这里不能再只断言"整页只读"——
|
||
// 要分别锁死两件事:**回放只读**(不改运行、不改历史结果)与**结果只可追加**(不覆盖历史)。
|
||
assert.match(trajectory, /回放是只读的|只读回放/, "回放必须声明只读(第四批加的是追加,不是改写)");
|
||
assert.match(trajectory, /supersedes_id/, "会议结果更正必须指向旧记录(supersedes_id)");
|
||
assert.match(trajectory, /不覆盖|只\*\*追加\*\*/, "界面必须声明更正只追加、不覆盖历史");
|
||
// 判据落在"是否渲染推理字段"上,而不是禁用词本身——否则解释规则的注释会把守卫自己绊倒。
|
||
assert.ok(!/\.\s*(reasoning|reasoning_content|thoughts?|chain_of_thought)\b/i.test(trajectory),
|
||
"不得渲染模型隐藏推理字段");
|
||
// 服务端已声明轨迹不含内部推理,界面必须把它显示出来(而不是自己编一句)
|
||
assert.match(trajectory, /trajectory\.note|note\}/, "应展示服务端对轨迹范围的说明");
|
||
// AC-56:既读又写——只读列表不再是本面板的全部职责
|
||
assert.match(trajectory, /review-outcomes/, "轨迹面板应消费会议结果端点");
|
||
assert.match(trajectory, /"POST",\s*`\$\{PRESCREEN\}\$\{path\}\/review-outcomes`/,
|
||
"会议结果必须真的能录入(POST 到 review-outcomes)");
|
||
assert.match(trajectory, /raw_code/, "录入必须围绕原始结果码,不得自行推断通过或否决");
|
||
assert.match(trajectory, /不计入分母/, "未匹配的结果必须标明不计入分母(单列)");
|
||
// 预审模块必须真的挂载预审面板(否则"AI 预审入口"是空壳)
|
||
assert.match(read("src/app/prescreen-workbench/page.tsx"), /<PrescreenPanel /, "AI 预审模块应挂载预审面板");
|
||
|
||
// ---------------------------------------------------------------- 6. 前后端路径一致
|
||
// 界面调用的端点必须在后端真的声明过——否则"入口可点"只是连到 404。
|
||
const prescreenApi = read("backend/api/prescreen.py");
|
||
for (const route of ['"/projects/{project_id}/runs/{run_id}/trajectory"',
|
||
'"/projects/{project_id}/review-outcomes"',
|
||
'"/projects/{project_id}/improvements"',
|
||
'"/improvements/{candidate_id}/transitions"']) {
|
||
assert.ok(prescreenApi.includes(route), `后端应声明路由 ${route}`);
|
||
}
|
||
assert.ok(read("backend/api/project_assistant.py").includes('"/projects/{project_id}/runs"'),
|
||
"后端应声明发起运行的路由");
|
||
|
||
// ---------------------------------------------------------------- 7. 既有禁令继续有效
|
||
for (const [name, source] of [["home", home], ["nav", nav], ["trajectory", trajectory]]) {
|
||
assert.ok(!/0\.(42|58|51|64)\b/.test(source), `${name} 不得硬编码演示概率值`);
|
||
assert.ok(!/"(00|10|01|11)"\s*:/.test(source), `${name} 不得复制演示情景表`);
|
||
}
|
||
|
||
console.log("module-shell-ui: PASS");
|