feat: standalone rectification entry at /rectification (issue #145)
Independent Staging Quality Gate / validate (push) Failing after 13m25s
Independent Staging Quality Gate / publish (push) Skipped

- 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>
This commit is contained in:
jesse
2026-10-28 16:50:55 +08:00
co-authored by Claude Opus 5.5
parent 12eb7018ec
commit dfd3208979
32 changed files with 1198 additions and 100 deletions
+6 -5
View File
@@ -130,7 +130,8 @@ test("the birth line is followed by whether the ascendant changes inside the sta
} } }));
assert.match(fixed, /chart-lagna-precheck/);
assert.match(fixed, /按你给的时间精度(前后约 2 分钟),上升星座都是白羊座,这一点不会因为时间误差而变。/);
assert.doesNotMatch(fixed, /出生时间校正|href="\/\?rectify=1"/);
// 原值: href="/?rectify=1";新值: href="/rectification";原因: issue #145 独立入口
assert.doesNotMatch(fixed, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"/);
const two = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: {
state: "may_change",
@@ -142,7 +143,7 @@ test("the birth line is followed by whether the ascendant changes inside the sta
afterMinutes: 15,
} } }));
assert.match(two, /按你给的时间精度,上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。想定下来可以做一次/);
assert.match(two, /href="\/\?rectify=1"/);
assert.match(two, /href="\/rectification"/);
assert.match(two, />出生时间校正</);
const many = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: {
@@ -199,12 +200,12 @@ test("someone else's chart states the ascendant without a rectification link", a
};
const otherTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: mayChange } }));
assert.match(otherTwo, /上升可能是白羊座,也可能是金牛座(大约 12:01 换座)。/);
assert.doesNotMatch(otherTwo, /出生时间校正|href="\/\?rectify=1"|想定下来/);
assert.doesNotMatch(otherTwo, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|想定下来/);
const otherUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...other, lagnaPrecheck: unknown } }));
assert.match(otherUnknown, /不知道出生时间时,上升星座定不下来。/);
assert.doesNotMatch(otherUnknown, /出生时间校正|href="\/\?rectify=1"|可以缩小范围/);
assert.doesNotMatch(otherUnknown, /出生时间校正|href="\/rectification"|href="\/\?rectify=1"|可以缩小范围/);
const selfTwo = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: mayChange } }));
assert.match(selfTwo, /href="\/\?rectify=1"/);
assert.match(selfTwo, /href="\/rectification"/);
assert.match(selfTwo, />出生时间校正</);
const selfUnknown = withSidebarProvider(React.createElement(ChartPageView, { view: { ...self, lagnaPrecheck: unknown } }));
assert.match(selfUnknown, />出生时间校正</);
@@ -157,8 +157,12 @@ test("ordinary product drafts keep the public question and clear hidden routing
// 原值: 发送时 consultEntrypoint === "birth_time_rectification"(文字口令)→ openRectificationFromHomepage(pendingQuestion, currentSession.id)
// 新值: 发送路径不再开校正;提议卡 → openRectificationFromChat(sourceSessionId)
// 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
// 原值(2): 提议卡 → openRectificationFromChat(sourceSessionId)
// 新值(2): 提议卡与明说要校正都去独立页 /rectification(openRectificationPage),不从这段对话开案例
// 原因(2): issue #145(产品 2026-10-28 定:入口单独拆出,对话里只给链接)
assert.doesNotMatch(source, /isRectificationHandoffQuestion\(question\)/);
assert.match(source, /openRectificationFromChat\(sourceSessionId\)/);
assert.match(source, /openRectificationPage\(\)/);
assert.doesNotMatch(source, /openRectificationFromChat\(sourceSessionId\)/);
assert.doesNotMatch(source, /personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /messages: questionAlreadyPresent \? preservedMessages : \[\.\.\.preservedMessages, \{ role: "user", text: question \}\]/);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*consultEntrypoint \?\? undefined,[\s\S]*?question,/);
@@ -0,0 +1,233 @@
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;/);
});
@@ -16,14 +16,11 @@ import {
RECTIFICATION_DIRECT_NOT_SELF_COPY,
RECTIFICATION_DIRECT_RESUME_COPY,
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
RECTIFICATION_OFFER_OPENED_COPY,
RECTIFICATION_OFFER_START_LABEL,
RECTIFICATION_OFFER_TOOL_ID,
attachOpenedOfferToFreeMessage,
bindOfferToCase,
offerForCase,
parseRectificationOfferRecord,
rectificationOfferCardCopy,
sourceFromSessionRows,
type RectificationOfferRecord,
} from "../src/lib/rectification-in-chat.ts";
@@ -737,16 +734,19 @@ test("the offer rule is defined once, and direct entry settles with the free com
assert.doesNotMatch(stream, /请你帮我进行生时纠正/);
const hook = src("../src/hooks/use-consultation-run.ts");
assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1);
const directOpen = hook.slice(hook.indexOf("rectificationDirectOpen?.mode"), hook.indexOf("await persistSession(completedSession)"));
assert.match(directOpen, /if \(opened\) return/);
// 原值:直达在浏览器里 openRectificationFromChat(sourceSessionId) 新开,或 openRectificationSession(resumeSessionId) 续上。
// 新值:新开和续上都只跳到 /rectification(openRectificationPage),页面自己给「开始」或「继续」。
// 原因:issue #145(产品 2026-10-28 定:入口单独拆出,不从对话开案例)。
assert.equal(hook.match(/if \(rectificationDirectOpen\) openRectificationPage\(\);/g)?.length, 1);
assert.doesNotMatch(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
assert.doesNotMatch(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
const directOpen = hook.slice(hook.indexOf("if (rectificationDirectOpen)"), hook.indexOf("await persistSession(completedSession)"));
assert.doesNotMatch(directOpen, /status:\s*"opened"/);
assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/);
const page = src("../src/app/(app)/page.tsx");
assert.match(page, /openAccountDialog, openRectificationSession, openRectificationFromChat,/);
assert.match(page, /openAccountDialog, openRectificationSession, openRectificationPage,/);
assert.match(page, /navigateInApp\(RECTIFICATION_PAGE_HREF\)/);
assert.doesNotMatch(page, /请你帮我进行生时纠正/);
const server = src("../src/lib/rectification-in-chat-server.ts");
@@ -779,13 +779,16 @@ function cardHtml(record: RectificationOfferRecord) {
}
test("a direct-open offer still shows the ordinary card until the case is bound", () => {
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
const pending = offer("offered", { autoOpen: true });
const html = cardHtml(pending);
assert.match(html, /大约 8 题,会用掉 3 点/);
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
assert.equal(html.includes("继续核对出生时间"), false);
assert.match(html, new RegExp(rectificationOfferCardCopy(pending).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(html, /出生时间还不确定。/);
assert.match(html, /href="\/rectification"/);
assert.match(html, /去做生时校正/);
assert.doesNotMatch(html, /会用掉|点;/);
assert.equal(html.includes("开始核对出生时间"), false);
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
@@ -794,9 +797,8 @@ test("a direct-open offer still shows the ordinary card until the case is bound"
assert.equal(opened.autoOpen, true);
assert.equal(opened.caseId, CASE_ID);
const openedHtml = cardHtml(opened);
assert.match(openedHtml, new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
assert.match(openedHtml, /继续核对出生时间/);
assert.equal(openedHtml.includes(RECTIFICATION_OFFER_START_LABEL), false);
assert.match(openedHtml, /href="\/rectification"/);
assert.equal(openedHtml.includes("出生时间核对已经开始"), false);
});
test("a failed direct open leaves the offer offered and clickable", () => {
@@ -808,8 +810,9 @@ test("a failed direct open leaves the offer offered and clickable", () => {
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).status, "offered");
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
const html = cardHtml(pending);
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.equal(html.includes("出生时间核对已经开始"), false);
assert.match(html, /href="\/rectification"/);
assert.equal(html.includes("aria-disabled"), false);
assert.equal(html.includes("disabled=\"\""), false);
assert.equal(html.includes("disabled=\"disabled\""), false);
});
@@ -826,8 +829,8 @@ test("stored offers offered, opened, and returned still parse; returned hides th
assert.equal(flagged?.autoOpen, true);
assert.equal(parseRectificationOfferRecord({ ...offer("opened"), extra: true }), null);
assert.equal(cardHtml(offer("returned")), "");
assert.match(cardHtml(offer("opened")), new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
assert.match(cardHtml(offer("offered")), new RegExp(RECTIFICATION_OFFER_START_LABEL));
assert.match(cardHtml(offer("opened")), /href="\/rectification"/);
assert.match(cardHtml(offer("offered")), /href="\/rectification"/);
});
test("the free reply stores the direct-open offer still offered, with autoOpen", () => {
@@ -249,9 +249,13 @@ function transcriptProps(messages: ChatMessage[], calls: string[]): ChatTranscri
test("the transcript shows the offer card, the divider, the old-time caption and the revisit button (P2, BUG-1248/1250)", () => {
const offered = conversation() as ChatMessage[];
const card = renderToString(createElement(ChatTranscript, transcriptProps(offered, [])));
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
assert.match(card, /rectification-offer-card/);
assert.match(card, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
assert.match(card, /开始核对出生时间/);
assert.match(card, /出生时间还不确定。/);
assert.match(card, /href="\/rectification"/);
assert.doesNotMatch(card, /会用掉/);
assert.doesNotMatch(card, /按校正前的时间/);
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
@@ -310,15 +314,17 @@ test("an auto-open offer shows the ordinary card until the case is bound, and a
{ role: "assistant" as const, text: "好,我们开始核对出生时间。", responseKind: "smalltalk" as const, rectificationOffer: pending },
];
const html = renderToString(createElement(ChatTranscript, transcriptProps(waiting, [])));
assert.match(html, /开始核对出生时间/);
assert.match(html, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
// 原值:卡片写价格和题数,按钮「开始核对出生时间」,开案后改为「出生时间核对已经开始」「继续核对出生时间」。
// 新值:一句「出生时间还不确定。」加链接「去做生时校正」,指向 /rectification;不写价格,不分开案前后。
// 原因:issue #145(产品 2026-10-28 定:对话里只给链接,不从这段对话开案例)。
assert.match(html, /href="\/rectification"/);
assert.doesNotMatch(html, /会用掉/);
assert.doesNotMatch(html, /出生时间核对已经开始/);
assert.doesNotMatch(html, /继续核对出生时间/);
const bound = bindOfferToCase(waiting, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
const openedHtml = renderToString(createElement(ChatTranscript, transcriptProps(bound as ChatMessage[], [])));
assert.match(openedHtml, /出生时间核对已经开始/);
assert.match(openedHtml, /继续核对出生时间/);
assert.match(openedHtml, /href="\/rectification"/);
assert.doesNotMatch(openedHtml, /出生时间核对已经开始/);
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);