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["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( <> {}} /> {}} /> , ); 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(); } });