Validate on Linux Node 22 and PostgreSQL 17: 3894 frontend tests and 64 database tests pass, with no removed test names or new failures. Preserve static Home, bounded gzip, assertion-change records and manual acceptance gaps. Co-Authored-By: Claude Code <noreply@anthropic.com>
214 lines
9.4 KiB
TypeScript
214 lines
9.4 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import React from "react";
|
|
import test from "node:test";
|
|
|
|
import { bindCurrentSubjectAccount, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
|
|
import { EphemerisPage } from "../src/components/ephemeris/ephemeris-page.tsx";
|
|
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
|
import { EPHEMERIS_COPY, panchangaFactorsFromEngine, transitsFromEngine } from "../src/lib/ephemeris-view.ts";
|
|
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "../src/lib/ephemeris-contract.ts";
|
|
import { peekEphemerisPage, resetSecondaryPageDataForTests, writeEphemerisPage } from "../src/lib/secondary-page-data.ts";
|
|
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
|
|
|
Object.assign(globalThis, { React });
|
|
|
|
const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8"));
|
|
const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8"));
|
|
const transit15 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
|
|
const transit14 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-14.golden.json", import.meta.url), "utf8"));
|
|
|
|
function payloadFor(date: "2026-09-14" | "2026-09-15"): EphemerisOkResponse {
|
|
const transits = transitsFromEngine({
|
|
transitChart: date === "2026-09-14" ? transit14 : transit15,
|
|
natalChart: natalGolden,
|
|
});
|
|
const parsed = parseEphemerisOkResponse({
|
|
status: "ok",
|
|
date,
|
|
today: "2026-09-15",
|
|
timezoneId: "Asia/Shanghai",
|
|
panchanga: {
|
|
available: true,
|
|
ayanamsaNote: "lahiri_not_account",
|
|
factors: panchangaFactorsFromEngine(panchangaGolden, date),
|
|
},
|
|
transits: {
|
|
available: true,
|
|
natalRelativeAvailable: true,
|
|
natalRelativeNote: "shown",
|
|
ayanamsa: "raman",
|
|
bodies: transits.bodies,
|
|
},
|
|
events: { status: "unavailable" },
|
|
});
|
|
assert.ok(parsed);
|
|
return parsed;
|
|
}
|
|
|
|
function asDate(payload: EphemerisOkResponse, date: string): EphemerisOkResponse {
|
|
return { ...payload, date };
|
|
}
|
|
|
|
function json(payload: EphemerisOkResponse) {
|
|
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((next) => { resolve = next; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function withPage(run: (h: ReturnType<typeof createClientLifecycleHarness>) => Promise<void>) {
|
|
resetSecondaryPageDataForTests();
|
|
resetSubjectCatalogForTests();
|
|
bindCurrentSubjectAccount("fictional-account", []);
|
|
const originalFetch = globalThis.fetch;
|
|
const h = createClientLifecycleHarness();
|
|
Object.assign(window, { location: { assign() {}, pathname: "/ephemeris", search: "" } });
|
|
try {
|
|
await run(h);
|
|
assert.deepEqual(h.errors, []);
|
|
} finally {
|
|
await h.close();
|
|
globalThis.fetch = originalFetch;
|
|
resetSecondaryPageDataForTests();
|
|
}
|
|
}
|
|
|
|
function click(h: ReturnType<typeof createClientLifecycleHarness>, label: string) {
|
|
const node = h.elements().find((element) => element.getAttribute("aria-label") === label);
|
|
assert.ok(node, label);
|
|
return h.event(node);
|
|
}
|
|
|
|
test("warm ephemeris mount reuses the cached date without any engine request", async () => withPage(async (h) => {
|
|
writeEphemerisPage({ kind: "ready", payload: payloadFor("2026-09-15") });
|
|
let requests = 0;
|
|
globalThis.fetch = async (input) => {
|
|
if (String(input).startsWith("/api/ephemeris")) { requests += 1; throw new Error("warm mount must not fetch"); }
|
|
return Response.json({}, { status: 503 });
|
|
};
|
|
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
|
|
await h.idle();
|
|
assert.match(h.container.text, /Shukla Panchami/);
|
|
assert.equal(requests, 0);
|
|
}));
|
|
|
|
test("initial ephemeris fetch failure offers a working retry instead of permanent waiting", async () => withPage(async (h) => {
|
|
let requests = 0;
|
|
globalThis.fetch = async (input) => {
|
|
if (!String(input).startsWith("/api/ephemeris")) return Response.json({}, { status: 503 });
|
|
requests += 1;
|
|
if (requests === 1) throw new Error("fictional network failure");
|
|
return json(payloadFor("2026-09-15"));
|
|
};
|
|
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
|
|
await h.idle();
|
|
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.dateFailed));
|
|
const retry = h.elements().find((element) => element.tagName === "BUTTON" && element.text === "重试");
|
|
assert.ok(retry);
|
|
await h.event(retry);
|
|
await h.idle();
|
|
assert.equal(requests, 2);
|
|
assert.match(h.container.text, /Shukla Panchami/);
|
|
}));
|
|
|
|
test("switching to an uncached date keeps the date bar and ignores a late today refresh", async () => withPage(async (h) => {
|
|
const today = payloadFor("2026-09-15");
|
|
const sixteenth = asDate(payloadFor("2026-09-14"), "2026-09-16");
|
|
writeEphemerisPage({ kind: "ready", payload: today });
|
|
const refresh = deferred<Response>();
|
|
const requested = deferred<Response>();
|
|
globalThis.fetch = async (input) => {
|
|
const url = String(input);
|
|
if (url.includes("date=2026-09-16")) return requested.promise;
|
|
return refresh.promise;
|
|
};
|
|
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
|
|
await h.idle();
|
|
assert.match(h.container.text, /2026年9月15日/);
|
|
assert.match(h.container.text, /Shukla Panchami/);
|
|
await click(h, EPHEMERIS_COPY.nextDay);
|
|
await h.idle();
|
|
assert.match(h.container.text, /2026年9月16日/);
|
|
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.panchangaUnavailable));
|
|
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.transitsUnavailable));
|
|
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.eventsPending));
|
|
assert.doesNotMatch(h.container.text, /Shukla Panchami|还没接上|正在加载|skeleton/i);
|
|
assert.ok(h.elements().some((element) => element.getAttribute("aria-label") === EPHEMERIS_COPY.previousDay));
|
|
await h.update(() => refresh.resolve(json(today)));
|
|
await h.idle();
|
|
assert.match(h.container.text, /2026年9月16日/);
|
|
assert.doesNotMatch(h.container.text, /Shukla Panchami/);
|
|
await h.update(() => requested.resolve(json(sixteenth)));
|
|
await h.idle();
|
|
assert.match(h.container.text, /2026年9月16日/);
|
|
assert.match(h.container.text, /Shukla Chaturthi/);
|
|
assert.doesNotMatch(h.container.text, /Shukla Panchami/);
|
|
}));
|
|
|
|
test("a late response for the previous date cannot overwrite the date now on screen", async () => withPage(async (h) => {
|
|
writeEphemerisPage({ kind: "ready", payload: payloadFor("2026-09-15") });
|
|
const first = deferred<Response>();
|
|
const second = deferred<Response>();
|
|
globalThis.fetch = async (input) => {
|
|
const url = String(input);
|
|
if (url.includes("date=2026-09-16")) return first.promise;
|
|
if (url.includes("date=2026-09-17")) return second.promise;
|
|
return json(payloadFor("2026-09-15"));
|
|
};
|
|
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
|
|
await h.idle();
|
|
await click(h, EPHEMERIS_COPY.nextDay);
|
|
await h.idle();
|
|
await click(h, EPHEMERIS_COPY.nextDay);
|
|
await h.idle();
|
|
assert.match(h.container.text, /2026年9月17日/);
|
|
await h.update(() => first.resolve(json(asDate(payloadFor("2026-09-14"), "2026-09-16"))));
|
|
await h.idle();
|
|
assert.match(h.container.text, /2026年9月17日/);
|
|
assert.match(h.container.text, new RegExp(EPHEMERIS_COPY.panchangaUnavailable));
|
|
assert.doesNotMatch(h.container.text, /Shukla Chaturthi/);
|
|
}));
|
|
|
|
test("429 shows the failure sentence and the same date or an arrow retries successfully", async () => withPage(async (h) => {
|
|
writeEphemerisPage({ kind: "ready", payload: payloadFor("2026-09-15") });
|
|
const sixteenth = asDate(payloadFor("2026-09-14"), "2026-09-16");
|
|
let calls = 0;
|
|
globalThis.fetch = async (input) => {
|
|
const url = String(input);
|
|
if (!url.includes("date=2026-09-16")) return json(payloadFor("2026-09-15"));
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
return new Response(JSON.stringify({ status: "unavailable", reason: "rate_limited" }), { status: 429 });
|
|
}
|
|
return json(sixteenth);
|
|
};
|
|
await h.render(React.createElement(SidebarProvider, null, React.createElement(EphemerisPage)));
|
|
await h.idle();
|
|
await click(h, EPHEMERIS_COPY.nextDay);
|
|
await h.idle();
|
|
assert.equal((h.container.text.match(new RegExp(EPHEMERIS_COPY.dateFailed, "g")) ?? []).length, 3);
|
|
assert.match(h.container.text, /2026年9月16日/);
|
|
assert.doesNotMatch(h.container.text, /Shukla Panchami|正在加载|skeleton/i);
|
|
await click(h, EPHEMERIS_COPY.nextDay);
|
|
await h.idle();
|
|
assert.equal(calls, 2);
|
|
assert.match(h.container.text, /2026年9月16日/);
|
|
assert.match(h.container.text, /Shukla Chaturthi/);
|
|
assert.doesNotMatch(h.container.text, new RegExp(EPHEMERIS_COPY.dateFailed));
|
|
}));
|
|
|
|
|
|
test("warm ephemeris self cache never crosses account binding", () => {
|
|
resetSecondaryPageDataForTests(); resetSubjectCatalogForTests();
|
|
bindCurrentSubjectAccount("fictional-A", []);
|
|
writeEphemerisPage({ kind: "ready", payload: payloadFor("2026-09-15") });
|
|
assert.ok(peekEphemerisPage());
|
|
bindCurrentSubjectAccount("fictional-B", []);
|
|
assert.equal(peekEphemerisPage(), null);
|
|
resetSecondaryPageDataForTests(); resetSubjectCatalogForTests();
|
|
});
|