Files
Jyotisha/frontend/tests/home-warm-snapshot.test.ts
T

298 lines
19 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";
import { ensureChinaLocations } from "../src/lib/china-locations-client.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");
}
}
// 原值:夹具 selfProfile 只有省市编码,直接可判为资料完整。
// 新值:用例前先加载城市表(ensureChinaLocations),断言不变。
// 原因:TASK-home-first-load T7 把城市表移出首屏,只有编码的老资料要等表加载后才能解析地点;
// 正式流程在启动时(人物目录读完之前)就会加载,这里补上同样的一步。
test.before(async () => { await ensureChinaLocations(); });
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\(\);/);
// 原值:/readCurrentSubjectAccountId\(\) !== accountPayload\.user\.id\) \{ … clearHomeWarmSnapshot\(\);/
// 新值:先 `const boundAccount = readCurrentSubjectAccountId();`,再 `boundAccount !== accountPayload.user.id) { … clearHomeWarmSnapshot();`
// 原因:TASK-home-first-load T1 要用同一个值判断「是否已有绑定账户」(首登不作废刚读到的人物目录);换账户仍先清快照。
assert.match(provider, /const boundAccount = readCurrentSubjectAccountId\(\);\n\s+if \(boundAccount !== 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");
});