feat: complete verifiable birth-time rectification flow

This commit is contained in:
Jesse_Chen
2026-07-21 22:16:18 +08:00
parent eb8ed8bee4
commit bfc6870614
117 changed files with 6215 additions and 1038 deletions
+7 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseAgentReply } from "../src/lib/agent-reply.ts";
import { parseAgentReply, resolveSessionTitle } from "../src/lib/agent-reply.ts";
test("extracts a model-generated session title without exposing hidden metadata", () => {
// Given
@@ -51,3 +51,9 @@ test("hides an incomplete metadata block while a reply is streaming", () => {
// Then
assert.equal(reply.text, "回答正文");
});
test("general no-birth-time replies keep a question-specific session title", () => {
assert.equal(resolveSessionTitle("工作变化的重点是什么?", "一般占星咨询"), "工作变化的重点是什么");
assert.equal(resolveSessionTitle("如何理解印度占星里的行星关系?"), "如何理解印度占星里的行星关系");
assert.equal(resolveSessionTitle("工作变化的重点是什么?", "事业方向与工作变化"), "事业方向与工作变化");
});
@@ -144,6 +144,20 @@ test("unverified consent resolves to a chart request and confirmed profiles need
});
});
test("minute-free mode ignores an unverified reported minute", () => {
const general = grantBirthTimeConsultationConsent(
createBirthTimeConsultationConsentState(),
"chat-a",
"general_no_birth_time",
);
assert.deepEqual(resolveBirthTimeConsultationRoute(reportedExactTime, general, "chat-a"), {
kind: "consult",
mode: "general_no_birth_time",
time: null,
});
});
test("confirmed time does not request unverified-use consent", () => {
const confirmed = {
...reportedExactTime,
@@ -194,15 +208,19 @@ test("account refresh identities reject an older response after a newer case req
assert.equal(guard.isCurrent(newCaseRequest), true);
});
test("soft choice announces itself and locks every action while rectification opens", () => {
const source = readFileSync(new URL("../src/components/unverified-birth-time-choice.tsx", import.meta.url), "utf8");
test("unverified birth time uses a self-dismissing notice instead of a modal", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const notice = readFileSync(new URL("../src/components/birth-time-soft-notice.tsx", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const sonner = readFileSync(new URL("../src/components/ui/sonner.tsx", import.meta.url), "utf8");
assert.match(source, /aria-live="polite"/);
assert.match(source, /role="alertdialog"/);
assert.match(source, /aria-modal="true"/);
assert.match(source, /keepFocusWithin/);
assert.match(source, /继续不依赖出生分钟的一般咨询/);
assert.ok((source.match(/disabled=\{pending\}/g) ?? []).length >= 3);
assert.match(page, /<BirthTimeSoftNotice/);
assert.match(notice, /toast\("出生时间尚未校正"/);
assert.match(notice, /durationMs = 4_500/);
assert.match(notice, /window\.setTimeout\(onDismiss, durationMs\)/);
assert.match(layout, /<Toaster \/>/);
assert.match(sonner, /from "sonner"/);
assert.doesNotMatch(page, /role="alertdialog"[\s\S]{0,300}出生时间还没有完成校正/);
});
test("homepage and profile result copy use the source-aware consultation options", () => {
@@ -9,9 +9,16 @@ import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts";
function matchingCandidateModel(activation = 0) {
return {
version: "birth-time-choice-scoring-v2",
opportunity_model_version: "birth-time-opportunity-model-v2",
opportunity_model_version: "birth-time-opportunity-model-v4",
historical_event_fingerprint: "fixture-event-fingerprint",
range: { start_time: "05:02", end_time: "05:03" },
windows: [{ activations: { "05:02": activation, "05:03": 1 } }],
windows: [{
activations: { "05:02": activation, "05:03": 1 },
fact_selection_priority: 1,
fact_priority_version: "birth-time-question-fact-priority-v1",
event_fact_selection_priority: 0,
event_fact_priority_version: "birth-time-question-event-fact-priority-v1",
}],
};
}
@@ -80,6 +87,18 @@ test("matching candidate models remain reusable", () => {
assert.equal(input.candidateModel, matchingModel);
});
test("legacy v2 candidate models are rebuilt for fact-driven question selection", () => {
const stored = narrowedCase();
const matchingModel = {
...matchingCandidateModel(),
opportunity_model_version: "birth-time-opportunity-model-v2",
};
const input = dynamicDifferenceInput({ ...stored, candidateModel: matchingModel });
assert.equal(input.candidateModel, null);
});
test("persisted candidate models with legacy negative activations are rebuilt", () => {
const stored = narrowedCase();
const input = dynamicDifferenceInput({
@@ -37,7 +37,7 @@ test("medium and high results must satisfy deterministic confidence gates", () =
candidate: {
...result.candidate,
confidence: "high",
canApply: true,
canApply: false,
eventCount: 4,
domainCount: 3,
marginPercent: 20,
@@ -180,6 +180,72 @@ test("high confidence requires explicit confirmation without applying a time", (
assert.equal(result.dynamicTurnState.permissions.canConfirmCandidate, true);
});
test("release-blocked high confidence continues instead of showing a dead confirmation", () => {
const stored = dynamicCase();
const candidate = {
...lowCandidate,
resultId: "a97b7b4c-60f3-4ed8-b290-64b2084182e7",
confidence: "high" as const,
canApply: false,
winningSegment: {
startTime: "05:11",
endTime: "05:11",
representativeTime: "05:11",
widthMinutes: 1,
},
eventCount: 4,
domainCount: 3,
marginPercent: 20,
reasons: ["minute_holdout_not_ready"],
};
const result = completeDynamicScoreTransition({
stored: { ...stored, currentChoiceQuestion: null },
candidate,
usefulOpportunityCount: 1,
repeatedOnly: false,
nextVersion: 8,
});
assert.equal(result.dynamicTurnState.nextAction.kind, "generate_dynamic_question");
assert.equal(result.dynamicTurnState.permissions.canConfirmCandidate, false);
assert.equal(result.snapshot.activeTime, null);
});
test("release-blocked high confidence ends as a saved range when no question remains", () => {
const stored = dynamicCase();
const candidate = {
...lowCandidate,
resultId: "b97b7b4c-60f3-4ed8-b290-64b2084182e7",
confidence: "high" as const,
canApply: false,
winningSegment: {
startTime: "05:10",
endTime: "05:12",
representativeTime: "05:11",
widthMinutes: 3,
},
eventCount: 4,
domainCount: 3,
marginPercent: 20,
reasons: ["minute_holdout_not_ready"],
};
const result = completeDynamicScoreTransition({
stored: { ...stored, currentChoiceQuestion: null },
candidate,
usefulOpportunityCount: 0,
repeatedOnly: false,
nextVersion: 8,
});
assert.deepEqual(result.dynamicTurnState.nextAction, {
kind: "present_medium_result",
resultId: candidate.resultId,
});
assert.equal(result.dynamicTurnState.permissions.canConfirmCandidate, false);
});
test("dynamic scoring claims once, completes atomically, and replays", async () => {
const flow = scoringFlow();
const pending = await flow.service.answerDynamicChoice(ownerId, {
@@ -95,6 +95,16 @@ test("forced terminal reasons win over a high-confidence score", () => {
}), "user_finished");
});
test("a release-blocked high score does not stop as confirmable", () => {
const decision = decisionFor({
result: { ...lowCandidate, confidence: "high", canApply: false, winningSegment: {
startTime: "09:00", endTime: "09:00", representativeTime: "09:00", widthMinutes: 1,
}, eventCount: 4, domainCount: 3, marginPercent: 20 },
});
assert.deepEqual(decision, { kind: "continue", plateauCount: 0 });
});
test("a two point margin change resets the plateau", () => {
const decision = decisionFor({
result: { ...mediumCandidate, marginPercent: 17 },
@@ -85,6 +85,53 @@ test("rectification adapter normalizes Python questionnaire samples and options"
d10Sign: "Virgo",
d24Sign: null,
d30Sign: null,
a7Sign: null,
ulSign: null,
a10Sign: null,
});
});
test("rectification adapter preserves real engine sample times and named Varga keys", () => {
const questionnaire = parseRectificationQuestionnaire({
questions: [],
candidate_scan: {
samples: [{
time: "1997-08-08 06:00",
ascendant: { sign: "Cancer" },
varga_lagna: {
D2_Hora: { sign: "Cancer" },
D4_Turyamsa: { sign: "Aries" },
D9_Navamsa: { sign: "Aquarius" },
D10_Dasamsa: { sign: "Scorpio" },
D11_Rudramsa: { sign: "Libra" },
D24_Siddhamsa: { sign: "Pisces" },
D30_Trimsamsa: { sign: "Pisces" },
},
arudha: {
A7: { sign: "Scorpio" },
UL: { sign: "Virgo" },
A10: { sign: "Capricorn" },
},
}],
},
});
const rawScan = questionnaire.raw.candidate_scan as {
samples: Array<{ time?: unknown }>;
};
assert.equal(rawScan.samples[0]?.time, "1997-08-08 06:00");
assert.deepEqual(questionnaire.samples[0], {
ascendantSign: "Cancer",
d2Sign: "Cancer",
d4Sign: "Aries",
d9Sign: "Aquarius",
d10Sign: "Scorpio",
d11Sign: "Libra",
d24Sign: "Pisces",
d30Sign: "Pisces",
a7Sign: "Scorpio",
ulSign: "Virgo",
a10Sign: "Capricorn",
});
});
@@ -119,6 +166,9 @@ test("rectification adapter normalizes all evidence-domain Varga signs", () => {
d10Sign: "Virgo",
d24Sign: "Gemini",
d30Sign: "Pisces",
a7Sign: null,
ulSign: null,
a10Sign: null,
});
});
@@ -203,6 +253,7 @@ test("rectification adapter normalizes an event-scored candidate result", () =>
assert.equal(result.resultId, "1d8ee348-61a3-433d-8907-ff6d281b9992");
assert.equal(result.winningSegment?.representativeTime, "14:24");
assert.equal(result.canApply, false, "an old or incomplete engine receipt cannot open minute confirmation");
assert.deepEqual(result.evidence[0]?.ruleIds, ["vim_md_domain_house"]);
assert.equal("legacy_server_metadata" in (result.evidence[0] ?? {}), false);
});
@@ -194,3 +194,22 @@ test("dynamic scores map independent engine values into guarded candidates", ()
assert.deepEqual(parsed.candidate.evidence, []);
assert.equal(parsed.candidate.algorithmVersion, "birth-time-choice-scoring-v2");
});
test("dynamic score adapters keep minute confirmation closed before holdout release", () => {
const parsed = parseDynamicChoiceScoring({
...apiScore,
confidence: "high",
can_apply: true,
event_count: 4,
domain_count: 3,
effective_answer_count: 4,
dimension_count: 3,
top_score: 20,
second_score: 10,
margin_percent: 50,
});
assert.equal(parsed.candidate.confidence, "high");
assert.equal(parsed.candidate.canApply, false);
assert.ok(parsed.candidate.reasons.includes("minute_holdout_not_ready"));
});
@@ -60,6 +60,12 @@ const dynamicInput = {
candidateScores: { "05:30": 0, "05:31": 1, "05:32": 1, "05:33": 0 },
informationGain: 0.5,
}],
events: [{
id: "11111111-1111-4111-8111-111111111111",
domain: "career",
date: "2020",
precision: "year",
}],
dismissedOpportunityIds: ["dismissed-1"],
questionFingerprints: ["question-fingerprint-1"],
partitionFingerprints: ["partition-fingerprint-1"],
@@ -85,6 +91,12 @@ const expectedOpportunityBody = {
lon: 121.47,
tz: 8,
evidence: expectedChoiceEvidence,
events: [{
id: "11111111-1111-4111-8111-111111111111",
domain: "career",
date: "2020",
precision: "year",
}],
dismissed_opportunity_ids: ["dismissed-1"],
question_fingerprints: ["question-fingerprint-1"],
partition_fingerprints: ["partition-fingerprint-1"],
+104 -28
View File
@@ -83,54 +83,130 @@ test("homepage birth-time card opens the v3 surface instead of ordinary consulta
assert.match(source, /function openBirthTimeRectification/);
assert.match(source, /<ConversationalBirthTimeRectification/);
assert.match(source, /sendConversationalRectificationCommand/);
assert.match(source, /rectificationPriceCredits/);
assert.doesNotMatch(source, /\/api\/birth-rectification/);
assert.doesNotMatch(source, /\/api\/birth-time-journey/);
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
});
test("ordinary consultation is softly diverted before calling consult", () => {
test("homepage birth-time card starts the first rectification turn without a second confirmation card", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const sendStart = source.indexOf("async function send(");
const consultCall = source.indexOf('fetch("/api/consult"', sendStart);
const softChoice = source.indexOf("setPendingBirthTimeChoice", sendStart);
assert.ok(sendStart >= 0);
assert.ok(softChoice > sendStart && softChoice < consultCall);
assert.match(source, /grantBirthTimeConsultationConsent\([\s\S]*activeSession\.id/);
assert.match(source, /pendingConsultationQuestion=/);
});
test("minute-free choice restores an editable general question without an immediate network call or charge", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("function continueGenerallyWithoutBirthTime");
const end = source.indexOf("function rectifyBeforePendingConsultation", start);
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
assert.match(handler, /"general_no_birth_time"/);
assert.match(handler, /setDraft\(pending\.question\)/);
assert.match(handler, /setDraftEntrypoint\(null\)/);
assert.match(handler, /尚未发送,也没有扣点/);
assert.doesNotMatch(handler, /fetch\(|void send\(|send\(/);
assert.match(handler, /sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"/);
});
test("rectification mutations report pending state to the page and lock card and return actions", () => {
test("homepage birth-time card creates a dedicated session before starting the first turn", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
const create = handler.indexOf('createSession(modelCatalog.defaultModelId, "birth_time_rectification")');
const persist = handler.indexOf('await persistSession(rectificationSession, "create")');
const request = handler.indexOf("sendConversationalRectificationCommand");
assert.ok(create >= 0);
assert.ok(persist > create);
assert.ok(request > persist);
assert.match(handler, /setActiveSessionId\(rectificationSession\.id\)/);
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
assert.match(handler, /setSessions\(\(current\) => current\.filter\(\(session\) => session\.id !== rectificationSession\.id\)\)/);
});
test("rectification cards render only inside the active rectification session", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /onPendingChange=\{setRectificationMutationPending\}/);
assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/);
assert.match(source, /disabled=\{rectificationLoading \|\| rectificationMutationPending\}/);
assert.match(source, /if \(!rectificationLoading && !rectificationMutationPending\)/);
assert.match(source, /activeSession\?\.sessionType === "birth_time_rectification"/);
assert.match(source, /session_type:\s*session\.sessionType/);
assert.match(source, /rectification_case_id:\s*session\.rectificationCaseId/);
assert.doesNotMatch(source, /这个会话保存了生时校正入口|恢复生时校正<\/button>/);
});
test("switching chats hides rather than destroys another chat's pending soft choice", () => {
test("selecting a rectification session resumes it without an intermediate confirmation", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const selectSession = source.slice(
source.indexOf("function selectSession("),
source.indexOf("async function selectSessionModel", source.indexOf("function selectSession(")),
);
assert.doesNotMatch(selectSession, /setPendingBirthTimeChoice\(null\)/);
assert.match(source, /pendingBirthTimeChoice\?\.sessionId === activeSession\?\.id/);
assert.match(selectSession, /nextSession\?\.sessionType === "birth_time_rectification"/);
assert.match(selectSession, /resumeRectificationSession\.current\(nextSession\)/);
assert.match(source, /resumeRectificationSession\.current\(activeSession\)/);
assert.match(source, /正在和星星核对校正进度/);
assert.doesNotMatch(source, /正在加载生时校正对话|正在恢复账户里的校正进度/);
});
test("homepage reuses the session bound to an unfinished rectification case", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
assert.match(handler, /action === "resume" && account\.rectificationCase/);
assert.match(handler, /session\.rectificationCaseId === account\.rectificationCase\?\.caseId/);
assert.match(handler, /resumableSession \?\? createSession/);
});
test("rectify-first suggestions hand the source question to a dedicated rectification session", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("function chooseConversationSuggestion");
const end = source.indexOf("function draftDailyStarlanguageQuestion", start);
const handler = source.slice(start, end);
assert.match(handler, /suggestion !== rectifyBeforeConsultationSuggestion/);
assert.match(handler, /find\(\(message\) => message\.role === "user"\)/);
assert.match(handler, /rectificationQuestionHandoff\.current\.capture\(\{/);
assert.match(handler, /sessionId: activeSession\.id/);
assert.match(handler, /openBirthTimeRectification\(originalQuestion, activeSession\)/);
assert.match(source, /onClick=\{\(\) => chooseConversationSuggestion\(question\)\}/);
});
test("completed handoffs automatically return and continue the source question", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /turn\.status !== "completed"/);
assert.match(source, /turn\.actions\.includes\("continue_original_question"\)/);
assert.match(source, /automaticRectificationContinuation\.current === continuationIdentity/);
assert.match(source, /continueRectificationQuestion\.current\(question\)/);
assert.match(source, /setActiveSessionId\(context\.sessionId\)/);
assert.match(source, /clearBirthTimeConsultationConsent\([\s\S]*?context\.sessionId/);
});
test("ordinary consultation falls back to minute-free mode with a non-blocking notice", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const sendStart = source.indexOf("async function send(");
const consultCall = source.indexOf('fetch("/api/consult"', sendStart);
const softNotice = source.indexOf("setBirthTimeSoftNotice", sendStart);
assert.ok(sendStart >= 0);
assert.ok(softNotice > sendStart && softNotice < consultCall);
assert.match(source, /mode: "general_no_birth_time" as const/);
assert.match(source, /<BirthTimeSoftNotice/);
assert.doesNotMatch(source, /setPendingBirthTimeChoice|<UnverifiedBirthTimeChoice/);
});
test("rectification mutations report pending state while session-level return controls stay absent", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /onPendingChange=\{setRectificationMutationPending\}/);
assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/);
assert.match(source, /disabled=\{rectificationLoading \|\| rectificationMutationPending\}/);
assert.doesNotMatch(source, /返回并恢复原问题|返回首页/);
});
test("birth-time soft notice dismisses itself without blocking session changes", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const selectSession = source.slice(
source.indexOf("function selectSession("),
source.indexOf("async function selectSessionModel", source.indexOf("function selectSession(")),
);
assert.doesNotMatch(selectSession, /birthTimeSoftNotice|setBirthTimeSoftNotice/);
assert.match(source, /dismissBirthTimeSoftNotice/);
});
test("profile and place saves do not auto-start the retired assessment flow", () => {
@@ -58,6 +58,19 @@ test("never invents a missing month or day", () => {
assert.equal(evidence?.eventSummary, "毕业");
});
test("classifies dated income and asset changes as finance evidence", () => {
const [evidence] = extractLifeEventEvidence({
rawText: "2022年8月收入大幅增加并开始投资",
sourceTurnId,
asOfDate: "2026-07-20",
});
assert.equal(evidence?.domain, "finance");
assert.equal(evidence?.dateValue, "2022-08");
assert.equal(evidence?.scoreable, true);
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
});
test("keeps a bare year as non-scoreable clarification instead of an event summary", () => {
const rawText = "2021年";
const [evidence] = extractLifeEventEvidence({
@@ -172,7 +172,7 @@ test("rejects duplicated generic domain reasons that omit a packet discriminatio
assert.ok(result.issues.some((issue) => issue.includes("packet discrimination pairs")));
});
test("rejects a generic broad-year choice questionnaire and falls back without scoring", async () => {
test("rejects a generic broad-year choice questionnaire and uses a safe scoring-compatible fallback", async () => {
const invalid = {
...richOutput(),
narrative: [
@@ -198,7 +198,7 @@ test("rejects a generic broad-year choice questionnaire and falls back without s
});
assert.equal(result.attempts, 2);
assert.equal(result.fallbackUsed, true);
assert.equal(result.allowEvidenceScoringAdvance, false);
assert.equal(result.allowEvidenceScoringAdvance, true);
});
test("rejects generic individual-year options even without a written range", () => {
@@ -320,7 +320,7 @@ test("retries expression exactly once with the same grounded packet", async () =
assert.equal(prompts.some((prompt) => prompt.includes("private-partition")), false);
});
test("uses a deterministic rich Chinese fallback after the second mismatch and holds scoring", async () => {
test("uses a deterministic rich Chinese fallback after the second mismatch without blocking scoring", async () => {
const packet = syntheticTechnicalPacket();
const invalid = { ...richOutput(), sensitiveLayers: ["D60"] };
const first = await generateRectificationNarrative({
@@ -336,7 +336,7 @@ test("uses a deterministic rich Chinese fallback after the second mismatch and h
assert.equal(first.attempts, 2);
assert.equal(first.fallbackUsed, true);
assert.equal(first.allowEvidenceScoringAdvance, false);
assert.equal(first.allowEvidenceScoringAdvance, true);
assert.equal(first.narrative, second.narrative);
assert.match(first.narrative, /05:20[\s\S]*待验证/);
assert.match(first.narrative, /D1[\s\S]*稳定/);
@@ -92,14 +92,40 @@ test("rich narrative precedes 24 domain choices while free text remains avail
assert.match(markup, /<h2>当前判断<\/h2>/);
assert.match(markup, /<strong>05:18<\/strong>/);
assert.ok(markup.indexOf("候选时间") < markup.indexOf("当前判断"));
assert.ok(markup.indexOf("当前判断") < markup.indexOf("重要关系"));
assert.equal((markup.match(/data-evidence-domain=/g) ?? []).length, 3);
assert.match(markup, /<textarea[^>]+id="conversational-rectification-answer"/);
assert.match(markup, /aria-label="经历发生年份"/);
assert.match(markup, /aria-label="经历发生月份"/);
assert.match(markup, /<option value=""[^>]*>不确定<\/option>/);
assert.match(markup, /Ctrl\/⌘ \+ Enter/);
assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/);
});
test("evidence is correctable, technical receipts stay visible, and confirmation is explicit", () => {
test("an uninitialized surface shows progress without a second start card", () => {
const emptyController = controller({
turn: null,
pending: true,
getSnapshot: () => ({
turn: null,
draft: "",
selectedDomain: null,
correctionTarget: null,
pending: true,
error: "",
}),
});
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: emptyController },
));
assert.match(markup, /正在建立校正记录/);
assert.doesNotMatch(markup, /系统会先说明候选边界|开始生时校正<\/button>/);
});
test("evidence is correctable, secondary controls stay hidden, and confirmation is explicit", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: controller() },
@@ -108,14 +134,12 @@ test("evidence is correctable, technical receipts stay visible, and confirmation
assert.match(markup, /已记录的真实经历/);
assert.match(markup, /2021 年 7 月/);
assert.match(markup, /更正这条经历:开始第一份长期工作/);
assert.match(markup, /本轮技术回执/);
assert.match(markup, /rectification-technical-v1/);
assert.match(markup, /consult-d9/);
assert.doesNotMatch(markup, /本轮分析|等待经历验证/);
assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/);
assert.match(markup, /待确认 · 未验证/);
assert.match(markup, /确认将 05:18 设为当前排盘时间/);
assert.match(markup, /不会自动采用/);
assert.match(markup, /暂停,稍后继续/);
assert.match(markup, /放弃本次校正/);
assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/);
});
test("correction mode identifies its durable target, can be cancelled, and marks revised recaps", () => {
@@ -179,9 +203,9 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
assert.match(css, /\.conversational-rectification[^}]*overflow-wrap:\s*anywhere/);
assert.match(css, /\.conversational-rectification button[^}]*min-height:\s*44px/);
assert.match(css, /\.conversational-rectification[^}]*:focus-visible/);
assert.match(css, /summary, \.conversational-status\):focus-visible/);
assert.match(css, /:where\(button, textarea\):focus-visible/);
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/);
assert.match(component, /确认放弃且不应用候选/);
assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/);
assert.match(component, /onPendingChange/);
assert.match(component, /onPendingChange:\s*props\.onPendingChange/);
assert.match(component, /&& onContinueOriginalQuestion &&/);
@@ -458,23 +482,6 @@ async function terminateChildProcess(browser: ChildProcess): Promise<void> {
}
}
async function pressEscape(cdp: CdpSession) {
await cdp.send("Input.dispatchKeyEvent", {
type: "rawKeyDown",
key: "Escape",
code: "Escape",
windowsVirtualKeyCode: 27,
nativeVirtualKeyCode: 27,
});
await cdp.send("Input.dispatchKeyEvent", {
type: "keyUp",
key: "Escape",
code: "Escape",
windowsVirtualKeyCode: 27,
nativeVirtualKeyCode: 27,
});
}
async function launchFixture(htmlPath: string, userDataDirectory: string): Promise<{
browser: ChildProcess;
cdp: CdpSession;
@@ -554,7 +561,7 @@ test("Chromium harness keeps the browser sandbox and bounds every external wait"
assert.match(source, /fetchJsonWithDeadline/);
});
test("real Chromium at 390px verifies layout, keyboard focus, pause affordance, dialog lifecycle, and live hook inputs", {
test("real Chromium at 390px verifies layout, keyboard focus, streamlined controls, and live hook inputs", {
timeout: 30_000,
}, async () => {
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
@@ -568,11 +575,10 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
const css = readFileSync(join(frontendRoot, "src/app/globals.css"), "utf8")
.replace(/^@import[^;]+;\s*/gm, "");
const fixture = `
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { ConversationalRectificationSurface } from ${JSON.stringify(componentPath)};
import { useConversationalRectification } from ${JSON.stringify(hookPath)};
import { UnverifiedBirthTimeChoice } from ${JSON.stringify(join(frontendRoot, "src/components/unverified-birth-time-choice.tsx"))};
const caseA = "00000000-0000-4000-8000-000000000821";
const caseB = "00000000-0000-4000-8000-000000000829";
@@ -623,10 +629,6 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
const [initialTurn, setInitialTurn] = useState(null);
const [transportLabel, setTransportLabel] = useState("first");
const [callbackLabel, setCallbackLabel] = useState("first");
const [screen, setScreen] = useState("rectification");
const [choicePending, setChoicePending] = useState(false);
const [activeChat, setActiveChat] = useState("chat-a");
const returnComposer = useRef(null);
const send = async (command) => {
events.push("send:" + transportLabel + ":" + command.type);
await new Promise((resolveSend) => setTimeout(resolveSend, 20));
@@ -646,45 +648,12 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
useEffect(() => {
globalThis.__rectificationHarness = {
events,
networkCalls: 0,
setActiveChat,
setCallbackLabel,
setChoicePending,
setScreen,
setTransportLabel,
setTurn(name) { setInitialTurn(name === "none" ? null : turns[name]); },
};
globalThis.__rectificationReady = true;
});
useEffect(() => {
if (screen !== "composer" && activeChat === "chat-a") return;
const frame = requestAnimationFrame(() => returnComposer.current?.focus());
return () => cancelAnimationFrame(frame);
}, [activeChat, screen]);
if (screen !== "rectification") {
if (activeChat !== "chat-a" || screen === "composer") {
return <textarea ref={returnComposer} id="choice-return-composer" defaultValue="原问题" />;
}
const canUse = screen === "choice-concrete";
return <UnverifiedBirthTimeChoice
canUseUnverifiedTime={canUse}
unverifiedTime={canUse ? "05:30" : null}
pending={choicePending}
onUseUnverifiedTime={() => events.push("choice:unverified")}
onContinueGenerally={() => {
events.push("choice:general");
setScreen("composer");
}}
onRectifyFirst={() => {
events.push("choice:rectify");
setChoicePending(true);
}}
onCancel={() => {
events.push("choice:cancel");
setScreen("composer");
}}
/>;
}
return <ConversationalRectificationSurface controller={controller} />;
}
createRoot(document.getElementById("root")).render(<Harness />);
@@ -731,19 +700,24 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
scrollWidth: number;
surfaceWidth: number;
shortestButton: number;
dateFieldsShareRow: boolean;
}>(`(() => {
const buttons = [...document.querySelectorAll('.conversational-rectification button')];
const year = document.querySelector('[aria-label="经历发生年份"]').getBoundingClientRect();
const month = document.querySelector('[aria-label="经历发生月份"]').getBoundingClientRect();
return {
viewport: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
surfaceWidth: document.querySelector('.conversational-rectification').getBoundingClientRect().width,
shortestButton: Math.min(...buttons.map((button) => button.getBoundingClientRect().height)),
dateFieldsShareRow: Math.abs(year.top - month.top) < 2,
};
})()`);
assert.equal(layout.viewport, 390);
assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`);
assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`);
assert.equal(layout.dateFieldsShareRow, true, "year and month should stay on one row at 390px");
await cdp.evaluate("document.querySelector('[aria-label^=\"更正这条经历\"]').click()");
await waitFor(
@@ -767,157 +741,19 @@ test("real Chromium at 390px verifies layout, keyboard focus, pause affordance,
"domain-to-composer focus",
);
await cdp.evaluate("globalThis.__rectificationHarness.setTransportLabel('second'); globalThis.__rectificationHarness.setCallbackLabel('second')");
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('暂停,稍后继续')).click()");
await waitFor(
() => cdp?.evaluate<boolean>("document.body.textContent.includes('继续校正')") ?? Promise.resolve(false),
"paused response",
);
assert.deepEqual(
await cdp.evaluate<string[]>("globalThis.__rectificationHarness.events.slice()"),
["send:second:pause", "turn:second:paused"],
);
const beforeContinue = await cdp.evaluate<number>("globalThis.__rectificationHarness.events.length");
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('继续校正')).click()");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'conversational-rectification-answer' && document.body.textContent.includes('现在可以继续填写')") ?? Promise.resolve(false),
"local paused continuation feedback",
);
assert.equal(await cdp.evaluate<number>("globalThis.__rectificationHarness.events.length"), beforeContinue);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
await waitFor(
() => cdp?.evaluate<boolean>("document.body.textContent.includes('放弃本次校正') && !document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false),
"newer same-case initial turn",
);
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()");
const dialog = await waitFor<{ title: string; description: string; active: string }>(
() => cdp!.evaluate<false | { title: string; description: string; active: string }>(`(() => {
const dialog = document.querySelector('[role=alertdialog]');
if (!dialog || document.activeElement?.tagName !== 'BUTTON') return false;
return {
title: document.getElementById(dialog.getAttribute('aria-labelledby'))?.textContent ?? '',
description: document.getElementById(dialog.getAttribute('aria-describedby'))?.textContent ?? '',
active: document.activeElement?.textContent?.trim() ?? '',
};
})()`),
"abandon alertdialog",
);
assert.match(dialog.title, /确认放弃/);
assert.match(dialog.description, /不会应用任何候选时间/);
assert.equal(dialog.active, "返回校正");
await pressEscape(cdp);
await waitFor(
() => cdp?.evaluate<boolean>("!document.querySelector('[role=alertdialog]') && document.activeElement?.textContent?.includes('放弃本次校正')") ?? Promise.resolve(false),
"Escape close and trigger focus restoration",
);
await cdp.evaluate("document.activeElement.click()");
await waitFor(
() => cdp?.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
"reopened abandon dialog",
);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeB1')");
await waitFor(
() => cdp?.evaluate<boolean>("!document.querySelector('[role=alertdialog]')") ?? Promise.resolve(false),
"case-switch dialog reset",
);
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('放弃本次校正')).click()");
await waitFor(
() => cdp?.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
"case-B abandon dialog",
);
await cdp.evaluate("[...document.querySelectorAll('[role=alertdialog] button')].find((button) => button.textContent.includes('确认放弃且不应用候选')).click()");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
const status = document.querySelector('.conversational-status');
return !document.querySelector('[role=alertdialog]')
&& document.body.textContent.includes('本次校正已放弃')
&& status?.tabIndex === -1
&& document.activeElement === status;
const text = document.body.textContent;
return text.includes('候选时间')
&& !text.includes('本轮技术回执')
&& !text.includes('暂停,稍后继续')
&& !text.includes('放弃本次校正')
&& !document.querySelector('[role=alertdialog]');
})()`) ?? Promise.resolve(false),
"terminal dialog close and terminal status focus",
"streamlined rectification controls",
);
await cdp.evaluate("globalThis.__rectificationHarness.setScreen('choice-general')");
const generalChoice = await waitFor<{ role: string; focused: string; buttons: string[] }>(
() => cdp!.evaluate<false | { role: string; focused: string; buttons: string[] }>(`(() => {
const dialog = document.querySelector('[role=alertdialog]');
if (!dialog || document.activeElement?.tagName !== 'BUTTON') return false;
return {
role: dialog.getAttribute('role'),
focused: document.activeElement?.textContent?.trim() ?? '',
buttons: [...dialog.querySelectorAll('button')].map((button) => button.textContent.trim()),
};
})()`),
"minute-free choice initial focus",
);
assert.equal(generalChoice.role, "alertdialog");
assert.equal(generalChoice.focused, "继续不依赖出生分钟的一般咨询");
assert.ok(generalChoice.buttons.includes("先校正再询问"));
const eventsBeforeGeneral = await cdp.evaluate<number>("globalThis.__rectificationHarness.events.length");
await cdp.evaluate("document.activeElement.click()");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'choice-return-composer'") ?? Promise.resolve(false),
"general choice restores composer focus",
);
assert.deepEqual(
await cdp.evaluate<string[]>(`globalThis.__rectificationHarness.events.slice(${eventsBeforeGeneral})`),
["choice:general"],
);
assert.equal(await cdp.evaluate<number>("globalThis.__rectificationHarness.networkCalls"), 0);
await cdp.evaluate("globalThis.__rectificationHarness.setScreen('choice-general')");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.tagName === 'BUTTON' && document.activeElement?.textContent?.includes('继续不依赖出生分钟')") ?? Promise.resolve(false),
"reopened minute-free choice",
);
await pressEscape(cdp);
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'choice-return-composer'") ?? Promise.resolve(false),
"choice Escape restores composer focus",
);
await cdp.evaluate("globalThis.__rectificationHarness.setScreen('choice-concrete')");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.textContent?.includes('先用 05:30')") ?? Promise.resolve(false),
"concrete choice initial focus",
);
await cdp.evaluate("[...document.querySelectorAll('[role=alertdialog] button')].find((button) => button.textContent.includes('先校正再询问')).click()");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
const dialog = document.querySelector('[role=alertdialog]');
const buttons = [...dialog.querySelectorAll('button')];
return dialog?.getAttribute('aria-busy') === 'true'
&& buttons.length === 3
&& buttons.every((button) => button.disabled);
})()`) ?? Promise.resolve(false),
"choice pending lock",
);
await pressEscape(cdp);
assert.equal(
await cdp.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))"),
true,
);
await cdp.evaluate("globalThis.__rectificationHarness.setChoicePending(false); globalThis.__rectificationHarness.setScreen('choice-general')");
await waitFor(
() => cdp?.evaluate<boolean>("Boolean(document.querySelector('[role=alertdialog]'))") ?? Promise.resolve(false),
"choice ready before chat switch",
);
await cdp.evaluate("globalThis.__rectificationHarness.setActiveChat('chat-b')");
await waitFor(
() => cdp?.evaluate<boolean>("!document.querySelector('[role=alertdialog]') && document.activeElement?.id === 'choice-return-composer'") ?? Promise.resolve(false),
"other chat hides the pending choice",
);
await cdp.evaluate("globalThis.__rectificationHarness.setActiveChat('chat-a')");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.textContent?.includes('继续不依赖出生分钟')") ?? Promise.resolve(false),
"returning chat restores its choice and focus",
);
} finally {
try {
cdp?.close();
@@ -808,6 +808,27 @@ test("generic date uncertainty does not suppress clear historical evidence", asy
.some((item) => item.eventSummary.includes("毕业") && item.scoreable === true));
});
test("a rejected professional narrative falls back safely while the first scoreable answer still narrows", async () => {
const value = harness({ invalidNarrativeFromGeneration: 2 });
await start(value, null);
const turn = await value.service.answer(userId, {
type: "answer",
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2021年7月开始第一份长期工作",
});
const stored = value.cases.get(startActionId)?.row;
assert.equal(turn.status, "confirming");
assert.equal(turn.candidate.status, "ready_for_confirmation");
assert.equal(stored?.privateCandidate.resultId, resultId);
assert.equal(stored?.validationReceipts.at(-1)?.fallbackUsed, true);
assert.match(turn.narrative, /下一步|当前证据已形成候选总结/);
assert.doesNotMatch(turn.narrative, /候选没有推进|请稍后重试/);
});
test("one and two supported events save and narrate before the third accumulated event ranks", async () => {
const value = harness({ readyAfterEvidenceCount: 3 });
await start(value, null);
@@ -835,6 +856,35 @@ test("one and two supported events save and narrate before the third accumulated
assert.equal(value.events.filter((event) => event === "narrative").length, 4);
});
test("a non-confirmable conversational case completes with its saved range instead of asking forever", async () => {
const value = harness({ readyAfterEvidenceCount: 99 });
await start(value, "请继续回答原来的事业问题");
const answers = [
[answerActionId, "2019年7月毕业"],
[secondAnswerActionId, "2020年8月搬家"],
[thirdAnswerActionId, "2021年9月换工作"],
] as const;
let latest = value.cases.get(startActionId)?.row.latestTurn;
for (const [index, [receivedActionId, answer]] of answers.entries()) {
latest = await value.service.answer(userId, {
type: "answer",
caseId: startActionId,
actionId: receivedActionId,
turnVersion: index,
answer,
});
}
assert.equal(latest?.status, "completed");
assert.equal(latest?.candidate.status, "pending_validation");
assert.deepEqual(latest?.actions, ["continue_original_question"]);
assert.equal(latest?.evidenceRequest, null);
assert.match(latest?.narrative ?? "", /候选范围.*不会替换当前排盘时间/);
assert.equal(value.cases.get(startActionId)?.row.status, "completed");
assert.equal(value.cases.get(startActionId)?.row.privateCandidate.representativeTime, "05:20");
});
test("family evidence remains stored and public without changing its domain", async () => {
const value = harness({ readyAfterEvidenceCount: 3 });
await start(value, null);
@@ -8,7 +8,10 @@ import {
type BirthTimeConversationRouteService,
} from "../src/app/api/birth-time-conversation/route.ts";
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
import type { BirthTimeJourneyEngine } from "../src/lib/birth-time-journey-service.ts";
import type {
BirthTimeJourneyEngine,
DifferencePacketInput,
} from "../src/lib/birth-time-journey-service.ts";
import type { LifeEventEvidence } from "../src/lib/conversational-rectification/persistence-contracts.ts";
import type { CandidateResult, LifeEvent } from "../src/lib/birth-time-evidence.ts";
@@ -89,6 +92,7 @@ function syntheticEvidence(
function packetEngine(options: {
readonly scoreCalls?: LifeEvent[][];
readonly differenceCalls?: DifferencePacketInput[];
readonly scanTimes?: readonly string[];
readonly scanCalls?: Array<{ readonly birthTime: string; readonly uncertaintyMinutes: number }>;
readonly scoreResults?: readonly CandidateResult[];
@@ -109,11 +113,10 @@ function packetEngine(options: {
uncertaintyMinutes: input.uncertaintyMinutes,
});
const center = minute(input.birthTime);
const times = options.scanTimes ?? [
clock(center - input.uncertaintyMinutes),
clock(center),
clock(center + input.uncertaintyMinutes),
];
const times = options.scanTimes ?? Array.from(
{ length: input.uncertaintyMinutes * 2 + 1 },
(_, index) => clock(center - input.uncertaintyMinutes + index),
);
return {
questionnaire: {
questions: [],
@@ -164,6 +167,7 @@ function packetEngine(options: {
};
},
async buildDifferencePacket(input) {
options.differenceCalls?.push(input);
return {
packet: {
caseId: input.caseId,
@@ -463,8 +467,10 @@ test("production unknown-time adapter covers the declared full day with bounded
async scan(input) {
scanCalls.push({ birthTime: input.birthTime, uncertaintyMinutes: input.uncertaintyMinutes });
const center = minute(input.birthTime);
const times = [center - input.uncertaintyMinutes, center, center + input.uncertaintyMinutes]
.map(clock);
const times = Array.from(
{ length: input.uncertaintyMinutes * 2 + 1 },
(_, index) => clock(center - input.uncertaintyMinutes + index),
);
return {
questionnaire: {
questions: [],
@@ -547,7 +553,8 @@ test("production unknown-time adapter covers the declared full day with bounded
test("production packet waits for three supported events and then scores the accumulated evidence", async () => {
const scoreCalls: LifeEvent[][] = [];
const engine = packetEngine({ scoreCalls });
const differenceCalls: DifferencePacketInput[] = [];
const engine = packetEngine({ scoreCalls, differenceCalls });
const evidence = [
syntheticEvidence(1, "education"),
syntheticEvidence(2, "relocation"),
@@ -580,6 +587,11 @@ test("production packet waits for three supported events and then scores the acc
assert.equal(scoreCalls.length, 1);
assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id));
assert.deepEqual(
differenceCalls.map((input) => input.events.map((event) => event.id)),
[evidence.slice(0, 1), evidence.slice(0, 2), evidence].map((items) => items.map((item) => item.id)),
"every next-question request receives the historical evidence available at that turn",
);
});
test("legacy import scores inherited events without silently replacing the inherited candidate range", async () => {
@@ -752,7 +764,8 @@ test("production rescans the declared range after correction while ordinary evid
test("production packet deterministically sends only the latest six supported events", async () => {
const scoreCalls: LifeEvent[][] = [];
const engine = packetEngine({ scoreCalls });
const differenceCalls: DifferencePacketInput[] = [];
const engine = packetEngine({ scoreCalls, differenceCalls });
const domains = ["education", "relocation", "career", "relationship"] as const;
const evidence = Array.from({ length: 8 }, (_, index) =>
syntheticEvidence(index + 1, domains[index % domains.length] ?? "career"));
@@ -779,6 +792,10 @@ test("production packet deterministically sends only the latest six supported ev
scoreCalls[0]?.map((event) => event.id),
evidence.slice(-6).map((item) => item.id),
);
assert.deepEqual(
differenceCalls.at(-1)?.events.map((event) => event.id),
evidence.slice(-6).map((item) => item.id),
);
});
test("persistable future background evidence never reaches the production scorer", async () => {
@@ -855,8 +872,41 @@ test("family evidence stays out of relationship scoring when three real scorer d
assert.equal(scoreCalls[0]?.some((event) => event.domain === "relationship"), false);
});
test("dated finance evidence reaches the minute scorer without being downgraded to other", async () => {
const scoreCalls: LifeEvent[][] = [];
const engine = packetEngine({ scoreCalls });
await buildProductionConversationalRectificationPacket(engine, {
userId,
caseId,
asOfDate: "2026-07-21",
declaredBirthInput: {
source: "approximate",
birthDate: "1990-01-01",
reportedTime: "05:20",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthTimeClue: null,
birthplace: packetBirthplace,
},
privateCandidate: null,
evidence: [
syntheticEvidence(71, "education"),
syntheticEvidence(72, "relocation"),
syntheticEvidence(73, "finance"),
],
});
assert.equal(scoreCalls.length, 1);
assert.deepEqual(scoreCalls[0]?.map((event) => event.domain), [
"education",
"relocation",
"finance",
]);
});
test("a single period-only scan filters duplicate and out-of-range samples from the exact :59 range", async () => {
const engine = packetEngine({ scanTimes: ["08:00", "10:00", "10:00", "12:00"] });
const engine = packetEngine({ scanTimes: ["08:00", "08:01", "10:00", "10:00", "12:00"] });
const built = await buildProductionConversationalRectificationPacket(engine, {
userId,
caseId,
@@ -873,7 +923,7 @@ test("a single period-only scan filters duplicate and out-of-range samples from
});
assert.deepEqual(built.packet.candidate.range, { startTime: "08:00", endTime: "11:59" });
assert.deepEqual(built.packet.sensitivityScope.sampleTimes, ["08:00", "10:00"]);
assert.deepEqual(built.packet.sensitivityScope.sampleTimes, ["08:00", "08:01", "10:00"]);
});
test("year-precision evidence before birth waits while the birth year can become the valid third event", async () => {
@@ -362,6 +362,37 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard
assert.equal("outputValidationReceipt" in (calls[0]?.[1].p_turn as object), false);
});
test("a completed unverified range uses the non-confirming completion RPC", async () => {
let called = "";
const completedTurn = {
...firstTurn,
status: "completed" as const,
turnVersion: 1,
evidenceRequest: null,
actions: [] as const,
};
const store = new ConversationalRectificationStore(rpcClient((name) => {
called = name;
return { ...storedRow, status: "completed", turn_version: 1, latest_turn: completedTurn };
}));
const result = await store.saveTurn({
userId,
caseId,
actionId,
expectedVersion: 0,
commandFingerprint,
turn: completedTurn,
evidence: [],
validationReceipt,
privateCandidate: { resultId, calculationVersion: "rectification-v3.1" },
});
assert.equal(called, "complete_conversational_rectification_with_range");
assert.equal(result.status, "completed");
assert.equal(result.latestTurn.candidate.status, "pending_validation");
});
test("maps only exact allowlisted database failures to stable domain codes", () => {
const exact = [
["conversational_case_not_found", "case_not_found"],
@@ -22,7 +22,7 @@ const scan = {
samples: [
{ time: "2000-01-01 05:10", ascendant: { sign: "Cancer" } },
{ time: "2000-01-01 05:16", ascendant: { sign: "Cancer" } },
{ time: "2000-01-01 05:24", ascendant: { sign: "Cancer" } },
{ time: "2000-01-01 05:17", ascendant: { sign: "Cancer" } },
{ time: "2000-01-01 05:30", ascendant: { sign: "Cancer" } },
],
},
@@ -116,7 +116,7 @@ export function syntheticTechnicalPacket() {
timeLinkedScanSamples: [
{ sampleIndex: 0, time: "05:10" },
{ sampleIndex: 1, time: "05:16" },
{ sampleIndex: 2, time: "05:24" },
{ sampleIndex: 2, time: "05:17" },
{ sampleIndex: 3, time: "05:30" },
],
boundaryDistanceMinutes: 4,
@@ -145,7 +145,7 @@ test("builds a deterministic private packet from server-computed engine receipts
source: "time_linked_candidate_scan_samples",
rangeStart: "05:16",
rangeEnd: "05:24",
sampleTimes: ["05:16", "05:24"],
sampleTimes: ["05:16", "05:17"],
});
assert.equal(first.candidateDifferenceRefs.includes("difference-d9-relationship"), false);
assert.ok(first.candidateDifferenceRefs.includes("consult-d9-candidate-difference"));
@@ -174,6 +174,64 @@ test("builds a deterministic private packet from server-computed engine receipts
}]);
});
test("chooses one strongest technical layer per domain from actual candidate switches", () => {
const differenceDrivenScan = {
...scan,
samples: [
{ ...scan.samples[0], d2Sign: "Aries", d11Sign: "Taurus", d9Sign: "Aries", d10Sign: "Taurus", a10Sign: "Cancer" },
{ ...scan.samples[1], d2Sign: "Leo", d11Sign: "Taurus", d9Sign: "Leo", d10Sign: "Taurus", a10Sign: "Leo" },
{ ...scan.samples[2], d2Sign: "Aries", d11Sign: "Virgo", d9Sign: "Virgo", d10Sign: "Virgo", a10Sign: "Cancer" },
{ ...scan.samples[3], d2Sign: "Leo", d11Sign: "Virgo", d9Sign: "Sagittarius", d10Sign: "Virgo", a10Sign: "Leo" },
],
} satisfies RectificationQuestionnaire;
const fullRangeScore = {
...eventScore,
winningSegment: {
startTime: "05:10",
endTime: "05:30",
representativeTime: "05:20",
widthMinutes: 21,
},
} satisfies CandidateResult;
const packet = buildRectificationTechnicalPacket({
scan: differenceDrivenScan,
candidateDifferences,
eventScore: fullRangeScore,
consultation: {
source: "server_consultation_workflow",
calculationVersion: "rectification-technical-v1",
availableLayers: ["D1", "D2", "D9", "D10", "D11", "A10"],
layerReferences: {
D1: ["consult-d1-ascendant"],
D2: ["consult-d2-candidate-difference"],
D9: ["consult-d9-candidate-difference"],
D10: ["consult-d10-candidate-difference"],
D11: ["consult-d11-candidate-difference"],
A10: ["consult-a10-candidate-difference"],
},
timeLinkedScanSamples: [
{ sampleIndex: 0, time: "05:10" },
{ sampleIndex: 1, time: "05:11" },
{ sampleIndex: 2, time: "05:12" },
{ sampleIndex: 3, time: "05:13" },
],
boundaryDistanceMinutes: 4,
futureWindows: [],
},
});
const careerDomains = packet.suggestedDomains.filter((item) => item.domain === "career");
assert.equal(careerDomains.length, 1);
assert.equal(careerDomains[0]?.layer, "A10");
assert.match(careerDomains[0]?.reason ?? "", /3 次实际切换/);
assert.equal(packet.suggestedDomains.some((item) => item.layer === "D10"), false);
const financeDomains = packet.suggestedDomains.filter((item) => item.domain === "finance");
assert.equal(financeDomains.length, 1);
assert.equal(financeDomains[0]?.layer, "D2");
assert.equal(packet.suggestedDomains.some((item) => item.layer === "D11"), false);
});
test("does not claim scan-wide 05:10-05:30 differences inside a 05:16-05:24 candidate", () => {
const scanWideOnly = {
...scan,
@@ -212,6 +270,35 @@ test("does not claim scan-wide 05:10-05:30 differences inside a 05:16-05:24 cand
}), /two time-linked scan samples inside the selected candidate range/);
});
test("does not describe sparse in-range samples as adjacent-minute switches", () => {
const sparseScan = {
...scan,
samples: [scan.samples[1], scan.samples[2]],
} satisfies RectificationQuestionnaire;
assert.throws(() => buildRectificationTechnicalPacket({
scan: sparseScan,
candidateDifferences,
eventScore,
consultation: {
source: "server_consultation_workflow",
calculationVersion: "rectification-technical-v1",
availableLayers: ["D1", "D9", "D10"],
layerReferences: {
D1: ["consult-d1-ascendant"],
D9: ["consult-d9-candidate-difference"],
D10: ["consult-d10-candidate-difference"],
},
timeLinkedScanSamples: [
{ sampleIndex: 0, time: "05:16" },
{ sampleIndex: 1, time: "05:24" },
],
boundaryDistanceMinutes: 4,
futureWindows: [],
},
}), /two time-linked discriminating domains/);
});
test("uses typed server time links when normalized scan raw metadata omits sample times", () => {
const normalizedScan = {
...scan,
@@ -236,7 +323,7 @@ test("uses typed server time links when normalized scan raw metadata omits sampl
timeLinkedScanSamples: [
{ sampleIndex: 0, time: "05:10" },
{ sampleIndex: 1, time: "05:16" },
{ sampleIndex: 2, time: "05:24" },
{ sampleIndex: 2, time: "05:17" },
{ sampleIndex: 3, time: "05:30" },
],
boundaryDistanceMinutes: 4,
@@ -244,7 +331,7 @@ test("uses typed server time links when normalized scan raw metadata omits sampl
},
});
assert.deepEqual(packet.sensitivityScope.sampleTimes, ["05:16", "05:24"]);
assert.deepEqual(packet.sensitivityScope.sampleTimes, ["05:16", "05:17"]);
assert.deepEqual(packet.sensitiveLayers.map((item) => item.values), [
["Leo", "Virgo"],
["Libra", "Scorpio"],
@@ -265,7 +352,7 @@ test("public projection strips weights, partition identifiers, and private finge
source: "time_linked_candidate_scan_samples",
rangeStart: "05:16",
rangeEnd: "05:24",
sampleTimes: ["05:16", "05:24"],
sampleTimes: ["05:16", "05:17"],
});
assert.deepEqual(projected.evidenceRequest.domains, ["relationship", "career"]);
assert.equal(projected.futureWindows[0]?.scoreable, false);
+6
View File
@@ -435,3 +435,9 @@ test("v3 readiness requires healthy dependencies and smoke proof for the exact f
}
}
});
test("production API probes health rapidly while a replacement container starts", () => {
const compose = readFileSync(new URL("../../deploy/docker-compose.server.yml", import.meta.url), "utf8");
assert.match(compose, /healthcheck:[\s\S]*start_period:\s*30s[\s\S]*start_interval:\s*1s/);
});
@@ -63,6 +63,27 @@ test("changed profile cannot wait on the previous profile's active pending claim
});
});
test("period-only declaration fields participate in the onboarding cache identity", () => {
const earlyMorning = {
...profileA,
birthTime: null,
activeBirthTime: null,
birthTimeStatus: "reported",
reportedBirthTime: null,
birthTimeSource: "period_only",
birthTimePeriod: "early_morning",
birthTimeClue: "凌晨或清晨",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
};
const morning = { ...earlyMorning, birthTimePeriod: "morning" };
assert.notEqual(
createOnboardingCacheIdentity(earlyMorning).readyVersion,
createOnboardingCacheIdentity(morning).readyVersion,
);
});
test("current profile accepts only valid ready content and an active current pending claim", () => {
// Given: one current profile identity and a valid cached payload.
const identity = createOnboardingCacheIdentity(profileA);
+24
View File
@@ -16,6 +16,30 @@ const personalizedOnboarding = {
source: "cache",
} as const;
test("default request deadline leaves enough room for server-side Agent generation", async () => {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
let observedRequestDeadline: number | null = null;
globalThis.fetch = () => Promise.resolve(Response.json(personalizedOnboarding));
globalThis.setTimeout = ((callback: TimerHandler, delay?: number, ...args: unknown[]) => {
observedRequestDeadline ??= Number(delay);
return originalSetTimeout(callback, delay, ...args);
}) as typeof globalThis.setTimeout;
try {
await requestOnboardingWithRecovery(
new AbortController().signal,
() => undefined,
);
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
}
assert.ok(observedRequestDeadline !== null);
assert.ok(observedRequestDeadline >= 25_000, `request deadline was only ${observedRequestDeadline}ms`);
});
test("returns personalized cache content after a timeout and pending response", async () => {
// Given: the first request times out, the second is provisional, and the third is terminal.
const originalFetch = globalThis.fetch;
+6
View File
@@ -74,7 +74,13 @@ export function completeProfileRow(
name: "林遥",
birth_date: "1990-06-15",
birth_time: "12:30",
reported_birth_time: "12:30",
active_birth_time: "12:30",
birth_time_source: "legacy_import",
birth_time_period: null,
birth_time_clue: null,
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
birth_time_status: "confirmed",
country_code: "CN",
province_code: "110000",
+64
View File
@@ -64,6 +64,70 @@ function createPost(
});
}
test("slow Agent generation is aborted and a terminal fallback is cached before the client deadline", async () => {
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
let generationAborted = false;
const dependencies = {
openSession: async () => ({
userId: repository.snapshot().id,
authError: false,
repository,
}),
generateText: (_name: string, signal?: AbortSignal) => new Promise<string | null>((_resolve, reject) => {
signal?.addEventListener("abort", () => {
generationAborted = true;
reject(signal.reason);
}, { once: true });
}),
generationTimeoutMs: 5,
now: () => new Date("2026-07-19T10:00:00.000Z"),
warn: () => undefined,
};
const deadline = new Promise<"deadline">((resolve) => {
setTimeout(() => resolve("deadline"), 50);
});
const outcome = await Promise.race([createOnboardingPost(dependencies)(), deadline]);
assert.notEqual(outcome, "deadline");
assert.ok(outcome instanceof Response);
assert.equal(generationAborted, true);
const body = await responseBody(outcome);
assert.equal(body.source, "fallback");
assert.deepEqual(repository.snapshot().onboarding_payload, {
greeting: body.greeting,
suggestions: body.suggestions,
});
});
test("period-only birth declaration can generate the home starter questions without a concrete minute", async () => {
const repository = new StatefulOnboardingProfileRepository({
...completeProfileRow({
birth_time: null,
active_birth_time: null,
birth_time_status: "reported",
}),
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "early_morning",
birth_time_clue: "家人只记得凌晨或清晨",
uncertainty_before_minutes: null,
uncertainty_after_minutes: null,
});
let generationCount = 0;
const post = createPost(repository, async () => {
generationCount += 1;
return generatedText(payloadA);
});
const response = await post();
const body = await responseBody(response);
assert.equal(response.status, 200);
assert.equal(generationCount, 1);
assert.deepEqual(body, { ...payloadA, source: "agent" });
});
test("stale A generation returns pending after profile B replaces its claim", async () => {
// Given: A owns a claim whose generation remains in flight.
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
+18 -2
View File
@@ -217,12 +217,23 @@ test("selects desktop and tablet shell widths from provider data", () => {
assert.match(globalStyles, /\[data-viewport="desktop"\]\[data-state="expanded"\]\s+\.chat-app\s*\{[^}]*grid-template-columns:\s*var\(--sidebar-width-desktop\)\s+minmax\(0,\s*1fr\)/);
assert.match(globalStyles, /\[data-viewport="tablet"\]\[data-state="expanded"\]\s+\.chat-app\s*\{[^}]*grid-template-columns:\s*var\(--sidebar-width-tablet\)\s+minmax\(0,\s*1fr\)/);
assert.match(globalStyles, /\[data-state="collapsed"\]\s+\.chat-app\s*\{[^}]*grid-template-columns:\s*var\(--sidebar-width-icon\)\s+minmax\(0,\s*1fr\)/);
assert.doesNotMatch(globalStyles, /transition:[^;}]*(?:width|grid-template-columns)/);
assert.doesNotMatch(globalStyles, /transition:[^;}]*\b(?:width|grid-template-columns)\b/);
});
test("changes sidebar state without transition frames", () => {
const sidebar = readProjectFile("src/components/ui/sidebar.tsx");
const design = readProjectFile("DESIGN.md");
assert.doesNotMatch(sidebar, /document\.startViewTransition/);
assert.doesNotMatch(sidebar, /skipMotion/);
assert.match(sidebar, /const setOpen = useCallback\(\(nextOpen: boolean\) => \{\s*commitOpen\(nextOpen\);/);
assert.match(sidebar, /else commitOpen\(!open\);/);
assert.doesNotMatch(globalStyles, /view-transition-name/);
assert.doesNotMatch(globalStyles, /::view-transition-/);
assert.match(globalStyles, /\[data-sidebar="trigger"\]\s+svg\s*\{[^}]*width:\s*18px[^}]*height:\s*18px/);
assert.doesNotMatch(cssBlock('[data-sidebar="sidebar"]'), /transition:/);
assert.doesNotMatch(cssBlock('[data-sidebar="trigger"]'), /transition:/);
assert.match(globalStyles, /\[data-sidebar="sidebar"\]\[data-mobile-open="false"\][^{]*\{[^}]*visibility:\s*hidden[^}]*transform:\s*translateX\(-100%\)/);
assert.match(design, /Sidebar state changes are immediate/);
});
test("keeps the chat title in the flexible left-aligned header column", () => {
@@ -231,6 +242,11 @@ test("keeps the chat title in the flexible left-aligned header column", () => {
assert.doesNotMatch(cssBlock(".chat-header"), /justify-content:\s*space-between/);
});
test("anchors the account footer to the bottom edge without trailing sidebar padding", () => {
assert.match(globalStyles, /\.sidebar \{ position:[^}]*padding:\s*var\(--space-5\)\s+var\(--space-3\)\s+0/);
assert.match(cssBlock(".sidebar-footer"), /margin-top:\s*0/);
});
test("makes SidebarContent the only sidebar scroll owner", () => {
assert.match(cssBlock('[data-sidebar="header"]'), /flex:\s*0\s+0\s+auto/);
assert.match(cssBlock('[data-sidebar="content"]'), /min-height:\s*0/);
+5
View File
@@ -28,6 +28,11 @@ test("keeps starter questions visible while the user edits a draft", () => {
assert.doesNotMatch(starterVisibilityGuard, /\bdraft\b/);
});
test("completed account initialization switches directly to the home cards", () => {
assert.doesNotMatch(pageSource, /setOnboardingJustCompleted\(true\)/);
assert.doesNotMatch(pageSource, /!profileComplete \|\| onboardingJustCompleted/);
});
test("keeps follow-up suggestions visible while the user edits a draft", () => {
// Given: the follow-up suggestion block and its render guard.
const suggestionGuard = sourceBetween(