import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { BOOTSTRAP_PREPARE_TIMEOUT_MS, SESSION_PREFETCH_COUNT, bootstrapLoadingCopy, bootstrapPrepareSettled, bootstrapRevealDelayMs, resolveStarterHomeLandingSessionId, sessionIdsToPrefetch, shouldAutoOpenRectificationSession, starterHomeLandingNeedsConsultation, } from "../src/lib/home-bootstrap.ts"; import { homeSurface } from "./home-surface.ts"; // Product rule (2026-09-02): the loading screen owns every wait. After the home // page is revealed there is no component-level spinner left — only the streaming // answer keeps a live marker, because that is content being generated, not data // being loaded. const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8"); const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8"); const rectificationId = "11111111-1111-4111-8111-111111111111"; const consultationId = "22222222-2222-4222-8222-222222222222"; const emptyConsultationId = "33333333-3333-4333-8333-333333333333"; test("bare / after login does not auto-open the latest rectification session", () => { assert.equal(shouldAutoOpenRectificationSession({ sessionType: "birth_time_rectification", sessionId: rectificationId, search: "", }), false); assert.equal(shouldAutoOpenRectificationSession({ sessionType: "birth_time_rectification", sessionId: rectificationId, search: `?c=${rectificationId}`, }), true); assert.equal(shouldAutoOpenRectificationSession({ sessionType: "birth_time_rectification", sessionId: rectificationId, search: `?c=${consultationId}`, }), false); assert.equal(shouldAutoOpenRectificationSession({ sessionType: "consultation", sessionId: consultationId, search: `?c=${consultationId}`, }), false); }); test("default landing replaces a latest rectification session with an empty consultation", () => { const sessions = [ { id: rectificationId, sessionType: "birth_time_rectification", messages: [] }, { id: emptyConsultationId, sessionType: "consultation", messages: [] }, { id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] }, ]; assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "none"), emptyConsultationId); assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-clear"), emptyConsultationId); assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "keep"), rectificationId); assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-selected"), rectificationId); assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId); assert.equal( starterHomeLandingNeedsConsultation( [{ id: rectificationId, sessionType: "birth_time_rectification", messages: [] }], rectificationId, "none", ), true, ); assert.equal( starterHomeLandingNeedsConsultation(sessions, emptyConsultationId, "none"), false, ); assert.equal( starterHomeLandingNeedsConsultation(sessions, rectificationId, "keep"), false, ); assert.match(page, /resolveStarterHomeLandingSessionId\(/); assert.match(page, /shouldAutoOpenRectificationSession\(/); assert.match(page, /starterHomeLandingNeedsConsultation\(/); }); test("prepare phase settles only when every applicable item has an answer", () => { const base = { profileComplete: true, onboardingSettled: true, dailyStarlanguageApplicable: true, dailyStarlanguageSettled: true, entrySummarySettled: true, }; assert.equal(bootstrapPrepareSettled(base), true); assert.equal(bootstrapPrepareSettled({ ...base, entrySummarySettled: false }), false); assert.equal(bootstrapPrepareSettled({ ...base, onboardingSettled: false }), false); assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageSettled: false }), false); // Items that do not apply to this account never hold the reveal. assert.equal(bootstrapPrepareSettled({ ...base, profileComplete: false, onboardingSettled: false }), true); assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageApplicable: false, dailyStarlanguageSettled: false }), true); }); test("reveal delay is zero once settled and counts down from the phase start otherwise", () => { assert.equal(BOOTSTRAP_PREPARE_TIMEOUT_MS, 4000); assert.equal(bootstrapRevealDelayMs(10_000, 9_000, true), 0); assert.equal(bootstrapRevealDelayMs(10_000, 9_000, false), 3000); assert.equal(bootstrapRevealDelayMs(20_000, 9_000, false), 0); assert.equal(bootstrapRevealDelayMs(10_000, null, false), BOOTSTRAP_PREPARE_TIMEOUT_MS); }); test("loading copy changes per phase so the single loading screen narrates progress", () => { const account = bootstrapLoadingCopy("account"); const prepare = bootstrapLoadingCopy("prepare"); assert.notEqual(account.title, prepare.title); assert.notEqual(account.detail, prepare.detail); assert.match(page, /const loadingCopy = bootstrapLoadingCopy\(bootstrapPhase\);/); assert.match(page, //); }); test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => { assert.match(page, /if \(bootstrapFailed\) setHydrated\(true\);\n\s*else setBootstrapPhase\("prepare"\);/); // The three secondary fetches start in the prepare phase, not after reveal. const guards = page.match(/if \(bootstrapPhase === "account" \|\| !accountId/g) ?? []; assert.equal(guards.length, 3, "onboarding, daily starlanguage and entry summary must all key off bootstrapPhase"); assert.match(page, /setRectificationEntrySummarySettled\(true\);/); assert.match(page, /bootstrapRevealDelayMs\(Date\.now\(\), prepareStartedAt\.current, bootstrapPrepareReady\)/); assert.match(page, /window\.setTimeout\(\(\) => setHydrated\(true\), delay\)/); assert.match(page, /void import\("@\/components\/birth-time-rectification"\);/); }); test("revealed home carries no component loading state", () => { assert.doesNotMatch(page, /starter-loading/); assert.doesNotMatch(starterHome, /starter-loading|dailyStarlanguageBusy|aria-busy=\{/); assert.doesNotMatch(globalsCss, /\.starter-loading/); assert.doesNotMatch(page, /InlineSpinner/); assert.doesNotMatch(page, /dailyStarlanguageBusy|正在写下今天的星语/); // Switching to a session whose messages are not cached keeps a silent placeholder. assert.match(page, /className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">\n\s*正在加载聊天记录<\/span>/); }); test("recent sessions are prefetched after reveal so switches do not wait", () => { assert.equal(SESSION_PREFETCH_COUNT, 5); const sessions = [ { id: "active", sessionType: "consultation" }, { id: "a", sessionType: "consultation" }, { id: "b", sessionType: "consultation", messagesHydrated: true }, { id: "r", sessionType: "birth_time_rectification" }, { id: "c", sessionType: "consultation" }, { id: "d", sessionType: "consultation" }, { id: "e", sessionType: "consultation" }, { id: "f", sessionType: "consultation" }, { id: "g", sessionType: "consultation" }, ]; assert.deepEqual(sessionIdsToPrefetch(sessions, "active"), ["a", "c", "d", "e", "f"]); assert.match(page, /sessionIdsToPrefetch\(sessionsRef\.current, activeSessionIdRef\.current\)/); assert.match(page, /await ensureSessionMessages\(sessionId\);/); }); test("the loading screen DOM contract stale-client recovery depends on is untouched", () => { assert.match(page, /
/); assert.match(page, /
/); assert.match(staleClientRecovery, /main\.app-loading\[aria-busy="true"\]/); assert.match(staleClientRecovery, /main\.app-loading-error/); assert.ok(homeSurface.includes("bootstrapPrepareSettled(")); });