feat: make birth-time rectification a soft homepage flow
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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, /普通咨询前可以选择临时使用或先校正/);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user