import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { clearHomeWarmSnapshot, isCompleteHomeWarmSnapshot, readHomeWarmDaily, readHomeWarmSnapshot, rememberHomeWarmDaily, writeHomeWarmSnapshot, type HomeWarmSnapshot, } from "../src/lib/home-warm-snapshot.ts"; import { applyWarmLanding, dailyStarlanguageWarmKey, reconcileSessionModels, resolveHomeWarmStart, resolveWarmLanding, type HomeWarmStartInput, } from "../src/lib/home-warm-start.ts"; import type { Account, ChatSession, Profile } from "../src/lib/home-types.ts"; // BUG-1040 T1: the warm snapshot store and the warm-start decision, as pure // functions. Synthetic ids and profiles only. const USER = "11111111-1111-4111-8111-111111111111"; const OTHER_USER = "22222222-2222-4222-8222-222222222222"; const CONS = "55555555-5555-4555-8555-555555555555"; const RECT = "33333333-3333-4333-8333-333333333333"; const PERSON = "66666666-6666-4666-8666-666666666666"; const catalog = { defaultModelId: "deepseek-pro", models: [{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "x", creditCost: 1, isDefault: true }] }; const card = { trend: "虚构趋势", action: "虚构行动", caution: "虚构提醒" }; const selfProfile = { name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", birthTimeSource: "legacy_import", birthTimePeriod: "", declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "", uncertaintyBeforeMinutes: null, uncertaintyAfterMinutes: null, birthTimeStatus: "confirmed", rectificationCaseId: "", countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", ayanamsa: "lahiri", }; function snapshotFor(accountId: string, patch: Partial = {}): HomeWarmSnapshot { return { accountId, modelCatalog: catalog, entrySummary: null, entrySummarySettled: true, sessionsCursor: null, listBoot: null, ...patch }; } function session(id: string, patch: Partial = {}): ChatSession { return { id, title: "事业方向", theme: "career", modelId: "deepseek-pro", messages: [{ role: "user", text: "虚构问题" }], createdAt: 1, updatedAt: 1, sessionType: "consultation", rectificationCaseId: null, pinned: false, archivedAt: null, messagesHydrated: true, chartProfileId: "self", chartProfileName: "林遥", chartProfileRole: "self", ...patch, }; } function account(id = USER, profile: unknown = selfProfile): Account { return { user: { id, email: "fictional@example.test" }, profile, avatar: null, credits: 9, isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null, hasConfirmedBirthTime: true, hasUsableBirthTime: true, } as unknown as Account; } function input(patch: Partial = {}): HomeWarmStartInput { const boot = { sessions: [], rawRows: [], cursor: null, account: account(), signedOut: false }; return { account: account(), settled: true, signedOut: false, boot, sessions: [session(CONS)], search: "?new=1", storedPendingRaw: null, subjectId: "self", subjectCatalog: [], previewRequested: false, now: new Date("2026-09-26T04:00:00Z"), ...patch, }; } function withStorage(run: () => T): T { const values = new Map(); const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } }; const previous = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true, writable: true }); try { return run(); } finally { if (previous) Object.defineProperty(globalThis, "localStorage", previous); else Reflect.deleteProperty(globalThis, "localStorage"); } } test("write → read returns the snapshot for the same account", () => { clearHomeWarmSnapshot(); writeHomeWarmSnapshot(snapshotFor(USER)); assert.equal(readHomeWarmSnapshot(USER)?.accountId, USER); assert.equal(isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(USER)), true); }); test("another account clears the snapshot instead of returning it", () => { clearHomeWarmSnapshot(); writeHomeWarmSnapshot(snapshotFor(USER)); rememberHomeWarmDaily({ accountId: USER, subjectId: "self", fingerprint: "f", day: "2026-09-26", card }); assert.equal(readHomeWarmSnapshot(OTHER_USER), null); assert.equal(readHomeWarmSnapshot(USER), null, "the previous account's snapshot is gone, not just hidden"); assert.equal(readHomeWarmDaily({ accountId: USER, subjectId: "self", fingerprint: "f", day: "2026-09-26" }), null); writeHomeWarmSnapshot(snapshotFor(USER)); writeHomeWarmSnapshot(snapshotFor(OTHER_USER)); assert.equal(readHomeWarmSnapshot(USER), null); }); test("a 401 login redirect clears the snapshot", async () => { clearHomeWarmSnapshot(); writeHomeWarmSnapshot(snapshotFor(USER)); const { redirectToLogin, LoginRedirectError } = await import("../src/lib/home-cloud-sync.ts"); const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); const previousSession = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); const replaced: string[] = []; Object.defineProperty(globalThis, "window", { value: { location: { search: "", replace: (href: string) => replaced.push(href) } }, configurable: true, writable: true }); Object.defineProperty(globalThis, "sessionStorage", { value: { setItem() {} }, configurable: true, writable: true }); try { assert.throws(() => redirectToLogin(), LoginRedirectError); } finally { if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); else Reflect.deleteProperty(globalThis, "window"); if (previousSession) Object.defineProperty(globalThis, "sessionStorage", previousSession); else Reflect.deleteProperty(globalThis, "sessionStorage"); } assert.deepEqual(replaced, ["/login"]); assert.equal(readHomeWarmSnapshot(USER), null); }); test("sign-out and the provider's signed-out and account-change branches clear the snapshot", () => { const onboarding = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8"); assert.match(onboarding, /clearHomeWarmSnapshot\(\);\n\s+await selfHostedOtpActions\.signOut\(\);\n\s+window\.location\.assign\("\/login"\);/); const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8"); assert.match(provider, /accountResult\.status === 401\) \{\n\s+clearHomeWarmSnapshot\(\);/); assert.match(provider, /sessionResponse\.status === 401\) \{\n\s+clearHomeWarmSnapshot\(\);/); assert.match(provider, /readCurrentSubjectAccountId\(\) !== accountPayload\.user\.id\) \{\n(?:\s+\/\/.*\n)*\s+clearHomeWarmSnapshot\(\);/); }); test("the snapshot lives in memory only", () => { const source = readFileSync(new URL("../src/lib/home-warm-snapshot.ts", import.meta.url), "utf8"); const code = source.replace(/\/\*\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); assert.doesNotMatch(code, /localStorage|sessionStorage|indexedDB|document\.cookie/); }); test("an incomplete snapshot is not a warm start", () => { assert.equal(isCompleteHomeWarmSnapshot(null), false); assert.equal(isCompleteHomeWarmSnapshot(snapshotFor(USER, { entrySummarySettled: false })), false); assert.equal(isCompleteHomeWarmSnapshot(snapshotFor(USER, { modelCatalog: { defaultModelId: "", models: [] } })), false); assert.equal(isCompleteHomeWarmSnapshot(snapshotFor("")), false); }); test("today's card is keyed by person, fingerprint and day: another day or person refetches", () => { clearHomeWarmSnapshot(); const profile = selfProfile as unknown as Profile; const today = dailyStarlanguageWarmKey("self", profile, new Date("2026-09-26T04:00:00Z")); assert.ok(today); rememberHomeWarmDaily({ accountId: USER, subjectId: "self", ...today, card }); assert.deepEqual(readHomeWarmDaily({ accountId: USER, subjectId: "self", ...today }), card); const tomorrow = dailyStarlanguageWarmKey("self", profile, new Date("2026-09-27T04:00:00Z")); assert.ok(tomorrow); assert.notEqual(tomorrow.day, today.day); assert.equal(readHomeWarmDaily({ accountId: USER, subjectId: "self", ...tomorrow }), null, "a new day refetches"); const other = dailyStarlanguageWarmKey(PERSON, profile, new Date("2026-09-26T04:00:00Z")); assert.ok(other); assert.equal(readHomeWarmDaily({ accountId: USER, subjectId: PERSON, ...other }), null, "another person refetches"); assert.equal(readHomeWarmDaily({ accountId: OTHER_USER, subjectId: "self", ...today }), null); assert.equal(dailyStarlanguageWarmKey("self", { ...profile, time: "", reportedTime: "", birthTimeStatus: "reported", birthTimeSource: "unknown" }, new Date()), null, "no birth minute → no card key"); }); test("warm start: complete snapshot + settled list → ready with a new empty chat for ?new=1", () => { clearHomeWarmSnapshot(); writeHomeWarmSnapshot(snapshotFor(USER, { entrySummary: null, sessionsCursor: "cursor-a" })); const start = withStorage(() => resolveHomeWarmStart(input())); assert.ok(start, "warm start"); assert.equal(start.modelCatalog, catalog); assert.equal(start.profile.name, "林遥"); assert.equal(start.onboardingStep, "name"); assert.ok(start.startGreeting.length > 0); assert.equal(start.landing.selection.urlAction, "new-chat"); assert.ok(start.landing.pendingSession, "a local empty consultation"); assert.equal(start.landing.activeSessionId, start.landing.pendingSession.id); assert.equal(start.landing.pendingSession.messages.length, 0); assert.equal(start.landing.pendingSession.modelId, "deepseek-pro"); assert.equal(start.daily.kind, "pending", "no remembered card → static sentence, not a wait"); assert.equal(start.sessionsCursor, null, "the provider reloaded since the snapshot: its cursor wins"); const boot = { sessions: [], rawRows: [], cursor: "cursor-boot", account: account(), signedOut: false }; writeHomeWarmSnapshot(snapshotFor(USER, { sessionsCursor: "cursor-after-load-more", listBoot: boot })); const same = withStorage(() => resolveHomeWarmStart(input({ boot }))); assert.equal(same?.sessionsCursor, "cursor-after-load-more", "same provider boot: the cursor Home last held"); }); test("warm start is refused whenever a piece is missing", () => { clearHomeWarmSnapshot(); withStorage(() => { assert.equal(resolveHomeWarmStart(input()), null, "no snapshot"); writeHomeWarmSnapshot(snapshotFor(USER)); assert.ok(resolveHomeWarmStart(input())); assert.equal(resolveHomeWarmStart(input({ settled: false })), null, "list not settled"); assert.equal(resolveHomeWarmStart(input({ signedOut: true })), null, "signed out"); assert.equal(resolveHomeWarmStart(input({ account: null })), null, "no account"); assert.equal(resolveHomeWarmStart(input({ boot: { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" } })), null, "list error"); assert.equal(resolveHomeWarmStart(input({ previewRequested: true })), null, "development preview"); assert.equal(resolveHomeWarmStart(input({ account: account(USER, { name: "林遥" }) })), null, "onboarding profile"); assert.equal(resolveHomeWarmStart(input({ search: "?c=99999999-9999-4999-8999-999999999999" })), null, "a ?c= not in memory needs the cold lookup"); writeHomeWarmSnapshot(snapshotFor(USER, { entrySummarySettled: false })); assert.equal(resolveHomeWarmStart(input()), null, "summary never settled"); writeHomeWarmSnapshot(snapshotFor(USER)); assert.equal(resolveHomeWarmStart(input({ account: account(OTHER_USER) })), null, "another account"); }); }); test("warm landing follows the cold rules: ?c= keeps, bare / is the blank starter home, ?new=1 is a new chat", () => { // 原值(BUG-1038/1040): 标题 "warm landing follows the cold rules: ?c= keeps, stash writes back, other person's stash is dropped"; // stash(RECT) → activeSessionId RECT + replace-selected + clearStoredReturn;foreign stash → none + clearStoredReturn; // bare → CONS("bare / on a rectification head lands on a consultation, as cold"); // newChat 带 stash → clearStoredReturn true;不在内存的 stash → null(回冷启动查)。 // 新值: resolveWarmLanding 没有 storedReturnId 入参;bare → 本地新建空对话(pendingSession),不是 CONS / RECT; // 已有同一人物的空草稿时复用它、不新建;另一人物的空草稿不复用;?c= keep 与 ?new=1 不变。 // 原因: BUG-1052 D1(裸 `/` 永远空白首页、复用空草稿)+ D3(删除登录返回存根);暖路径与冷路径同一套规则。 const scope = (subject: string) => (item: ChatSession) => (subject === "self" ? item.chartProfileId === "self" : item.chartProfileId === subject); const create = () => session("new-local", { messages: [], title: "新对话" }); const rect = session(RECT, { sessionType: "birth_time_rectification", messages: [] }); const other = session(PERSON, { chartProfileId: PERSON, chartProfileRole: "other" }); const list = [rect, session(CONS), other]; const keep = resolveWarmLanding({ sessions: list, search: `?c=${CONS}`, inSubjectScope: scope("self"), createHomeSession: create }); assert.equal(keep?.activeSessionId, CONS); assert.equal(keep?.selection.urlAction, "keep"); assert.equal(keep?.pendingSession, null); const keepRect = resolveWarmLanding({ sessions: list, search: `?c=${RECT}`, inSubjectScope: scope("self"), createHomeSession: create }); assert.equal(keepRect?.activeSessionId, RECT, "a ?c= rectification link still opens it (D2)"); const bare = resolveWarmLanding({ sessions: list, search: "", inSubjectScope: scope("self"), createHomeSession: create }); assert.equal(bare?.activeSessionId, "new-local", "bare / lands on the blank starter home, not the latest chat"); assert.equal(bare?.pendingSession?.id, "new-local"); assert.equal(bare?.selection.urlAction, "none"); const consHead = resolveWarmLanding({ sessions: [session(CONS), rect], search: "", inSubjectScope: scope("self"), createHomeSession: create }); assert.equal(consHead?.activeSessionId, "new-local", "a latest ordinary chat with messages is not the landing either"); const draft = session("self-draft", { messages: [], title: "新对话" }); const foreignDraft = session("other-draft", { messages: [], title: "新对话", chartProfileId: PERSON, chartProfileRole: "other" }); const reuse = resolveWarmLanding({ sessions: [foreignDraft, rect, draft, session(CONS)], search: "", inSubjectScope: scope("self"), createHomeSession: create }); assert.equal(reuse?.activeSessionId, "self-draft", "an empty draft of the current person is reused"); assert.equal(reuse?.pendingSession, null, "no duplicate draft is created"); const newChat = resolveWarmLanding({ sessions: list, search: "?new=1", inSubjectScope: scope("self"), createHomeSession: create }); assert.equal(newChat?.activeSessionId, "new-local"); assert.equal(newChat?.selection.urlAction, "new-chat"); const emptyPerson = resolveWarmLanding({ sessions: list, search: "?new=1", inSubjectScope: scope("nobody"), createHomeSession: create }); assert.equal(emptyPerson?.activeSessionId, "new-local", "a person with no sessions gets exactly one local chat"); const emptyPersonBare = resolveWarmLanding({ sessions: list, search: "", inSubjectScope: scope("nobody"), createHomeSession: create }); assert.equal(emptyPersonBare?.activeSessionId, "new-local"); assert.equal(resolveWarmLanding({ sessions: list, search: "?c=99999999-9999-4999-8999-999999999999", inSubjectScope: scope("self"), createHomeSession: create }), null, "a ?c= not in memory needs the cold lookup"); }); test("applying the landing replaces older unsaved empty chats; model reconcile mirrors readSessions", () => { const oldEmpty = session("old-empty", { messages: [] }); const pending = session("new-local", { messages: [] }); const landing = { activeSessionId: pending.id, pendingSession: pending, selection: { sessionId: pending.id, urlAction: "new-chat" as const, missing: false } }; const next = applyWarmLanding([oldEmpty, session(CONS)], landing); assert.deepEqual(next.map((item) => item.id), ["new-local", CONS]); const same = [pending, session(CONS)]; assert.equal(applyWarmLanding(same, landing), same, "already applied → unchanged"); const unchanged = [session(CONS)]; const kept = reconcileSessionModels(unchanged, catalog); assert.equal(kept.sessions, unchanged, "no fallback keeps identity (no re-render)"); const offline = reconcileSessionModels([session(CONS, { modelId: "retired-model" })], catalog); assert.deepEqual(offline.fallbackSessionIds, [CONS]); assert.equal(offline.sessions[0].modelId, "deepseek-pro"); }); test("the query of the / being opened: location when already there, else the noted AppLink href, else unknown", async () => { const { homeSearchForMount } = await import("../src/lib/home-warm-start.ts"); const { noteClientNavigationTarget, takeClientNavigationTarget } = await import("../src/lib/client-navigation-target.ts"); const origin = "http://localhost"; // Popstate / document load: the address bar already shows `/`. assert.equal(homeSearchForMount({ pathname: "/", search: "?c=abc", origin }, "/chart"), "?c=abc"); // Client navigation from /chart: Next writes the URL after rendering. assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, "/?new=1"), "?new=1"); assert.equal(homeSearchForMount({ pathname: "/reports", search: "", origin }, `/?c=${CONS}`), `?c=${CONS}`); assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, "/"), ""); assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, null), null, "nothing noted → cold path"); assert.equal(homeSearchForMount({ pathname: "/chart", search: "", origin }, "/ephemeris"), null); noteClientNavigationTarget("/?new=1"); assert.equal(takeClientNavigationTarget(), "/?new=1"); assert.equal(takeClientNavigationTarget(), null, "taken once"); noteClientNavigationTarget("/?new=1"); assert.equal(takeClientNavigationTarget(Date.now() + 60_000), null, "a stale note is not trusted"); noteClientNavigationTarget("https://example.test/"); assert.equal(takeClientNavigationTarget(), null, "only same-origin paths"); });