Files
Jyotisha/frontend/tests/home-warm-return-lifecycle.test.tsx
T
Jesse_ChenandClaude Opus 5.5 638b6a60c0 fix(home): warm return to / without replaying the loading ring (BUG-1040)
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
2026-09-26 08:21:35 +08:00

596 lines
29 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 stashed return to a rectification session reopens it on a warm return, with its URL", async () => {
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"), RECT);
// The read-only footer links to bare `/`: the stash decides the landing.
await doc.followLink("林遥,打开账户");
assert.equal(doc.firstCommits[1].loading, false);
assert.equal(doc.view().url, `/?c=${RECT}`, "the stashed ?c= is written back");
await doc.settle(() => doc!.view().rectificationSurface, "the stashed rectification session to open (not a locked page, BUG-1038)");
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
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();
}
});