import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { homeSurface as pageSource } from "./home-surface.ts"; 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"); 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("completed account initialization switches directly to the home cards", () => { assert.doesNotMatch(pageSource, /setOnboardingJustCompleted\(true\)/); assert.doesNotMatch(pageSource, /!profileComplete \|\| onboardingJustCompleted/); }); test("profiles without a usable birth minute still start daily fortune from the homepage card", () => { // 原值: 无可用分钟时主题卡换 generalGuidedJyotishTopics,边界句写在「从一个主题开始」小标题。 // 新值: 主题卡下线;无分钟账号只剩问候语、今日运势卡、生时校正卡。 // 原因: 2026-09-15 产品决定删除低价值入口,不做替代。 assert.match(pageSource, /GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION/); assert.match(pageSource, /startSuggestedConsultation\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/); assert.doesNotMatch(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/); assert.doesNotMatch(pageSource, /starter-themes-heading|starter-theme-accordion|starterSuggestions/); }); test("homepage daily card starts a consultation instead of filling the composer", () => { // 原值: 同时锁今日星语卡与主题卡 `guided_topic` 点击。 // 新值: 只覆盖今日星语卡。 // 原因: 主题卡入口已下线,`guided_topic` 不再有发出点。 const starterSend = sourceBetween( pageSource, "async function startSuggestedConsultation(", "function startDailyStarlanguageConsultation", ); assert.match(pageSource, /onClick=\{startDailyStarlanguageConsultation\}/); assert.doesNotMatch(pageSource, /"guided_topic"/); assert.doesNotMatch(pageSource, /draftDailyStarlanguageQuestion/); assert.doesNotMatch(pageSource, /onClick=\{\(\) => chooseSuggestedQuestion\(item\.text, item\.theme\)\}/); assert.match(starterSend, /await startNewChat\(\)/); assert.match(starterSend, /await send\(trimmed, theme, entrypoint, null, targetSession\.id/); assert.doesNotMatch(starterSend, /setDraft\(/); assert.doesNotMatch(starterSend, /composerInput\.current\?\.focus/); }); 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; billing opens in the settings pane. 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. // 原值: membershipHref 跳 /membership,不打开 redeem 弹窗 // 新值: 三处都打开 billing 分区,仍不打开独立兑换弹窗 // 原因: 兑换码是 billing 页签,不是独立 dialog assert.match(pageSource, /openAccountDialog\("billing", \{ source: "account-menu" \}\)/); assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/); assert.match(pageSource, /openAccountDialog\("billing", \{ source: "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", () => { // Former lock: a page-level effect starting `if (starterHomeVisible) return;` that called // `container.scrollTo`. The follow now lives in useConversationScrollAnchor (BUG-478); the page // switches it off for the starter home through the hook's `active` argument instead. assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,/); const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8"); const followEffect = anchorSource.slice(anchorSource.indexOf("// Follow: while anchored")); assert.match(followEffect, /if \(!active \|\| !element\) return;/); assert.ok(followEffect.indexOf("if (!active || !element) return;") < followEffect.indexOf("element.scrollTop = element.scrollHeight")); assert.doesNotMatch(pageSource, /container\.scrollTo\(/); 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>/); });