Files
Jyotisha/frontend/tests/rectification-entry-page-20261028.test.tsx
T
jesseandClaude Opus 5.5 dfd3208979
Independent Staging Quality Gate / validate (push) Failing after 13m25s
Independent Staging Quality Gate / publish (push) Skipped
feat: standalone rectification entry at /rectification (issue #145)
- 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>
2026-10-28 16:50:55 +08:00

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;/);
});