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
596 lines
29 KiB
TypeScript
596 lines
29 KiB
TypeScript
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();
|
||
}
|
||
});
|