From 06d0c7dc3782c838f4cc87c65f92dce161a097a8 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 28 Oct 2026 12:11:03 +0800 Subject: [PATCH] Cold /chart, /people, /synastry read in one round and stop re-reading the shell's data (issue #127, BUG-1478) - The HTML early-read script also runs on /chart, /people and /synastry: account, people list and the current person's sessions, plus on /chart the remembered person's chart-view (bound to that person, page deadline kept). The model list stays home-only. - Account and people-list early reads hand one clone to the shell and one to the page, once each, in either order; later reloads go to the network. - Synastry sends product-access, account, people and history together; access is still decided before anything about people paints. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + docs/BUG_HISTORY.md | 16 ++ docs/testing/code-health-118-20261028.md | 6 + .../src/components/people/people-page.tsx | 7 +- .../components/synastry/synastry-picker.tsx | 33 +++- frontend/src/lib/boot-early-reads.ts | 69 +++++++- frontend/src/lib/chart-view-client.ts | 17 +- frontend/src/lib/home-cloud-sync.ts | 5 +- frontend/src/lib/secondary-page-data.ts | 4 +- .../tests/home-first-load-20260930.test.tsx | 7 +- ...condary-page-early-reads-20261028.test.tsx | 162 ++++++++++++++++++ frontend/tests/synastry-full-report.test.ts | 5 +- 12 files changed, 314 insertions(+), 23 deletions(-) create mode 100644 frontend/tests/secondary-page-early-reads-20261028.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c39e8a..02122753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 星盘页、档案页、合盘页打开更快 + +- 直接打开或刷新星盘页时,账户、人物和这张盘在网页一到就同时开始读取,不再等页面代码跑完、账户回来以后才开始算盘。 +- 档案页、合盘页不再把账户和人物列表各读两遍;合盘页几条互不相关的读取改为同时发出。 +- 看到的内容不变。Skill 版本不变(BUG-1478,issue #127)。 + ## 2026-10-28 — 用链接打开对话更快 - 从分享链接、侧栏历史或刷新打开某条对话时,对话内容和账户同时开始读取,少等一轮(手机上约 0.15~0.4 秒)。打开别人的或已删除的对话,提示和以前一样。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5f018275..76b65184 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20171,3 +20171,19 @@ - 关联:BUG-1127(早读脚本)、BUG-1040(换账户丢弃早读)、BUG-1474(`?c=` 揭幕前等排版组件)。 - 复发自:无 - 修复版本:分支 `codex/home-early-session-detail-20261028`。 + +## BUG-1478 | 星盘、档案、合盘页冷开:代码 → 账户 → 页面主读串行,档案和合盘页还重复读账户和人物列表 + +- 状态:resolved(代码 + 回归测试;待部署) +- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 18 条 → #127) +- 影响面:冷开 `/chart`、`/people`、`/synastry`;`frontend/src/lib/boot-early-reads.ts`、`lib/chart-view-client.ts`、`lib/secondary-page-data.ts`、`lib/home-cloud-sync.ts`(`fetchAccount`)、`components/people/people-page.tsx`、`components/synastry/synastry-picker.tsx`。 +- 现象:① `/chart` 要等页面代码下载执行完才去读账户,账户回来了才去算星盘(`/api/chart-view` 是重计算);② `/people`、`/synastry` 外壳已经读了账户和人物列表,页面自己又各读一遍;③ 合盘页先等开关(`/api/product-access`)回来,再读账户和人物,最后再读历史报告,三轮串行。 +- 触发条件:刷新或直接打开这三个页面。 +- 根因:BUG-1127 的早读脚本只在 `/` 发;页面和外壳各自取数,互不共享;合盘页把互不依赖的请求写成了先后 `await`。 +- 修复:早读脚本扩到 `/chart`、`/people`、`/synastry`:同一轮发账户、人物列表、当前人物的会话三条(外壳要用);`/chart` 再按本机记住的人物发一条与页面首读完全相同的 `/api/chart-view?subject=…`,按人物绑定,人物不对就丢弃(与会话猜测同一条红线),首读仍受页面原来的超时约束,账户对不上照旧由盘面身份校验重读;模型目录只在首页读。账户和人物列表这两条早读改为「外壳一份、页面一份」,各拿一个 `clone()`,谁先谁后都行,各拿一次;之后页面里改完资料的重新读取照常发新请求,不会拿到旧数据。合盘页的开关、账户、人物、历史报告四条同时发出,开关没回来之前照旧不画选人区。 +- 验证:新测试 `frontend/tests/secondary-page-early-reads-20261028.test.tsx`:冷开 `/chart` 一次同步执行发出账户、人物、会话、星盘四条,星盘这条的路径、缓存选项、请求头与页面自己发的一致;`/people`、`/synastry` 只发三条,不读模型目录;外壳和页面各读一次账户、先后顺序两种都对,之后的重新读取走网络;星盘早读只给对应人物;早读挂住时页面超时照样生效;合盘页四条请求在任何一条返回前都已发出(改前只发出开关一条)。`home-first-load-20260930`「早读只在 / 发」改为「只在 / 和这三页发,其余页面不发」,`synastry-full-report` 一处断言改认「开关判定仍在用到账户之前」。全量前端测试与 staging 逐条比对无新增失败;tsc 0、lint 0。`next build` 后 `/` 仍 ○ Static;首屏 gzip-9 合计 `/` +397 B、`/chart` +379 B(+0.07%)。 +- 冷开请求数(代码路径):`/people` 改前外壳 3 条 + 页面 2 条,改后 HTML 3 条;`/synastry` 改前 3 + 开关 1 + 账户人物 2 + 历史 1,共三轮,改后 HTML 3 条 + 页面 2 条,一轮;`/chart` 改前代码 → 账户 → 星盘三段串行,改后四条都在 HTML 阶段发出。 +- 防复发:上述测试。页面和外壳共用一份启动读取,互不依赖的请求同时发。 +- 关联:BUG-1127(首页早读)、BUG-1477(`?c=` 早读)、BUG-1403(合盘开关未返回时不闪选人页)、BUG-1469(#120 次级页首屏)。 +- 复发自:无 +- 修复版本:分支 `codex/secondary-page-early-reads-20261028`。 diff --git a/docs/testing/code-health-118-20261028.md b/docs/testing/code-health-118-20261028.md index 3b26a09f..38cd3589 100644 --- a/docs/testing/code-health-118-20261028.md +++ b/docs/testing/code-health-118-20261028.md @@ -40,3 +40,9 @@ 19. 复制一条自己对话的链接(地址栏里带 `?c=`),新开一个无痕窗口登录后直接打开:对话一次出现、内容齐全。 20. 打开一条已删除的对话链接:仍提示「该对话不存在或已被删除」,停在空白首页。 +## #127 星盘、档案、合盘页冷开更快(BUG-1478) + +21. 在星盘页按刷新:盘照常出来,人物是上次看的那一位;切到另一个人再刷新,出来的是这个人的盘,不会先闪一下上一个人的。 +22. 在档案页按刷新:列表和以前一样;改一个人的资料、添加一个人后,列表马上是新的。 +23. 在合盘页按刷新:选人区、余额、历史报告照常;合盘服务关着时仍只显示「合盘服务暂未开放」,不闪选人区。 + diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index 4132d124..789ddb8c 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -9,6 +9,7 @@ import { effectiveBirthDate } from "@/lib/effective-birth-date"; import { personRelationLabel } from "@/lib/person-relation"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; +import { takeBootReadForPage, withBootFallback } from "@/lib/boot-early-reads"; import { fetchAccount } from "@/lib/home-cloud-sync"; import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { saveOtherProfile } from "@/lib/other-profile-save"; @@ -130,9 +131,11 @@ export function PeoplePage() { async function load() { setStatus("loading"); try { + // A cold open takes the HTML's account and people list; after that the + // copies are gone and a reload after an edit reads fresh (issue #127). const [account, response] = await Promise.all([ - fetchAccount(), - fetch("/api/chart-profiles", { cache: "no-store" }), + fetchAccount(undefined, takeBootReadForPage("account")), + withBootFallback(takeBootReadForPage("chartProfiles"), () => fetch("/api/chart-profiles", { cache: "no-store" })), ]); if (!response.ok) throw new Error("archive_unavailable"); const payload = await response.json() as { diff --git a/frontend/src/components/synastry/synastry-picker.tsx b/frontend/src/components/synastry/synastry-picker.tsx index 25a2a6d8..e630ad72 100644 --- a/frontend/src/components/synastry/synastry-picker.tsx +++ b/frontend/src/components/synastry/synastry-picker.tsx @@ -8,6 +8,7 @@ import { emptySynastryDraft, SynastryAddDialog } from "@/components/synastry/syn import { SecondaryHeader } from "@/components/secondary-header"; import { accountDialogHref } from "@/lib/account-dialog-intent"; import { effectiveBirthDate } from "@/lib/effective-birth-date"; +import { takeBootReadForPage, withBootFallback } from "@/lib/boot-early-reads"; import { fetchAccount } from "@/lib/home-cloud-sync"; import type { Profile } from "@/lib/home-types"; import { missingOtherProfileStep, readProfile } from "@/lib/home-profile"; @@ -45,6 +46,17 @@ export function synastryChargeCopy(creditCost: number, balance: number): { short return { short: false, text: `会用掉 ${creditCost} 点,余额 ${balance} 点。` }; } +type Settled = { ok: true; value: T } | { ok: false; error: unknown }; + +function settled(promise: Promise): Promise> { + return promise.then((value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error })); +} + +function unwrap(result: Settled): T { + if (!result.ok) throw result.error; + return result.value; +} + export function SynastryPicker() { const [people, setPeople] = useState([]); const [left, setLeft] = useState("self"); @@ -63,13 +75,18 @@ export function SynastryPicker() { const [confirming, setConfirming] = useState(false); async function load(prefer?: { id: string; slot: Slot }) { - let priceResponse: Response; - try { - priceResponse = await fetch("/api/product-access", { cache: "no-store" }); - } catch { + // The four reads are independent, so they go out together (issue #127); + // a cold open takes the HTML's account and people list instead of asking again. + const priceRead = settled(fetch("/api/product-access", { cache: "no-store" })); + const accountRead = settled(fetchAccount(undefined, takeBootReadForPage("account"))); + const profilesRead = settled(withBootFallback(takeBootReadForPage("chartProfiles"), () => fetch("/api/chart-profiles", { cache: "no-store" }))); + const historyRead = settled(fetch("/api/reports?reportType=synastry_full&limit=10", { cache: "no-store" })); + const priceResult = await priceRead; + if (!priceResult.ok) { setAccess("unreachable"); return; } + const priceResponse = priceResult.value; const price = await priceResponse.json().catch(() => null) as { compatibility?: boolean; synastryCreditCost?: number | null } | null; if (priceResponse.ok && price?.compatibility === false) { setAccess("closed"); @@ -87,10 +104,8 @@ export function SynastryPicker() { setCreditCost(null); setPricingNote("计费配置不可用"); } - const [account, response] = await Promise.all([ - fetchAccount(), - fetch("/api/chart-profiles", { cache: "no-store" }), - ]); + const account = unwrap(await accountRead); + const response = unwrap(await profilesRead); setBalance(typeof account.credits === "number" ? account.credits : null); if (!response.ok) throw new Error("archive_unavailable"); const payload = await response.json() as { profiles?: Array<{ id?: string; profile?: unknown }> }; @@ -112,7 +127,7 @@ export function SynastryPicker() { const wantedPerson = rows.find((person) => person.id === wanted); if (wantedPerson && !wantedPerson.incomplete) setRight(wanted); } - const historyResponse = await fetch("/api/reports?reportType=synastry_full&limit=10", { cache: "no-store" }); + const historyResponse = unwrap(await historyRead); const history = await historyResponse.json().catch(() => null) as { reports?: Earlier[] } | null; setEarlier(Array.isArray(history?.reports) ? history.reports.filter((report) => report.id) : []); } diff --git a/frontend/src/lib/boot-early-reads.ts b/frontend/src/lib/boot-early-reads.ts index 7d67b3d3..c98c24e1 100644 --- a/frontend/src/lib/boot-early-reads.ts +++ b/frontend/src/lib/boot-early-reads.ts @@ -32,6 +32,15 @@ export const CURRENT_SUBJECT_KEY_PREFIX = "jyotisha_current_subject:"; /** An early read older than this is not trusted; the boot reads fresh instead. */ export const BOOT_READ_MAX_AGE_MS = 30_000; +/** Pages the HTML reads for (issue #126 added `?c=`, #127 the three below). */ +export const BOOT_READ_PAGES = ["/", "/chart", "/people", "/synastry"] as const; + +/** Same request `fetchChartView` makes for the chart page's first read. */ +export function bootChartViewPath(subjectId: string): string { + return `/api/chart-view?subject=${encodeURIComponent(subjectId)}`; +} +export const BOOT_CHART_VIEW_HEADERS = { Accept: "application/json" } as const; + /** Same path `fetchSessionDetail` and `lookupSessionById` read. */ export function bootSessionDetailPath(sessionId: string): string { return `/api/sessions/${encodeURIComponent(sessionId)}`; @@ -50,9 +59,14 @@ type BootReads = { sessionsSubject?: string; sessionDetail?: Promise; sessionDetailId?: string; + chartView?: Promise; + chartViewSubject?: string; + /** Which takers already had their copy of a shared read (issue #127). */ + taken?: Partial>; }; type BootReadKey = "account" | "chartProfiles" | "models"; +type SharedBootReadKey = "account" | "chartProfiles"; function js(value: string): string { return JSON.stringify(value).replace(/)[BOOT_READS_GLOBAL]; } +/** + * A copy of a shared early read for one taker, once. The shell and the page + * each get their own `clone()`, in either order, so a cold `/people` or + * `/synastry` does not read the account and people list twice (issue #127). + * The original response is never read itself, so every clone has its body. + */ +function takeCopy(key: SharedBootReadKey, taker: "shell" | "page"): Promise | null { + const reads = bootReads(); + const read = reads?.[key]; + if (!reads || !read) return null; + const taken = (reads.taken ??= {}); + if (taken[`${key}:${taker}`]) return null; + taken[`${key}:${taker}`] = true; + if (taken[`${key}:shell`] && taken[`${key}:page`]) delete reads[key]; + return read.then((response) => response.clone()); +} + /** The early read for `key`, once; later calls get null. */ export function takeBootRead(key: BootReadKey): Promise | null { + if (key !== "models") return takeCopy(key, "shell"); const reads = bootReads(); const read = reads?.[key]; if (!reads || !read) return null; @@ -131,6 +171,25 @@ export function takeBootRead(key: BootReadKey): Promise | null { return read; } +/** The page's own copy of the account or people-list early read, once (issue #127). */ +export function takeBootReadForPage(key: SharedBootReadKey): Promise | null { + return takeCopy(key, "page"); +} + +/** + * The chart page's early chart read, once, and only for `subjectId`; a read + * for another person is dropped (same red line as the sessions guess). + */ +export function takeBootChartViewRead(subjectId: string): Promise | null { + const reads = bootReads(); + if (!reads?.chartView) return null; + const read = reads.chartView; + const asked = reads.chartViewSubject; + delete reads.chartView; + delete reads.chartViewSubject; + return asked === subjectId ? read : null; +} + /** * The early sessions read, once, and only if it asked for `subjectId`. A read * for another person is dropped, never returned (task red line 3). diff --git a/frontend/src/lib/chart-view-client.ts b/frontend/src/lib/chart-view-client.ts index 4f0fe6a5..01af3f00 100644 --- a/frontend/src/lib/chart-view-client.ts +++ b/frontend/src/lib/chart-view-client.ts @@ -1,3 +1,4 @@ +import { withBootFallback } from "./boot-early-reads.ts"; import { chartViewResponseSchema, type ChartViewResponse } from "./chart-view-contract.ts"; import type { ChartViewLayer } from "./chart-view-engine.ts"; import { chartViewFailureResponse } from "./chart-view-failure.ts"; @@ -8,6 +9,8 @@ export async function fetchChartView(input: { subjectId?: string; /** Transit layer day (YYYY-MM-DD); the server uses today when absent. */ transitDate?: string; + /** The HTML-sent first read of a cold `/chart` (issue #127), taken by the caller. */ + early?: Promise | null; }): Promise<{ httpStatus: number; body: ChartViewResponse | null }> { const params = new URLSearchParams(); if (input.layers?.length) params.set("layers", input.layers.join(",")); @@ -15,12 +18,13 @@ export async function fetchChartView(input: { if (input.transitDate) params.set("transitDate", input.transitDate); const serialized = params.toString(); const query = serialized ? `?${serialized}` : ""; - const response = await fetch(`/api/chart-view${query}`, { + const fresh = () => fetch(`/api/chart-view${query}`, { credentials: "same-origin", headers: { Accept: "application/json" }, cache: "no-store", signal: input.signal, }); + const response = await withBootFallback(input.early ? untilAborted(input.early, input.signal) : null, fresh); // Authentication is known from the headers; a missing body must not keep // a signed-out reader waiting for the JSON deadline. if (response.status === 401) return { httpStatus: 401, body: null }; @@ -41,3 +45,14 @@ export async function fetchChartView(input: { } return { httpStatus: response.status, body: parsed.data }; } + +/** The early read was sent without the page's deadline; the deadline still applies. */ +function untilAborted(early: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return early; + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + early.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); + }); +} diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index 0677f921..875dd8ee 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -480,8 +480,9 @@ export function waitForUndoWindow(signal: AbortSignal) { }); } -export async function fetchAccount(signal?: AbortSignal): Promise { - const response = await fetch("/api/account", { signal, cache: "no-store" }); +/** `early`: the HTML-sent cold-boot read, taken by the caller (boot-early-reads.ts). */ +export async function fetchAccount(signal?: AbortSignal, early: Promise | null = null): Promise { + const response = await withBootFallback(early, () => fetch("/api/account", { signal, cache: "no-store" })); if (response.status === 401) redirectToLogin(); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取账户信息")); diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index 41220d2f..28111eb5 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -1,3 +1,4 @@ +import { takeBootChartViewRead } from "@/lib/boot-early-reads"; import { fetchChartView } from "@/lib/chart-view-client"; import { currentSubjectCatalogReady, readCurrentSubjectAccountId, readCurrentSubjectId } from "@/lib/current-subject"; import type { ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract"; @@ -205,7 +206,8 @@ async function loadChartSnapshot(started: number, signal: AbortSignal): Promise< }; chartSlot = null; } - const first = snapshotFromChartResult(await fetchChartView({ signal, subjectId })); + // A cold `/chart` sent this read from the HTML for the remembered person (issue #127). + const first = snapshotFromChartResult(await fetchChartView({ signal, subjectId, early: takeBootChartViewRead(subjectId) })); if (readCurrentSubjectId() !== subjectId) return refreshChartPage(); if (started !== chartEpoch) return refreshChartPage(); let snapshot = first; diff --git a/frontend/tests/home-first-load-20260930.test.tsx b/frontend/tests/home-first-load-20260930.test.tsx index b9d5543a..d7df9c31 100644 --- a/frontend/tests/home-first-load-20260930.test.tsx +++ b/frontend/tests/home-first-load-20260930.test.tsx @@ -227,9 +227,12 @@ function runBootScript(pathname: string, storage: Record = {}) { return { sent, window }; } -test("the early-read script is classic ES5 and only acts on / (T8)", () => { +test("the early-read script is classic ES5 and only acts on / and the pages it reads for (T8)", () => { assert.doesNotThrow(() => acorn.parse(bootEarlyReadsScript(), { ecmaVersion: 5, sourceType: "script" })); - for (const path of ["/chart", "/reports", "/login", "/people", "/admin"]) { + // 原值:除 / 以外的页面(含 /chart、/people)一律不发早读 + // 新值:/chart、/people、/synastry 也发账户、人物、会话三条(/chart 再加一条记住的人物的盘);其他页面仍不发 + // 原因:issue #127 次级页冷开去掉「代码 → 账户 → 页面主读」的串行 + for (const path of ["/reports", "/login", "/admin", "/ephemeris"]) { assert.deepEqual(runBootScript(path).sent, [], `${path} sends nothing`); } const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); diff --git a/frontend/tests/secondary-page-early-reads-20261028.test.tsx b/frontend/tests/secondary-page-early-reads-20261028.test.tsx new file mode 100644 index 00000000..78c3c4ac --- /dev/null +++ b/frontend/tests/secondary-page-early-reads-20261028.test.tsx @@ -0,0 +1,162 @@ +/** + * Issue #127: cold `/chart`, `/people`, `/synastry` read their account, people + * list and (on /chart) the remembered person's chart from the HTML, and the + * page no longer asks again for what the shell already has. + */ +import "./install-test-document"; +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import React, { createElement } from "react"; + +import { SynastryPicker } from "../src/components/synastry/synastry-picker.tsx"; +import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; +import { + BOOT_ACCOUNT_PATH, + BOOT_CHART_PROFILES_PATH, + BOOT_CHART_VIEW_HEADERS, + BOOT_MODELS_PATH, + BOOT_READ_INIT, + BOOT_READS_GLOBAL, + bootChartViewPath, + bootEarlyReadsScript, + bootSessionsPath, + clearBootReads, + takeBootChartViewRead, + takeBootRead, + takeBootReadForPage, +} from "../src/lib/boot-early-reads.ts"; +import { fetchChartView } from "../src/lib/chart-view-client.ts"; +import { fetchAccount } from "../src/lib/home-cloud-sync.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +Object.assign(globalThis, { React }); + +const PERSON = "33333333-3333-4333-8333-333333333127"; +const ACCOUNT = "11111111-1111-4111-8111-111111111127"; + +function runBootScript(pathname: string, storage: Record = {}) { + const sent: { path: string; init: RequestInit }[] = []; + const window: Record = { + location: { pathname, search: "" }, + fetch: (path: string, init: RequestInit) => { 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, reads: window[BOOT_READS_GLOBAL] as { chartViewSubject?: string } }; +} + +test("cold /chart sends account, people, sessions and the remembered person's chart in one round", () => { + const remembered = { jyotisha_current_subject_account: ACCOUNT, [`jyotisha_current_subject:${ACCOUNT}`]: PERSON }; + const { sent, reads } = runBootScript("/chart", remembered); + assert.deepEqual(sent.map((request) => request.path).sort(), [ + BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, bootSessionsPath(PERSON), bootChartViewPath(PERSON), + ].sort()); + assert.equal(reads.chartViewSubject, PERSON); + const chart = sent.find((request) => request.path === bootChartViewPath(PERSON))!; + // The init object comes from the script's own realm; compare its values. + assert.deepEqual(JSON.parse(JSON.stringify(chart.init)), { ...BOOT_READ_INIT, headers: BOOT_CHART_VIEW_HEADERS }); + assert.ok(!sent.some((request) => request.path === BOOT_MODELS_PATH), "only the home reads the model list"); +}); + +test("cold /people and /synastry send the shell's three reads and nothing else", () => { + for (const page of ["/people", "/synastry"]) { + const { sent } = runBootScript(page); + assert.deepEqual(sent.map((request) => request.path).sort(), [BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, bootSessionsPath("self")].sort(), page); + } +}); + +test("the chart page's early read is the request the page would make itself", async () => { + const seen: { url: string; init?: RequestInit }[] = []; + const original = globalThis.fetch; + globalThis.fetch = (async (url: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(url), init }); + return new Response("{}", { status: 503 }); + }) as typeof fetch; + try { + await fetchChartView({ subjectId: PERSON }); + } finally { + globalThis.fetch = original; + } + assert.equal(seen[0].url, bootChartViewPath(PERSON)); + assert.deepEqual(seen[0].init?.headers, BOOT_CHART_VIEW_HEADERS); + assert.equal(seen[0].init?.cache, BOOT_READ_INIT.cache); +}); + +function withEarlyReads(reads: Record, run: (fetched: string[]) => Promise) { + const host = globalThis as unknown as Record; + const originals = { window: host.window, fetch: globalThis.fetch }; + const fetched: string[] = []; + host.window = globalThis; + globalThis.fetch = (async (input: RequestInfo | URL) => { fetched.push(String(input)); return new Response("{}", { status: 500 }); }) as typeof fetch; + host[BOOT_READS_GLOBAL] = { startedAt: Date.now(), ...reads }; + return run(fetched).finally(() => { + clearBootReads(); + globalThis.fetch = originals.fetch; + if (originals.window === undefined) Reflect.deleteProperty(host, "window"); else host.window = originals.window; + }); +} + +const json = (payload: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(payload), { status })); +const account = { user: { id: ACCOUNT, email: null }, profile: { name: "合成" }, credits: 4 }; + +test("the shell and the page each read the account once from one request, in either order", async () => { + for (const pageFirst of [true, false]) { + await withEarlyReads({ account: json(account), chartProfiles: json({ profiles: [] }) }, async (fetched) => { + const page = () => fetchAccount(undefined, takeBootReadForPage("account")); + const shell = async () => (await takeBootRead("account"))!.json(); + const [a, b] = pageFirst ? [await page(), await shell()] : [await shell(), await page()]; + assert.equal((a as typeof account).user.id, ACCOUNT); + assert.equal((b as typeof account).user.id, ACCOUNT); + assert.equal(takeBootRead("account"), null, "the shell's copy is taken once"); + assert.equal(takeBootReadForPage("account"), null, "the page's copy is taken once"); + // A later reload (after an edit) reads fresh, as before. + await assert.rejects(fetchAccount(undefined, takeBootReadForPage("account"))); + assert.deepEqual(fetched, ["/api/account"]); + }); + } +}); + +test("the chart read is only for the person it guessed", async () => withEarlyReads( + { chartView: json({ status: "ok" }), chartViewSubject: PERSON }, async () => { + assert.equal(takeBootChartViewRead("self"), null, "another person gets nothing"); + assert.equal(takeBootChartViewRead(PERSON), null, "and the guess is dropped for good"); + }, +)); + +test("an early chart read still honours the page's deadline", async () => { + const controller = new AbortController(); + const never = new Promise(() => undefined); + const pending = fetchChartView({ subjectId: PERSON, signal: controller.signal, early: never }); + const original = globalThis.fetch; + globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => { + if (init?.signal?.aborted) throw init.signal.reason; + return new Response("{}"); + }) as typeof fetch; + try { + controller.abort(new DOMException("deadline", "TimeoutError")); + await assert.rejects(pending, (error: unknown) => (error as DOMException).name === "TimeoutError"); + } finally { + globalThis.fetch = original; + } +}); + +test("the synastry page sends its four reads in one round", async () => { + const harness = createClientLifecycleHarness(); + const original = globalThis.fetch; + const pending: string[] = []; + globalThis.fetch = ((input: RequestInfo | URL) => { + pending.push(String(input)); + return new Promise(() => undefined); + }) as typeof fetch; + try { + await harness.render(createElement(SidebarProvider, null, createElement(SynastryPicker))); + for (let tick = 0; tick < 5; tick += 1) await harness.idle(); + const reads = ["/api/product-access", "/api/account", "/api/chart-profiles", "/api/reports?reportType=synastry_full&limit=10"]; + for (const path of reads) assert.ok(pending.includes(path), `${path} goes out before any answer (got ${pending.join(", ")})`); + } finally { + await harness.close(); + globalThis.fetch = original; + } +}); diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts index 2721546a..796bcc20 100644 --- a/frontend/tests/synastry-full-report.test.ts +++ b/frontend/tests/synastry-full-report.test.ts @@ -164,7 +164,10 @@ test("the synastry page is a static shell and reads the flag in the browser", () assert.match(picker, /compatibility === false/); assert.match(picker, /合盘服务暂未开放/); assert.match(picker, /合盘服务暂时连不上,请稍后再试/); - assert.ok(picker.indexOf('setAccess("closed")') < picker.indexOf("fetchAccount()")); + // 原值:账户在开关结果之后才去读(setAccess("closed") 早于 fetchAccount()) + // 新值:账户与开关同时去读,但开关判定仍在用到账户之前,关着时照旧不画选人区 + // 原因:issue #127 合盘页几条互不依赖的请求同一轮发出 + assert.ok(picker.indexOf('setAccess("closed")') < picker.indexOf("unwrap(await accountRead)")); const pending = renderToStaticMarkup(createElement(SidebarProvider, null, createElement(SynastryPicker))); // 原值:开关未返回时静态 HTML 有副标题「看这段感情往后的走向,和两人之间的助力与考验。」 // 新值:这段副标题不在页面上;未返回时仍不出现「开始合盘」和「第一个人」