此前空态是 hero 卡(h1 clamp 到 52px)加两张 min-height 228px 的产品卡, 输入框被压在约 800px 内容之下,主动作不是视觉焦点。 改法:问候拆成 StarterGreeting 留在对话区并底对齐,两个产品入口降级成 StarterEntries 渲染在 composer 之后。composer 本身一行未动——它在空态与 会话态是同一个 DOM 位置,首条消息落地时不重挂载,草稿/焦点/排队卡都不受影响。 顺带修掉:空态 composer 1040px、会话态 760px,发首条消息时输入框会当场 变窄一截。两态统一到 --session-column-width,并新增断言锁住。 校正入口保留三个服务端驱动的文案分支(我第一版收成了两个,是 rectification-agentic-entry 打红才发现)。 DESIGN.md:Product entrypoint card 整节重写为 Starter home;删掉 Suggestion card 死文档(它描述的主题卡样式正是本轮删除的 CSS)。 测试 3350 不变,fail 仍 31 且与基线双向零差异;/ 仍 Static; CSS gzip 41,096→41,044(−0.13%)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
467 lines
25 KiB
TypeScript
467 lines
25 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
import {
|
||
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||
consultationEntrypointSchema,
|
||
isGeneralDailyFortuneQuestion,
|
||
isRectificationHandoffQuestion,
|
||
pinsConsultationDomains,
|
||
resolveConsultationQuestion,
|
||
shouldLoadGeneralDailyPanchanga,
|
||
} from "../src/lib/consultation-entrypoint.ts";
|
||
import { homeSurface } from "./home-surface.ts";
|
||
|
||
test("plain consultation questions remain user-authored", () => {
|
||
// Given: an ordinary question without a product entrypoint.
|
||
const visibleQuestion = "未来半年适合换工作吗?";
|
||
|
||
// When: the server resolves the model-facing question.
|
||
const resolved = resolveConsultationQuestion({
|
||
visibleQuestion,
|
||
entrypoint: undefined,
|
||
currentDate: "2026-07-19",
|
||
});
|
||
|
||
// Then: the server does not rewrite ordinary user input.
|
||
assert.deepEqual(resolved, { kind: "plain", modelQuestion: visibleQuestion });
|
||
});
|
||
|
||
test("homepage and rectification entrypoints pin the server-selected domain", () => {
|
||
assert.equal(pinsConsultationDomains("daily_starlanguage"), true);
|
||
assert.equal(pinsConsultationDomains("birth_time_rectification"), true);
|
||
assert.equal(pinsConsultationDomains("guided_topic"), true);
|
||
assert.equal(pinsConsultationDomains(undefined), false);
|
||
});
|
||
|
||
test("daily entrypoint selects a private server expansion", () => {
|
||
// Given: the public short label and its closed entrypoint identity.
|
||
const visibleQuestion = "深入看今日";
|
||
|
||
// When: the server resolves the request.
|
||
const resolved = resolveConsultationQuestion({
|
||
visibleQuestion,
|
||
entrypoint: "daily_starlanguage",
|
||
currentDate: "2026-07-19",
|
||
});
|
||
|
||
// Then: routing is explicit and the model receives more than the public label.
|
||
assert.equal(resolved.kind, "expanded");
|
||
assert.notEqual(resolved.modelQuestion, visibleQuestion);
|
||
});
|
||
|
||
test("daily entrypoint without a birth minute selects a public-day expansion", () => {
|
||
const resolved = resolveConsultationQuestion({
|
||
visibleQuestion: GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||
entrypoint: "daily_starlanguage",
|
||
currentDate: "2026-08-15",
|
||
consultationMode: "general_no_birth_time",
|
||
});
|
||
|
||
assert.equal(resolved.kind, "expanded");
|
||
assert.match(resolved.modelQuestion, /公共 Panchanga/);
|
||
assert.match(resolved.modelQuestion, /不包含个人上升点、宫位、大运或本命过境叠加/);
|
||
assert.doesNotMatch(resolved.modelQuestion, /已校验的星盘资料/);
|
||
});
|
||
|
||
test("ordinary-session daily fortune without an entrypoint still uses the public-day expansion", () => {
|
||
const resolved = resolveConsultationQuestion({
|
||
visibleQuestion: GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||
entrypoint: undefined,
|
||
currentDate: "2026-08-15",
|
||
consultationMode: "general_no_birth_time",
|
||
});
|
||
|
||
assert.equal(isGeneralDailyFortuneQuestion(GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION), true);
|
||
assert.equal(shouldLoadGeneralDailyPanchanga({
|
||
consultationMode: "general_no_birth_time",
|
||
visibleQuestion: GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||
}), true);
|
||
assert.equal(shouldLoadGeneralDailyPanchanga({
|
||
consultationMode: "general_no_birth_time",
|
||
visibleQuestion: "未来半年是否适合换工作?",
|
||
}), false);
|
||
assert.equal(shouldLoadGeneralDailyPanchanga({
|
||
consultationMode: "declared_birth_window",
|
||
visibleQuestion: GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||
}), true);
|
||
assert.equal(shouldLoadGeneralDailyPanchanga({
|
||
consultationMode: "declared_birth_window",
|
||
visibleQuestion: "请帮我看事业方向",
|
||
}), false);
|
||
assert.equal(resolved.kind, "expanded");
|
||
assert.match(resolved.modelQuestion, /公共 Panchanga/);
|
||
});
|
||
|
||
test("rectification labels in ordinary sessions are handoffs, not consult questions", () => {
|
||
assert.equal(isRectificationHandoffQuestion("出生时间校正 (生时校正)"), true);
|
||
assert.equal(isRectificationHandoffQuestion("出生时间校正 (生时矫正)"), true);
|
||
assert.equal(isRectificationHandoffQuestion("先完成生时校正"), true);
|
||
assert.equal(isRectificationHandoffQuestion("生时校正是什么"), false);
|
||
assert.equal(isRectificationHandoffQuestion("未来半年是否适合换工作?"), false);
|
||
});
|
||
|
||
test("birth-time entrypoint selects a private server expansion", () => {
|
||
// Given: a completed profile starts another rectification from a public label.
|
||
const visibleQuestion = "再次校正";
|
||
|
||
// When: the server resolves the request.
|
||
const resolved = resolveConsultationQuestion({
|
||
visibleQuestion,
|
||
entrypoint: "birth_time_rectification",
|
||
currentDate: "2026-07-19",
|
||
});
|
||
|
||
// Then: the model question is expanded without changing the visible transcript.
|
||
assert.equal(resolved.kind, "expanded");
|
||
assert.notEqual(resolved.modelQuestion, visibleQuestion);
|
||
});
|
||
|
||
test("guided-topic entrypoint keeps the visible question and pins the theme", () => {
|
||
const visibleQuestion = "请帮我看看我家庭关系的整体模式和特点";
|
||
const resolved = resolveConsultationQuestion({
|
||
visibleQuestion,
|
||
entrypoint: "guided_topic",
|
||
currentDate: "2026-09-09",
|
||
});
|
||
|
||
assert.deepEqual(resolved, { kind: "plain", modelQuestion: visibleQuestion });
|
||
assert.equal(pinsConsultationDomains("guided_topic"), true);
|
||
});
|
||
|
||
test("consultation entrypoints form a closed public request enum", () => {
|
||
assert.equal(consultationEntrypointSchema.safeParse("daily_starlanguage").success, true);
|
||
assert.equal(consultationEntrypointSchema.safeParse("birth_time_rectification").success, true);
|
||
assert.equal(consultationEntrypointSchema.safeParse("guided_topic").success, true);
|
||
assert.equal(consultationEntrypointSchema.safeParse("client_prompt").success, false);
|
||
});
|
||
|
||
test("browser source does not own private entrypoint prompts", () => {
|
||
const source = homeSurface;
|
||
|
||
assert.doesNotMatch(source, /function buildDailyStarlanguageQuestion/);
|
||
assert.doesNotMatch(source, /function buildBirthTimeRectificationQuestion/);
|
||
assert.doesNotMatch(source, /请结合已校验的星盘资料/);
|
||
assert.doesNotMatch(source, /请基于已校验的出生资料继续/);
|
||
});
|
||
|
||
test("ordinary product drafts keep the public question and clear hidden routing after edits", () => {
|
||
const source = homeSurface;
|
||
|
||
assert.match(source, /dailyStarlanguageQuestion/);
|
||
assert.match(source, /从今日问起/);
|
||
assert.match(source, /深入看今日/);
|
||
assert.match(source, /startSuggestedConsultation\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/);
|
||
assert.match(source, /consultEntrypoint === "birth_time_rectification"/);
|
||
assert.match(source, /isRectificationHandoffQuestion\(question\)/);
|
||
assert.match(source, /openRectificationFromHomepage\(pendingQuestion\)/);
|
||
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,/);
|
||
const requestBody = source.slice(source.indexOf("body: JSON.stringify({"), source.indexOf("history: currentSession.messages", source.indexOf("body: JSON.stringify({")));
|
||
assert.match(requestBody, /consultationMode:[\s\S]*?entrypoint: consultEntrypoint \?\? undefined/);
|
||
assert.doesNotMatch(requestBody, /general_no_birth_time" \|\| consultationRoute\.mode === "declared_birth_window" \? \{\} : \{[\s\S]*?entrypoint/);
|
||
assert.match(source, /onChange=\{\(event\) => \{[\s\S]*?setDraft\(event\.target\.value\);[\s\S]*?setDraftTheme\(null\);[\s\S]*?setDraftEntrypoint\(null\);/);
|
||
assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/);
|
||
});
|
||
|
||
test("homepage birth-time card opens the V9 Agentic surface via the server case API", () => {
|
||
const source = homeSurface;
|
||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||
|
||
assert.match(source, /function openRectificationCase/);
|
||
assert.match(source, /openRectificationFromHomepage/);
|
||
assert.match(source, /<ConversationalBirthTimeRectification/);
|
||
assert.match(component, /<RectificationAgenticChat \{\.\.\.props\} \/>/);
|
||
// 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX
|
||
// 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel
|
||
// 原因:校正面子树状态收到 panel,Home 不再逐个传
|
||
assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/);
|
||
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
|
||
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
|
||
});
|
||
|
||
test("homepage mounts the Agentic surface without invoking retired rectification starters", () => {
|
||
const page = homeSurface;
|
||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||
|
||
assert.match(page, /<ConversationalBirthTimeRectification/);
|
||
assert.match(component, /<RectificationAgenticChat/);
|
||
assert.match(chat, /void send\("opening", ""\)/);
|
||
});
|
||
|
||
test("homepage opens through the server Case API and merges the returned session", () => {
|
||
const source = homeSurface;
|
||
const start = source.indexOf("async function openRectificationCase");
|
||
const end = source.indexOf("async function openRectificationFromHomepage", start);
|
||
const handler = source.slice(start, end);
|
||
const open = handler.indexOf('/api/rectification/cases/open');
|
||
const merge = handler.indexOf('setSessions((current) => [merged,');
|
||
const reveal = handler.indexOf('setActiveSessionId(opened.sessionId)');
|
||
|
||
assert.ok(open >= 0);
|
||
assert.ok(merge > open);
|
||
assert.ok(reveal > merge);
|
||
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
|
||
assert.doesNotMatch(handler, /onNarrativeDelta/);
|
||
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
|
||
// 原值:同一守卫内 JSX 写 pendingConsultationQuestion={rectificationPendingQuestion}
|
||
// 新值:同一守卫内 JSX 写 panel={rectificationPanel};pending 字段在 hook panel
|
||
// 原因:校正面子树状态收到 panel
|
||
assert.match(source, /rectificationSurfaceOpen && rectificationCaseId && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?panel=\{rectificationPanel\}/);
|
||
});
|
||
|
||
test("the page never creates the session shell locally; the server owns session creation", () => {
|
||
const page = homeSurface;
|
||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||
const start = page.indexOf("async function openRectificationCase");
|
||
const end = page.indexOf("async function openRectificationFromHomepage", start);
|
||
const handler = page.slice(start, end);
|
||
|
||
assert.doesNotMatch(handler, /persistSession\(rectificationSession, "create"\)/);
|
||
assert.match(handler, /const merged: ChatSession = \{/);
|
||
assert.match(component, /<RectificationAgenticChat/);
|
||
});
|
||
|
||
test("rectification cards render only inside the active rectification session", () => {
|
||
const source = homeSurface;
|
||
|
||
assert.match(source, /activeSession\?\.sessionType === "birth_time_rectification"/);
|
||
assert.match(source, /session_type:\s*session\.sessionType/);
|
||
assert.match(source, /rectification_case_id:\s*session\.rectificationCaseId/);
|
||
assert.doesNotMatch(source, /这个会话保存了生时校正入口|恢复生时校正<\/button>/);
|
||
});
|
||
|
||
test("selecting a rectification session resumes it through the exact-session open API", () => {
|
||
const source = homeSurface;
|
||
const selectSession = source.slice(
|
||
source.indexOf("function selectSession("),
|
||
source.indexOf("async function selectSessionModel", source.indexOf("function selectSession(")),
|
||
);
|
||
|
||
assert.match(selectSession, /nextSession\?\.sessionType === "birth_time_rectification"/);
|
||
assert.match(selectSession, /void openRectificationSession\(nextSession\.id\)/);
|
||
assert.match(source, /resumeRectificationSession\.current\(activeSession\)/);
|
||
assert.doesNotMatch(source, /RectificationLoadingState|重试恢复/);
|
||
assert.match(source, /<ConversationalBirthTimeRectification/);
|
||
});
|
||
|
||
test("homepage creation and sidebar selection resolve through distinct server intents", () => {
|
||
const page = homeSurface;
|
||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||
|
||
assert.match(page, /openRectificationCase\(\"session\", exactSessionId, null\)/);
|
||
assert.match(page, /openRectificationCase\(\"homepage\", null, pendingConsultationQuestion\)/);
|
||
assert.match(page, /openRectificationCase\(\"new\", null, null\)/);
|
||
assert.doesNotMatch(page, /sourceSession\.sessionType === "birth_time_rectification"/);
|
||
assert.doesNotMatch(page, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
|
||
assert.match(component, /<RectificationAgenticChat/);
|
||
});
|
||
|
||
test("an answered conversation offers no suggested follow-up questions", () => {
|
||
const source = homeSurface;
|
||
const transcript = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
|
||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
|
||
// Given: the chips above the composer stay gone. Grounded continuations sit under
|
||
// the latest answer and send immediately instead of filling the composer.
|
||
assert.doesNotMatch(source, /composer-suggestions|activeSuggestions|chooseConversationSuggestion/);
|
||
assert.doesNotMatch(styles, /composer-suggestions/);
|
||
assert.match(transcript, /ConversationFollowUps/);
|
||
assert.match(transcript, /deriveConsultationFollowUps/);
|
||
assert.match(styles, /\.conversation-follow-ups/);
|
||
|
||
// Then: nothing in the conversation carries or stores a suggestion list any more.
|
||
assert.doesNotMatch(source, /suggestions: (?:reply|previewReply|parsed)\.suggestions/);
|
||
assert.doesNotMatch(source, /readSuggestions/);
|
||
|
||
// And: rectification is still reachable without the chip that used to hand off to it.
|
||
assert.match(source, /onClick=\{\(\) => void openRectificationFromHomepage\(\)\}/);
|
||
});
|
||
|
||
test("rectify-first handoffs stay as Agent context", () => {
|
||
const source = homeSurface;
|
||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||
|
||
// 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX
|
||
// 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel
|
||
// 原因:校正面子树状态收到 panel,Home 不再逐个传
|
||
assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/);
|
||
assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/);
|
||
// 旧 横幅承诺按钮会带回原问题 → 新 按钮已删,改成结束后新建对话再问 → 保留 pendingConsultationQuestion 传递链
|
||
assert.match(chat, /结束后新建对话,按采用的时间再问/);
|
||
});
|
||
|
||
test("ordinary consultation uses current birth data without a rectification notice", () => {
|
||
const source = homeSurface;
|
||
const sendStart = source.indexOf("async function send(");
|
||
const consultCall = source.indexOf('fetch("/api/consult"', sendStart);
|
||
|
||
assert.ok(sendStart >= 0);
|
||
assert.ok(consultCall > sendStart);
|
||
assert.match(source, /mode: "general_no_birth_time" as const/);
|
||
assert.doesNotMatch(source, /<BirthTimeSoftNotice|setBirthTimeSoftNotice/);
|
||
assert.doesNotMatch(source, /setPendingBirthTimeChoice|<UnverifiedBirthTimeChoice/);
|
||
});
|
||
|
||
test("rectification mutations report pending state while session-level return controls stay absent", () => {
|
||
const source = homeSurface;
|
||
|
||
// 原值:onPendingChange={setRectificationMutationPending} 写在 page.tsx JSX
|
||
// 新值:onPendingChange: setRectificationMutationPending 写在 hook 的 panel
|
||
// 原因:mutationPending 仍由外壳对象持有,但 setter 经 panel 交给子树
|
||
assert.match(source, /onPendingChange: setRectificationMutationPending/);
|
||
assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/);
|
||
assert.doesNotMatch(source, /重试恢复/);
|
||
assert.doesNotMatch(source, /返回并恢复原问题|返回首页/);
|
||
});
|
||
|
||
test("session changes contain no birth-time notice state", () => {
|
||
const source = homeSurface;
|
||
const selectSession = source.slice(
|
||
source.indexOf("function selectSession("),
|
||
source.indexOf("async function selectSessionModel", source.indexOf("function selectSession(")),
|
||
);
|
||
|
||
assert.doesNotMatch(selectSession, /birthTimeSoftNotice|setBirthTimeSoftNotice/);
|
||
assert.doesNotMatch(source, /dismissBirthTimeSoftNotice/);
|
||
});
|
||
|
||
test("profile and place saves do not auto-start the retired assessment flow", () => {
|
||
const source = homeSurface;
|
||
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");
|
||
|
||
assert.match(source, /entrypoint:\s*consultationEntrypointSchema\.optional\(\)/);
|
||
assert.match(source, /entrypoint: consultationEntrypointSchema\.exclude\(\["birth_time_rectification"\]\)\.optional\(\)/);
|
||
assert.doesNotMatch(source, /entrypoint: z\.literal\("daily_starlanguage"\)\.optional\(\)/);
|
||
assert.match(source, /question:\s*resolvedQuestion\.modelQuestion/);
|
||
assert.match(source, /先用 3–6 句口语直接回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/);
|
||
assert.doesNotMatch(source, /JSON\.stringify\(toolInput\)/);
|
||
assert.match(source, /shouldLoadGeneralDailyPanchanga\(\{/);
|
||
assert.doesNotMatch(source, /用户明确选择的无出生分钟一般咨询/);
|
||
assert.match(source, /不要把整轮对话改成「一般知识或生时校正」二选一/);
|
||
});
|
||
|
||
// 原值:两个 `.product-entrypoint-hitarea` 绝对定位盖满卡片
|
||
// 新值:两个 `.starter-entry` 原生 button
|
||
// 原因:卡片降级成 pill 后,按钮本身就是可点区域,不再需要盖一层 hitarea。
|
||
// 意图不变:两个入口都是原生 button,且内部不嵌套第二个可点元素。
|
||
test("homepage entry points are exactly two native buttons with nothing nested inside", () => {
|
||
const source = homeSurface;
|
||
const entries = source.match(/className="starter-entry"/g) ?? [];
|
||
|
||
assert.equal(entries.length, 2);
|
||
assert.doesNotMatch(source, /product-entrypoint-hitarea/);
|
||
const row = source.slice(
|
||
source.indexOf('className="starter-entry-row"'),
|
||
source.indexOf("</div>", source.indexOf('className="starter-entry-row"')),
|
||
);
|
||
assert.doesNotMatch(row, /<a |onClick=\{[^}]*\}[\s\S]{0,40}<button/);
|
||
});
|
||
|
||
// 原值:锁 `.product-entrypoint-card:has(...:hover)` 不得有 background
|
||
// 新值:同一意图移到 `.starter-entry`
|
||
// 原因:卡片下线,但「hover 只动描边、按下才改底色」这条规则要跟着形态走——
|
||
// 一个会改底色的 hover 在触屏上会粘住,读起来像「已选中」。
|
||
test("entry pills use transient pressed feedback instead of sticky hover shading", () => {
|
||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const hoverRules = [...styles.matchAll(/\.starter-entry:not\(:disabled\):hover\s*\{([^}]*)\}/g)];
|
||
|
||
assert.ok(hoverRules.length > 0, "the entry pill needs a hover rule");
|
||
for (const rule of hoverRules) assert.doesNotMatch(rule[1], /background\s*:/);
|
||
assert.match(styles, /\.starter-entry:not\(:disabled\):active\s*\{[^}]*background\s*:/);
|
||
assert.doesNotMatch(styles, /\.starter-theme-card|\.product-entrypoint-card/);
|
||
});
|
||
|
||
test("the starter heading is drawn per visit and the entry pills carry no fine print", () => {
|
||
const source = homeSurface;
|
||
|
||
// Given: the heading reads from a variant seeded once per mount and redrawn when the home is left.
|
||
assert.match(source, /<h1 id="starter-heading">\{starterGreeting\.question\}<\/h1>/);
|
||
assert.match(source, /useState\(\(\) => Math\.random\(\)\)/);
|
||
assert.match(source, /starterGreetingUnseen\.current = true;\s*\n\s*setStarterGreetingSelection\(Math\.random\(\)\)/);
|
||
|
||
// 原值:校正卡片的 footer 里不得有 <small> 细则,且 action 靠 margin-left: auto 右对齐
|
||
// 新值:pill 里只有图标和一个词,细则根本没有容身处;一行合并提示在 pill 之下
|
||
// 原因:卡片下线。意图(入口上不堆小字)以更强的形式保住了。
|
||
const entryRow = source.slice(
|
||
source.indexOf('className="starter-entry-row"'),
|
||
source.indexOf('className="starter-entry-hint"'),
|
||
);
|
||
assert.doesNotMatch(entryRow, /<small>/);
|
||
assert.doesNotMatch(entryRow, /进度会自动保存/);
|
||
});
|
||
|
||
// 原值:锁手机断点下两张卡的 min-height / grid-template-rows / footer 折行 / action 左对齐
|
||
// 新值:pill 行自己会换行并保持居中
|
||
// 原因:被锁的那套卡片布局已不存在。原意图是「手机上别用固定高度、别把动作挤到右边」,
|
||
// pill 形态下由 flex-wrap 直接满足,不需要断点特判。
|
||
test("entry pills wrap instead of needing a phone-specific card layout", () => {
|
||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const row = styles.match(/\.starter-entry-row \{([^}]*)\}/);
|
||
|
||
assert.ok(row);
|
||
assert.match(row[1], /flex-wrap:\s*wrap;/);
|
||
assert.match(row[1], /justify-content:\s*center;/);
|
||
assert.doesNotMatch(styles, /\.daily-starlanguage-card|\.birth-rectification-card/);
|
||
});
|
||
|
||
test("the starter greeting keeps the display stack and display weight", () => {
|
||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const heading = styles.match(/\.starter-greeting-block h1 \{([^}]*)\}/)?.[1] ?? "";
|
||
|
||
// 原值:`.starter-hero h1` 与 `.product-entrypoint-copy h2`,字重 400
|
||
// 新值:`.starter-greeting-block h1`,字重 500
|
||
// 原因:hero 卡与入口卡下线;字重 400 是衬线时代的值,--font-display 改无衬线后发虚(BUG-737)。
|
||
assert.match(heading, /font-family:\s*var\(--font-display\)\s*;/);
|
||
assert.match(heading, /font-weight:\s*500\s*;/);
|
||
assert.doesNotMatch(heading, /font-family:\s*var\(--font-body\)/);
|
||
// 问候不该再是 52px 的落地页大标题,它现在只是输入框上面的一行。
|
||
assert.match(heading, /font-size:\s*clamp\(26px,/);
|
||
});
|
||
|
||
test("the starter home hides technical chart parameters", () => {
|
||
const source = homeSurface;
|
||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
|
||
// 原值:先 indexOf('<div className="starter-list starter-workbench"') 切片,再用
|
||
// /\/\* Starter workbench \*\/[\s\S]*?\.starter-list \{.../ 匹配 CSS。
|
||
// 新值:直接在整份 starter 源码上断言。
|
||
// 原因:那个切片起点消失后 indexOf 返回 -1,slice(-1) 得到最后一个字符,
|
||
// 随后的 [\s\S]*? 正则在 250KB CSS 上灾难性回溯——本条单测跑了 532 秒。
|
||
// 断言对象没有放宽,反而从「片段」扩大到了「整份文件」。
|
||
// 技术参数这一条只对 starter 组件本身断言:`homeSurface` 是十几个文件的拼接,
|
||
// 里面本来就含 `D1` 一类的标识符,拿整份去断会误伤(我第一版就踩了)。
|
||
const starter = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
|
||
assert.doesNotMatch(source, /starter-theme-accordion|starterSuggestions/);
|
||
assert.doesNotMatch(starter, /evidencePreview|birthTimeDisplay|Vimshottari|D1|D9/);
|
||
assert.match(source, /composer-wrap-starter/);
|
||
assert.match(styles, /\.starter-entries \{/);
|
||
assert.doesNotMatch(styles, /Starter workbench/);
|
||
});
|
||
|
||
test("rectification 402 navigates to the membership page with the rectification source", () => {
|
||
// Given: the Agentic rectification chat owns the /api/rectification/agent call.
|
||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||
const membershipLib = readFileSync(new URL("../src/lib/membership.ts", import.meta.url), "utf8");
|
||
|
||
// 原值: 402 后 window.location.assign(membershipHref("rectification"))
|
||
// 新值: 留下说明并 onOpenBilling({ source: "rectification" }),不离开当前页
|
||
// 原因: 硬跳转清单只能缩小;对话状态不丢
|
||
assert.match(chat, /onOpenBilling\?\.\(\{ source: "rectification" \}\)/);
|
||
assert.match(chat, /if \(response\.status === 402\) \{[\s\S]*onOpenBilling\?\.\(\{ source: "rectification" \}\)/);
|
||
assert.doesNotMatch(chat, /window\.location\.assign\(membershipHref\(/);
|
||
assert.doesNotMatch(chat, /咨询点数不足/);
|
||
assert.doesNotMatch(chat, /setError\(`咨询点数不足/);
|
||
assert.match(membershipLib, /rectification: "生时校正需要点数或会员权益"/);
|
||
});
|