Coming back to / from /chart, /ephemeris, /reports or /people by client navigation remounted Home from hydrated=false and replayed the whole bootstrap: model catalog, consult status, entry summary and daily card, 3-5 round trips plus up to 4 s of reveal budget, every time. The reveal now counts per document load, not per mount: - lib/home-warm-snapshot.ts: memory-only, per-account snapshot of the model catalog, rectification entry summary, session cursor and today's card (keyed by person + birth fingerprint + day). Account, profile and the session list already survive in SessionListProvider. Cleared on account change, sign-out, provider 401 and redirectToLogin. - lib/home-warm-start.ts: Home's useState initializers take one warm decision per mount. Complete snapshot + settled list + complete profile + a landing computable in memory -> start ready, landing resolved with the cold-path functions (?new=1, ?c=, login-return stash and its person scope from BUG-1038). Anything missing or needing a lookup -> the unchanged cold path (no half-reveal, BUG-1021). - runHomeWarmRefresh commits the landing, then refreshes catalog, account and background-consultation recovery in the background (recovery shares resolveReservedConsultation with the cold path). Summary and daily card refresh through their existing effects. - Next renders the new page before it writes the address bar, so AppLink / navigateAppPath note the target href (lib/client-navigation-target.ts); unknown target -> cold path. Home() useState 33 / useRef 37 unchanged; page.tsx 1329 -> 1373 lines. Tests: home-warm-return-lifecycle (13, real Home + sidebar + provider, navigating in Next's render-then-write-URL order; 11 fail with warm start disabled) and home-warm-snapshot (12). Full suite 3953 / 61 failing, failure names identical to the 3928 / 61 baseline. Build: / stays Static, rootMainFiles gzip 130933 B unchanged. Local Chrome with every /api/* held 1.5 s: /chart -> 新建对话 interactive in 18-23 ms with no loading ring (baseline 5530 ms with ring). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
275 lines
17 KiB
TypeScript
275 lines
17 KiB
TypeScript
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",
|
|
storedReturnId: null, 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, stash writes back, other person's stash is dropped", () => {
|
|
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}`, storedReturnId: null, inSubjectScope: scope("self"), createHomeSession: create });
|
|
assert.equal(keep?.activeSessionId, CONS);
|
|
assert.equal(keep?.selection.urlAction, "keep");
|
|
assert.equal(keep?.pendingSession, null);
|
|
|
|
const stash = resolveWarmLanding({ sessions: list, search: "", storedReturnId: RECT, inSubjectScope: scope("self"), createHomeSession: create });
|
|
assert.equal(stash?.activeSessionId, RECT);
|
|
assert.equal(stash?.selection.urlAction, "replace-selected");
|
|
assert.equal(stash?.selection.clearStoredReturn, true);
|
|
|
|
const foreign = resolveWarmLanding({ sessions: list, search: "", storedReturnId: PERSON, inSubjectScope: scope("self"), createHomeSession: create });
|
|
assert.notEqual(foreign?.activeSessionId, PERSON, "another person's stash is not a place to return to (BUG-1038)");
|
|
assert.equal(foreign?.selection.urlAction, "none");
|
|
assert.equal(foreign?.selection.clearStoredReturn, true);
|
|
|
|
const bare = resolveWarmLanding({ sessions: list, search: "", storedReturnId: null, inSubjectScope: scope("self"), createHomeSession: create });
|
|
assert.equal(bare?.activeSessionId, CONS, "bare / on a rectification head lands on a consultation, as cold");
|
|
|
|
const newChat = resolveWarmLanding({ sessions: list, search: "?new=1", storedReturnId: RECT, inSubjectScope: scope("self"), createHomeSession: create });
|
|
assert.equal(newChat?.activeSessionId, "new-local");
|
|
assert.equal(newChat?.selection.clearStoredReturn, true, "the stash is ignored and cleared by a new chat");
|
|
|
|
const emptyPerson = resolveWarmLanding({ sessions: list, search: "?new=1", storedReturnId: null, inSubjectScope: scope("nobody"), createHomeSession: create });
|
|
assert.equal(emptyPerson?.activeSessionId, "new-local", "a person with no sessions gets exactly one local chat");
|
|
|
|
assert.equal(resolveWarmLanding({ sessions: list, search: "", storedReturnId: "99999999-9999-4999-8999-999999999999", inSubjectScope: scope("self"), createHomeSession: create }), null, "a stash 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, clearStoredReturn: true } };
|
|
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");
|
|
});
|