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 = personalConsultationAvailable \? 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, /natalMinuteAvailable/); assert.match(themeSectionHeading, /出生时间不足以支持的部分,我会明确说明,不会补造具体分钟/); assert.match(pageSource, /GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION/); assert.match(pageSource, /chooseSuggestedQuestion\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/); assert.doesNotMatch(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\(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, //); }); 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, /

\{activeError\}<\/p>/); });