Files
Jyotisha/frontend/tests/starter-questions.test.ts
T
Jesse_Chen 52f3c04b3b refactor(chat): remove the post-answer suggestion chips and the table that fed them
Measured use of the three chips above the composer was negligible. They
were also not what they appeared to be: the server looked up a fixed
triplet by session theme and passed it as metadata that overrode
anything the model produced, so the same ten hardcoded sets served every
user regardless of question or chart. That is a plausible reason nobody
pressed them.

Both copies of the per-theme table are gone, reply metadata narrows to
the session title, and the two parse entry points collapse into one now
that they return the same shape. The write schema still tolerates a
suggestions field so a client on the previous bundle does not lose its
message mid-deploy, and stored answers containing the legacy hidden
block are still stripped rather than shown raw.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 15:44:19 +08:00

218 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
consultationDomainIds,
consultationDomainRegistry,
} from "../src/lib/consultation-domain-registry.ts";
import {
defaultGuidedJyotishTopics,
generalGuidedJyotishTopics,
} from "../src/lib/guided-jyotish-topics.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const appSidebarSource = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
const guidedTopicsSource = readFileSync(new URL("../src/lib/guided-jyotish-topics.ts", import.meta.url), "utf8");
function sourceBetween(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
return source.slice(start, end);
}
test("keeps starter questions visible while the user edits a draft", () => {
// Given: the empty-session starter block and its render guard.
const guardStart = pageSource.indexOf("{profileComplete && presetMessageFinished");
const onboardingBranch = pageSource.indexOf("(onboardingPending ?", guardStart);
// When: the guard is inspected independently of the card copy and layout.
assert.notEqual(guardStart, -1);
assert.notEqual(onboardingBranch, -1);
const starterVisibilityGuard = pageSource.slice(guardStart, onboardingBranch);
// Then: draft text cannot hide the cards before a message is submitted.
assert.doesNotMatch(starterVisibilityGuard, /\bdraft\b/);
});
test("completed account initialization switches directly to the home cards", () => {
assert.doesNotMatch(pageSource, /setOnboardingJustCompleted\(true\)/);
assert.doesNotMatch(pageSource, /!profileComplete \|\| onboardingJustCompleted/);
});
test("default starter questions derive every canonical domain with evidence and claim boundaries", () => {
assert.match(pageSource, /defaultGuidedJyotishTopics/);
assert.match(pageSource, /starterSuggestions\.map/);
assert.match(pageSource, /starterThemes\.find\(\(candidate\) => candidate\.id === item\.theme\)/);
assert.match(pageSource, /chooseSuggestedQuestion\(item\.text, item\.theme\)/);
assert.equal(consultationDomainIds.length, 10);
assert.deepEqual(consultationDomainRegistry.map((domain) => domain.id), [...consultationDomainIds]);
assert.match(guidedTopicsSource, /consultationDomainRegistry\.map/);
assert.deepEqual(defaultGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]);
assert.equal(defaultGuidedJyotishTopics.length, consultationDomainRegistry.length);
for (const [index, topic] of defaultGuidedJyotishTopics.entries()) {
const domain = consultationDomainRegistry[index];
assert.equal(topic.id, domain.id);
assert.equal(topic.label, domain.label);
assert.equal(topic.prompt, domain.prompt);
assert.equal(topic.strictWorkflowRoute, domain.strictWorkflowRoute);
assert.deepEqual(topic.evidencePreview, [...domain.evidencePreview]);
assert.equal(topic.confidenceCap, domain.confidenceCap);
assert.equal(topic.claimBoundary, domain.claimBoundary);
}
const domainById = new Map(consultationDomainRegistry.map((domain) => [domain.id, domain]));
assert.match(domainById.get("career")?.requiredLayers.join(" ") ?? "", /D10/);
assert.match(domainById.get("marriage")?.requiredLayers.join(" ") ?? "", /D9/);
assert.match(domainById.get("wealth")?.requiredLayers.join(" ") ?? "", /Ashtakavarga/);
assert.match(domainById.get("timing")?.requiredLayers.join(" ") ?? "", /negative holdout gate/);
});
test("profiles without a usable birth minute receive user-centered starter prompts", () => {
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.id), [...consultationDomainIds]);
assert.deepEqual(generalGuidedJyotishTopics.map((topic) => topic.prompt), [
"请帮我梳理目前的事业方向和下一步重点。",
"请帮我看看我在关系中容易重复什么模式。",
"请帮我分析目前的财富重点、风险和更稳妥的选择。",
"请从非医疗诊断的角度,帮我看看近期的身心压力和调整重点。",
"请帮我看看我更适合怎样学习,以及如何安排下一步。",
"请帮我分析现阶段是否适合迁居、置业或考虑海外发展。",
"请帮我看看家庭关系中最需要处理的问题和责任边界。",
"请帮我看看未来一年的主要趋势、机会和需要留意的阶段。",
"请帮我看看目前适合推进什么,哪些事情需要再等等。",
"请结合我的情况,帮我找出现在最值得优先处理的三个问题。",
]);
for (const topic of generalGuidedJyotishTopics) {
const personalTopic = defaultGuidedJyotishTopics.find((candidate) => candidate.id === topic.id);
assert.ok(personalTopic);
assert.equal(topic.label, personalTopic.label);
assert.equal(topic.strictWorkflowRoute, personalTopic.strictWorkflowRoute);
assert.deepEqual(topic.evidencePreview, personalTopic.evidencePreview);
assert.equal(topic.confidenceCap, personalTopic.confidenceCap);
assert.equal(topic.claimBoundary, personalTopic.claimBoundary);
assert.notEqual(topic.prompt, personalTopic.prompt);
}
assert.ok(generalGuidedJyotishTopics.every((topic) => /我/.test(topic.prompt)));
assert.ok(generalGuidedJyotishTopics.every((topic) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(topic.prompt)));
assert.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/);
// The birth-time boundary is disclosed next to the topics the reader is about to pick from.
const themeSectionHeading = sourceBetween(
pageSource,
'id="starter-themes-heading"',
"starter-theme-accordion");
assert.match(themeSectionHeading, /personalChartAvailable/);
assert.match(themeSectionHeading, /出生时间不足以支持的部分,我会明确说明,不会补造具体分钟/);
assert.match(pageSource, /请帮我看一下今天的运势,重点告诉我适合推进什么、需要注意什么/);
assert.match(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
});
test("keeps session history clickable while another session is answering", () => {
// Given: the page-owned selection callback and the app sidebar session action.
const selectSession = pageSource.match(/function selectSession\(sessionId: string\) \{([\s\S]*?)\n \}/);
// When: request-time navigation constraints are inspected.
// Then: pending request state cannot disable read-only session switching.
assert.ok(selectSession);
assert.doesNotMatch(selectSession[1], /pendingSessionId|isLoading|cancellationPending|creatingSession/);
assert.match(appSidebarSource, /onSelectSession\(session\.id\)/);
assert.doesNotMatch(appSidebarSource, /pendingSession|isLoading|cancellationPending|requestPending/);
});
test("sizes the model popup to its content with responsive bounds", () => {
// Given: the model selector popup styles.
const popupStyles = sourceBetween(globalStyles, ".model-selector-popup {", "}\n.model-selector-popup[data-starting-style]");
// When: its responsive width is inspected.
// Then: short labels stay compact while long content remains viewport-safe.
assert.match(popupStyles, /width:\s*max-content/);
assert.match(popupStyles, /min-width:\s*180px/);
assert.match(popupStyles, /max-width:\s*min\(420px,/);
});
test("centers the credit value with its icon", () => {
// Given: the credit value styles next to the existing flex-centered icon.
const creditValueStyles = sourceBetween(globalStyles, ".credit-button span {", "}\n.credit-button .credit-icon");
// When: the value line box is inspected.
// Then: it participates in flex centering without extra line-height drift.
assert.match(creditValueStyles, /display:\s*inline-flex/);
assert.match(creditValueStyles, /align-items:\s*center/);
assert.match(creditValueStyles, /line-height:\s*1/);
});
test("routes account actions through a menu and focused dialogs", () => {
// Given: the account surface state and entry-point handlers.
// When: the page source is inspected for independent menu and dialog routes.
// Then: profile and logout stay focused dialogs; redeem leaves for /membership.
assert.match(pageSource, /const \[accountMenuOpen, setAccountMenuOpen\] = useState\(false\)/);
assert.match(pageSource, /const \[activeAccountDialog, setActiveAccountDialog\] = useState<AccountDialog \| null>\(null\)/);
assert.match(pageSource, /openAccountDialog\("profile"/);
assert.match(pageSource, /openAccountDialog\("logout"/);
assert.doesNotMatch(pageSource, /type AccountDialog = "profile" \| "redeem" \| "logout"/);
assert.doesNotMatch(pageSource, /openAccountDialog\("redeem"/);
assert.match(appSidebarSource, /<Menu\.Popup className="account-menu-popup"/);
assert.doesNotMatch(appSidebarSource, /<Menu\.Popup className="account-menu"/);
assert.match(appSidebarSource, /<Menu\.Root open=\{accountMenuOpen\} onOpenChange=\{onAccountMenuOpenChange\} modal=\{false\}>/);
});
test("membership entries navigate without auto-opening redeem", () => {
// Given: the account menu, credit control and insufficient-balance paths.
// Then: every redeem/purchase path lands on /membership with a source hint, without opening a dialog.
assert.match(pageSource, /membershipHref\("account-menu"\)/);
assert.match(pageSource, /membershipHref\("credits"\)/);
assert.match(pageSource, /membershipHref\("insufficient-credits"\)/);
assert.doesNotMatch(pageSource, /activeAccountDialog === "redeem"/);
assert.doesNotMatch(pageSource, /paymentEnabled|paymentPackages|paymentOrder|payingPackageId/);
assert.doesNotMatch(pageSource, /redeemCode|redeeming|redeemError|redeemMessage/);
});
test("removes the monolithic account sheet", () => {
// Given: the former sheet implementation names.
// When: the page and global styles are inspected.
// Then: no right-side account sheet remains.
assert.doesNotMatch(pageSource, /profile-overlay|profile-dialog|openAccount\(/);
assert.doesNotMatch(globalStyles, /\.profile-overlay|\.profile-dialog/);
});
test("removes the user-facing admin button from the chat page", () => {
// Given: the administrator-only route and the new account menu.
// Then: code management is no longer surfaced to users on the chat page.
assert.doesNotMatch(appSidebarSource, /adminUrl|account\.isAdmin|后台管理/);
assert.doesNotMatch(pageSource, /account\.isAdmin && account\.adminUrl/);
assert.doesNotMatch(pageSource, /className="admin-button"/);
assert.doesNotMatch(pageSource, /ShieldCheck/);
});
test("keeps the empty starter home at the top instead of auto-scrolling", () => {
const autoScrollEffect = sourceBetween(
pageSource,
"useEffect(() => {\n if (starterHomeVisible) return;",
"profileComplete, starterHomeVisible]);",
);
assert.match(autoScrollEffect, /if \(starterHomeVisible\) return/);
assert.match(autoScrollEffect, /const container = conversation\.current/);
assert.match(autoScrollEffect, /container\.scrollTo\(\{ top: container\.scrollHeight/);
assert.match(pageSource, /ref=\{conversation\} className=\{`conversation/);
assert.doesNotMatch(pageSource, /conversationEnd|scrollIntoView/);
});
test("does not submit the composer while an IME composition is active", () => {
const keyHandler = sourceBetween(
pageSource,
"function handleComposerKeyDown",
"\n\n if (!hydrated",
);
assert.match(keyHandler, /if \(event\.nativeEvent\.isComposing\) return/);
assert.match(keyHandler, /event\.currentTarget\.form\?\.requestSubmit\(\)/);
});
test("announces conversation errors to assistive technology", () => {
assert.match(pageSource, /<p className="error-message" role="alert">\{activeError\}<\/p>/);
});