Files
Jyotisha/frontend/tests/consultation-entrypoint.test.ts
T
Jesse_Chen 9958e00abc fix(web): keep unrectified birth data usable without inventing a minute
Rectification stays optional. Reported minutes can consult and generate reports; date-plus-period uses a declared window instead of a midpoint or 00:00. Updates BUG-341.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 15:29:58 +08:00

405 lines
22 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,
resolveConsultationQuestion,
shouldLoadGeneralDailyPanchanga,
} from "../src/lib/consultation-entrypoint.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("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("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("client_prompt").success, false);
});
test("browser source does not own private entrypoint prompts", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /dailyStarlanguageQuestion/);
assert.match(source, /从今日问起/);
assert.match(source, /深入看今日/);
assert.match(source, /chooseSuggestedQuestion\(\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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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\} \/>/);
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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/);
assert.match(source, /rectificationSurfaceOpen && rectificationCaseId && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
});
test("the page never creates the session shell locally; the server owns session creation", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
// Given: the chips above the composer were removed after measured use stayed negligible.
assert.doesNotMatch(source, /composer-suggestions|activeSuggestions|chooseConversationSuggestion/);
assert.doesNotMatch(styles, /composer-suggestions/);
// 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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/);
assert.match(chat, /之后会回到你原来的问题/);
});
test("ordinary consultation uses current birth data without a rectification notice", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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 = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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, /question:\s*resolvedQuestion\.modelQuestion/);
assert.match(source, /请按 skill Level 2 模板回答下面的问题[\s\S]*?resolvedQuestion\.modelQuestion/);
assert.doesNotMatch(source, /JSON\.stringify\(toolInput\)/);
assert.match(source, /shouldLoadGeneralDailyPanchanga\(\{/);
assert.doesNotMatch(source, /用户明确选择的无出生分钟一般咨询/);
assert.match(source, /不要把整轮对话改成「一般知识或生时校正」二选一/);
});
test("homepage entrypoints use two whole-card native actions", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const wholeCardActions = source.match(/className="product-entrypoint-hitarea"/g) ?? [];
assert.equal(wholeCardActions.length, 2);
assert.doesNotMatch(source, /className="daily-starlanguage-heading">[\s\S]{0,180}<button/);
});
test("starter cards use transient pressed feedback instead of sticky hover shading", () => {
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const productHoverRules = [...styles.matchAll(
/\.product-entrypoint-card:has\(\.product-entrypoint-hitarea:not\(:disabled\):hover\)\s*\{([^}]*)\}/g,
)];
const starterHoverSurfaceRules = [...styles.matchAll(
/\.starter-theme-card:not\(:disabled\):hover\s*\{([^}]*)\}/g,
)];
assert.ok(productHoverRules.length > 0);
for (const rule of productHoverRules) assert.doesNotMatch(rule[1], /background\s*:/);
assert.equal(starterHoverSurfaceRules.length, 0);
assert.match(styles, /\.product-entrypoint-card:has\(\.product-entrypoint-hitarea:not\(:disabled\):active\)\s*\{[^}]*background\s*:/);
assert.match(styles, /\.starter-theme-card:not\(:disabled\):active\s*\{[^}]*background\s*:/);
const disabledProductRules = [...styles.matchAll(
/\.product-entrypoint-card:has\(\.product-entrypoint-hitarea:disabled\)\s*\{([^}]*)\}/g,
)];
assert.equal(disabledProductRules.length, 2);
for (const rule of disabledProductRules) assert.match(rule[1], /opacity:\s*1\s*;/);
});
test("the starter heading is drawn per visit and the rectification card carries no fine print", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
// 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\(\)\)/);
// Then: the rectification card's footer is the action alone, still flush right.
const rectificationCard = source.slice(
source.indexOf('<article className="birth-rectification-card'),
source.indexOf("</article>", source.indexOf('<article className="birth-rectification-card')),
);
assert.doesNotMatch(rectificationCard, /进度会自动保存/);
assert.doesNotMatch(rectificationCard, /<small>/);
assert.match(styles, /\.product-entrypoint-action \{[^}]*margin-left: auto;/);
});
test("starter homepage keeps its editorial display typography", () => {
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const heroHeading = styles.match(/\.starter-hero h1\s*\{([^}]*)\}/)?.[1] ?? "";
const entrypointHeading = styles.match(/\.product-entrypoint-copy h2\s*\{([^}]*)\}/)?.[1] ?? "";
assert.match(heroHeading, /font-family:\s*var\(--font-display\)\s*;/);
assert.match(heroHeading, /font-weight:\s*400\s*;/);
assert.doesNotMatch(heroHeading, /font-family:\s*var\(--font-body\)/);
assert.match(entrypointHeading, /font-family:\s*var\(--font-display\)\s*;/);
assert.match(entrypointHeading, /font-weight:\s*400\s*;/);
assert.doesNotMatch(entrypointHeading, /font-family:\s*var\(--font-body\)/);
});
test("starter homepage stays editorial and hides technical chart parameters", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const start = source.indexOf('<div className="starter-list starter-workbench"');
const end = source.indexOf("{onboardingError &&", start);
const starterHomepage = source.slice(start, end);
assert.ok(start >= 0 && end > start);
assert.match(starterHomepage, /className="starter-hero"/);
assert.match(starterHomepage, /className="starter-theme-accordion"/);
assert.match(starterHomepage, /starterSuggestions\.map/);
assert.doesNotMatch(starterHomepage, /evidencePreview|birthTimeDisplay|Vimshottari|D1|D9/);
assert.match(source, /const starterSuggestions = starterThemes\.map/);
assert.match(source, /composer-wrap-starter/);
assert.match(styles, /\/\* Starter workbench \*\/[\s\S]*?\.starter-list \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\);/);
assert.match(styles, /\.starter-hero,[\s\S]*?\.product-entrypoints,[\s\S]*?\.starter-themes \{[\s\S]*?width: 100%;/);
});
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");
// Then: a 402 (insufficient credits) hands off to /membership with the
// rectification source instead of leaving a dead-end error.
assert.match(chat, /membershipHref\("rectification"\)/);
assert.match(chat, /if \(response\.status === 402\) \{[\s\S]*window\.location\.assign\(membershipHref\("rectification"\)\)/);
assert.doesNotMatch(chat, /咨询点数不足/);
assert.doesNotMatch(chat, /setError\(`咨询点数不足/);
assert.match(membershipLib, /rectification: "生时校正需要点数或会员权益"/);
});