Files
Jyotisha/frontend/tests/consultation-entrypoint.test.ts
T
Jesse_Chen 4d1a77be9e
Independent Staging Quality Gate / validate (pull_request) Successful in 10m37s
Independent Staging Quality Gate / publish (pull_request) Has been cancelled
fix(home): request the daily card on the same condition that renders it
The daily starlanguage card sat on "正在结合你的星盘写今天的星语。" forever for
every account whose birth time was usable. Its effect bailed out on
birthTimeDisplayState(profile), which returns a value precisely when the
birth time is candidate, accepted or confirmed, so the request went out
only for accounts that had nothing to read. The guard predates the Agent
rewrite and was masked by the written-in client fallback that rewrite
deleted. It now gates on personalChartAvailable, the same fact the card
uses to render personal content, and retries once before admitting that
today has no card.

The route stops letting one engine call take the whole card down
silently: /api/chart fails into a named reason like the other four
layers, and the engine and agent budgets leave room for a cold chart and
an observed 30s generation inside the 60s ceiling.

The home also had three greeting implementations. The hero heading drew
from a static pool while the time-aware greeting lived elsewhere and the
Agent's own greeting was overwritten client-side into a field nothing
rendered. createStartGreeting now exposes its salutation and question
halves, the hero uses both, and the served greeting reaches the hero note.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:36:26 +08:00

349 lines
19 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
consultationEntrypointSchema,
resolveConsultationQuestion,
} 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: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。",
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("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, /personalChartAvailable[\s\S]*?\? "深入看今日"[\s\S]*?: "请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么。"[\s\S]*?"timing",[\s\S]*?personalChartAvailable \? "daily_starlanguage" : null/);
assert.match(source, /messages:\s*\[\.\.\.preservedMessages,[\s\S]*?\{ role: "user", text: question \}\]/);
assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*entrypoint \?\? 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: entrypoint \?\? undefined/);
assert.doesNotMatch(requestBody, /general_no_birth_time" \? \{\} : \{[\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("rectify-first suggestions hand the source question to the homepage open flow", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("function chooseConversationSuggestion");
const end = source.indexOf("function draftDailyStarlanguageQuestion", start);
const handler = source.slice(start, end);
assert.match(handler, /suggestion !== rectifyBeforeConsultationSuggestion/);
assert.match(handler, /find\(\(message\) => message\.role === "user"\)/);
assert.match(handler, /openRectificationFromHomepage\(originalQuestion \?\? null\)/);
assert.match(source, /onClick=\{\(\) => chooseConversationSuggestion\(question\)\}/);
});
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, /resolvedQuestion\.modelQuestion,[\s\S]*?"\\n需要查询星盘时/);
});
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: "生时校正需要点数或会员权益"/);
});