test: close local release gate regressions
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Jesse_Chen
2026-08-15 09:32:28 +08:00
parent e58bc36909
commit 0e5575a8a9
7 changed files with 289 additions and 83 deletions
+15
View File
@@ -3227,3 +3227,18 @@
- 防复发:golden fixture 只能保存 synthetic intent code/context tags,不得保存真实用户正文或出生资料;事实、权限、证据、状态与精确时间边界只能由确定性门禁判定,模型评审必须保持 pending 直到真实执行;catalog membership 不能替代本轮 evidence availability`requiresEvidence` 不能关闭事实/时间证据规则,accepted 不得升级为 confirmedAgent observability 不得加入自由格式 metadata、正文、prompt、messages、出生资料、身份信息、secret/API key、provider payload、stack 或内部路径,结算失败不得降级为 cancelled,公开 NDJSON 不得扩展为内部 telemetry。 - 防复发:golden fixture 只能保存 synthetic intent code/context tags,不得保存真实用户正文或出生资料;事实、权限、证据、状态与精确时间边界只能由确定性门禁判定,模型评审必须保持 pending 直到真实执行;catalog membership 不能替代本轮 evidence availability`requiresEvidence` 不能关闭事实/时间证据规则,accepted 不得升级为 confirmedAgent observability 不得加入自由格式 metadata、正文、prompt、messages、出生资料、身份信息、secret/API key、provider payload、stack 或内部路径,结算失败不得降级为 cancelled,公开 NDJSON 不得扩展为内部 telemetry。
- 相关记录:BUG-181、BUG-186、BUG-187、BUG-188、BUG-189 - 相关记录:BUG-181、BUG-186、BUG-187、BUG-188、BUG-189
- 修复版本:本次功能分支提交(精确 SHA 以提交与远程分支核对结果为准;未合并 main/staging,未部署) - 修复版本:本次功能分支提交(精确 SHA 以提交与远程分支核对结果为准;未合并 main/staging,未部署)
## BUG-191 | PR-8 全量发布门禁被过期测试合同与本地解释器假设阻断
- 状态:resolved(本地候选,待 staging 精确 SHA 发布验收)
- 首次发现:2026-08-15
- 最近更新:2026-08-15
- 影响面:PR-8 全量前端测试、TypeScript 编译、本地 PostgreSQL 业务迁移测试、staging workflow YAML 合同与质量门禁。
- 用户现象:PR-1 至 PR-8 功能提交本身已完成,但全量前端门禁仍有 5 个失败:starter questions 继续锁死旧四主题并扫描派生 wrapper 的重复文案;数据库业务测试继续调用已撤权的 legacy 校时候选接受 RPC;生产迁移 fixture 缺少新增列元数据;staging workflow 测试使用 ES2018 `/s` flag 且假设 PATH 中存在可导入 PyYAML 的 `python`。因此不能安全提交、推送或发布 staging。
- 触发条件:在 PR-7 十域 registry、PR-4 V2 candidate decision contract、生产迁移列模型与独立 staging workflow 合同合并后运行全量 `tsx --test tests/*.test.ts``tsc --noEmit` 或 Python quality gate。
- 根因:测试仍复制旧业务常量和旧 RPC 调用方式,没有跟随新的单一真源与服务端 UUID 合同;测试 fixture 未补齐列模型新增字段;Node 启动的 YAML 检查未继承质量门禁实际使用的 Python 解释器,并包含依赖特定 worktree 深度的临时 fallback。
- 修复:starter tests 改为验证全部 10 个 canonical domain、`label`/`prompt`/evidence/claim 投影及按 domain 归属的 D10、D9、Ashtakavarga、negative holdout gatebilling 测试分别锁定 reserve/complete/release 的免费 turn 短路与付费调用;数据库测试保留 legacy RPC 撤权断言,并恢复 `persist_agentic_rectification_candidate_v2``accept_agentic_rectification_candidate_for_case_v2` 的真实纵向链路,覆盖服务端 candidate UUID、首次接受、幂等重放、切换候选、profile 落库、reported time 保留及基线变化后的 expired 拒绝;生产迁移 fixture 补齐 identity/data type 元数据;YAML 检查改用 `PYTHON``VIRTUAL_ENV`、仓库 `.venv` 与 PATH fallbackquality gate 通过 `os.environ.setdefault("PYTHON", sys.executable)` 向前端测试传递解释器,并移除 worktree 层级假设。
- 验证:聚焦非数据库测试 70 passed、0 failed;本地 PostgreSQL 全迁移与业务链路 1 passed、0 failed;全量前端 1532 passed、0 failed`tsc --noEmit` 通过;ESLint 0 errors、4 个既有 warningsNext 16 webpack production build 与静态生成通过;`git diff --check` 通过。四档质量门禁中的 quick、browser、accuracy 已完整通过;release 档的公开发布隐私扫描通过(3212 files、0 findings),三引擎 parity validator 命令成功执行且 golden cases 3/3 passed。三引擎业务比对仍如实记录为 92 行中 32 match、60 mismatch、无缺失引擎或高严谨 section,未启用 `--require-external-parity`,不得解释为外部公式完全一致。release 档唯一未完成项是 Playwright/Chromium 报告渲染隔离 POC;因本机浏览器进程持续闪退并按用户明确要求停止,未将该项宣称为 passed,后续不再通过 Playwright 调用 Chrome。staging 仍需以正式 Gitea 独立质量门禁、精确 SHA 部署与非 Playwright HTTP/API smoke 完成验收。
- 防复发:测试必须读取 canonical registry,不得重新硬编码派生业务真源;正式 V2 RPC 替代 legacy RPC 时必须保留完整成功、幂等、切换、失效和持久化业务覆盖,不能以“旧入口被拒绝”替代纵向链路;跨语言测试必须显式传递当前解释器,不得依赖 PATH 别名或 worktree 深度;类型 fixture 必须跟随共享列模型演进。
- 相关记录:BUG-178、BUG-181、BUG-189、BUG-190
- 修复版本:本次功能分支提交(精确 SHA 以提交、远程分支和 staging 发布核对结果为准)
@@ -8,6 +8,14 @@ const rectificationRoute = readFileSync(new URL("src/app/api/rectification/agent
const consultRoute = readFileSync(new URL("src/app/api/consult/route.ts", root), "utf8"); const consultRoute = readFileSync(new URL("src/app/api/consult/route.ts", root), "utf8");
const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8"); const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8");
function sourceBetween(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex + start.length);
assert.ok(startIndex >= 0, `missing start marker: ${start}`);
assert.ok(endIndex > startIndex, `missing end marker: ${end}`);
return source.slice(startIndex, endIndex);
}
test("Agentic rectification reuses one case-level usage authorization and the session-pinned model version", () => { test("Agentic rectification reuses one case-level usage authorization and the session-pinned model version", () => {
assert.match(rectificationRoute, /select\("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/); assert.match(rectificationRoute, /select\("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
assert.match(rectificationRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/); assert.match(rectificationRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
@@ -21,9 +29,19 @@ test("Agentic rectification reuses one case-level usage authorization and the se
assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/); assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/);
}); });
test("free Agentic rectification turns bypass both usage reservation and settlement", () => { test("free Agentic rectification turns bypass reservation, completion, and cancellation settlement", () => {
assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 2); const reserveBilling = sourceBetween(rectificationRoute, "async reserve() {", "async complete(usage) {");
assert.match(rectificationRoute, /async complete\(usage\) \{\s*if \(action !== "message"\) return true;/); const completeBilling = sourceBetween(rectificationRoute, "async complete(usage) {", "async release() {");
const releaseBilling = sourceBetween(rectificationRoute, "async release() {", " };\n\n try {");
assert.match(rectificationRoute, /action: z\.enum\(\["opening", "message", "read_only"\]\)/);
assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 3);
assert.match(reserveBilling, /if \(action !== "message"\) \{[\s\S]*return \{ success: true, status: 200 \};/);
assert.match(reserveBilling, /authorizeUsage\(/);
assert.match(completeBilling, /if \(action !== "message"\) return true;/);
assert.match(completeBilling, /completeUsage\(/);
assert.match(releaseBilling, /if \(action !== "message"\) return true;/);
assert.match(releaseBilling, /releaseUsage\(/);
}); });
+174 -57
View File
@@ -14,6 +14,23 @@ const runnerPath = fileURLToPath(
new URL("../scripts/db-migrate.mjs", import.meta.url), new URL("../scripts/db-migrate.mjs", import.meta.url),
); );
function rpcError(error: unknown): string {
if (!error || typeof error !== "object") return "";
const value = error as { message?: unknown };
return typeof value.message === "string" ? value.message : "";
}
function candidateIds(value: unknown): string[] {
if (!value || typeof value !== "object") return [];
const candidates = (value as { candidates?: unknown }).candidates;
if (!Array.isArray(candidates)) return [];
return candidates.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const candidateId = (candidate as { candidate_id?: unknown }).candidate_id;
return typeof candidateId === "string" ? [candidateId] : [];
});
}
test("local PostgreSQL applies the reviewed business schema and serves authenticated business calls", async () => { test("local PostgreSQL applies the reviewed business schema and serves authenticated business calls", async () => {
const fixture = startPostgresFixture(); const fixture = startPostgresFixture();
const schemaUrl = fixture.connectionUrl( const schemaUrl = fixture.connectionUrl(
@@ -600,62 +617,11 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
{ time: "05:07", relative_support: 40 }, { time: "05:07", relative_support: 40 },
]); ]);
assert.equal( assert.equal(
fixture.psqlAs( fixture.psql(`
"service_runtime", select
"service-runtime-test-password", not has_function_privilege('service_role', 'public.accept_agentic_rectification_candidate(uuid,uuid,uuid,time without time zone)', 'EXECUTE')
`set role service_role; and has_function_privilege('service_role', 'public.accept_agentic_rectification_candidate_for_case_v2(uuid,uuid,uuid,uuid,uuid)', 'EXECUTE')
select (result ->> 'saved_time') || ':' || (result ->> 'status') || ':' || (result ->> 'idempotent') `),
from (
select public.accept_agentic_rectification_candidate(
'${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '04:55'
) as result
) accepted`,
),
"SET\n04:55:accepted:false",
);
assert.equal(
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') || ':' || coalesce(to_char(birth_time, 'HH24:MI'), 'null') from public.profiles where id = '${userId}'`),
"04:55:accepted:05:00:null",
);
assert.equal(
fixture.psqlAs(
"service_runtime",
"service-runtime-test-password",
`set role service_role;
select result ->> 'idempotent'
from (
select public.accept_agentic_rectification_candidate(
'${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '04:55'
) as result
) accepted`,
),
"SET\ntrue",
);
assert.equal(
fixture.psqlAs(
"service_runtime",
"service-runtime-test-password",
`set role service_role;
select (result ->> 'saved_time') || ':' || (result ->> 'status') || ':' || (result ->> 'idempotent')
from (
select public.accept_agentic_rectification_candidate(
'${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '05:07'
) as result
) accepted`,
),
"SET\n05:07:accepted:false",
);
assert.equal(
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') || ':' || coalesce(to_char(birth_time, 'HH24:MI'), 'null') from public.profiles where id = '${userId}'`),
"05:07:accepted:05:00:null",
);
assert.equal(
fixture.psql(`select invalidated_at is null from public.agentic_rectification_results where id = '33333333-3333-4333-8333-333333333333'`),
"t",
);
fixture.psql(`update public.profiles set reported_birth_time = '05:01' where id = '${userId}'`);
assert.equal(
fixture.psql(`select invalidated_at is not null from public.agentic_rectification_results where id = '33333333-3333-4333-8333-333333333333'`),
"t", "t",
); );
assert.throws( assert.throws(
@@ -667,7 +633,158 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
'${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '04:55' '${userId}', '${rectificationSessionId}', '33333333-3333-4333-8333-333333333333', '04:55'
)`, )`,
), ),
/agentic_rectification_candidate_expired/, /permission denied for function accept_agentic_rectification_candidate/,
);
assert.equal(
fixture.psql(`select coalesce(to_char(active_birth_time, 'HH24:MI'), 'null') || ':' || birth_time_status || ':' || to_char(reported_birth_time, 'HH24:MI') from public.profiles where id = '${userId}'`),
"null:reported:05:00",
);
const rectificationCaseId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const baselineBirthSnapshot = {
birth_date: "1997-08-08",
reported_birth_time: "05:00",
active_birth_time: null,
birth_time_source: "family_exact",
birth_time_period: null,
uncertainty_before_minutes: 10,
uncertainty_after_minutes: 10,
latitude: 36.420487,
longitude: 114.209936,
timezone_offset: 8,
};
const candidateRange = { start_time: "04:50", end_time: "05:10" };
fixture.psql(`
insert into public.agentic_rectification_cases (
id, user_id, session_id, status, skill_name, skill_version,
baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range
) values (
'${rectificationCaseId}', '${userId}', '${rectificationSessionId}', 'candidate_ready',
'jyotish-birth-time-rectification', '10.0.0', '${"e".repeat(64)}',
'${JSON.stringify(baselineBirthSnapshot)}'::jsonb,
'${JSON.stringify(candidateRange)}'::jsonb
)
`);
const persisted = await admin.rpc("persist_agentic_rectification_candidate_v2", {
p_user_id: userId,
p_case_id: rectificationCaseId,
p_engine_result_id: "database-local-business-v2",
p_evidence_ledger_fingerprint: "f".repeat(64),
p_candidate_range_fingerprint: "1".repeat(64),
p_skill_version: "10.0.0",
p_algorithm_version: "database-local-business-v2",
p_event_contract_version: "rectification-event-contract-v2",
p_decision_policy_version: "decision-policy-v2",
p_candidate_range: candidateRange,
p_candidates: [
{ rank: 1, time: "04:55", relative_support: 60, tied_minute_count: 1 },
{ rank: 2, time: "05:07", relative_support: 40, tied_minute_count: 1 },
],
p_decision_receipt: {
display_allowed: true,
accept_allowed: true,
confirm_allowed: false,
representative_time: "04:55",
overall_confidence: "medium",
margin_percent: 20,
},
p_execution_ledger: [
{ phase: "candidate.score", status: "completed", engine: "fixture-engine" },
{ phase: "decision.evaluate", status: "completed", policy: "decision-policy-v2" },
],
});
assert.equal(persisted.error, null, rpcError(persisted.error));
const persistedRow = persisted.data as Record<string, unknown>;
const v2ResultId = String(persistedRow.result_id);
const [firstCandidateId, secondCandidateId] = candidateIds(persistedRow);
assert.ok(firstCandidateId);
assert.ok(secondCandidateId);
const firstAcceptRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const firstAccepted = await admin.rpc("accept_agentic_rectification_candidate_for_case_v2", {
p_user_id: userId,
p_case_id: rectificationCaseId,
p_result_id: v2ResultId,
p_candidate_id: firstCandidateId,
p_request_id: firstAcceptRequestId,
});
assert.equal(firstAccepted.error, null, rpcError(firstAccepted.error));
assert.deepEqual(
{
saved_time: (firstAccepted.data as Record<string, unknown>).saved_time,
status: (firstAccepted.data as Record<string, unknown>).status,
case_status: (firstAccepted.data as Record<string, unknown>).case_status,
idempotent: (firstAccepted.data as Record<string, unknown>).idempotent,
},
{ saved_time: "04:55", status: "accepted", case_status: "candidate_accepted", idempotent: false },
);
assert.equal(
fixture.psql(`
select to_char(active_birth_time, 'HH24:MI') || ':' ||
to_char(birth_time, 'HH24:MI') || ':' || birth_time_status || ':' ||
to_char(reported_birth_time, 'HH24:MI')
from public.profiles where id = '${userId}'
`),
"04:55:04:55:accepted:05:00",
);
const firstReplay = await admin.rpc("accept_agentic_rectification_candidate_for_case_v2", {
p_user_id: userId,
p_case_id: rectificationCaseId,
p_result_id: v2ResultId,
p_candidate_id: firstCandidateId,
p_request_id: firstAcceptRequestId,
});
assert.equal(firstReplay.error, null, rpcError(firstReplay.error));
assert.equal((firstReplay.data as Record<string, unknown>).idempotent, true);
const secondAccepted = await admin.rpc("accept_agentic_rectification_candidate_for_case_v2", {
p_user_id: userId,
p_case_id: rectificationCaseId,
p_result_id: v2ResultId,
p_candidate_id: secondCandidateId,
p_request_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
});
assert.equal(secondAccepted.error, null, rpcError(secondAccepted.error));
assert.deepEqual(
{
saved_time: (secondAccepted.data as Record<string, unknown>).saved_time,
status: (secondAccepted.data as Record<string, unknown>).status,
case_status: (secondAccepted.data as Record<string, unknown>).case_status,
idempotent: (secondAccepted.data as Record<string, unknown>).idempotent,
},
{ saved_time: "05:07", status: "accepted", case_status: "candidate_accepted", idempotent: false },
);
assert.equal(
fixture.psql(`select to_char(active_birth_time, 'HH24:MI') || ':' || to_char(birth_time, 'HH24:MI') || ':' || to_char(reported_birth_time, 'HH24:MI') from public.profiles where id = '${userId}'`),
"05:07:05:07:05:00",
);
assert.equal(
fixture.psql(`select invalidated_at is null from public.agentic_rectification_results where id = '33333333-3333-4333-8333-333333333333'`),
"t",
);
fixture.psql(`update public.profiles set reported_birth_time = '05:01' where id = '${userId}'`);
assert.equal(
fixture.psql(`select invalidated_at is not null from public.agentic_rectification_results where id = '33333333-3333-4333-8333-333333333333'`),
"t",
);
assert.equal(
fixture.psql(`select invalidated_at is not null from public.agentic_rectification_results where id = '${v2ResultId}'`),
"t",
);
const expiredAccept = await admin.rpc("accept_agentic_rectification_candidate_for_case_v2", {
p_user_id: userId,
p_case_id: rectificationCaseId,
p_result_id: v2ResultId,
p_candidate_id: firstCandidateId,
p_request_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
});
assert.match(rpcError(expiredAccept.error), /agentic_rectification_candidate_expired/);
assert.equal(
await loadLatestAgenticRectificationResult(admin as never, userId, rectificationSessionId),
null,
); );
fixture.psql(` fixture.psql(`
@@ -336,9 +336,11 @@ test("transfer plan uses non-nullable dependencies and rejects unsafe cycles", (
target.get("parent")!.columns.push({ target.get("parent")!.columns.push({
name: "child_id", nullable: false, defaultValue: null, generated: false, name: "child_id", nullable: false, defaultValue: null, generated: false,
identity: false, identityGeneration: null, dataType: "text", udtName: "text",
}); });
source.get("parent")!.columns.push({ source.get("parent")!.columns.push({
name: "child_id", nullable: false, defaultValue: null, generated: false, name: "child_id", nullable: false, defaultValue: null, generated: false,
identity: false, identityGeneration: null, dataType: "text", udtName: "text",
}); });
target.get("parent")!.foreignKeys.push({ target.get("parent")!.foreignKeys.push({
columns: ["child_id"], refSchema: "public", refTable: "child", refColumns: ["id"], columns: ["child_id"], refSchema: "public", refTable: "child", refColumns: ["id"],
@@ -107,6 +107,32 @@ function assertOrder(text: string, labels: string[]): void {
} }
} }
function yamlPythonCandidates(): string[] {
return [
process.env.PYTHON,
process.env.VIRTUAL_ENV ? join(process.env.VIRTUAL_ENV, "bin", "python") : undefined,
fileURLToPath(new URL("../../.venv/bin/python", import.meta.url)),
"python3",
"python",
].filter((candidate): candidate is string => Boolean(candidate));
}
function parseYaml(workflow: URL) {
for (const python of yamlPythonCandidates()) {
const result = spawnSync(
python,
[
"-c",
"import sys,yaml; yaml.safe_load(open(sys.argv[1], encoding='utf-8'))",
fileURLToPath(workflow),
],
{ encoding: "utf8" },
);
if (!result.error || (result.error as NodeJS.ErrnoException).code !== "ENOENT") return result;
}
throw new Error("No Python interpreter is available for YAML validation");
}
test("changed staging workflows are syntactically valid YAML", () => { test("changed staging workflows are syntactically valid YAML", () => {
for (const workflow of [ for (const workflow of [
qualityWorkflow, qualityWorkflow,
@@ -118,15 +144,7 @@ test("changed staging workflows are syntactically valid YAML", () => {
giteaProductionWorkflow, giteaProductionWorkflow,
giteaProductionMigrationWorkflow, giteaProductionMigrationWorkflow,
]) { ]) {
const result = spawnSync( const result = parseYaml(workflow);
"python",
[
"-c",
"import sys,yaml; yaml.safe_load(open(sys.argv[1], encoding='utf-8'))",
fileURLToPath(workflow),
],
{ encoding: "utf8" },
);
assert.ifError(result.error); assert.ifError(result.error);
assert.equal(result.status, 0, result.stderr); assert.equal(result.status, 0, result.stderr);
} }
@@ -932,7 +950,7 @@ test("production deploy is manual-only and consumes the accepted staging artifac
assert.match(production, /endswith\("backend-quality-gate\.yml"\)/); assert.match(production, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(production, /endswith\("release-quality-gate\.yml"\)/); assert.match(production, /endswith\("release-quality-gate\.yml"\)/);
assert.match(production, /public staging has not accepted the requested SHA/); assert.match(production, /public staging has not accepted the requested SHA/);
assert.match(production, /current_main.*DEPLOY_SHA.*current_staging.*DEPLOY_SHA/s); assert.match(production, /current_main[\s\S]*DEPLOY_SHA[\s\S]*current_staging[\s\S]*DEPLOY_SHA/);
assert.match(production, /verification_mode:/); assert.match(production, /verification_mode:/);
assert.match(production, /PRODUCTION_HOST/); assert.match(production, /PRODUCTION_HOST/);
assert.match(production, /118\.194\.235\.34/); assert.match(production, /118\.194\.235\.34/);
@@ -1035,12 +1053,12 @@ test("Gitea production schema migration is exact-SHA gated and isolated from ETL
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m); assert.match(workflow, /^on:\n\s+workflow_dispatch:/m);
assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/); assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/);
assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/); assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/);
assert.match(workflow, /main_head.*DEPLOY_SHA.*staging_head.*DEPLOY_SHA/s); assert.match(workflow, /main_head[\s\S]*DEPLOY_SHA[\s\S]*staging_head[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /GITEA_SHA.*DEPLOY_SHA/); assert.match(workflow, /GITEA_SHA.*DEPLOY_SHA/);
assert.match(workflow, /STAGING_URL: \$\{\{ vars\.STAGING_URL \}\}/); assert.match(workflow, /STAGING_URL: \$\{\{ vars\.STAGING_URL \}\}/);
assert.match(workflow, /\[\[ "\$STAGING_URL" == "https:\/\/staging\.jyotisha\.chat" \]\]/); assert.match(workflow, /\[\[ "\$STAGING_URL" == "https:\/\/staging\.jyotisha\.chat" \]\]/);
assert.match(workflow, /"\$STAGING_URL\/api\/health"/); assert.match(workflow, /"\$STAGING_URL\/api\/health"/);
assert.match(workflow, /observed_staging_sha.*DEPLOY_SHA/s); assert.match(workflow, /observed_staging_sha[\s\S]*DEPLOY_SHA/);
assert.match(workflow, /branch=staging&event=push&status=success/); assert.match(workflow, /branch=staging&event=push&status=success/);
assert.match(workflow, /endswith\("backend-quality-gate\.yml"\)/); assert.match(workflow, /endswith\("backend-quality-gate\.yml"\)/);
assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/); assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/);
+46 -11
View File
@@ -1,6 +1,10 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import test from "node:test"; import test from "node:test";
import {
consultationDomainIds,
consultationDomainRegistry,
} from "../src/lib/consultation-domain-registry.ts";
import { import {
defaultGuidedJyotishTopics, defaultGuidedJyotishTopics,
generalGuidedJyotishTopics, generalGuidedJyotishTopics,
@@ -38,29 +42,60 @@ test("completed account initialization switches directly to the home cards", ()
assert.doesNotMatch(pageSource, /!profileComplete \|\| onboardingJustCompleted/); assert.doesNotMatch(pageSource, /!profileComplete \|\| onboardingJustCompleted/);
}); });
test("default starter questions are guided Jyotish topics with evidence and claim boundaries", () => { test("default starter questions derive every canonical domain with evidence and claim boundaries", () => {
assert.match(pageSource, /defaultGuidedJyotishTopics/); assert.match(pageSource, /defaultGuidedJyotishTopics/);
assert.match(pageSource, /starterSuggestions\.map/); assert.match(pageSource, /starterSuggestions\.map/);
assert.match(pageSource, /starterThemes\.find\(\(candidate\) => candidate\.id === item\.theme\)/); assert.match(pageSource, /starterThemes\.find\(\(candidate\) => candidate\.id === item\.theme\)/);
assert.match(pageSource, /chooseSuggestedQuestion\(item\.text, item\.theme\)/); assert.match(pageSource, /chooseSuggestedQuestion\(item\.text, item\.theme\)/);
assert.match(guidedTopicsSource, /strictWorkflowRoute/); assert.equal(consultationDomainIds.length, 10);
assert.match(guidedTopicsSource, /evidencePreview/); assert.deepEqual(consultationDomainRegistry.map((domain) => domain.id), [...consultationDomainIds]);
assert.match(guidedTopicsSource, /confidenceCap/); assert.match(guidedTopicsSource, /consultationDomainRegistry\.map/);
assert.match(guidedTopicsSource, /claimBoundary/); assert.deepEqual(defaultGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]);
assert.match(guidedTopicsSource, /D10/); assert.equal(defaultGuidedJyotishTopics.length, consultationDomainRegistry.length);
assert.match(guidedTopicsSource, /D9/);
assert.match(guidedTopicsSource, /Ashtakavarga/); for (const [index, topic] of defaultGuidedJyotishTopics.entries()) {
assert.match(guidedTopicsSource, /独立 holdout/); const domain = consultationDomainRegistry[index];
assert.equal(topic.id, domain.id);
assert.equal(topic.label, domain.label);
assert.equal(topic.prompt, domain.prompt);
assert.equal(topic.strictWorkflowRoute, domain.strictWorkflowRoute);
assert.deepEqual(topic.evidencePreview, [...domain.evidencePreview]);
assert.equal(topic.confidenceCap, domain.confidenceCap);
assert.equal(topic.claimBoundary, domain.claimBoundary);
}
const domainById = new Map(consultationDomainRegistry.map((domain) => [domain.id, domain]));
assert.match(domainById.get("career")?.requiredLayers.join(" ") ?? "", /D10/);
assert.match(domainById.get("marriage")?.requiredLayers.join(" ") ?? "", /D9/);
assert.match(domainById.get("wealth")?.requiredLayers.join(" ") ?? "", /Ashtakavarga/);
assert.match(domainById.get("timing")?.requiredLayers.join(" ") ?? "", /negative holdout gate/);
}); });
test("profiles without a usable birth minute only receive general-knowledge homepage prompts", () => { test("profiles without a usable birth minute receive all canonical general-knowledge prompts", () => {
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), defaultGuidedJyotishTopics.map((topic) => topic.id)); assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]);
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.prompt), [ assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.prompt), [
"印度占星一般会从哪些因素理解事业方向?", "印度占星一般会从哪些因素理解事业方向?",
"印度占星一般如何分析关系模式?", "印度占星一般如何分析关系模式?",
"印度占星一般如何分析财富结构与风险?", "印度占星一般如何分析财富结构与风险?",
"印度占星如何在非医疗诊断边界内理解身心压力?",
"印度占星一般如何理解学习方式与进阶节奏?",
"印度占星一般如何分析迁居、置业与海外发展?",
"印度占星一般如何理解家庭关系与责任模式?",
"印度占星中的年运分析通常包含哪些证据层?",
"印度占星中的时间推运通常会看哪些因素?", "印度占星中的时间推运通常会看哪些因素?",
"印度占星综合咨询会如何划分不同主题与证据边界?",
]); ]);
for (const topic of generalGuidedJyotishTopics) {
const personalTopic = defaultGuidedJyotishTopics.find((candidate) => candidate.id === topic.id);
assert.ok(personalTopic);
assert.equal(topic.label, personalTopic.label);
assert.equal(topic.strictWorkflowRoute, personalTopic.strictWorkflowRoute);
assert.deepEqual(topic.evidencePreview, personalTopic.evidencePreview);
assert.equal(topic.confidenceCap, personalTopic.confidenceCap);
assert.equal(topic.claimBoundary, personalTopic.claimBoundary);
assert.notEqual(topic.prompt, personalTopic.prompt);
}
assert.ok(generalGuidedJyotishTopics.every((topic) => !/我的|我近期|未来一年,事业和收入/.test(topic.prompt)));
assert.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/); assert.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/);
assert.doesNotMatch(pageSource, /回答一般占星知识/); assert.doesNotMatch(pageSource, /回答一般占星知识/);
assert.match(pageSource, /完成生时校正后,再讨论个人星盘结论/); assert.match(pageSource, /完成生时校正后,再讨论个人星盘结论/);
+1
View File
@@ -20,6 +20,7 @@ from local_env import load_local_env # noqa: E402
load_local_env(ROOT) load_local_env(ROOT)
APP = ROOT / "frontend" APP = ROOT / "frontend"
PYTHON = sys.executable PYTHON = sys.executable
os.environ.setdefault("PYTHON", PYTHON)
COMPILE_DIRS = [ COMPILE_DIRS = [
ROOT / "scripts", ROOT / "scripts",