- Sidebar 「生时校正」 under 星盘, behind a new account `rectification` flag. - /rectification page: status card from entry-summary (not started / in progress with window and confirmed event count / adopted time), a short data check, then the home rectification surface (?rectify=1, or ?rectify=new to start over; ?c=<id> to continue or view). No price on the page. - entry-summary gains session id, candidate range, confirmed evidence count and adopted times (add-only migration 20261028170000). - The offer card under a consultation answer becomes one line and a link to /rectification; an explicit request in chat also goes there. No case is opened from a conversation any more. - Starter home shows a rectification prompt for self charts with an estimated time; onboarding checklist gains 做一次生时校正 for them. - Chart-page link points to /rectification. Shell registers its router so home hooks can navigate client-side without useRouter in Home(). - Docs: VOICE, DESIGN, CHANGELOG, device checklist. Self profile only; other people are issue #160. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
12 KiB
TypeScript
234 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { createElement } from "react";
|
|
import { renderToString } from "react-dom/server";
|
|
|
|
import { StarterQuestions } from "../src/components/starter-questions.tsx";
|
|
import {
|
|
RECTIFICATION_PAGE_HREF,
|
|
parseRectificationEntryIntent,
|
|
rectificationEntryHref,
|
|
rectificationEntryIntent,
|
|
} from "../src/lib/chat-session-url.ts";
|
|
import { buildChecklist, type ChecklistFacts } from "../src/lib/onboarding-checklist.ts";
|
|
import { entrySummaryFromResponse, type RectificationEntrySummary } from "../src/lib/rectification-entry.ts";
|
|
import {
|
|
RECTIFICATION_HOME_HINT,
|
|
RECTIFICATION_PAGE_COPY,
|
|
birthTimeNeedsRectification,
|
|
birthTimeWasEstimated,
|
|
clockLabel,
|
|
rectificationPageState,
|
|
rectificationSessionHref,
|
|
rectificationStartHref,
|
|
} from "../src/lib/rectification-page.ts";
|
|
|
|
// Issue #145: the standalone /rectification entry. Fictional ids only.
|
|
const SESSION = "11111111-1111-4111-8111-111111111111";
|
|
const CASE = "22222222-2222-4222-8222-222222222222";
|
|
const EMPTY: RectificationEntrySummary = {
|
|
hasResumableCase: false,
|
|
hasTerminalCaseWithTime: false,
|
|
latestResumable: null,
|
|
latestTerminal: null,
|
|
};
|
|
|
|
function state(summary: RectificationEntrySummary, patch: Partial<{ signedOut: boolean; enabled: boolean | undefined; selfComplete: boolean }> = {}) {
|
|
return rectificationPageState({ signedOut: false, enabled: true, selfComplete: true, summary, ...patch });
|
|
}
|
|
|
|
test("the page refuses before reading cases: signed out, product off or unknown, no self chart", () => {
|
|
assert.equal(state(EMPTY, { signedOut: true }).kind, "signed-out");
|
|
assert.equal(state(EMPTY, { enabled: false }).kind, "unavailable");
|
|
assert.equal(state(EMPTY, { enabled: undefined }).kind, "unavailable", "a flag that did not arrive keeps the page closed");
|
|
assert.equal(state(EMPTY, { selfComplete: false }).kind, "self-missing");
|
|
assert.equal(state(EMPTY).kind, "none");
|
|
});
|
|
|
|
test("an open case shows its window and confirmed events; an accepted case is finished (BUG-1423)", () => {
|
|
const active = state({
|
|
...EMPTY,
|
|
hasResumableCase: true,
|
|
latestResumable: {
|
|
caseId: CASE,
|
|
status: "collecting_evidence",
|
|
lastActivityAt: "2026-10-26T00:00:00Z",
|
|
sessionId: SESSION,
|
|
candidateRange: { startTime: "07:00:00", endTime: "08:00" },
|
|
evidenceCount: 3,
|
|
},
|
|
});
|
|
assert.deepEqual(active, { kind: "active", sessionId: SESSION, range: { start: "07:00", end: "08:00" }, evidenceCount: 3 });
|
|
|
|
const accepted = state({
|
|
...EMPTY,
|
|
hasResumableCase: true,
|
|
latestResumable: { caseId: CASE, status: "candidate_accepted", lastActivityAt: "", sessionId: SESSION, acceptedTime: "07:42:00" },
|
|
});
|
|
assert.deepEqual(accepted, { kind: "done", sessionId: SESSION, time: "07:42" });
|
|
});
|
|
|
|
test("a finished case prefers the confirmed time; a finished case without a time is not done", () => {
|
|
const confirmed = state({
|
|
...EMPTY,
|
|
hasTerminalCaseWithTime: true,
|
|
latestTerminal: { caseId: CASE, status: "confirmed", hasUsableTime: true, sessionId: SESSION, acceptedTime: "07:40:00", confirmedTime: "07:42:10" },
|
|
});
|
|
assert.deepEqual(confirmed, { kind: "done", sessionId: SESSION, time: "07:42" });
|
|
const empty = state({
|
|
...EMPTY,
|
|
latestTerminal: { caseId: CASE, status: "abandoned", hasUsableTime: false, sessionId: SESSION },
|
|
});
|
|
assert.equal(empty.kind, "none");
|
|
});
|
|
|
|
test("start goes to the homepage opener, or a new case after a finished one; resume uses the case's conversation", () => {
|
|
assert.equal(rectificationStartHref({ kind: "none" }), "/?rectify=1");
|
|
assert.equal(rectificationStartHref({ kind: "done", sessionId: SESSION, time: "07:42" }), "/?rectify=new");
|
|
assert.equal(rectificationSessionHref(SESSION), `/?c=${SESSION}`);
|
|
assert.equal(rectificationSessionHref(null), "/?rectify=1", "an older server without session ids still resumes");
|
|
assert.equal(rectificationEntryHref(), "/?rectify=1");
|
|
assert.equal(rectificationEntryIntent("?rectify=1"), "homepage");
|
|
assert.equal(rectificationEntryIntent("?rectify=new"), "new");
|
|
assert.equal(rectificationEntryIntent("?rectify=yes"), null);
|
|
assert.equal(parseRectificationEntryIntent("?rectify=new"), true);
|
|
assert.equal(RECTIFICATION_PAGE_HREF, "/rectification");
|
|
});
|
|
|
|
test("the summary parser reads the new fields in either case and keeps older payloads valid", () => {
|
|
const camel = entrySummaryFromResponse({
|
|
hasResumableCase: true,
|
|
hasTerminalCaseWithTime: false,
|
|
latestResumable: { caseId: CASE, status: "paused", lastActivityAt: "x", sessionId: SESSION, candidateRange: { startTime: "06:30", endTime: "07:30" }, evidenceCount: 2 },
|
|
latestTerminal: null,
|
|
});
|
|
assert.equal(camel.latestResumable?.sessionId, SESSION);
|
|
assert.deepEqual(camel.latestResumable?.candidateRange, { startTime: "06:30", endTime: "07:30" });
|
|
assert.equal(camel.latestResumable?.evidenceCount, 2);
|
|
|
|
const snake = entrySummaryFromResponse({
|
|
has_resumable_case: false,
|
|
has_terminal_case_with_time: true,
|
|
latest_resumable: null,
|
|
latest_terminal: { case_id: CASE, status: "confirmed", has_usable_time: true, session_id: SESSION, confirmed_time: "07:42:00" },
|
|
});
|
|
assert.equal(snake.latestTerminal?.sessionId, SESSION);
|
|
assert.equal(snake.latestTerminal?.confirmedTime, "07:42:00");
|
|
|
|
const old = entrySummaryFromResponse({ hasResumableCase: true, latestResumable: { caseId: CASE, status: "draft", lastActivityAt: "x" } });
|
|
assert.equal(old.latestResumable?.sessionId, null);
|
|
assert.equal(old.latestResumable?.candidateRange, null);
|
|
assert.equal(old.latestResumable?.evidenceCount, null);
|
|
});
|
|
|
|
test("clock labels drop seconds only", () => {
|
|
assert.equal(clockLabel("07:42:00"), "07:42");
|
|
assert.equal(clockLabel("07:42"), "07:42");
|
|
assert.equal(clockLabel(" 07:42:05.5 "), "07:42");
|
|
});
|
|
|
|
test("the page never mentions credits (product 2026-10-28)", () => {
|
|
for (const text of Object.values(RECTIFICATION_PAGE_COPY)) {
|
|
assert.doesNotMatch(text, /点数|扣点|\d+\s*点|会用掉/);
|
|
}
|
|
const page = readFileSync(new URL("../src/components/rectification-page/rectification-entry-page.tsx", import.meta.url), "utf8");
|
|
assert.doesNotMatch(page, /credit|Credit|扣/);
|
|
});
|
|
|
|
test("an estimated time needs rectification until a time is adopted; an exact minute never does", () => {
|
|
for (const source of ["approximate", "period_only", "unknown"]) {
|
|
assert.equal(birthTimeNeedsRectification({ birthTimeSource: source, birthTimeStatus: "reported" }), true, source);
|
|
assert.equal(birthTimeNeedsRectification({ birthTimeSource: source, birthTimeStatus: "accepted" }), false, source);
|
|
assert.equal(birthTimeWasEstimated({ birthTimeSource: source }), true, source);
|
|
}
|
|
for (const source of ["hospital_record", "family_exact", "legacy_import", ""]) {
|
|
assert.equal(birthTimeNeedsRectification({ birthTimeSource: source, birthTimeStatus: "" }), false, source);
|
|
assert.equal(birthTimeWasEstimated({ birthTimeSource: source }), false, source);
|
|
}
|
|
});
|
|
|
|
function facts(rectification: ChecklistFacts["rectification"], selfComplete = true): ChecklistFacts {
|
|
return {
|
|
people: [{ id: "self", role: "self", name: "小林", complete: selfComplete, createdAt: null }],
|
|
profileComplete: true,
|
|
hasNonSmalltalkAnswer: true,
|
|
dailyCount: 0,
|
|
chartVisited: false,
|
|
synastryReportCount: 0,
|
|
personalReportCount: 0,
|
|
flags: { compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: null },
|
|
rectification,
|
|
};
|
|
}
|
|
|
|
test("the checklist adds 做一次生时校正 after the first question only when given, and ticks it once a time is adopted", () => {
|
|
const without = buildChecklist(facts(null));
|
|
assert.equal(without.some((item) => item.id === "rectify"), false);
|
|
assert.equal(without.length, 7);
|
|
|
|
const open = buildChecklist(facts({ done: false }));
|
|
assert.deepEqual(open.slice(0, 3).map((item) => item.id), ["self", "ask", "rectify"]);
|
|
const row = open.find((item) => item.id === "rectify")!;
|
|
assert.equal(row.label, "做一次生时校正");
|
|
assert.equal(row.done, false);
|
|
assert.deepEqual(row.go, { kind: "href", href: "/rectification" });
|
|
assert.equal(open.length, 8);
|
|
|
|
assert.equal(buildChecklist(facts({ done: true })).find((item) => item.id === "rectify")?.done, true);
|
|
assert.equal(buildChecklist(facts({ done: false }, false)).some((item) => item.id === "rectify"), false, "help-other path never lists it");
|
|
});
|
|
|
|
test("the provider gives the checklist item only with the product on and an estimated self time", () => {
|
|
const provider = readFileSync(new URL("../src/lib/onboarding-journey.tsx", import.meta.url), "utf8");
|
|
assert.match(provider, /list\.account\?\.rectification === true && birthTimeWasEstimated\(selfProfile\)/);
|
|
assert.match(provider, /rectification: rectificationItem,/);
|
|
});
|
|
|
|
test("the starter home shows the rectification prompt only when asked, linking to /rectification", () => {
|
|
const props = {
|
|
audience: { kind: "self" } as const,
|
|
subjectId: "self",
|
|
signs: { ascendantSign: null, moonSign: null },
|
|
chartSignsFallback: false,
|
|
onAsk() {},
|
|
onNudge() {},
|
|
};
|
|
const plain = renderToString(createElement(StarterQuestions, props));
|
|
assert.doesNotMatch(plain, /starter-rectify-hint/);
|
|
const hinted = renderToString(createElement(StarterQuestions, { ...props, rectifyHint: true }));
|
|
assert.match(hinted, /starter-rectify-hint/);
|
|
assert.match(hinted, new RegExp(RECTIFICATION_HOME_HINT.text));
|
|
assert.match(hinted, /href="\/rectification"/);
|
|
|
|
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
|
assert.match(page, /rectifyHint=\{currentSubjectId === "self" && account\.rectification === true && birthTimeNeedsRectification\(profile\)\}/);
|
|
});
|
|
|
|
test("the sidebar lists 生时校正 under 星盘, behind the account flag", () => {
|
|
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
|
|
const chart = sidebar.indexOf('{ href: "/chart", label: "星盘"');
|
|
const entry = sidebar.indexOf('{ href: "/rectification", label: "生时校正"');
|
|
const ephemeris = sidebar.indexOf('{ href: "/ephemeris", label: "星历"');
|
|
assert.ok(chart >= 0 && chart < entry && entry < ephemeris);
|
|
assert.match(sidebar, /page\.href !== "\/rectification" \|\| rectificationEnabled/);
|
|
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
|
|
assert.match(layout, /rectificationEnabled=\{rectificationEnabled\}/);
|
|
assert.match(layout, /registerShellNavigate\(/);
|
|
const account = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
|
|
assert.match(account, /isProductEnabled\("rectification"\)/);
|
|
assert.match(account, /\n\s+rectification,\n/);
|
|
});
|
|
|
|
test("the entry-summary migration is add-only and counts confirmed events only", () => {
|
|
const sql = readFileSync(new URL("../supabase/migrations/20261028170000_rectification_entry_summary_detail.sql", import.meta.url), "utf8");
|
|
assert.match(sql, /create or replace function public\.get_agentic_rectification_entry_summary\(/);
|
|
for (const key of ["'session_id', v_resumable.session_id", "'candidate_range', v_resumable.candidate_range", "'session_id', v_terminal.session_id", "'confirmed_time', v_terminal.confirmed_time"]) {
|
|
assert.ok(sql.includes(key), key);
|
|
}
|
|
assert.match(sql, /evidence\.status = 'confirmed'/);
|
|
assert.doesNotMatch(sql, /baseline_birth_snapshot/);
|
|
assert.doesNotMatch(sql, /\bdrop (table|column|function)\b/i);
|
|
assert.match(sql, /grant execute on function public\.get_agentic_rectification_entry_summary\(uuid\)\s+to service_role;/);
|
|
});
|