From 0e5575a8a9108c63f42cdb94af52abd5eb3f6b86 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 15 Aug 2026 09:32:28 +0800 Subject: [PATCH] test: close local release gate regressions --- docs/BUG_HISTORY.md | 15 ++ .../application-billing-contract.test.ts | 24 +- .../tests/database-local-business.test.ts | 231 +++++++++++++----- .../tests/production-data-migration.test.ts | 2 + .../tests/staging-backend-workflows.test.ts | 42 +++- frontend/tests/starter-questions.test.ts | 57 ++++- scripts/run_quality_gate.py | 1 + 7 files changed, 289 insertions(+), 83 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 99bb94fe..e73c010e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3227,3 +3227,18 @@ - 防复发:golden fixture 只能保存 synthetic intent code/context tags,不得保存真实用户正文或出生资料;事实、权限、证据、状态与精确时间边界只能由确定性门禁判定,模型评审必须保持 pending 直到真实执行;catalog membership 不能替代本轮 evidence availability,`requiresEvidence` 不能关闭事实/时间证据规则,accepted 不得升级为 confirmed;Agent observability 不得加入自由格式 metadata、正文、prompt、messages、出生资料、身份信息、secret/API key、provider payload、stack 或内部路径,结算失败不得降级为 cancelled,公开 NDJSON 不得扩展为内部 telemetry。 - 相关记录:BUG-181、BUG-186、BUG-187、BUG-188、BUG-189 - 修复版本:本次功能分支提交(精确 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 gate;billing 测试分别锁定 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 fallback,quality 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 个既有 warnings;Next 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 发布核对结果为准) diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts index 43267fc7..2422b575 100644 --- a/frontend/tests/application-billing-contract.test.ts +++ b/frontend/tests/application-billing-contract.test.ts @@ -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 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", () => { 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*\)/); @@ -21,9 +29,19 @@ test("Agentic rectification reuses one case-level usage authorization and the se assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/); }); -test("free Agentic rectification turns bypass both usage reservation and settlement", () => { - assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 2); - assert.match(rectificationRoute, /async complete\(usage\) \{\s*if \(action !== "message"\) return true;/); +test("free Agentic rectification turns bypass reservation, completion, and cancellation settlement", () => { + const reserveBilling = sourceBetween(rectificationRoute, "async reserve() {", "async complete(usage) {"); + 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\(/); }); diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 21b91e0e..d24d81e7 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -14,6 +14,23 @@ const runnerPath = fileURLToPath( 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 () => { const fixture = startPostgresFixture(); 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 }, ]); 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', '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'`), + fixture.psql(` + select + not has_function_privilege('service_role', 'public.accept_agentic_rectification_candidate(uuid,uuid,uuid,time without time zone)', 'EXECUTE') + and has_function_privilege('service_role', 'public.accept_agentic_rectification_candidate_for_case_v2(uuid,uuid,uuid,uuid,uuid)', 'EXECUTE') + `), "t", ); 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' )`, ), - /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; + 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).saved_time, + status: (firstAccepted.data as Record).status, + case_status: (firstAccepted.data as Record).case_status, + idempotent: (firstAccepted.data as Record).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).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).saved_time, + status: (secondAccepted.data as Record).status, + case_status: (secondAccepted.data as Record).case_status, + idempotent: (secondAccepted.data as Record).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(` diff --git a/frontend/tests/production-data-migration.test.ts b/frontend/tests/production-data-migration.test.ts index 8f554030..3d79a40e 100644 --- a/frontend/tests/production-data-migration.test.ts +++ b/frontend/tests/production-data-migration.test.ts @@ -336,9 +336,11 @@ test("transfer plan uses non-nullable dependencies and rejects unsafe cycles", ( target.get("parent")!.columns.push({ name: "child_id", nullable: false, defaultValue: null, generated: false, + identity: false, identityGeneration: null, dataType: "text", udtName: "text", }); source.get("parent")!.columns.push({ name: "child_id", nullable: false, defaultValue: null, generated: false, + identity: false, identityGeneration: null, dataType: "text", udtName: "text", }); target.get("parent")!.foreignKeys.push({ columns: ["child_id"], refSchema: "public", refTable: "child", refColumns: ["id"], diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 23b8aa98..2ec43a5b 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -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", () => { for (const workflow of [ qualityWorkflow, @@ -118,15 +144,7 @@ test("changed staging workflows are syntactically valid YAML", () => { giteaProductionWorkflow, giteaProductionMigrationWorkflow, ]) { - const result = spawnSync( - "python", - [ - "-c", - "import sys,yaml; yaml.safe_load(open(sys.argv[1], encoding='utf-8'))", - fileURLToPath(workflow), - ], - { encoding: "utf8" }, - ); + const result = parseYaml(workflow); assert.ifError(result.error); 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\("release-quality-gate\.yml"\)/); 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, /PRODUCTION_HOST/); 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.doesNotMatch(workflow, /workflow_run:|\n\s+push:/); 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, /STAGING_URL: \$\{\{ vars\.STAGING_URL \}\}/); assert.match(workflow, /\[\[ "\$STAGING_URL" == "https:\/\/staging\.jyotisha\.chat" \]\]/); 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, /endswith\("backend-quality-gate\.yml"\)/); assert.match(workflow, /endswith\("release-quality-gate\.yml"\)/); diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index c0a1bf33..2a99c0af 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { + consultationDomainIds, + consultationDomainRegistry, +} from "../src/lib/consultation-domain-registry.ts"; import { defaultGuidedJyotishTopics, generalGuidedJyotishTopics, @@ -38,29 +42,60 @@ test("completed account initialization switches directly to the home cards", () 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, /starterSuggestions\.map/); assert.match(pageSource, /starterThemes\.find\(\(candidate\) => candidate\.id === item\.theme\)/); assert.match(pageSource, /chooseSuggestedQuestion\(item\.text, item\.theme\)/); - assert.match(guidedTopicsSource, /strictWorkflowRoute/); - assert.match(guidedTopicsSource, /evidencePreview/); - assert.match(guidedTopicsSource, /confidenceCap/); - assert.match(guidedTopicsSource, /claimBoundary/); - assert.match(guidedTopicsSource, /D10/); - assert.match(guidedTopicsSource, /D9/); - assert.match(guidedTopicsSource, /Ashtakavarga/); - assert.match(guidedTopicsSource, /独立 holdout/); + assert.equal(consultationDomainIds.length, 10); + assert.deepEqual(consultationDomainRegistry.map((domain) => domain.id), [...consultationDomainIds]); + assert.match(guidedTopicsSource, /consultationDomainRegistry\.map/); + assert.deepEqual(defaultGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]); + assert.equal(defaultGuidedJyotishTopics.length, consultationDomainRegistry.length); + + for (const [index, topic] of defaultGuidedJyotishTopics.entries()) { + 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", () => { - assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), defaultGuidedJyotishTopics.map((topic) => topic.id)); +test("profiles without a usable birth minute receive all canonical general-knowledge prompts", () => { + assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]); 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.doesNotMatch(pageSource, /回答一般占星知识/); assert.match(pageSource, /完成生时校正后,再讨论个人星盘结论/); diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index ff19ed09..d6f2831e 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -20,6 +20,7 @@ from local_env import load_local_env # noqa: E402 load_local_env(ROOT) APP = ROOT / "frontend" PYTHON = sys.executable +os.environ.setdefault("PYTHON", PYTHON) COMPILE_DIRS = [ ROOT / "scripts",