Files
Jyotisha/frontend/tests/home-warm-return-lifecycle.test.tsx
T

707 lines
35 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 = {
/** Today's home line; null answers 204 (no card), as before BUG-1110. */
daily: { trend: string; action: string; caution: string } | null;
/** Paths whose response never arrives (to prove nothing waits for them). */
hang: Set<string>;
/** Paths answered with 500 (a prefetch that fails must leave nothing behind). */
fail: Set<string>;
models: unknown;
summary: unknown;
consultStatus: unknown;
accountUser: string;
};
function installApi() {
const originalFetch = globalThis.fetch;
const requests: string[] = [];
const mode: Mode = { daily: null, hang: new Set(), fail: 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 (mode.fail.has(path)) return json({ error: "unavailable" }, 500);
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 mode.daily ? json({ status: "ok", card: mode.daily, source: "library" }) : 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; text: string };
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}
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, text: h.container.text });
}, []);
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;
}
/**
* An in-app link to `href` that is not on screen: what `AppLink` does on a
* plain click (note the target, then the client router renders and writes
* the address bar). The read-only footer was the only bare-`/` link; since
* TASK-self-edit-avatar-menu-20260928 S2 it is the account menu.
*/
async function followHref(href: string) {
const { noteClientNavigationTarget } = await import("../src/lib/client-navigation-target.ts");
noteClientNavigationTarget(href);
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, followHref, 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);
// 原值: `await doc.followLink("林遥,打开账户")`——只读页脚是回裸 `/` 的链接
// 新值: 页脚是账户菜单(不再是链接,下面断言);回裸 `/` 用等价的站内链接跳转 `followHref("/")`
// 原因: TASK-self-edit-avatar-menu-20260928 S2——各页头像都弹账户菜单;本测试锁的是「暖回裸 `/` 落空白首页」,与从哪个链接回来无关
assert.equal(doc.h.elements().some((node) => node.tagName === "A" && node.props["aria-label"] === "林遥,打开账户"), false);
await doc.followHref("/");
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();
}
});
// BUG-1104. A tab that opens on a secondary page (refresh, shared link) has
// never cold-started Home, so before the prefetch its first 「新建对话」 played
// the whole loading ring — the 2–3 s the product saw.
for (const path of ["/chart", "/reports"] as const) {
test(`a tab that opens on ${path}: 新建对话 is warm once the background prefetch has run`, async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
api.mode.daily = { trend: "今天工作和对外的事更显眼,你的表现更容易被人看到。月亮渐盈,适合往前推一步。", action: "先完成最要紧的一件,再回消息。", caution: "别为了表现把自己逼得太紧。" };
doc = await openDocument(tab, path, api.requests);
const { readHomeWarmSnapshot, isCompleteHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts");
await doc.settle(() => isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(USER)) && api.requests.includes("POST /api/daily-starlanguage"), "the background prefetch");
for (let i = 0; i < 3; i += 1) await doc.h.idle();
const prefetch = api.requests.filter((line) => !line.startsWith("GET /api/account") && !line.startsWith("GET /api/sessions") && !line.startsWith("GET /api/chart-profiles"));
// 原值:预取只有 GET /api/models 与 GET /api/rectification/cases/entry-summary,且挂载前只有 GET
// 新值:另加 POST /api/daily-starlanguage(只读:按日计算并返回今天这句,不写任何数据);其余请求仍全是 GET
// 原因:BUG-1110——没有预取今天这句时,首页首帧先显示「今天的星语还没写出来。」再换成真句子
assert.deepEqual(prefetch.sort(), ["GET /api/models", "GET /api/rectification/cases/entry-summary", "POST /api/daily-starlanguage"], `read-only prefetch only: ${api.requests.join(", ")}`);
assert.ok(api.requests.every((line) => line.startsWith("GET ") || line === "POST /api/daily-starlanguage"), "nothing but GETs (and the read-only daily line) before Home mounts");
for (const waited of WAITED_PATHS) api.mode.hang.add(waited);
const before = api.requests.length;
await doc.followLink("新建对话");
const first = doc.firstCommits[0];
assert.ok(first, "home mounted");
assert.equal(first.loading, false, "no loading ring");
assert.equal(first.title, "新对话");
assert.equal(first.requests, before, "ready before any request was sent");
assert.equal(doc.view().rectificationCard, "再次校正", "the prefetched summary drives the card");
assert.ok(first.text.includes("你的表现更容易被人看到"), "today's line is in the very first frame");
assert.ok(!first.text.includes("今天的星语还没写出来"), "no placeholder that later swaps (BUG-1110)");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
}
test("a failed prefetch leaves nothing behind and 新建对话 takes the unchanged cold path", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
api.mode.fail.add("/api/models");
doc = await openDocument(tab, "/chart", api.requests);
await doc.settle(() => api.requests.includes("GET /api/models"), "the prefetch attempt");
for (let i = 0; i < 5; i += 1) await doc.h.idle();
const { readHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts");
assert.equal(readHomeWarmSnapshot(USER), null, "no partial snapshot");
api.mode.fail.delete("/api/models");
await doc.followLink("新建对话");
assert.equal(doc.firstCommits[0]?.loading, true, "without a snapshot Home cold-starts as before");
await doc.settle(() => doc!.view().title === "新对话", "the cold reveal");
assert.equal(doc.view().loading, false);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});