Files
Jyotisha/frontend/tests/home-warm-return-lifecycle.test.tsx
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

623 lines
30 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 test from "node:test";
import React, { useInsertionEffect, useLayoutEffect, type ReactNode } from "react";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
// BUG-1040. Real React lifecycle: the real Home, the real read-only sidebar and
// the real SessionListProvider composed like app/(app)/layout.tsx. A client
// navigation swaps the route child (Home unmounts and remounts) while the
// provider stays; a document load resets module state and keeps storages.
// The claim under test: the loading ring plays once per document load, and a
// client return to `/` is ready in its first committed frame.
// Synthetic ids and profiles only (no real person data).
const USER = "11111111-1111-4111-8111-111111111111";
const OTHER_USER = "22222222-2222-4222-8222-222222222222";
const RECT = "33333333-3333-4333-8333-333333333333";
const CASE = "44444444-4444-4444-8444-444444444444";
const CONS = "55555555-5555-4555-8555-555555555555";
const REQUEST = "77777777-7777-4777-8777-777777777777";
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",
};
const rectRow = {
id: RECT, title: "生时校正 · 04:45–05:15", theme: "general", model_id: "deepseek-pro", messages: [],
session_type: "birth_time_rectification", rectification_case_id: CASE, chart_profile_id: "self",
chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null,
created_at: "2026-09-20T00:00:00Z", updated_at: "2026-09-25T00:00:00Z",
};
const consultationRow = {
id: CONS, title: "事业方向", theme: "career", model_id: "deepseek-pro",
messages: [{ role: "user", text: "虚构问题" }, { role: "assistant", text: "虚构回答" }],
session_type: "consultation", rectification_case_id: null, chart_profile_id: "self",
chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null,
created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z",
};
const catalogPro = { defaultModelId: "deepseek-pro", models: [{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "x", creditCost: 1, isDefault: true }] };
const catalogFlash = { defaultModelId: "deepseek-flash", models: [{ id: "deepseek-flash", label: "DeepSeek V4 Flash", description: "x", creditCost: 1, isDefault: true }] };
const terminalSummary = { has_resumable_case: false, has_terminal_case_with_time: true, latest_resumable: null, latest_terminal: { case_id: CASE, status: "completed" } };
const emptySummary = { has_resumable_case: false, has_terminal_case_with_time: false, latest_resumable: null, latest_terminal: null };
type Mode = {
/** Paths whose response never arrives (to prove nothing waits for them). */
hang: Set<string>;
models: unknown;
summary: unknown;
consultStatus: unknown;
accountUser: string;
};
function installApi() {
const originalFetch = globalThis.fetch;
const requests: string[] = [];
const mode: Mode = { hang: new Set(), models: catalogPro, summary: terminalSummary, consultStatus: null, accountUser: USER };
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = new URL(String(input), "http://localhost");
const method = init?.method ?? "GET";
const path = url.pathname;
requests.push(`${method} ${path}${url.search}`);
if (mode.hang.has(path)) return new Promise<Response>(() => undefined);
if (path === "/api/account") {
return json({
user: { id: mode.accountUser, email: "fictional@example.test" }, profile: selfProfile, avatar: null, credits: 9,
isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null,
hasConfirmedBirthTime: true, hasUsableBirthTime: true,
});
}
if (path === "/api/chart-profiles") return json({ profiles: [] });
if (path === "/api/models") return json(mode.models);
if (path === "/api/sessions" && method === "GET") return json({ sessions: [rectRow, consultationRow], nextCursor: null });
if (path === `/api/sessions/${RECT}`) return json(method === "GET" ? { session: rectRow } : { ok: true });
if (path === `/api/sessions/${CONS}`) return json(method === "GET" ? { session: consultationRow } : { ok: true });
if (path.startsWith("/api/sessions")) return json({ ok: true });
if (path === "/api/consult/status") {
if (!mode.consultStatus) return new Response(null, { status: 204 });
return json(mode.consultStatus);
}
if (path === "/api/rectification/cases/entry-summary") return json(mode.summary);
if (path === "/api/rectification/cases/open") {
return json({ disposition: "readonly", caseId: CASE, sessionId: RECT, status: "completed", shouldStartOpening: false, skillVersion: "test" });
}
if (path === `/api/rectification/cases/${CASE}`) return json({ turns: [], snapshot: null });
if (path === "/api/daily-starlanguage") return new Response(null, { status: 204 });
return json({ error: "not_mocked" }, 404);
}) as typeof fetch;
return { requests, mode, restore: () => { globalThis.fetch = originalFetch; } };
}
function memoryStorage(): Storage {
const values = new Map<string, string>();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, String(value)); },
removeItem: (key) => { values.delete(key); },
clear: () => values.clear(),
key: (index) => Array.from(values.keys())[index] ?? null,
get length() { return values.size; },
};
}
function createTab() {
const session = memoryStorage();
const local = memoryStorage();
const location = {
origin: "http://localhost", protocol: "http:", host: "localhost", hostname: "localhost",
href: "http://localhost/", pathname: "/", search: "", hash: "",
assigned: [] as string[],
assign(href: string) { this.assigned.push(href); },
replace(href: string) { this.assigned.push(href); },
};
const setUrl = (href: string) => {
const next = new URL(href, "http://localhost");
location.pathname = next.pathname;
location.search = next.search;
location.href = next.href;
};
const history = {
pushState(_state: unknown, _title: string, url: string) { setUrl(url); },
replaceState(_state: unknown, _title: string, url: string) { setUrl(url); },
};
return { session, local, location, history, setUrl };
}
type Tab = ReturnType<typeof createTab>;
type FirstCommit = { loading: boolean; title: string | null; url: string; requests: number };
async function openDocument(tab: Tab, href: string, requests: string[]) {
const h = createClientLifecycleHarness();
const win = globalThis.window as unknown as Record<string, unknown>;
tab.setUrl(href);
Object.assign(win, {
location: tab.location,
history: tab.history,
navigator: { onLine: true, userAgent: "node" },
sessionStorage: tab.session,
localStorage: tab.local,
innerWidth: 1440,
Element: (globalThis as unknown as { Element: unknown }).Element,
Node: (globalThis as unknown as { Element: unknown }).Element,
ShadowRoot: class {},
matchMedia: (query: string) => ({
matches: false,
media: query, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {},
}),
});
const hostProto = (globalThis as unknown as { HTMLElement: { prototype: Record<string, unknown> } }).HTMLElement.prototype;
Object.assign(hostProto, {
querySelector: () => null,
querySelectorAll: () => [],
closest: () => null,
matches: () => false,
getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 }),
getClientRects: () => [],
scrollTo() {},
scrollIntoView() {},
scrollHeight: 0, clientHeight: 0, scrollTop: 0, offsetHeight: 0, offsetTop: 0,
});
if (!Object.getOwnPropertyDescriptor(hostProto, "children")) {
Object.defineProperty(hostProto, "children", { configurable: true, get(this: { childNodes: unknown[] }) { return this.childNodes; } });
}
const doc = globalThis.document as unknown as Record<string, unknown>;
const createElement = (doc.createElement as (tag: string) => Record<string, unknown>).bind(doc);
doc.createElement = (tag: string) => {
const node = createElement(tag);
node.style = { setProperty() {}, removeProperty() {}, getPropertyValue: () => "" };
return node;
};
doc.getElementsByTagName = () => [doc.head];
doc.head = createElement("head");
doc.documentElement = Object.assign(createElement("html"), { dataset: {} });
doc.body = createElement("body");
const previous = {
sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"),
localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"),
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
matchMedia: Object.getOwnPropertyDescriptor(globalThis, "matchMedia"),
Node: Object.getOwnPropertyDescriptor(globalThis, "Node"),
requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"),
cancelAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "cancelAnimationFrame"),
};
Object.defineProperty(globalThis, "sessionStorage", { value: tab.session, configurable: true, writable: true });
Object.defineProperty(globalThis, "localStorage", { value: tab.local, configurable: true, writable: true });
Object.defineProperty(globalThis, "navigator", { value: win.navigator, configurable: true, writable: true });
Object.defineProperty(globalThis, "matchMedia", { value: win.matchMedia, configurable: true, writable: true });
Object.defineProperty(globalThis, "Node", { value: win.Node, configurable: true, writable: true });
Object.defineProperty(globalThis, "requestAnimationFrame", { value: win.requestAnimationFrame, configurable: true, writable: true });
Object.defineProperty(globalThis, "cancelAnimationFrame", { value: win.cancelAnimationFrame, configurable: true, writable: true });
// A document load re-evaluates every module: person store, warm snapshot.
const subject = await import("../src/lib/current-subject.ts");
subject.resetSubjectCatalogForTests();
(await import("../src/lib/home-warm-snapshot.ts")).clearHomeWarmSnapshot();
(await import("../src/lib/home-warm-start.ts")).resetHomeWarmStartForTests();
const { SessionListProvider, useSessionList, useShellRegistration, sessionListSidebarModel } = await import("../src/lib/session-list-context.tsx");
const { SidebarInset, SidebarProvider } = await import("../src/components/ui/sidebar.tsx");
const { AppSidebar } = await import("../src/components/app-sidebar.tsx");
function AppShell({ children }: { children: ReactNode }) {
const list = useSessionList();
const registration = useShellRegistration();
const { sessions, account } = sessionListSidebarModel(list, registration);
return (
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
<main className="chat-app">
<AppSidebar
sessions={sessions}
activeSessionId={registration?.activeSessionId ?? null}
openingSessionId={registration?.openingSessionId ?? null}
openErrorSessionId={registration?.openErrorSessionId ?? null}
openErrorMessage={registration?.openErrorMessage ?? ""}
account={account}
settled={list.settled}
signedOut={list.signedOut}
controls={registration?.controls}
/>
<SidebarInset className={registration?.insetClassName ?? "chat-panel secondary-panel"} inert={registration?.insetInert}>
{children}
</SidebarInset>
</main>
</SidebarProvider>
);
}
const byClass = (name: string) => h.elements().filter((node) => String(node.props.className ?? "").split(/\s+/).includes(name));
const view = () => {
const title = byClass("chat-header-title")[0]?.childNodes.find((node) => node.tagName === "STRONG")?.text ?? null;
const textarea = h.elements().find((node) => node.tagName === "TEXTAREA");
return {
url: `${tab.location.pathname}${tab.location.search}`,
loading: byClass("app-loading").length > 0 || byClass("app-loading-orbit").length > 0,
title,
inputDisabled: Boolean(textarea?.props.disabled),
rectificationSurface: byClass("is-rectification").length > 0,
starter: byClass("composer-wrap-starter").length > 0,
transcript: h.elements().some((node) => node.text === "虚构回答"),
rectificationCard: (byClass("starter-entry").find((node) => /再次校正|开始新的生时校正/.test(String(node.props["aria-label"] ?? "")))?.props["aria-label"] as string | undefined) ?? null,
stopVisible: h.elements().some((node) => node.tagName === "BUTTON" && String(node.props["aria-label"] ?? "").includes("停止回答")),
modelLabel: h.elements().find((node) => node.tagName === "BUTTON" && /DeepSeek V4/.test(node.text))?.text ?? null,
};
};
// Records the first committed frame of each Home mount: a layout effect of
// a sibling mounted after Home runs once Home's host tree is committed and
// before the browser would paint.
const firstCommits: FirstCommit[] = [];
function FirstCommitProbe() {
useLayoutEffect(() => {
const v = view();
firstCommits.push({ loading: v.loading, title: v.title, url: v.url, requests: requests.length });
}, []);
return null;
}
const Home = (await import("../src/app/(app)/page.tsx")).default;
const route = (pathname: string): ReactNode => {
if (pathname === "/") return <React.Fragment key="home"><Home /><FirstCommitProbe /></React.Fragment>;
return <section key={pathname} data-secondary-route={pathname} />;
};
// Next's order, not the test's convenience: the router renders the next
// page while the address bar still shows the page being left, and writes
// the URL in an insertion effect of that commit (app-router HistoryUpdater).
let routePath = tab.location.pathname;
let pendingHref: string | null = null;
function HistoryUpdater() {
useInsertionEffect(() => {
if (!pendingHref) return;
tab.history.pushState(null, "", pendingHref);
pendingHref = null;
});
return null;
}
const renderRoute = () => h.render(
<SessionListProvider><HistoryUpdater /><AppShell>{route(routePath)}</AppShell></SessionListProvider>,
);
await renderRoute();
const link = (label: string) => {
// Session rows carry their date after the title; match on the title.
const found = h.elements().find((node) => node.tagName === "A"
&& (node.text.trim() === label || node.props["aria-label"] === label))
?? h.elements().find((node) => node.tagName === "A" && String(node.props.href ?? "").startsWith("/?c=") && node.text.startsWith(label));
assert.ok(found, `link ${label} is rendered`);
return found;
};
async function followLink(label: string) {
const anchor = link(label);
const href = String(anchor.props.href);
const own = anchor.props.onClick as ((event: unknown) => void) | undefined;
const click = {
button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, defaultPrevented: false,
preventDefault() { click.defaultPrevented = true; }, stopPropagation() {},
currentTarget: anchor, target: anchor, nativeEvent: {},
};
await h.update(() => { own?.(click); });
routePath = new URL(href, "http://localhost").pathname;
pendingHref = href;
await renderRoute();
return href;
}
async function settle(until: () => boolean, label: string, ms = 12000) {
const started = Date.now();
while (Date.now() - started < ms) {
await h.idle();
if (until()) return;
}
assert.fail(`timed out waiting for ${label}: ${JSON.stringify(view())}`);
}
async function close() {
try { await h.close(); } finally {
for (const [key, descriptor] of Object.entries(previous)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
}
return { h, view, link, followLink, settle, close, firstCommits, errors: h.errors };
}
type Doc = Awaited<ReturnType<typeof openDocument>>;
/** Cold document load of `/`, revealed and settled (the snapshot is written). */
async function coldHome(tab: Tab, api: ReturnType<typeof installApi>, href = "/"): Promise<Doc> {
const doc = await openDocument(tab, href, api.requests);
assert.equal(doc.firstCommits[0]?.loading, true, "a document load starts on the loading ring");
const { readHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts");
await doc.settle(() => doc.view().title !== null && readHomeWarmSnapshot(USER) !== null, "the cold reveal and its snapshot");
for (let i = 0; i < 5; i += 1) await doc.h.idle();
return doc;
}
const WAITED_PATHS = ["/api/models", "/api/consult/status", "/api/rectification/cases/entry-summary", "/api/daily-starlanguage", "/api/account", "/api/sessions", "/api/chart-profiles"];
for (const page of [
{ label: "星盘", path: "/chart" },
{ label: "星历", path: "/ephemeris" },
{ label: "我的报告", path: "/reports" },
{ label: "星盘档案", path: "/people" },
] as const) {
test(`${page.path} → 新建对话 paints a ready home in its first frame, with every request still unanswered`, async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api);
assert.equal(doc.firstCommits.length, 1);
await doc.followLink(page.label);
assert.equal(tab.location.pathname, page.path);
// From here on nothing answers. A home that waited for any of these
// could never leave the loading ring.
for (const path of WAITED_PATHS) api.mode.hang.add(path);
const before = api.requests.length;
const href = await doc.followLink("新建对话");
assert.equal(href, "/?new=1");
const first = doc.firstCommits[1];
assert.ok(first, "home mounted again");
assert.equal(first.loading, false, "no loading ring on the warm return");
assert.equal(first.title, "新对话", "the new empty chat is active in the first frame");
assert.equal(first.requests, before, "the first frame was committed before any request was sent");
const view = doc.view();
assert.equal(view.loading, false);
assert.equal(view.title, "新对话");
assert.equal(view.starter, true, "starter home, not a transcript");
assert.equal(view.transcript, false);
assert.equal(view.inputDisabled, false);
assert.equal(view.url, "/", "?new=1 is consumed");
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
assert.equal(view.rectificationCard, "再次校正", "the rectification card uses the remembered summary");
const background = api.requests.slice(before);
assert.ok(background.includes("GET /api/models"), `the catalog refreshes in the background: ${background.join(", ")}`);
assert.ok(background.includes("GET /api/consult/status"), "background consultation recovery still checks");
for (let i = 0; i < 5; i += 1) await doc.h.idle();
assert.equal(doc.view().loading, false, "an unanswered background refresh never brings the ring back");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
}
test("/reports → a history row opens that conversation without the loading ring", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api, "/?new=1");
assert.equal(doc.view().title, "新对话");
await doc.followLink("我的报告");
for (const path of WAITED_PATHS) api.mode.hang.add(path);
const before = api.requests.length;
const href = await doc.followLink("事业方向");
assert.equal(href, `/?c=${CONS}`);
const first = doc.firstCommits[1];
assert.equal(first.loading, false);
assert.equal(first.title, "事业方向");
assert.equal(first.requests, before);
assert.equal(doc.view().url, `/?c=${CONS}`, "?c= stays");
await doc.settle(() => doc!.view().transcript, "the conversation's messages");
assert.equal(doc.view().loading, false);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
test("a full reload of / still plays the loading ring once", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api);
await doc.followLink("星盘");
await doc.followLink("新建对话");
assert.equal(doc.firstCommits[1].loading, false);
await doc.close();
doc = undefined;
// Same tab, new document: module memory is gone, storages survive.
doc = await openDocument(tab, "/", api.requests);
assert.equal(doc.firstCommits[0].loading, true, "reload starts on the ring");
await doc.settle(() => doc!.view().title !== null, "the cold reveal after reload");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
test("a snapshot missing a piece falls back to the unchanged cold path", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api);
await doc.followLink("星盘");
const { clearHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts");
clearHomeWarmSnapshot();
api.mode.hang.add("/api/models");
await doc.followLink("新建对话");
assert.equal(doc.firstCommits[1].loading, true, "no snapshot → the loading ring, never a half-reveal");
for (let i = 0; i < 5; i += 1) await doc.h.idle();
assert.equal(doc.view().loading, true, "the cold path waits for the catalog as before (BUG-1021)");
assert.equal(doc.view().title, null);
api.mode.hang.delete("/api/models");
// The cold path's own retry is out of scope; a new document proves it recovers.
await doc.close();
doc = undefined;
} finally {
await doc?.close();
api.restore();
}
});
test("background refresh: a model that went offline switches to the default with the existing notice", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api, `/?c=${CONS}`);
assert.equal(doc.view().title, "事业方向");
await doc.followLink("星盘");
api.mode.models = catalogFlash;
const { toast } = await import("sonner");
const toastsBefore = toast.getHistory().length;
await doc.followLink("事业方向");
assert.equal(doc.firstCommits[1].loading, false);
assert.match(doc.firstCommits[1].title ?? "", /事业方向/);
await doc.settle(() => /Flash/.test(doc!.view().modelLabel ?? ""), "the default model after the catalog refresh");
const notices = toast.getHistory().slice(toastsBefore).map((item) => String((item as { title?: unknown }).title ?? ""));
assert.ok(notices.includes("此前选择的模型已下线,已切换为默认模型。"), `notice: ${notices.join(" | ")}`);
assert.ok(api.requests.some((line) => line === `PATCH /api/sessions/${CONS}`), "the fallback is synced like the cold path");
assert.equal(doc.view().loading, false);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
test("background refresh: an unfinished consultation resumes on a warm return to its conversation", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api, `/?c=${CONS}`);
await doc.followLink("星盘");
// Left while an answer was still generating: the tab's pending record
// survives and the server still reports the request as reserved.
tab.session.setItem("jyotisha.pending-consultation", JSON.stringify({ sessionId: CONS, requestId: REQUEST, question: "虚构追问", theme: "career", entrypoint: null }));
api.mode.consultStatus = { sessionId: CONS, requestId: REQUEST, status: "reserved" };
await doc.followLink("事业方向");
assert.equal(doc.firstCommits[1].loading, false);
await doc.settle(() => doc!.view().stopVisible, "the recovering answer's stop control");
assert.equal(doc.view().title, "事业方向");
assert.ok(tab.session.getItem("jyotisha.pending-consultation"), "the pending record is kept while recovering");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
test("background refresh: a reserved consultation keeps recovering without taking over 新建对话", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api);
await doc.followLink("星盘");
tab.session.setItem("jyotisha.pending-consultation", JSON.stringify({ sessionId: CONS, requestId: REQUEST, question: "虚构追问", theme: "career", entrypoint: null }));
api.mode.consultStatus = { sessionId: CONS, requestId: REQUEST, status: "reserved" };
await doc.followLink("新建对话");
await doc.settle(() => Boolean(tab.session.getItem("jyotisha.pending-consultation")) && api.requests.some((line) => line.startsWith(`GET /api/consult/status?sessionId=${CONS}`)), "the stored request to be checked");
for (let i = 0; i < 5; i += 1) await doc.h.idle();
assert.equal(doc.view().title, "新对话", "the explicit new chat stays active");
assert.equal(doc.view().starter, true);
assert.ok(tab.session.getItem("jyotisha.pending-consultation"), "the older answer is still being recovered");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
test("background refresh: a changed rectification entry summary replaces the card label silently", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api, "/?new=1");
assert.equal(doc.view().rectificationCard, "再次校正");
await doc.followLink("星盘");
api.mode.summary = emptySummary;
await doc.followLink("新建对话");
assert.equal(doc.firstCommits[1].loading, false);
await doc.settle(() => doc!.view().rectificationCard === "开始新的生时校正", "the refreshed summary");
assert.equal(doc.view().loading, false);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
test("a warm return to bare / after a rectification session lands on the blank starter home, not back in it", async () => {
// 原值(BUG-1038/1040): 标题 "a stashed return to a rectification session reopens it on a warm return, with its URL";
// 离开时存根 === RECT → 账户页脚回裸 `/` → 地址栏写回 `/?c=RECT`、校正面重新打开、存根清空。
// 新值: 离开时不存根(null)→ 账户页脚回裸 `/` → 首帧无加载环、就是空白首页「新对话」,地址栏仍是 `/`,不打开校正面。
// 原因: BUG-1052 D1(裸 `/` 永远空白首页)+ D3(删除存根);暖路径与冷路径同规则,BUG-1040「无加载环」照旧锁住。
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api, `/?c=${RECT}`);
await doc.settle(() => doc!.view().rectificationSurface, "the rectification surface");
await doc.followLink("星盘");
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
// The read-only footer links to bare `/`.
await doc.followLink("林遥,打开账户");
assert.equal(doc.firstCommits[1].loading, false);
assert.equal(doc.firstCommits[1].title, "新对话", "the blank starter home is the first frame");
for (let i = 0; i < 5; i += 1) await doc.h.idle();
const view = doc.view();
assert.equal(view.url, "/");
assert.equal(view.title, "新对话");
assert.equal(view.starter, true);
assert.equal(view.rectificationSurface, false);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
test("a cold load of bare / with an old conversation and a rectification in the list shows the blank starter home", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api);
const view = doc.view();
assert.equal(view.title, "新对话");
assert.equal(view.starter, true);
assert.equal(view.transcript, false);
assert.equal(view.url, "/");
assert.equal(view.rectificationSurface, false);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
test("another account in the same tab never paints the previous account's snapshot", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await coldHome(tab, api);
const snapshot = await import("../src/lib/home-warm-snapshot.ts");
assert.ok(snapshot.readHomeWarmSnapshot(USER), "the cold reveal wrote a snapshot");
// The provider reloads (as after a person switch) and now sees another account.
api.mode.accountUser = OTHER_USER;
const subject = await import("../src/lib/current-subject.ts");
await doc.followLink("星盘");
await doc.h.update(() => { subject.setCurrentSubject("self"); subject.invalidateAccountFlight(); });
await doc.h.update(() => { subject.setCurrentSubject("someone-else"); });
await doc.settle(() => snapshot.readHomeWarmSnapshot(USER) === null, "the other account to clear the snapshot");
assert.equal(snapshot.readHomeWarmSnapshot(OTHER_USER), null);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});