Files
Jyotisha/frontend/tests/home-warm-snapshot.test.ts
T
Jesse_ChenandClaude Opus 5.5 d2551437c4 fix(home): land on the blank starter home after login and on bare / (BUG-1052)
Product decision 2026-09-27 (D1-D4), overriding the BUG-1038 login-return
stash and the "latest session" default landing:

- A full load of / without ?c= (login, typed address, bookmark, refresh of
  bare /) lands on the current person's blank starter home; an existing
  empty draft of that person is reused, otherwise one is created locally.
- ?c= and ?new=1 are unchanged; a refresh inside a conversation keeps its ?c=.
- The login-return stash is removed: redirectToLogin and sidebar links no
  longer write it, bootstrap no longer reads it and clears a leftover value
  once. replace-selected, the lookup origin/other-subject branch and the warm
  resumeRectification dependency go with it.
- A background answer being recovered no longer takes over the blank home
  (same rule BUG-1015 set for ?new=1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 00:51:51 +08:00

288 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> = {}): HomeWarmSnapshot {
return { accountId, modelCatalog: catalog, entrySummary: null, entrySummarySettled: true, sessionsCursor: null, listBoot: null, ...patch };
}
function session(id: string, patch: Partial<ChatSession> = {}): 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> = {}): 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<T>(run: () => T): T {
const values = new Map<string, string>();
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");
});