An inline classic script after the stylesheets waited for them to download; on staging the reads left at 2.4-3.1 s. Measurement script gains --css-delay and --ua iphone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
493 lines
26 KiB
TypeScript
493 lines
26 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
import vm from "node:vm";
|
||
import React from "react";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
|
||
import * as acorn from "acorn";
|
||
|
||
import { bindCurrentSubjectAccount, currentSubjectStorageKey, LAST_SUBJECT_ACCOUNT_KEY, readCurrentSubjectId, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
|
||
import { BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_READ_INIT, BOOT_READ_MAX_AGE_MS, BOOT_READS_GLOBAL, bootEarlyReadsScript, bootEarlyReadsScriptSrc, bootSessionsPath, clearBootReads, takeBootRead, takeBootSessionsRead, withBootFallback } from "../src/lib/boot-early-reads.ts";
|
||
import { chinaLocations } from "../src/data/china-locations.ts";
|
||
import { ensureChinaLocations, loadedChinaCountry, profileNeedsChinaLocations, resetChinaLocationsForTests } from "../src/lib/china-locations-client.ts";
|
||
import { readProfile, selectedBirthPlace } from "../src/lib/home-profile.ts";
|
||
import { HomeOnboardingShellSlot } from "../src/components/home-onboarding-shell-slot.tsx";
|
||
import type { BirthTimeGuidedController } from "../src/hooks/use-birth-time-guided-journey.ts";
|
||
import { loadHomeOnboardingShell } from "../src/lib/home-onboarding-shell-loader.ts";
|
||
import { emptyProfile, presetOnboardingMessage, previewModelCatalog, type Account } from "../src/lib/home-types.ts";
|
||
import { resetRectificationEntrySummaryReadForTests, startRectificationEntrySummaryRead, takeRectificationEntrySummary } from "../src/lib/rectification-entry-summary-read.ts";
|
||
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
|
||
import { loadSessionList, type SessionListBoot } from "../src/lib/session-list-context.tsx";
|
||
import { installWindow } from "./home-bootstrap-fake-timers.ts";
|
||
|
||
// TASK-home-first-load-20260930 T1 (BUG-1127): the cold boot reads the account,
|
||
// the people list and the remembered person's sessions in one round. Synthetic
|
||
// data only.
|
||
Object.assign(globalThis, { React });
|
||
|
||
const ACCOUNT_ID = "11111111-1111-4111-8111-111111111127";
|
||
const OTHER_ID = "33333333-3333-4333-8333-333333333127";
|
||
const account: Account = {
|
||
user: { id: ACCOUNT_ID, email: null },
|
||
profile: { ...emptyProfile, name: "Synthetic" },
|
||
avatar: null, credits: 1, isAdmin: false, adminUrl: null,
|
||
rectificationPriceCredits: 0, activeSubscription: null,
|
||
hasConfirmedBirthTime: false, hasUsableBirthTime: false,
|
||
};
|
||
const otherProfile = { ...emptyProfile, name: "Synthetic other", chartRelationship: "friend" };
|
||
|
||
function sessionRow(id: string, subject: string) {
|
||
return {
|
||
id, title: `Synthetic ${subject}`, theme: "general", modelId: "test", messages: [],
|
||
createdAt: 1, updatedAt: 1, sessionType: "consultation", rectificationCaseId: null,
|
||
chartProfileId: subject === "self" ? null : subject, chartProfileName: null,
|
||
chartProfileRole: subject === "self" ? "self" : "other", pinned: false, archivedAt: null,
|
||
};
|
||
}
|
||
|
||
type Pending = { url: string; resolve: (payload: unknown, status?: number) => void };
|
||
|
||
function installStorage(values: Record<string, string>) {
|
||
const data = new Map(Object.entries(values));
|
||
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||
Object.defineProperty(globalThis, "localStorage", {
|
||
configurable: true,
|
||
value: {
|
||
getItem: (key: string) => data.get(key) ?? null,
|
||
setItem: (key: string, value: string) => { data.set(key, String(value)); },
|
||
removeItem: (key: string) => { data.delete(key); },
|
||
clear: () => data.clear(),
|
||
key: (index: number) => Array.from(data.keys())[index] ?? null,
|
||
get length() { return data.size; },
|
||
} satisfies Storage,
|
||
});
|
||
return () => {
|
||
if (original) Object.defineProperty(globalThis, "localStorage", original);
|
||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||
};
|
||
}
|
||
|
||
/** Every request waits until the test answers it, so "sent in the same round" is observable. */
|
||
function installDeferredFetch() {
|
||
const pending: Pending[] = [];
|
||
const original = globalThis.fetch;
|
||
globalThis.fetch = ((input: RequestInfo | URL) => new Promise<Response>((resolve) => {
|
||
pending.push({ url: String(input), resolve: (payload, status = 200) => resolve(new Response(JSON.stringify(payload), { status })) });
|
||
})) as typeof fetch;
|
||
return { pending, restore: () => { globalThis.fetch = original; } };
|
||
}
|
||
|
||
async function flush() {
|
||
for (let i = 0; i < 20; i += 1) await Promise.resolve();
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
}
|
||
|
||
function answer(pending: Pending[], match: (url: string) => boolean, payload: unknown, status = 200) {
|
||
const index = pending.findIndex((request) => match(request.url));
|
||
assert.notEqual(index, -1, "request not sent");
|
||
const [request] = pending.splice(index, 1);
|
||
request.resolve(payload, status);
|
||
}
|
||
|
||
async function withBoot(storage: Record<string, string>, run: (net: ReturnType<typeof installDeferredFetch>) => Promise<void>) {
|
||
resetSubjectCatalogForTests();
|
||
const restoreStorage = installStorage(storage);
|
||
const net = installDeferredFetch();
|
||
try {
|
||
// The module reads the remembered person when the tab starts; tests replay that.
|
||
const remembered = storage[LAST_SUBJECT_ACCOUNT_KEY];
|
||
if (remembered) bindCurrentSubjectAccount(remembered, null);
|
||
await run(net);
|
||
} finally {
|
||
net.restore();
|
||
restoreStorage();
|
||
resetSubjectCatalogForTests();
|
||
}
|
||
}
|
||
|
||
test("cold boot sends account, people list and sessions in the same round (BUG-1127)", async () => withBoot({}, async ({ pending }) => {
|
||
const boot = loadSessionList(new AbortController().signal);
|
||
await flush();
|
||
// Nothing has been answered yet, and all three reads are already out.
|
||
const sent = pending.map((request) => request.url.split("?")[0]).sort();
|
||
assert.deepEqual(sent, ["/api/account", "/api/chart-profiles", "/api/sessions"]);
|
||
assert.match(pending.find((request) => request.url.startsWith("/api/sessions"))!.url, /subject=self/);
|
||
|
||
answer(pending, (url) => url.startsWith("/api/sessions"), { sessions: [sessionRow("44444444-4444-4444-8444-444444444127", "self")], nextCursor: null });
|
||
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
|
||
answer(pending, (url) => url === "/api/account", account);
|
||
const result = await boot;
|
||
assert.equal(result.subjectId, "self");
|
||
assert.equal(result.account?.user.id, ACCOUNT_ID);
|
||
assert.deepEqual(result.sessions.map((session) => session.id), ["44444444-4444-4444-8444-444444444127"]);
|
||
assert.equal(pending.length, 0, "no second sessions read when the guess held");
|
||
}));
|
||
|
||
test("a remembered person that is no longer in the people list: the guessed list is dropped, self is read (red line 3)", async () => withBoot({
|
||
[LAST_SUBJECT_ACCOUNT_KEY]: ACCOUNT_ID,
|
||
[currentSubjectStorageKey(ACCOUNT_ID)]: OTHER_ID,
|
||
}, async ({ pending }) => {
|
||
assert.equal(readCurrentSubjectId(), OTHER_ID);
|
||
const boot = loadSessionList(new AbortController().signal);
|
||
await flush();
|
||
const guessed = pending.find((request) => request.url.startsWith("/api/sessions"))!;
|
||
assert.match(guessed.url, new RegExp(`subject=${OTHER_ID}`));
|
||
|
||
// The guessed person's list arrives first; it must never become the boot.
|
||
answer(pending, (url) => url === guessed.url, { sessions: [sessionRow("55555555-5555-4555-8555-555555555127", OTHER_ID)], nextCursor: null });
|
||
answer(pending, (url) => url === "/api/account", account);
|
||
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
|
||
await flush();
|
||
assert.equal(readCurrentSubjectId(), "self");
|
||
answer(pending, (url) => url.startsWith("/api/sessions") && url.includes("subject=self"), { sessions: [sessionRow("66666666-6666-4666-8666-666666666127", "self")], nextCursor: null });
|
||
const result = await boot;
|
||
assert.equal(result.subjectId, "self");
|
||
assert.deepEqual(result.sessions.map((session) => session.id), ["66666666-6666-4666-8666-666666666127"]);
|
||
}));
|
||
|
||
test("a remembered person still in the people list keeps the single-round read", async () => withBoot({
|
||
[LAST_SUBJECT_ACCOUNT_KEY]: ACCOUNT_ID,
|
||
[currentSubjectStorageKey(ACCOUNT_ID)]: OTHER_ID,
|
||
}, async ({ pending }) => {
|
||
const boot = loadSessionList(new AbortController().signal);
|
||
await flush();
|
||
answer(pending, (url) => url === "/api/account", account);
|
||
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [{ id: OTHER_ID, role: "other", profile: otherProfile, updated_at: "2026-09-30T00:00:00Z" }] });
|
||
answer(pending, (url) => url.startsWith("/api/sessions") && url.includes(`subject=${OTHER_ID}`), { sessions: [sessionRow("77777777-7777-4777-8777-777777777127", OTHER_ID)], nextCursor: null });
|
||
const result = await boot;
|
||
assert.equal(result.subjectId, OTHER_ID);
|
||
assert.deepEqual(result.sessions.map((session) => session.id), ["77777777-7777-4777-8777-777777777127"]);
|
||
assert.equal(pending.length, 0);
|
||
}));
|
||
|
||
test("another account on this device: the previous account's person and people list are not reused (BUG-1040)", async () => withBoot({
|
||
[LAST_SUBJECT_ACCOUNT_KEY]: "99999999-9999-4999-8999-999999999127",
|
||
[currentSubjectStorageKey("99999999-9999-4999-8999-999999999127")]: OTHER_ID,
|
||
}, async ({ pending }) => {
|
||
const boot = loadSessionList(new AbortController().signal);
|
||
await flush();
|
||
const guessed = pending.find((request) => request.url.startsWith("/api/sessions"))!;
|
||
assert.match(guessed.url, new RegExp(`subject=${OTHER_ID}`), "the guess follows the previous account's choice");
|
||
answer(pending, (url) => url === "/api/account", account);
|
||
// The early people list was read before the account was known: it is dropped and read again.
|
||
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [{ id: OTHER_ID, role: "other", profile: otherProfile, updated_at: "2026-09-30T00:00:00Z" }] });
|
||
answer(pending, (url) => url === guessed.url, { sessions: [sessionRow("55555555-5555-4555-8555-555555555127", OTHER_ID)], nextCursor: null });
|
||
await flush();
|
||
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
|
||
await flush();
|
||
answer(pending, (url) => url.startsWith("/api/sessions") && url.includes("subject=self"), { sessions: [sessionRow("66666666-6666-4666-8666-666666666127", "self")], nextCursor: null });
|
||
const result = await boot;
|
||
assert.equal(result.account?.user.id, ACCOUNT_ID);
|
||
assert.equal(result.subjectId, "self");
|
||
assert.deepEqual(result.sessions.map((session) => session.id), ["66666666-6666-4666-8666-666666666127"]);
|
||
}));
|
||
|
||
test("a signed-out cold boot still reports signed out when every read went out at once", async () => withBoot({}, async ({ pending }) => {
|
||
const boot = loadSessionList(new AbortController().signal);
|
||
await flush();
|
||
answer(pending, (url) => url === "/api/account", {}, 401);
|
||
answer(pending, (url) => url === "/api/chart-profiles", {}, 401);
|
||
answer(pending, (url) => url.startsWith("/api/sessions"), {}, 401);
|
||
const result = await boot;
|
||
assert.equal(result.signedOut, true);
|
||
assert.deepEqual(result.sessions, []);
|
||
}));
|
||
|
||
test("the entry-card summary started with the account reads is taken once, then read fresh (BUG-1127)", async () => {
|
||
resetRectificationEntrySummaryReadForTests();
|
||
let reads = 0;
|
||
const summary = { status: "none" } as never;
|
||
startRectificationEntrySummaryRead(async () => { reads += 1; return summary; });
|
||
assert.equal(reads, 1, "sent with the account round, not after the session list");
|
||
assert.equal(await takeRectificationEntrySummary(async () => { reads += 1; return null; }), summary);
|
||
assert.equal(reads, 1, "the started read is reused");
|
||
assert.equal(await takeRectificationEntrySummary(async () => { reads += 1; return null; }), null);
|
||
assert.equal(reads, 2, "a second take reads fresh");
|
||
startRectificationEntrySummaryRead(async () => { throw new Error("down"); });
|
||
assert.equal(await takeRectificationEntrySummary(), null, "a failed read keeps the default card labels");
|
||
resetRectificationEntrySummaryReadForTests();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T8 (BUG-1127): reads sent by the HTML before the bundle runs.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type BootWindow = { location: { pathname: string }; fetch: (path: string, init: RequestInit) => Promise<Response>; Promise: PromiseConstructor; localStorage: Pick<Storage, "getItem">; [key: string]: unknown };
|
||
|
||
function runBootScript(pathname: string, storage: Record<string, string> = {}) {
|
||
const sent: { path: string; init: RequestInit }[] = [];
|
||
const window: BootWindow = {
|
||
location: { pathname },
|
||
fetch: (path, init) => { sent.push({ path, init }); return Promise.resolve(new Response("{}")); },
|
||
Promise,
|
||
localStorage: { getItem: (key: string) => storage[key] ?? null },
|
||
};
|
||
vm.runInNewContext(bootEarlyReadsScript(), { window, Date, encodeURIComponent });
|
||
return { sent, window };
|
||
}
|
||
|
||
test("the early-read script is classic ES5 and only acts on / (T8)", () => {
|
||
assert.doesNotThrow(() => acorn.parse(bootEarlyReadsScript(), { ecmaVersion: 5, sourceType: "script" }));
|
||
for (const path of ["/chart", "/reports", "/login", "/people", "/admin"]) {
|
||
assert.deepEqual(runBootScript(path).sent, [], `${path} sends nothing`);
|
||
}
|
||
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
|
||
// 原值:assert.match(layout, /__html: bootEarlyReadsScript\(\)/)(内联经典脚本)。
|
||
// 新值:<script async src={bootEarlyReadsScriptSrc()} />,src 是同一段脚本的 base64 data: URL。
|
||
// 原因:TASK-home-first-load-fix-20261001——内联脚本排在样式表之后,要等样式表下完才执行,线上早发推迟 2.4~3.1 s。
|
||
assert.match(layout, /<script async src=\{bootEarlyReadsScriptSrc\(\)\} \/>/);
|
||
const src = bootEarlyReadsScriptSrc();
|
||
assert.match(src, /^data:text\/javascript;base64,[A-Za-z0-9+/=]+$/);
|
||
assert.equal(Buffer.from(src.slice(src.indexOf(",") + 1), "base64").toString("utf8"), bootEarlyReadsScript(), "the data URL carries exactly the ES5 script");
|
||
});
|
||
|
||
test("the early reads are the normal requests, byte for byte (T8)", () => {
|
||
const { sent, window } = runBootScript("/", {
|
||
[LAST_SUBJECT_ACCOUNT_KEY]: ACCOUNT_ID,
|
||
[currentSubjectStorageKey(ACCOUNT_ID)]: OTHER_ID,
|
||
});
|
||
assert.deepEqual(sent.map((request) => request.path).sort(), [BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, bootSessionsPath(OTHER_ID)].sort());
|
||
for (const request of sent) assert.deepEqual({ ...request.init }, { ...BOOT_READ_INIT });
|
||
const reads = window[BOOT_READS_GLOBAL] as { sessionsSubject: string };
|
||
assert.equal(reads.sessionsSubject, OTHER_ID);
|
||
// No remembered account: the sessions read is for 本人.
|
||
assert.ok(runBootScript("/").sent.some((request) => request.path === bootSessionsPath("self")));
|
||
});
|
||
|
||
function withEarlyReads(reads: Record<string, unknown>, run: () => Promise<void>) {
|
||
const host = globalThis as unknown as { window?: unknown };
|
||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||
host.window = globalThis;
|
||
(globalThis as Record<string, unknown>)[BOOT_READS_GLOBAL] = { startedAt: Date.now(), ...reads };
|
||
return run().finally(() => {
|
||
clearBootReads();
|
||
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
|
||
else Reflect.deleteProperty(globalThis, "window");
|
||
});
|
||
}
|
||
|
||
const json = (payload: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(payload), { status }));
|
||
|
||
test("the boot takes each early read once, and the rest of the tab reads normally (T8)", async () => withEarlyReads({ account: json(account), models: json({}) }, async () => {
|
||
assert.ok(takeBootRead("account"));
|
||
assert.equal(takeBootRead("account"), null, "consumed once");
|
||
assert.ok(takeBootRead("models"));
|
||
assert.equal(takeBootRead("chartProfiles"), null, "never sent here");
|
||
}));
|
||
|
||
test("an early sessions read for another person is dropped, not returned (T8)", async () => withEarlyReads({ sessions: json({ sessions: [] }), sessionsSubject: OTHER_ID }, async () => {
|
||
assert.equal(takeBootSessionsRead("self"), null);
|
||
assert.equal(takeBootSessionsRead(OTHER_ID), null, "dropped for good after the mismatch");
|
||
}));
|
||
|
||
test("an early read older than the budget is not trusted (T8)", async () => withEarlyReads({ account: json(account) }, async () => {
|
||
(globalThis as unknown as Record<string, { startedAt: number }>)[BOOT_READS_GLOBAL].startedAt = Date.now() - BOOT_READ_MAX_AGE_MS - 1;
|
||
assert.equal(takeBootRead("account"), null);
|
||
}));
|
||
|
||
test("a failed early read falls back to the normal request (T8)", async () => {
|
||
let fresh = 0;
|
||
const response = await withBootFallback(Promise.reject(new TypeError("network")), async () => { fresh += 1; return new Response("{}"); });
|
||
assert.equal(fresh, 1);
|
||
assert.equal(response.status, 200);
|
||
assert.equal(await withBootFallback(null, async () => { fresh += 1; return new Response("{}", { status: 202 }); }).then((r) => r.status), 202);
|
||
});
|
||
|
||
test("cold boot on / uses the HTML's reads and sends nothing itself (T8)", async () => withBoot({}, async ({ pending }) => withEarlyReads({
|
||
account: json(account),
|
||
chartProfiles: json({ profiles: [] }),
|
||
sessions: json({ sessions: [sessionRow("88888888-8888-4888-8888-888888888127", "self")], nextCursor: null }),
|
||
sessionsSubject: "self",
|
||
}, async () => {
|
||
const result = await loadSessionList(new AbortController().signal);
|
||
assert.equal(pending.length, 0, "no request left the bundle");
|
||
assert.equal(result.account?.user.id, ACCOUNT_ID);
|
||
assert.deepEqual(result.sessions.map((session) => session.id), ["88888888-8888-4888-8888-888888888127"]);
|
||
})));
|
||
|
||
test("an early 401 still signs out, exactly like the normal read (T8)", async () => withBoot({}, async ({ pending }) => withEarlyReads({
|
||
account: json({}, 401),
|
||
chartProfiles: json({}, 401),
|
||
sessions: json({}, 401),
|
||
sessionsSubject: "self",
|
||
}, async () => {
|
||
const result = await loadSessionList(new AbortController().signal);
|
||
assert.equal(result.signedOut, true);
|
||
assert.equal(pending.length, 0);
|
||
})));
|
||
|
||
test("an early read that failed on the network: the boot reads again and still lands (T8)", async () => withBoot({}, async ({ pending }) => withEarlyReads({
|
||
account: Promise.reject(new TypeError("network")),
|
||
chartProfiles: json({ profiles: [] }),
|
||
sessions: json({ sessions: [], nextCursor: null }),
|
||
sessionsSubject: "self",
|
||
}, async () => {
|
||
const boot = loadSessionList(new AbortController().signal);
|
||
await flush();
|
||
answer(pending, (url) => url === "/api/account", account);
|
||
const result = await boot;
|
||
assert.equal(result.account?.user.id, ACCOUNT_ID);
|
||
})));
|
||
|
||
test("another account than the one this device remembered: the early sessions list is discarded (T8, red line 8)", async () => withBoot({
|
||
[LAST_SUBJECT_ACCOUNT_KEY]: "99999999-9999-4999-8999-999999999127",
|
||
}, async ({ pending }) => withEarlyReads({
|
||
account: json(account),
|
||
chartProfiles: json({ profiles: [] }),
|
||
sessions: json({ sessions: [sessionRow("55555555-5555-4555-8555-555555555127", "self")], nextCursor: null }),
|
||
sessionsSubject: "self",
|
||
}, async () => {
|
||
const boot = loadSessionList(new AbortController().signal);
|
||
await flush();
|
||
// The account changed, so the people list is read again and so is the sessions list.
|
||
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
|
||
await flush();
|
||
answer(pending, (url) => url.startsWith("/api/sessions") && url.includes("subject=self"), { sessions: [sessionRow("66666666-6666-4666-8666-666666666127", "self")], nextCursor: null });
|
||
const result = await boot;
|
||
assert.deepEqual(result.sessions.map((session) => session.id), ["66666666-6666-4666-8666-666666666127"]);
|
||
})));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T7 (BUG-1130): the China city table leaves the first-screen bundle.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const legacyPlaces = [
|
||
{ provinceCode: "130000", cityCode: "130100", districtCode: "130102" },
|
||
{ provinceCode: "110000", cityCode: "110000-city", districtCode: "110101" },
|
||
];
|
||
|
||
/** The pre-T7 lookup, written out against the JSON directly. */
|
||
function legacyPlace(codes: { provinceCode: string; cityCode: string; districtCode: string }) {
|
||
const country = chinaLocations.country;
|
||
const province = country.provinces.find((item) => item.code === codes.provinceCode)!;
|
||
const city = province.cities.find((item) => item.code === codes.cityCode)!;
|
||
const district = city.districts.find((item) => item.code === codes.districtCode);
|
||
const location = district ?? city;
|
||
return {
|
||
label: [country.name, province.name, city.name, district?.name].filter((name, index, names) => Boolean(name) && names.indexOf(name) === index).join(" · "),
|
||
lat: location.center[1], lon: location.center[0], tz: country.timezone, timezoneId: "Asia/Shanghai",
|
||
};
|
||
}
|
||
|
||
test("an old profile with only province / city codes resolves exactly as before once the table is loaded (T7)", async () => {
|
||
resetChinaLocationsForTests();
|
||
for (const codes of legacyPlaces) {
|
||
const profile = { ...emptyProfile, ...codes };
|
||
assert.equal(profileNeedsChinaLocations(profile), true);
|
||
assert.equal(selectedBirthPlace(profile), null, "not resolvable before the table is loaded");
|
||
}
|
||
await ensureChinaLocations();
|
||
for (const codes of legacyPlaces) {
|
||
assert.deepEqual(selectedBirthPlace({ ...emptyProfile, ...codes }), legacyPlace(codes));
|
||
}
|
||
// A profile saved with coordinates never asks for the table.
|
||
assert.equal(profileNeedsChinaLocations({ ...emptyProfile, provinceCode: "130000", birthPlaceLabel: "中国 · 河北省", latitude: 38, longitude: 114.5, timezoneId: "Asia/Shanghai", timezoneOffset: 8 }), false);
|
||
resetChinaLocationsForTests();
|
||
});
|
||
|
||
test("cold boot loads the table before the people list settles when the account profile is an old one (T7)", async () => withBoot({}, async ({ pending }) => {
|
||
resetChinaLocationsForTests();
|
||
const legacyAccount: Account = {
|
||
...account,
|
||
profile: { name: "Synthetic", birth_date: "1991-04-07", birth_time: "09:15", province_code: "130000", city_code: "130100", district_code: "130102" },
|
||
};
|
||
const readAccount = readProfile(legacyAccount.profile);
|
||
assert.equal(readAccount.provinceCode, "130000", "fixture uses the stored field names");
|
||
const boot = loadSessionList(new AbortController().signal);
|
||
await flush();
|
||
answer(pending, (url) => url === "/api/account", legacyAccount);
|
||
answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] });
|
||
answer(pending, (url) => url.startsWith("/api/sessions"), { sessions: [], nextCursor: null });
|
||
await boot;
|
||
assert.ok(loadedChinaCountry(), "table loaded during boot, before anything reads the profile");
|
||
assert.deepEqual(selectedBirthPlace(readAccount), legacyPlace(legacyPlaces[0]));
|
||
resetChinaLocationsForTests();
|
||
}));
|
||
|
||
test("the home first screen no longer imports the city table by value (T7)", () => {
|
||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||
for (const file of ["../src/lib/home-types.ts", "../src/lib/home-profile.ts", "../src/lib/rectification-agentic/v9/confirmation-gate.ts"]) {
|
||
assert.doesNotMatch(read(file), /^import (?!type)[^;]*(data\/china-locations|rectification_sealed_holdout)/m, file);
|
||
}
|
||
});
|
||
|
||
test("an incomplete profile waits for the onboarding shell before reveal; a complete one does not load it (T7)", async () => {
|
||
installWindow();
|
||
const restoreStorage = installStorage({});
|
||
for (const complete of [false, true]) {
|
||
let release!: () => void;
|
||
const preload = new Promise<void>((resolve) => { release = resolve; });
|
||
let preloads = 0;
|
||
const phases: string[] = [];
|
||
const profile = complete
|
||
? { ...emptyProfile, name: "Synthetic", date: "1991-04-07", time: "09:15", birthTimeStatus: "confirmed", birthTimeSource: "remembered", birthPlaceLabel: "虚构市", latitude: 30.57, longitude: 104.07, timezoneId: "Asia/Shanghai", timezoneOffset: 8 }
|
||
: { ...emptyProfile, name: "Synthetic" };
|
||
const boot: SessionListBoot = { sessions: [], rawRows: [], cursor: null, account: { ...account, profile }, signedOut: false };
|
||
const storage = new Map<string, string>();
|
||
const noop = () => undefined;
|
||
const deps: HomeBootstrapDeps = {
|
||
sessionListReady: Promise.resolve(),
|
||
sessionListBoot: () => boot,
|
||
uiPreview: { current: false }, uiPreviewMode: { current: null },
|
||
consultationStatusMissingCount: { current: 0 },
|
||
isDevelopment: false,
|
||
readSearch: () => "",
|
||
storage: { getItem: (key: string) => storage.get(key) ?? null, removeItem: (key: string) => { storage.delete(key); } },
|
||
restoreConsultationRecovery: noop,
|
||
setComposerNotice: noop,
|
||
commit: {
|
||
setAccount: noop, setAccountError: (message) => { if (message) phases.push(`error:${message}`); }, setActiveSessionId: noop, setBirthTimeAssessmentPhase: noop,
|
||
setBirthTimeJourney: noop, setBootstrapPhase: (phase) => { phases.push(phase); }, setGuidedJourneyPreview: noop,
|
||
setHydrated: noop, setModelCatalog: noop, setOnboardingStep: noop, setProfile: noop, setProfileDraft: noop,
|
||
setSessions: noop, setSessionsCursor: noop, setStartGreeting: noop,
|
||
},
|
||
io: {
|
||
fetchModelCatalog: async () => previewModelCatalog,
|
||
fetchActiveConsultationStatus: async () => null,
|
||
startEntrySummaryRead: noop,
|
||
preloadOnboardingShell: () => { preloads += 1; return preload; },
|
||
resolveLookupBootstrap: async (input) => ({ selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false }, sessions: input.sessions, notice: null }),
|
||
},
|
||
};
|
||
const run = runHomeBootstrap(deps, new AbortController().signal);
|
||
await flush();
|
||
if (complete) {
|
||
assert.equal(preloads, 0, "a complete profile never loads the onboarding shell");
|
||
} else {
|
||
assert.equal(preloads, 1);
|
||
assert.deepEqual(phases, [], "no reveal step before the shell is there");
|
||
release();
|
||
}
|
||
await run;
|
||
assert.deepEqual(phases, ["prepare"]);
|
||
}
|
||
restoreStorage();
|
||
});
|
||
|
||
test("a preloaded onboarding shell renders in the reveal frame, not behind an empty fallback (T7)", async () => {
|
||
await loadHomeOnboardingShell();
|
||
const markup = renderToStaticMarkup(
|
||
<HomeOnboardingShellSlot
|
||
profileComplete={false}
|
||
profileDraft={emptyProfile}
|
||
setProfileDraft={() => undefined}
|
||
onboardingStep="name"
|
||
presetMessageLength={presetOnboardingMessage.length}
|
||
currentOnboardingMessage={presetOnboardingMessage}
|
||
onboardingJustCompleted={false}
|
||
onboardingCardReady={false}
|
||
presetMessageFinished
|
||
birthTimeJourney={null}
|
||
birthTimeAssessmentPhase={null}
|
||
birthTimeError=""
|
||
birthTimeGuided={{} as BirthTimeGuidedController}
|
||
profileSaving={false}
|
||
saveOnboardingBirth={() => undefined}
|
||
saveOnboardingPlace={() => undefined}
|
||
retryBirthTimeAssessment={() => undefined}
|
||
/>,
|
||
);
|
||
assert.match(markup, /你好,我是 Jyotisha/);
|
||
});
|