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>
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user