feat: make birth-time rectification a soft homepage flow

This commit is contained in:
Jesse_Chen
2026-07-21 06:28:47 +08:00
parent 5c8d965f16
commit fc1e9f1085
11 changed files with 727 additions and 115 deletions
+30
View File
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
test("account API reads and returns the server-configured rectification price", () => {
assert.match(source, /parseRectificationPriceCredits\(\s*process\.env\.RECTIFICATION_PRICE_CREDITS,?\s*\)/);
assert.match(source, /rectificationPriceCredits/);
assert.doesNotMatch(source, /RECTIFICATION_PRICE_CREDITS[^\n]*\?\?\s*["']1["']/);
});
test("account API projects only the minimum case state needed by the homepage", () => {
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(1\)/)?.[0] ?? "";
assert.match(caseSelect, /id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,updated_at/);
assert.doesNotMatch(caseSelect, /candidate_scan|event_evidence|validation_receipt|pending_consultation_question|journey_snapshot|turn_state/);
assert.match(source, /caseId:/);
assert.match(source, /journeyProtocol:/);
assert.match(source, /turnVersion:/);
assert.match(source, /preservesActiveTime:/);
});
test("account API scopes the service-role case lookup to the authenticated account", () => {
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(1\)/)?.[0] ?? "";
assert.match(caseSelect, /\.eq\("user_id", user\.id\)/);
assert.match(caseSelect, /\.eq\("journey_protocol", "conversational-evidence-v3"\)/);
assert.match(caseSelect, /\.order\("updated_at", \{ ascending: false \}\)/);
});
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
canUseUnverifiedBirthTime,
createBirthTimeConsultationConsentState,
grantBirthTimeConsultationConsent,
hasBirthTimeConsultationConsent,
parseRectificationPriceCredits,
requiresBirthTimeConsent,
resolveRectificationCardAction,
} from "../src/lib/birth-time-consultation-consent.ts";
import type { BirthTimeDraft } from "../src/lib/birth-time-intake-model.ts";
const reportedExactTime = {
date: "1997-08-08",
time: "",
reportedTime: "05:30",
birthTimeSource: "family_exact",
birthTimePeriod: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
birthTimeStatus: "reported",
} satisfies BirthTimeDraft;
test("an unverified concrete time requires consent only until this chat grants it", () => {
const initial = createBirthTimeConsultationConsentState();
assert.equal(canUseUnverifiedBirthTime(reportedExactTime), true);
assert.equal(requiresBirthTimeConsent(reportedExactTime), true);
assert.equal(hasBirthTimeConsultationConsent(initial, "chat-a"), false);
const consented = grantBirthTimeConsultationConsent(initial, "chat-a");
assert.equal(hasBirthTimeConsultationConsent(consented, "chat-a"), true);
assert.equal(hasBirthTimeConsultationConsent(consented, "chat-b"), false);
assert.equal(hasBirthTimeConsultationConsent(initial, "chat-a"), false);
});
test("period-only and unknown declarations never pretend to provide an unverified minute", () => {
const periodOnly = {
...reportedExactTime,
reportedTime: "",
birthTimeSource: "period_only",
birthTimePeriod: "early_morning",
} satisfies BirthTimeDraft;
const unknown = {
...reportedExactTime,
reportedTime: "",
birthTimeSource: "unknown",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
} satisfies BirthTimeDraft;
assert.equal(canUseUnverifiedBirthTime(periodOnly), false);
assert.equal(canUseUnverifiedBirthTime(unknown), false);
assert.equal(requiresBirthTimeConsent(periodOnly), false);
assert.equal(requiresBirthTimeConsent(unknown), false);
});
test("confirmed time does not request unverified-use consent", () => {
const confirmed = {
...reportedExactTime,
time: "05:28",
birthTimeStatus: "confirmed",
} satisfies BirthTimeDraft;
assert.equal(canUseUnverifiedBirthTime(confirmed), false);
assert.equal(requiresBirthTimeConsent(confirmed), false);
});
test("card action resumes unfinished account cases and otherwise starts or revises", () => {
const unfinishedCase = {
caseId: "11111111-1111-4111-8111-111111111111",
journeyProtocol: "conversational-evidence-v3",
status: "paused",
turnVersion: 4,
isRevision: true,
preservesActiveTime: true,
} as const;
assert.equal(resolveRectificationCardAction({ rectificationCase: null, hasConfirmedBirthTime: false }), "start");
assert.equal(resolveRectificationCardAction({ rectificationCase: unfinishedCase, hasConfirmedBirthTime: true }), "resume");
assert.equal(resolveRectificationCardAction({
rectificationCase: { ...unfinishedCase, status: "completed" },
hasConfirmedBirthTime: true,
}), "revise");
assert.equal(resolveRectificationCardAction({
rectificationCase: { ...unfinishedCase, status: "abandoned" },
hasConfirmedBirthTime: false,
}), "start");
});
test("fixed rectification price uses a checked default and rejects invalid configured values", () => {
assert.equal(parseRectificationPriceCredits(undefined), 1);
assert.equal(parseRectificationPriceCredits(" 7 "), 7);
for (const invalid of ["", "0", "101", "1.5", "1e1", "free"]) {
assert.throws(() => parseRectificationPriceCredits(invalid), /RECTIFICATION_PRICE_CREDITS/);
}
});
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");
assert.match(source, /aria-live="polite"/);
assert.equal((source.match(/disabled=\{pending\}/g) ?? []).length, 3);
assert.match(source, /\{canUseUnverifiedTime && \(/);
});
@@ -145,12 +145,14 @@ test("terminal candidate owns one explicit next step and its completion error",
assert.match(globalCssSource, /\.birth-time-next-step/);
});
test("terminal and entrypoint CJK phrases stay intact at narrow widths", () => {
test("terminal CJK copy stays intact while homepage candidates remain unconfirmed in v3", () => {
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(candidateResultSource, /作为<span className="phrase-nowrap">当前排盘时间<\/span>并进入对话;<span className="phrase-nowrap">原始填报<\/span>和本次<span className="phrase-nowrap">候选结果<\/span><span className="phrase-nowrap">仍会保留<\/span>。/);
assert.match(pageSource, /当前使用候选时间排盘;<span className="phrase-nowrap">原始填报范围<\/span>仍保留。/);
assert.match(pageSource, /未确认,可临时选择使用/);
assert.match(pageSource, /<ConversationalBirthTimeRectification/);
assert.doesNotMatch(pageSource, /当前使用候选时间排盘/);
});
test("completed rectification transcript does not repeat the birth place turn", () => {
+31
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
assistantIntentCopy,
@@ -6,6 +7,7 @@ import {
birthTimePersistenceValues,
describeBirthTimeDraft,
formatBirthDate,
isDeclaredBirthProfileComplete,
isBirthTimeReadyForConsultation,
isBirthTimeDraftReady,
parseBirthDate,
@@ -59,6 +61,28 @@ test("a persisted candidate working time can leave rectification onboarding", ()
assert.equal(isBirthTimeReadyForConsultation({ ...candidate, birthTimeStatus: "rectifying" }), false);
});
test("declared birth data completes onboarding without an active or confirmed minute", () => {
const declaredExact = {
...emptyDraft,
reportedTime: "05:30",
birthTimeSource: "family_exact",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
birthTimeStatus: "reported",
} satisfies BirthTimeDraft;
const declaredPeriod = {
...emptyDraft,
birthTimeSource: "period_only",
birthTimePeriod: "early_morning",
birthTimeStatus: "reported",
} satisfies BirthTimeDraft;
assert.equal(isDeclaredBirthProfileComplete(declaredExact), true);
assert.equal(isBirthTimeReadyForConsultation(declaredExact), false);
assert.equal(isDeclaredBirthProfileComplete(declaredPeriod), true);
assert.equal(isBirthTimeReadyForConsultation(declaredPeriod), false);
});
test("a persisted candidate working time takes precedence over the reported range", () => {
// Given: rectification saved a candidate minute while preserving the user's original period.
const candidate = {
@@ -132,3 +156,10 @@ test("birth date values round trip leap days and reject invalid input", () => {
assert.equal(parseBirthDate(""), undefined);
assert.equal(parseBirthDate("2001-02-29"), undefined);
});
test("candidate copy does not claim an unconfirmed minute is automatically in use", () => {
const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /已用于当前排盘/);
assert.match(source, /普通咨询前可以选择临时使用或先校正/);
});
+32 -2
View File
@@ -68,17 +68,47 @@ test("browser source does not own private entrypoint prompts", () => {
assert.doesNotMatch(source, /请基于已校验的出生资料继续/);
});
test("composer keeps the public question and clears hidden routing after edits", () => {
test("ordinary product drafts keep the public question and clear hidden routing after edits", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /chooseSuggestedQuestion\("",\s*"timing",\s*"daily_starlanguage"\)/s);
assert.match(source, /birthTimeDisplay \? "" : "",\s*"timing",\s*"birth_time_rectification"/s);
assert.match(source, /messages:\s*\[\.\.\.preservedMessages,\s*\{ role: "user", text: question \}\]/s);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*entrypoint \?\? undefined,[\s\S]*?question,/);
assert.match(source, /onChange=\{\(event\) => \{\s*setDraft\(event\.target\.value\);\s*setDraftTheme\(null\);\s*setDraftEntrypoint\(null\);/s);
assert.match(source, /setDraft\(pending\.question\);\s*setDraftTheme\(pending\.theme\);\s*setDraftEntrypoint\(pending\.entrypoint\);/s);
});
test("homepage birth-time card opens the v3 surface instead of ordinary consultation", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /function openBirthTimeRectification/);
assert.match(source, /<ConversationalBirthTimeRectification/);
assert.match(source, /rectificationPriceCredits/);
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
});
test("ordinary consultation is softly diverted before calling consult", () => {
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/s);
assert.match(source, /pendingConsultationQuestion=/);
});
test("profile and place saves do not auto-start the retired assessment flow", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const normalSave = source.slice(source.indexOf("async function saveProfile"), source.indexOf("async function saveOnboardingName"));
const placeSave = source.slice(source.indexOf("async function saveOnboardingPlace"), source.indexOf("function completeGuidedBirthTime"));
assert.doesNotMatch(normalSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
assert.doesNotMatch(placeSave, /assessSavedBirthTime|requestBirthTimeAssessment/);
});
test("consult route expands an optional entrypoint for both Agent and tool input", () => {
const source = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");