Files
Jyotisha/frontend/tests/chart-birth-editor-and-report-table.test.tsx
T

313 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import {
ChartBirthEditorForm,
birthFactsChanged,
chartBirthDialogShouldClose,
reportedTimeStored,
} from "../src/components/chart-page/chart-birth-editor.tsx";
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
import { emptyProfile, type Profile } from "../src/lib/home-types.ts";
import { saveOtherProfile } from "../src/lib/other-profile-save.ts";
import { formatReportSpeedColumns, isNumericReportTable, splitNakshatraLabel } from "../src/lib/report-table-reading.ts";
Object.assign(globalThis, { React });
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const switcher = readFileSync(new URL("../src/components/subject-switcher.tsx", import.meta.url), "utf8");
const chartPage = readFileSync(new URL("../src/components/chart-page/chart-page-view.tsx", import.meta.url), "utf8");
const editor = readFileSync(new URL("../src/components/chart-page/chart-birth-editor.tsx", import.meta.url), "utf8");
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
const otherSave = readFileSync(new URL("../src/lib/other-profile-save.ts", import.meta.url), "utf8");
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
const readerExport = readFileSync(new URL("../../scripts/pl9_reader_export.py", import.meta.url), "utf8");
const sample: Profile = {
...emptyProfile,
name: "甲",
date: "1990-01-01",
time: "08:00",
reportedTime: "08:00",
birthTimeSource: "hospital_record",
birthPlaceLabel: "某市",
latitude: 31.2,
longitude: 121.5,
};
type Call = { url: string; init: RequestInit };
function fakeFetch(status: number, body: unknown, calls: Call[]): typeof fetch {
return (async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {} });
return new Response(body == null ? "" : JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
}
function cssRules(source: string): { selector: string; body: string }[] {
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, " ");
const found: { selector: string; body: string }[] = [];
for (const match of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
found.push({ selector: match[1] ?? "", body: match[2] ?? "" });
}
return found;
}
test("speed cells with five or more decimals become four, and longitude stays", () => {
const markdown = [
"| 行星 | 黄经 | 速度 |",
"| --- | --- | --- |",
"| 月亮 | 123.456789 | 11.852534 |",
"| 太阳 | 10.000000 | -0.012345 |",
"",
"```",
"| 速度 |",
"| 11.852534 |",
"```",
"",
"| Planet | Speed |",
"| --- | --- |",
"| Moon | 11.852534 |",
].join("\n");
const formatted = formatReportSpeedColumns(markdown);
assert.match(formatted, /\| 月亮 \| 123\.456789 \| 11\.8525 \|/);
assert.match(formatted, /\| 太阳 \| 10\.000000 \| -0\.0123 \|/);
assert.match(formatted, /```\n\| 速度 \|\n\| 11\.852534 \|/);
assert.match(formatted, /\| Moon \| 11\.8525 \|/);
});
test("a nakshatra keeps the Chinese name and marks the Latin name", () => {
assert.deepEqual(splitNakshatraLabel("阿湿毗尼(Ashwini)"), [
{ english: false, text: "阿湿毗尼" },
{ english: true, text: "(Ashwini)" },
]);
assert.deepEqual(splitNakshatraLabel("Mula(根、本源)"), [
{ english: false, text: "Mula(根、本源)" },
]);
});
test("the person control is a button, and add-person follows the live cap of 20", () => {
assert.match(switcher, /ChevronDown/);
// 原值:`+ 添加一个人`
// 新值:Lucide `Plus` 加文案「添加一个人」
// 原因:K1 去掉手写加号。入口仍去人物页,人数上限仍是 MAX_SAVED_SUBJECTS
assert.match(switcher, /<Plus /);
// 原值:「添加一个人」,`href="/people"`
// 新值:「新增一个人」,`href={PERSON_ADD_HREF}`(/people?add=1,直接打开新增表单)
// 原因:issue #150 原型(2026-10-28 jesse 同意)第 4 条
assert.match(switcher, /新增一个人/);
assert.doesNotMatch(switcher, /+ (添加|新增)一个人/);
assert.match(switcher, /href=\{PERSON_ADD_HREF\}/);
assert.match(switcher, /MAX_SAVED_SUBJECTS/);
assert.doesNotMatch(switcher, /library\.length < 5/);
assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*min-height:\s*44px/);
assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*border:\s*1px solid var\(--color-border\)/);
assert.match(css, /\.chat-profile-picker-trigger\s*\{[^}]*border-radius:\s*999px/);
});
test("only numeric report tables stay on one line, and print wraps every table", () => {
assert.equal(isNumericReportTable(["行星", "黄经", "速度"]), true);
assert.equal(isNumericReportTable(["Planet", "Degree", "Longitude", "Speed"]), true);
assert.equal(isNumericReportTable(["落座度数"]), true);
assert.equal(isNumericReportTable(["瑜伽", "作用说明"]), false);
assert.equal(isNumericReportTable(["主题", "建议", "说明"]), false);
const markdown = [
"# 标题",
"",
"## 成品阅读导航",
"",
"### 行星原始位置",
"",
"| 行星 | 黄经 | 速度 |",
"| --- | --- | --- |",
"| 月亮 | 10.2500 | 13.1764 |",
"",
"### 作用说明",
"",
"| 瑜伽 | 作用说明 |",
"| --- | --- |",
"| 某组合 | 这一整句是给读者看的说明,应该在格子里换行。 |",
].join("\n");
const markup = renderToStaticMarkup(React.createElement(PersonalReportMarkdownView, { markdown }));
const tables = [...markup.matchAll(/<div class="(markdown-table [^"]*)">([\s\S]*?)<\/table><\/div>/g)];
assert.equal(tables.length, 2);
const numeric = tables.find((table) => table[2]?.includes("<th>速度</th>"));
const prose = tables.find((table) => table[2]?.includes("<th>作用说明</th>"));
assert.ok(numeric && prose);
assert.match(numeric[1] ?? "", /personal-report-table-wrap personal-report-table-numeric$/);
assert.equal(prose[1], "markdown-table personal-report-table-wrap");
assert.match(css, /\.personal-report-table-wrap table\s*\{[^}]*width:\s*100%/);
assert.match(css, /\.personal-report-table-wrap table\s*\{[^}]*table-layout:\s*auto/);
assert.match(css, /\.personal-report-table-numeric table\s*\{[^}]*width:\s*max-content/);
assert.match(css, /\.report-nakshatra-en\s*\{[^}]*color:\s*var\(--color-ink-tertiary\)/);
assert.match(readerExport, /_num\(row\.get\('speed'\), 4\)/);
const regionStart = css.indexOf(".personal-report-table-wrap table {");
const regionEnd = css.indexOf(".personal-report-chart-grid { display: grid");
assert.ok(regionStart >= 0 && regionEnd > regionStart);
const region = css.slice(regionStart, regionEnd);
const locked = cssRules(region).filter((rule) => /nowrap|max-content/.test(rule.body));
assert.ok(locked.length >= 2);
for (const rule of locked) {
assert.match(rule.selector, /personal-report-table-numeric/);
}
const printStart = css.search(/@media print \{\s*\/\* Paper is always paper\./);
assert.ok(printStart >= 0);
const print = css.slice(printStart, css.indexOf("/* ---- Membership page ---- */"));
assert.doesNotMatch(print, /nowrap/);
assert.doesNotMatch(print, /max-content/);
assert.match(print, /\.personal-report-table-wrap table \{ width: 100% !important/);
assert.match(print, /white-space: normal !important/);
});
test("the chart page birth line drops the name and opens an editor on this page", () => {
assert.match(chartPage, /chart-birth-line/);
assert.match(chartPage, /编辑出生资料/);
assert.doesNotMatch(chartPage, /\$\{view\.profile\.name\} · \$\{view\.profile\.date\}/);
assert.match(chartPage, /onBirthSaved/);
});
test("someone else's saved birth time stays locked, and the lock sentence appears once", () => {
assert.equal(reportedTimeStored(sample), true);
assert.equal(reportedTimeStored({ ...emptyProfile, declaredWindowStart: "06:00" }), true);
assert.equal(reportedTimeStored({ ...emptyProfile, birthTimeSource: "legacy_import", time: "08:00" }), true);
assert.equal(reportedTimeStored(emptyProfile), false);
assert.equal(birthFactsChanged(sample, { ...sample, name: "乙" }), false);
assert.equal(birthFactsChanged(sample, { ...sample, ayanamsa: "lahiri" }), false);
assert.equal(birthFactsChanged(sample, { ...sample, date: "1991-02-02" }), true);
const other = renderToStaticMarkup(
<ChartBirthEditorForm
role="other"
baseline={sample}
draft={{ ...sample, date: "1991-02-02" }}
saving={false}
onChange={() => {}}
onCancel={() => {}}
onSubmit={() => {}}
/>,
);
assert.equal((other.match(/填报的出生时间保存后不能再改/g) ?? []).length, 1);
assert.match(other, /birth-time-lock-note">填报的出生时间保存后不能再改/);
assert.match(other, /改了出生时间、日期或地点,之前生时校正采用的时间会作废,星盘改按你新填的时间排。/);
assert.match(other, /disabled/);
const unlocked = renderToStaticMarkup(
<ChartBirthEditorForm
role="other"
baseline={emptyProfile}
draft={emptyProfile}
saving={false}
onChange={() => {}}
onCancel={() => {}}
onSubmit={() => {}}
/>,
);
assert.doesNotMatch(unlocked, /填报的出生时间保存后不能再改/);
const self = renderToStaticMarkup(
<ChartBirthEditorForm
role="self"
baseline={sample}
draft={sample}
saving={false}
onChange={() => {}}
onCancel={() => {}}
onSubmit={() => {}}
/>,
);
assert.doesNotMatch(self, /填报的出生时间保存后不能再改/);
assert.doesNotMatch(self, /改了出生时间、日期或地点/);
assert.equal((intake.match(/填报的出生时间保存后不能再改/g) ?? []).length, 1);
assert.doesNotMatch(editor, /填报的出生时间保存后不能再改/);
assert.doesNotMatch(people, /填报的出生时间保存后不能再改/);
assert.match(people, /reportedTimeStored\(selected\.profile\)/);
});
test("a click on the dialog padding keeps the editor open, and the backdrop click closes it", () => {
const dialog = {};
const rect = { left: 40, top: 30, right: 400, bottom: 500 };
assert.equal(chartBirthDialogShouldClose(dialog, dialog, 48, 36, rect), false);
assert.equal(chartBirthDialogShouldClose(dialog, dialog, 8, 8, rect), true);
assert.equal(chartBirthDialogShouldClose({}, dialog, 8, 8, rect), false);
// 原值:源码把点击交给 chartBirthDialogShouldClose(event.target,并用 dialog 的 onCancel 关掉
// 新值:shadcn Dialog 打开时,onOpenChange 收到 false 才关;几何函数仍导出,由上面三行覆盖
// 原因:K3 出生资料改用 Dialog。遮罩在内容外面,点内容区的内边距不会关,和 BUG-1412 的结果相同。
assert.match(editor, /<Dialog open/);
assert.match(editor, /onOpenChange=\{\(next\) => \{ if \(!next\) onClose\(\); \}\}/);
assert.match(editor, /export function chartBirthDialogShouldClose/);
assert.doesNotMatch(editor, /<dialog/);
assert.doesNotMatch(editor, /if \(event\.target === event\.currentTarget\) onClose\(\)/);
});
test("saving someone else is one function, and a success drops the chart and ephemeris caches", async () => {
assert.match(editor, /saveOtherProfile\(draft, \{ id: record\.id \}\)/);
assert.match(people, /saveOtherProfile\(draft, \{ id: editingOther \}\)/);
assert.match(otherSave, /\(options\.invalidateChart \?\? invalidateChartPage\)\(\)/);
assert.match(otherSave, /\(options\.invalidateEphemeris \?\? invalidateEphemerisPage\)\(\)/);
const calls: Call[] = [];
let charts = 0;
let ephemerides = 0;
const saved = await saveOtherProfile(sample, {
id: "11111111-1111-4111-8111-111111111111",
fetchImpl: fakeFetch(200, { profile: { id: "11111111-1111-4111-8111-111111111111" } }, calls),
invalidateChart: () => { charts += 1; },
invalidateEphemeris: () => { ephemerides += 1; },
});
assert.equal(saved.id, "11111111-1111-4111-8111-111111111111");
assert.equal(calls[0]?.init.method, "PUT");
assert.equal(charts, 1);
assert.equal(ephemerides, 1);
assert.match(String(calls[0]?.url), /\/api\/chart-profiles\/11111111-1111-4111-8111-111111111111$/);
const created: Call[] = [];
const create = await saveOtherProfile(emptyProfile, {
fetchImpl: fakeFetch(200, { profile: { id: "22222222-2222-4222-8222-222222222222" } }, created),
invalidateChart: () => { charts += 1; },
invalidateEphemeris: () => { ephemerides += 1; },
});
assert.equal(created[0]?.init.method, "POST");
assert.equal(created[0]?.url, "/api/chart-profiles");
assert.equal(create.id, "22222222-2222-4222-8222-222222222222");
assert.equal(charts, 2);
assert.equal(ephemerides, 2);
await assert.rejects(
saveOtherProfile(sample, {
id: "11111111-1111-4111-8111-111111111111",
fetchImpl: fakeFetch(409, { error: "reported_birth_time_is_immutable", code: "reported_birth_time_is_immutable" }, []),
invalidateChart: () => { charts += 1; },
invalidateEphemeris: () => { ephemerides += 1; },
}),
/填报的出生时间不能再改/,
);
assert.equal(charts, 2);
assert.equal(ephemerides, 2);
await assert.rejects(
saveOtherProfile(emptyProfile, {
fetchImpl: fakeFetch(409, { error: "最多保存 20 个人的星盘", code: "subject_limit_reached" }, []),
invalidateChart: () => { charts += 1; },
}),
/最多保存 20 个人的星盘/,
);
await assert.rejects(
saveOtherProfile(emptyProfile, {
fetchImpl: fakeFetch(500, null, []),
}),
/保存失败,请重试/,
);
assert.equal(charts, 2);
});