Files
Jyotisha/frontend/tests/birth-sky-draw.test.ts
T
Jesse_ChenandClaude Opus 5.5 dec36bce01 feat(birth-sky): poster-only dialog, date/time/city caption, slower reveal, paywall on first refused send (P1–P5, BUG-1080)
TASK-birth-sky-polish-20260928.
- P1: chart page dialog shows only the picture on a dimmed stage, 「保存图片」 below, icon × in the corner.
- P2: BFF returns one server-built `caption` (「1994年5月18日 07:40 · 杭州」); printed under the fact line in all three places; adopted time adds 「 · 校正后」.
- P3: convergence 3 s + words 2.5 s + fade 1 s; settled frame still equals the exported cover.
- P4: redeem paywall no longer opens when the starter home appears; first send refused for credits opens it, later ones open billing; convergence dialog portalled to <body>. BUG-1080 investigating (Chrome repro rules out the overlay covering the convergence).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-09-29 00:03:46 +08:00

160 lines
7.8 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
BIRTH_SKY_CENTER,
BIRTH_SKY_EXPORT_HEIGHT,
BIRTH_SKY_EXPORT_WIDTH,
BIRTH_SKY_PALETTES,
drawBirthSky,
skyPoint,
type BirthSkyCanvas,
} from "../src/lib/birth-sky/draw.ts";
import { goldenCaption, goldenSky } from "./birth-sky-test-support.ts";
type Call = { method: string; args: unknown[]; state: Record<string, unknown> };
function recorder() {
const calls: Call[] = [];
const state: Record<string, unknown> = {};
const target = {} as Record<string, unknown>;
const ctx = new Proxy(target, {
get(_target, key: string) {
if (key in state) return state[key];
if (key === "measureText") return (text: string) => ({ width: text.length * 10 });
return (...args: unknown[]) => { calls.push({ method: key, args, state: { ...state } }); };
},
set(_target, key: string, value: unknown) {
state[key] = value;
return true;
},
}) as unknown as BirthSkyCanvas;
return { ctx, calls, texts: () => calls.filter((call) => call.method === "fillText").map((call) => String(call.args[0])) };
}
function draw(phase: Parameters<typeof goldenSky>[0], timeBasis: "adopted" | "reported", palette: "night" | "paper" = "night") {
const canvas = recorder();
drawBirthSky(canvas.ctx, goldenSky(phase, timeBasis), {
palette,
width: BIRTH_SKY_EXPORT_WIDTH,
height: BIRTH_SKY_EXPORT_HEIGHT,
});
return canvas;
}
test("export size is 1080 x 1440 (3:4) and the background fills it", () => {
assert.equal(BIRTH_SKY_EXPORT_WIDTH, 1080);
assert.equal(BIRTH_SKY_EXPORT_HEIGHT, 1440);
const { calls } = draw("day", "adopted");
const background = calls.find((call) => call.method === "fillRect");
assert.deepEqual(background?.args, [0, 0, 1080, 1440]);
assert.equal(background?.state.fillStyle, BIRTH_SKY_PALETTES.night.background);
});
// 原值: no drawn text carries a date, a time, coordinates, a place or a name.
// 新值: only the caption line carries the date, time and city; nothing carries coordinates, a district or a name.
// 原因: TASK-birth-sky-polish-20260928 P2 — product overturned 「不印出生资料」 for date + time + city.
test("only the caption line carries birth data: date, time, city; never coordinates, district or name", () => {
for (const phase of ["day", "twilight_morning", "twilight_evening", "night"] as const) {
const texts = draw(phase, "reported").texts();
const caption = goldenCaption(phase);
assert.ok(texts.includes(caption), caption);
const others = texts.filter((text) => text !== caption).join("\n");
assert.ok(texts.length >= 8, others);
assert.doesNotMatch(others, /\d{4}|\d{1,2}:\d{2}|杭州|年|月\d|日\d/);
const joined = texts.join("\n");
assert.doesNotMatch(joined, /\d+\s*[°′']|\d\s*[NSEW]\b|北纬|东经|UTC|西湖区|浙江/);
assert.doesNotMatch(joined, /示例|虚构|王芳/);
}
const canvas = draw("day", "reported");
const texts = canvas.texts();
const topLabel = canvas.calls.filter((call) => call.method === "fillText" && call.args[2] === 64).map((call) => call.args[0]).join("");
assert.equal(topLabel, "JYOTISHA · 那 一 刻");
assert.ok(texts.includes("星星都在,只是还看不见。"));
assert.ok(texts.includes("太阳在东,月亮在脚下,火星正悬在东南"));
assert.ok(texts.includes("1994年5月18日 07:40 · 杭州"));
assert.ok(texts.includes("仅供自我探索,不替你做决定。"));
for (const direction of ["北", "南", "东", "西"]) assert.ok(texts.includes(direction));
});
// 原值: 「按你填写的时间」 for a reported time, nothing for an adopted one.
// 新值: the caption sits where the footnote was (y = 712); adopted appends 「 · 校正后」; 「按你填写的时间」 is gone.
// 原因: TASK-birth-sky-polish-20260928 P2.
test("the caption replaces the footnote; an adopted time adds 「· 校正后」", () => {
const reported = draw("day", "reported");
const adopted = draw("day", "adopted");
const at712 = (canvas: ReturnType<typeof draw>) => canvas.calls.filter((call) => call.method === "fillText" && call.args[2] === 712).map((call) => call.args[0]);
assert.deepEqual(at712(reported), ["1994年5月18日 07:40 · 杭州"]);
assert.deepEqual(at712(adopted), ["1994年5月18日 07:40 · 杭州 · 校正后"]);
for (const canvas of [reported, adopted]) assert.ok(!canvas.texts().includes("按你填写的时间"));
const paper = draw("twilight_evening", "reported", "paper");
const line = paper.calls.find((call) => call.method === "fillText" && call.args[2] === 712);
assert.equal(line?.args[0], "1994年5月18日 19:10 · 杭州");
assert.equal(line?.state.fillStyle, BIRTH_SKY_PALETTES.paper.inkSecondary);
});
test("east is on the left, north is up", () => {
const east = skyPoint(0, 90);
const west = skyPoint(0, 270);
const north = skyPoint(0, 0);
assert.ok(east.x < BIRTH_SKY_CENTER.x);
assert.ok(west.x > BIRTH_SKY_CENTER.x);
assert.ok(north.y < BIRTH_SKY_CENTER.y);
assert.deepEqual(skyPoint(90, 123), BIRTH_SKY_CENTER);
// The real Sun of the sample (azimuth ~84.7, east) is painted left of centre.
const sun = goldenSky("day").planets.find((planet) => planet.id === "sun")!;
const { calls } = draw("day", "adopted");
const sunRing = calls.find((call) => call.method === "arc" && call.args[2] === 6);
assert.ok(sunRing, "the Sun ring is drawn");
assert.ok(Number(sunRing.args[0]) < BIRTH_SKY_CENTER.x);
assert.equal(sunRing.args[0], skyPoint(sun.altitude, sun.azimuth).x);
});
test("bodies below the horizon are not drawn; the Moon of the sample is not named on the disc", () => {
const texts = draw("day", "adopted").texts();
assert.ok(!texts.includes("月亮"));
assert.ok(!texts.includes("木星"));
assert.ok(texts.includes("土星"));
assert.ok(texts.includes("火星"));
assert.ok(!texts.some((text) => /罗睺|计都/.test(text)));
});
test("headline and top label use the heading serif unless it timed out", () => {
const headlineFont = (serif: boolean | undefined) => {
const canvas = recorder();
drawBirthSky(canvas.ctx, goldenSky("night"), { palette: "night", width: 600, height: 800, ...(serif === undefined ? {} : { serif }) });
const call = canvas.calls.find((item) => item.method === "fillText" && item.args[0] === "那一刻,头顶就是这片天。");
const fact = canvas.calls.find((item) => item.method === "fillText" && String(item.args[0]).startsWith("月亮"));
return { headline: String(call?.state.font), fact: String(fact?.state.font) };
};
assert.match(headlineFont(undefined).headline, /^600 30px "Jyotisha Serif SC", /);
assert.doesNotMatch(headlineFont(undefined).fact, /Jyotisha Serif SC/);
assert.doesNotMatch(headlineFont(false).headline, /Jyotisha Serif SC/);
});
test("the paper palette paints ink stars on a light sheet", () => {
const { calls } = draw("night", "adopted", "paper");
assert.equal(calls[0]?.method, "save");
const background = calls.find((call) => call.method === "fillRect");
assert.equal(background?.state.fillStyle, "#fbfaf7");
const starFill = calls.find((call) => call.method === "fill");
assert.equal(starFill?.state.fillStyle, BIRTH_SKY_PALETTES.paper.star);
});
test("asterism lines only join stars that are above the horizon", () => {
const sky = goldenSky("night");
const visible = new Set(sky.stars.map((star) => star.hr));
const { calls } = draw("night", "adopted");
const segments = calls.filter((call) => call.method === "lineTo").length;
let expected = 0;
for (const line of sky.lines) {
for (const stroke of line.strokes) {
for (let index = 1; index < stroke.length; index += 1) {
if (visible.has(stroke[index - 1]) && visible.has(stroke[index])) expected += 1;
}
}
}
assert.ok(expected > 0, "the night sample shows at least one asterism segment");
assert.equal(segments, expected);
});