北印星盘(星盘页 / 校正右栏 / 我的报告共用 VedicChartSvg)宫内不再画 「水 19°」「罗逆 29°」这类显示文本——一宫三颗星就叠三行、字号压到 8。 改成九个彩色符号横排,每行最多 3 个;逆行是符号下方一道同色横线, 第 1 宫加 As 小标,盘下方两行图例。度数、星宿、顺逆下沉到星盘页的 行星表,列从四列扩到八列,首行是上升,度数精确到分。 - 符号字体:vendored 的 9 字形 Noto Sans Symbols 子集(OFL,1.5 KB), 经 next/font/local 挂成 --font-planet-glyphs;♀ ♂ 带 U+FE0E 防 iOS 画成表情。构建镜像不能联网,所以必须自带,与 Inter 同一条理由。 - 颜色:--color-planet-* 九个 token,:root 与两个深色块各一份。只用于 认星不表吉凶,罗计共用中性灰。挂 is-* 修饰类而非行内 style——报告 文档的标记合同禁止渲染结果出现 style=,星盘不做例外。 - 数据来源:toNorthIndianChart() 加结构化 occupantGlyphs(星盘页与 markdown fence 走这条);校正与报告两条路线的模型只有引擎 / golden 显示文本,按首字解析,解析由测试锁住。 - 星宿沿用引擎返回名:仓库无中文宿名表,印度 27 宿与中国二十八宿不是 同一套,不自造对照。 tsc 0 错,lint 0 error,npm test 3471→3484 条、36 红与基线逐条相同, / 仍 Static,首屏 JS gzip +0.29%。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
187 lines
7.8 KiB
TypeScript
187 lines
7.8 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 { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx";
|
|
import { planetDisplayLabel, type ReportChartBlock } from "../src/lib/report-chart-block.ts";
|
|
|
|
Object.assign(globalThis, { React });
|
|
|
|
const SAMPLE_BLOCK: ReportChartBlock = {
|
|
version: 1,
|
|
id: "D1",
|
|
title: "D1 — Rashi Chart(本命盘)",
|
|
layout: "north",
|
|
ascendant: { sign: "Leo", degree: 12.34 },
|
|
planets: [
|
|
{ name: "Sun", sign: "Leo", degree: 3.21, retrograde: false },
|
|
{ name: "Saturn", sign: "Capricorn", degree: 3.4, retrograde: true },
|
|
],
|
|
};
|
|
|
|
const D9_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "D9", title: "D9 — Navamsa(婚盘)" };
|
|
const MOON_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "MOON", title: "Moon Chart(月亮参考盘)" };
|
|
|
|
const MARKDOWN = [
|
|
"#### D1 — Rashi Chart(本命盘)",
|
|
"",
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 480"><text>engine</text></svg>',
|
|
"",
|
|
"```jyotish-chart",
|
|
JSON.stringify(SAMPLE_BLOCK),
|
|
"```",
|
|
"",
|
|
"```jyotish-chart",
|
|
"{not-json",
|
|
"```",
|
|
].join("\n");
|
|
|
|
const GOLDEN = JSON.parse(
|
|
readFileSync(new URL("./fixtures/report-chart-blocks-golden.json", import.meta.url), "utf8"),
|
|
) as { blocks: ReportChartBlock[] };
|
|
|
|
function fence(block: ReportChartBlock): string {
|
|
return ["```jyotish-chart", JSON.stringify(block), "```"].join("\n");
|
|
}
|
|
|
|
function countClass(markup: string, className: string): number {
|
|
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
return (markup.match(new RegExp(`(?:class|className)="[^"]*\\b${escaped}\\b`, "g")) ?? []).length;
|
|
}
|
|
|
|
function cardInnerHtml(markup: string): string[] {
|
|
const cards: string[] = [];
|
|
const re = /<div class="personal-report-chart-card">([\s\S]*?)<\/div>/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = re.exec(markup))) cards.push(match[1] ?? "");
|
|
return cards;
|
|
}
|
|
|
|
test("markdown view draws the North Indian component and skips engine SVG HTML", () => {
|
|
const markup = renderToStaticMarkup(
|
|
React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }),
|
|
);
|
|
assert.match(markup, /<svg/);
|
|
assert.match(markup, /role="img"/);
|
|
assert.match(markup, /personal-report-chart-figure/);
|
|
assert.doesNotMatch(markup, /viewBox="0 0 420 480"/);
|
|
assert.match(markup, /图盘数据无效/);
|
|
assert.doesNotMatch(markup, /<script/i);
|
|
// 原值:assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)))
|
|
// —— 盘里必须出现「土逆 3°」这串显示文本。
|
|
// 新值:盘里必须出现 ♄ 符号并带一条 .personal-report-chart-retrograde 横线,
|
|
// 且不得再出现「土逆 3°」或任何「逆」字与度数符号。
|
|
// 原因:本轮产品拍板把盘内文字换成彩色行星符号,度数 / 星宿 / 逆行下沉到星盘页
|
|
// 的行星表。原断言守的正是本轮要去掉的东西,无法原样保留。断言主语(这颗
|
|
// 逆行土星画在盘里、且是从 fence 数据来的)没变,新增的 doesNotMatch 比
|
|
// 原断言更强:它同时锁住「不再写字」这一半。
|
|
// planetDisplayLabel 本身没删——occupants 字符串仍然喂 aria-label 与宫位表。
|
|
assert.match(markup, /♄/);
|
|
assert.match(markup, /class="personal-report-chart-retrograde is-saturn"/);
|
|
assert.doesNotMatch(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)));
|
|
assert.doesNotMatch(markup, /°/, "degrees moved to the chart page planet table");
|
|
// 「逆」只剩图例里那一句「横线=逆行」,宫内不再写这个字。
|
|
assert.deepEqual(markup.match(/逆/g), ["逆"]);
|
|
assert.match(markup, /横线=逆行/);
|
|
});
|
|
|
|
test("consecutive chart heading+fence pairs become one two-column grid of cards", () => {
|
|
const markdown = [
|
|
"#### D1 — Rashi Chart(本命盘)",
|
|
"",
|
|
fence(SAMPLE_BLOCK),
|
|
"",
|
|
"#### D9 — Navamsa(婚盘)",
|
|
"",
|
|
fence(D9_BLOCK),
|
|
"",
|
|
"#### Moon Chart(月亮参考盘)",
|
|
"",
|
|
"月亮盘画在本命月上,用来对照夜间事件。",
|
|
"",
|
|
fence(MOON_BLOCK),
|
|
"",
|
|
"#### Shodashvarga 星座总表",
|
|
"",
|
|
"| 分盘 | 星座 |",
|
|
"| --- | --- |",
|
|
"| D1 | Leo |",
|
|
].join("\n");
|
|
const markup = renderToStaticMarkup(
|
|
React.createElement(PersonalReportMarkdownView, { markdown }),
|
|
);
|
|
assert.equal(countClass(markup, "personal-report-chart-grid"), 1);
|
|
assert.equal(countClass(markup, "is-single"), 0);
|
|
assert.equal(countClass(markup, "personal-report-chart-card"), 3);
|
|
const cards = cardInnerHtml(markup);
|
|
assert.equal(cards.length, 3);
|
|
assert.match(cards[0] ?? "", /^<h4 class="personal-report-chart-heading">[\s\S]*<\/h4><figure class="personal-report-chart-figure">/);
|
|
assert.match(cards[1] ?? "", /^<h4 class="personal-report-chart-heading">[\s\S]*<\/h4><figure class="personal-report-chart-figure">/);
|
|
assert.match(
|
|
cards[2] ?? "",
|
|
/^<h4 class="personal-report-chart-heading">[\s\S]*<\/h4><p>月亮盘画在本命月上,用来对照夜间事件。<\/p><figure class="personal-report-chart-figure">/,
|
|
);
|
|
const withoutCards = markup.replace(/<div class="personal-report-chart-card">[\s\S]*?<\/div>/g, "");
|
|
assert.match(withoutCards, /<h4>Shodashvarga 星座总表<\/h4>/);
|
|
assert.doesNotMatch(withoutCards, /personal-report-chart-card/);
|
|
});
|
|
|
|
test("a single chart pair is wrapped in an is-single grid", () => {
|
|
const markdown = ["#### D1 — Rashi Chart(本命盘)", "", fence(SAMPLE_BLOCK)].join("\n");
|
|
const markup = renderToStaticMarkup(
|
|
React.createElement(PersonalReportMarkdownView, { markdown }),
|
|
);
|
|
assert.equal(countClass(markup, "personal-report-chart-grid"), 1);
|
|
assert.equal(countClass(markup, "is-single"), 1);
|
|
assert.equal(countClass(markup, "personal-report-chart-card"), 1);
|
|
});
|
|
|
|
test("golden 22 chart figures all sit inside chart cards", () => {
|
|
const markdown = GOLDEN.blocks.map((block) => [
|
|
`#### ${block.title}`,
|
|
"",
|
|
`<svg viewBox="0 0 420 480"></svg>`,
|
|
"",
|
|
fence(block),
|
|
"",
|
|
].join("\n")).join("\n");
|
|
const markup = renderToStaticMarkup(
|
|
React.createElement(PersonalReportMarkdownView, { markdown }),
|
|
);
|
|
assert.equal(countClass(markup, "personal-report-chart-figure"), 22);
|
|
assert.equal(countClass(markup, "personal-report-chart-card"), 22);
|
|
const leftover = markup.replace(/<div class="personal-report-chart-card">[\s\S]*?<\/div>/g, "");
|
|
assert.doesNotMatch(leftover, /personal-report-chart-figure/);
|
|
});
|
|
|
|
test("chart layout CSS does not float personal-report-chart boxes", () => {
|
|
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8")
|
|
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
for (const chunk of css.split("}")) {
|
|
if (!chunk.includes("personal-report-chart")) continue;
|
|
assert.doesNotMatch(chunk, /float\s*:/);
|
|
assert.doesNotMatch(chunk, /margin-left\s*:\s*-50%/);
|
|
}
|
|
});
|
|
|
|
test("article markdown trees are memoized and hold no scroll state", () => {
|
|
const src = readFileSync(
|
|
new URL("../src/components/personal-report/personal-report-markdown-view.tsx", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const viewStart = src.indexOf("export function PersonalReportMarkdownView");
|
|
const viewEnd = src.indexOf("\nfunction TocList", viewStart);
|
|
assert.ok(viewStart >= 0 && viewEnd > viewStart);
|
|
const view = src.slice(viewStart, viewEnd);
|
|
assert.doesNotMatch(view, /\buseState\b/);
|
|
const lines = src.split("\n");
|
|
lines.forEach((line, index) => {
|
|
if (!line.includes("renderMarkdown(")) return;
|
|
if (line.includes("function renderMarkdown")) return;
|
|
const window = lines.slice(Math.max(0, index - 6), index + 1).join("\n");
|
|
assert.match(window, /useMemo\s*\(/, `renderMarkdown at line ${index + 1} must be inside useMemo`);
|
|
});
|
|
});
|