223 lines
12 KiB
TypeScript
223 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 all canonical general-knowledge 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.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/);
|
|
assert.doesNotMatch(pageSource, /回答一般占星知识/);
|
|
assert.match(pageSource, /完成生时校正后,再讨论个人星盘结论/);
|
|
assert.match(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/);
|
|
});
|
|
|
|
test("keeps follow-up suggestions visible while the user edits a draft", () => {
|
|
// Given: the follow-up suggestion block and its render guard.
|
|
const suggestionGuard = sourceBetween(
|
|
pageSource,
|
|
"{activeSuggestions.length > 0",
|
|
"(\n <div className=\"composer-suggestions\"",
|
|
);
|
|
|
|
// When: the visibility inputs are inspected before the suggestion markup.
|
|
// Then: only submission-related request state may hide the current set.
|
|
assert.doesNotMatch(suggestionGuard, /\bdraft\b/);
|
|
});
|
|
|
|
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>/);
|
|
});
|