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
288 lines
18 KiB
TypeScript
288 lines
18 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",
|
||
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");
|
||
});
|