313 lines
14 KiB
TypeScript
313 lines
14 KiB
TypeScript
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);
|
||
});
|