feat: complete verifiable birth-time rectification flow
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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 2–4 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user