测试里的类型标注补全。侧栏拖宽把手改用组件库按钮,样子仍是 6px 透明细条。报告列表换人时不再在渲染中改引用。删掉已经没人用的证据回执类名。 Co-Authored-By: Grok (agent loop)
179 lines
7.4 KiB
TypeScript
179 lines
7.4 KiB
TypeScript
import "./install-test-document";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { ReportActions } from "../src/components/personal-report/report-actions.tsx";
|
|
import { ReportExportDrawer } from "../src/components/personal-report/report-export-drawer.tsx";
|
|
import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx";
|
|
import { buildReportExportBlocks } from "../src/lib/report-export-blocks.ts";
|
|
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
|
|
|
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
|
|
|
type Node = ReturnType<ReturnType<typeof createClientLifecycleHarness>["elements"]>[number];
|
|
|
|
function splitTop(input: string): string[] {
|
|
const parts: string[] = [];
|
|
let depth = 0;
|
|
let start = 0;
|
|
for (let index = 0; index < input.length; index += 1) {
|
|
const char = input[index];
|
|
if (char === "(") depth += 1;
|
|
else if (char === ")") depth -= 1;
|
|
else if (char === "," && depth === 0) {
|
|
parts.push(input.slice(start, index));
|
|
start = index + 1;
|
|
}
|
|
}
|
|
parts.push(input.slice(start));
|
|
return parts;
|
|
}
|
|
|
|
function matchesCompound(node: Node, compound: string): boolean {
|
|
const isAt = compound.indexOf(":is(");
|
|
let rest = compound;
|
|
if (isAt >= 0) {
|
|
rest = compound.slice(0, isAt);
|
|
const inner = compound.slice(isAt + 4, compound.lastIndexOf(")"));
|
|
if (!splitTop(inner).some((option) => matchesCompound(node, option.trim()))) return false;
|
|
}
|
|
const parts = rest.match(/[a-zA-Z][\w-]*|\.[\w-]+|\[[^\]]+\]/g) ?? [];
|
|
if (parts.length === 0) return isAt >= 0;
|
|
const classes = (node.getAttribute("class") ?? "").split(/\s+/);
|
|
for (const part of parts) {
|
|
if (part.startsWith(".")) {
|
|
if (!classes.includes(part.slice(1))) return false;
|
|
} else if (part.startsWith("[")) {
|
|
const body = part.slice(1, -1);
|
|
const named = /^([\w-]+)(?:="([^"]*)")?$/.exec(body);
|
|
if (!named || !node.hasAttribute(named[1])) return false;
|
|
if (named[2] !== undefined && node.getAttribute(named[1]) !== named[2]) return false;
|
|
} else if (node.tagName.toLowerCase() !== part.toLowerCase()) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function selectorCompounds(selector: string): string[] {
|
|
const compounds: string[] = [];
|
|
let depth = 0;
|
|
let start = 0;
|
|
const source = selector.trim();
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
const char = source[index];
|
|
if (char === "(") depth += 1;
|
|
else if (char === ")") depth -= 1;
|
|
else if (/\s/.test(char) && depth === 0) {
|
|
if (index > start) compounds.push(source.slice(start, index));
|
|
start = index + 1;
|
|
}
|
|
}
|
|
if (start < source.length) compounds.push(source.slice(start));
|
|
return compounds.filter(Boolean);
|
|
}
|
|
|
|
function matchesSelector(node: Node, selector: string): boolean {
|
|
const compounds = selectorCompounds(selector);
|
|
let cursor: Node | null = node;
|
|
for (let index = compounds.length - 1; index >= 0; index -= 1) {
|
|
const start: Node | null = index === compounds.length - 1 ? cursor : cursor?.parentNode ?? null;
|
|
let found: Node | null = null;
|
|
for (let walk: Node | null = start; walk; walk = index === compounds.length - 1 ? null : walk.parentNode) {
|
|
if (matchesCompound(walk, compounds[index])) {
|
|
found = walk;
|
|
break;
|
|
}
|
|
if (index === compounds.length - 1) break;
|
|
}
|
|
if (!found) return false;
|
|
cursor = found;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function themeRules() {
|
|
const clean = css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
const rules: { selector: string; body: string }[] = [];
|
|
for (const hit of clean.matchAll(/([^{}@]+)\{([^{}]*)\}/g)) {
|
|
const selector = hit[1].trim();
|
|
if (!selector || selector.includes("@")) continue;
|
|
rules.push({ selector, body: hit[2] });
|
|
}
|
|
return rules.filter((rule) => /\[data-active\]|\[aria-selected="true"\]/.test(rule.selector)
|
|
&& /--color-action-strong|--color-action\b/.test(rule.body));
|
|
}
|
|
|
|
function hitsTheme(node: Node): boolean {
|
|
return themeRules().some((rule) => splitTop(rule.selector).some((selector) => matchesSelector(node, selector.trim())));
|
|
}
|
|
|
|
function tabsIn(nodes: Node[], owner: Node) {
|
|
return nodes.filter((node) => node.getAttribute("role") === "tab" && owner.contains(node));
|
|
}
|
|
|
|
function pair(nodes: Node[], owner: Node, label: string) {
|
|
const tabs = tabsIn(nodes, owner);
|
|
const selected = tabs.find((node) => node.getAttribute("aria-selected") === "true");
|
|
const unselected = tabs.find((node) => node.getAttribute("aria-selected") === "false");
|
|
assert.ok(selected, `${label} selected`);
|
|
assert.ok(unselected, `${label} unselected`);
|
|
assert.equal(selected.hasAttribute("data-active"), true, `${label} selected mark`);
|
|
assert.equal(unselected.hasAttribute("data-active"), false, `${label} unselected mark`);
|
|
assert.notEqual(
|
|
`${selected.getAttribute("class")}|${selected.getAttribute("aria-selected")}|${selected.hasAttribute("data-active")}`,
|
|
`${unselected.getAttribute("class")}|${unselected.getAttribute("aria-selected")}|${unselected.hasAttribute("data-active")}`,
|
|
`${label} class or attribute`,
|
|
);
|
|
assert.equal(hitsTheme(selected), true, `${label} theme rule`);
|
|
assert.equal(hitsTheme(unselected), false, `${label} unselected theme rule`);
|
|
}
|
|
|
|
test("the three language switches and the export scope card show which one is selected", async () => {
|
|
assert.doesNotMatch(css, /\.report-language-switch button\[aria-pressed/);
|
|
assert.doesNotMatch(css, /\.report-export-segment button\[aria-checked/);
|
|
assert.doesNotMatch(css, /\.synastry-lang button\[aria-pressed/);
|
|
|
|
const zh = buildReportExportBlocks({ markdown: "# 报告\n\n## 摘要\n\n甲的月亮在第九宫。\n" });
|
|
const en = buildReportExportBlocks({
|
|
markdown: "# Report\n\n## Summary\n\nThe Moon sits in the ninth house.\n",
|
|
language: "en",
|
|
});
|
|
const harness = createClientLifecycleHarness();
|
|
try {
|
|
await harness.render(
|
|
<>
|
|
<ReportActions markdown="# 报告" englishEdition="ready" language="zh" onLanguageChange={() => {}} />
|
|
<ReportExportDrawer editions={{ zh, en }} englishEdition="ready" onClose={() => {}} />
|
|
<SynastryReportView
|
|
reportId="00000000-0000-4000-8000-000000001445"
|
|
snapshot={{
|
|
names: { a: "甲", b: "乙" },
|
|
sections: [{ id: "bond", title: "关系", text: "一段说明。" }],
|
|
english: { sections: [{ id: "bond", title: "Bond", text: "A note." }] },
|
|
}}
|
|
/>
|
|
</>,
|
|
);
|
|
await harness.idle();
|
|
const roots = [harness.container, ...[...harness.document.body.childNodes]];
|
|
const nodes = roots.flatMap((node) => {
|
|
const walk = (current: Node): Node[] => [current, ...current.childNodes.flatMap((child) => walk(child))];
|
|
return walk(node);
|
|
});
|
|
const list = (label: string) => {
|
|
const found = nodes.find((node) => node.getAttribute("role") === "tablist" && node.getAttribute("aria-label") === label);
|
|
assert.ok(found, label);
|
|
return found;
|
|
};
|
|
pair(nodes, list("报告语言"), "报告语言");
|
|
pair(nodes, list("导出语言"), "导出语言");
|
|
pair(nodes, list("导出范围"), "导出范围");
|
|
const synastry = nodes.find((node) => (node.getAttribute("class") ?? "").split(/\s+/).includes("synastry-lang"));
|
|
assert.ok(synastry, "合盘语言");
|
|
pair(nodes, synastry, "合盘语言");
|
|
assert.equal(harness.errors.length, 0, harness.errors.map(String).join("\n"));
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|