Files
Jyotisha/frontend/tests/birth-sky-export.test.ts
T
Jesse_ChenandClaude Opus 5.5 d277cbd8ad feat(chart): 「那一刻的天空」封面 — engine, BFF, canvas painter, chart header entry
T1 scripts/birth_sky.py: real alt/az of the seven visible grahas (swe.calc_ut +
swe.azalt, geometric altitude, azimuth converted from swe's south origin to
north-east) and Yale BSC5 stars V<=4.0 (IAU 1976 precession); phase
day / twilight_morning / twilight_evening / night. Thin POST /api/birth_sky
registration in do_POST (no new handler method, no __new__).

T2 GET /api/birth-sky?subject=: birth truth read server side through
loadSubjectBirth + prepareChartViewProfile; zod contract; no billing, no
writes, no birth data in the response.

T3/T4 lib/birth-sky: sentence table, pure Canvas 2D drawBirthSky (night /
paper palettes from DESIGN tokens, east on the left, serif headline with
1.5 s font wait), PNG export via navigator.share or Blob + a.download.
Chart page prefetches and paints the cover once the chart is ok; the header
button appears only when the PNG is ready. Dialog is a lazy chunk.

T7 DESIGN §17, VOICE, CONTEXT glossary, CHANGELOG, real-device checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-28 20:27:40 +08:00

80 lines
3.5 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { BIRTH_SKY_FILENAME, saveBirthSkyImage } from "../src/lib/birth-sky/export.ts";
import { waitForHeadingSerif } from "../src/lib/birth-sky/render.ts";
const exportSource = readFileSync(new URL("../src/lib/birth-sky/export.ts", import.meta.url), "utf8");
function environment(share: "ok" | "cancel" | "fail" | "unsupported") {
const downloads: string[] = [];
const shared: ShareData[] = [];
const nav = share === "unsupported"
? { canShare: () => false, share: async () => { throw new Error("must not share"); } }
: {
canShare: (data: ShareData) => Array.isArray(data.files) && data.files.length === 1,
share: async (data: ShareData) => {
shared.push(data);
if (share === "cancel") throw Object.assign(new Error("dismissed"), { name: "AbortError" });
if (share === "fail") throw Object.assign(new Error("denied"), { name: "NotAllowedError" });
},
};
return {
downloads,
shared,
env: {
navigator: nav,
makeFile: (blob: Blob, name: string) => new File([blob], name, { type: "image/png" }),
download: (_blob: Blob, name: string) => { downloads.push(name); },
},
};
}
const png = new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" });
test("the file name carries no name and no date", () => {
assert.equal(BIRTH_SKY_FILENAME, "jyotisha-sky.png");
assert.match(exportSource, /toBlob\([\s\S]*"image\/png"\)/);
});
test("share-capable browsers get the system share sheet with the PNG file", async () => {
const { env, downloads, shared } = environment("ok");
assert.equal(await saveBirthSkyImage(png, env), "shared");
assert.equal(shared.length, 1);
const file = shared[0]!.files![0]!;
assert.equal(file.name, "jyotisha-sky.png");
assert.equal(file.type, "image/png");
assert.deepEqual(downloads, []);
});
test("without file sharing it downloads through Blob + a.download", async () => {
const { env, downloads } = environment("unsupported");
assert.equal(await saveBirthSkyImage(png, env), "downloaded");
assert.deepEqual(downloads, ["jyotisha-sky.png"]);
const bare = environment("unsupported");
assert.equal(await saveBirthSkyImage(png, { ...bare.env, navigator: null }), "downloaded");
assert.match(exportSource, /anchor\.download = name/);
assert.match(exportSource, /URL\.revokeObjectURL/);
});
test("a dismissed share sheet is not an error and does not fall back to a download", async () => {
const { env, downloads } = environment("cancel");
assert.equal(await saveBirthSkyImage(png, env), "cancelled");
assert.deepEqual(downloads, []);
const failing = environment("fail");
assert.equal(await saveBirthSkyImage(png, failing.env), "downloaded");
assert.deepEqual(failing.downloads, ["jyotisha-sky.png"]);
});
test("the heading serif wait gives up after its budget instead of hanging", async () => {
const never = { load: () => new Promise<unknown>(() => {}) };
const started = Date.now();
assert.equal(await waitForHeadingSerif("那一刻", never, 30), false);
assert.ok(Date.now() - started < 1_000);
assert.equal(await waitForHeadingSerif("那一刻", { load: async () => [{}] }, 30), true);
assert.equal(await waitForHeadingSerif("那一刻", { load: async () => [] }, 30), false);
assert.equal(await waitForHeadingSerif("那一刻", { load: async () => { throw new Error("x"); } }, 30), false);
assert.equal(await waitForHeadingSerif("那一刻", null, 30), false);
});