feat(reports): streamline reader actions and center export dialog
Independent Staging Quality Gate / validate (push) Failing after 11m16s
Independent Staging Quality Gate / publish (push) Skipped

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-24 20:43:35 +08:00
co-authored by Claude Code
parent 133aca49db
commit f125fae0a0
15 changed files with 105 additions and 215 deletions
+7 -5
View File
@@ -304,10 +304,10 @@ test("legacy consultation Markdown export is untouched and still works", () => {
});
test("ready reports expose Markdown export only in the ready branch", () => {
assert.match(reportCenterSource, /PERSONAL_REPORT_EXPORT_LABEL/);
assert.match(reportCenterSource, /#export/);
test("ready reports expose only the reader action in the ready branch", () => {
assert.doesNotMatch(reportCenterSource, /PERSONAL_REPORT_EXPORT_LABEL|#export/);
assert.doesNotMatch(reportCenterSource, /downloadPersonalReportLongformAppendix|exportingReportId|exportError/);
assert.match(reportCenterSource, /查看报告/);
assert.match(reportCenterSource, /cardSummary/);
assert.doesNotMatch(longformDownloadSource, /professional-reference/);
assert.match(longformDownloadSource, /projectOrdinaryReportMarkdown/);
@@ -324,8 +324,10 @@ test("ready reports expose Markdown export only in the ready branch", () => {
);
assert.ok(readyActionStart >= 0 && generatingActionStart > readyActionStart);
const readyBranch = reportCenterSource.slice(readyActionStart, generatingActionStart);
assert.match(readyBranch, /PERSONAL_REPORT_EXPORT_LABEL/);
assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /PERSONAL_REPORT_EXPORT_LABEL/);
assert.match(readyBranch, /查看报告/);
assert.equal((readyBranch.match(/<Button\b/g) ?? []).length, 1, "ready row has exactly one document action");
assert.doesNotMatch(readyBranch, /#export|分块导出/);
assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /分块导出/);
});
test("report centre is a single-column row list with a dotted status chip", () => {
+15 -63
View File
@@ -1,18 +1,16 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
detectPrintRestriction,
isPrintSupported,
printPersonalReport,
safeReportFilename,
} from "../src/lib/client-report-export.ts";
import { detectPrintRestriction } from "../src/lib/client-report-export.ts";
const exportSource = readFileSync(
new URL("../src/lib/client-report-export.ts", import.meta.url),
"utf8",
);
const actionsSource = readFileSync(
new URL("../src/components/personal-report/report-actions.tsx", import.meta.url),
"utf8",
);
const pageSource = readFileSync(
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
"utf8",
@@ -22,61 +20,14 @@ const documentViewSource = readFileSync(
"utf8",
);
test("safeReportFilename strips path, query and control characters", () => {
assert.equal(safeReportFilename("../../etc/passwd?x=1&y=2"), "jyotisha-report-etc-passwd-x-1-y-2");
assert.equal(safeReportFilename("a b<c>d|e:f"), "jyotisha-report-a-b-c-d-e-f");
assert.equal(safeReportFilename(""), "jyotisha-report");
assert.equal(safeReportFilename(null as unknown as string), "jyotisha-report");
const cleaned = safeReportFilename("abcdefghijklmnopqrstuvwxyz0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ-extra");
assert.ok(cleaned.startsWith("jyotisha-report-"));
assert.ok(cleaned.length <= "jyotisha-report-".length + 64);
assert.doesNotMatch(cleaned, /[^a-zA-Z0-9_-]/);
});
// TASK-report-reader-actions D2 retires the print helper's four tests, not Ctrl+P.
// Old/new assertions and test-name mapping are recorded in the task PROGRESS.
test("detectPrintRestriction flags WeChat in-app browser and nothing else", () => {
assert.equal(detectPrintRestriction("Mozilla/5.0 MicroMessenger/8.0.49").restricted, true);
assert.equal(detectPrintRestriction("Mozilla/5.0 (iPhone) Safari").restricted, false);
assert.equal(detectPrintRestriction(undefined).restricted, false);
assert.match(detectPrintRestriction("MicroMessenger").message ?? "", /系统浏览器/);
});
test("isPrintSupported requires a usable window.print", () => {
assert.equal(isPrintSupported(), false);
(globalThis as Record<string, unknown>).window = { print: () => undefined };
assert.equal(isPrintSupported(), true);
delete (globalThis as Record<string, unknown>).window;
});
test("printPersonalReport waits for fonts, then calls window.print exactly once", async () => {
const calls: string[] = [];
let resolveFonts: (() => void) | undefined;
const fontsReady = new Promise<void>((resolve) => {
resolveFonts = resolve;
});
const fakeDocument = { fonts: { ready: fontsReady }, title: "original-title" };
(globalThis as Record<string, unknown>).window = {
print: () => {
calls.push("print");
// Browsers derive the PDF filename from document.title at print time.
assert.equal(fakeDocument.title, "safe-filename-title");
},
};
(globalThis as Record<string, unknown>).document = fakeDocument;
const printPromise = printPersonalReport({ title: "safe-filename-title" });
// Fonts not ready yet: print must not have fired.
assert.deepEqual(calls, []);
resolveFonts?.();
await printPromise;
assert.deepEqual(calls, ["print"]);
// Original title restored afterwards.
assert.equal(fakeDocument.title, "original-title");
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
});
test("printPersonalReport is a no-op without window", async () => {
// window is already deleted; must not throw.
await printPersonalReport({ title: "x" });
assert.equal(detectPrintRestriction("MicroMessenger").message,
"微信内置浏览器可能无法完整打印,请在系统浏览器中打开本页后打印。");
});
test("client export never touches a server PDF pipeline", () => {
@@ -84,15 +35,16 @@ test("client export never touches a server PDF pipeline", () => {
assert.doesNotMatch(exportSource, /html2canvas|jsPDF|jspdf|playwright|puppeteer|chromium/i);
assert.doesNotMatch(exportSource, /canvas|base64/i);
assert.doesNotMatch(exportSource, /\bfetch\s*\(/);
assert.match(exportSource, /window\.print/);
assert.match(exportSource, /document\.fonts/);
assert.doesNotMatch(exportSource, /window\.print|document\.fonts|document\.title/);
assert.doesNotMatch(exportSource, /printPersonalReport|isPrintSupported|safeReportFilename/);
});
test("native print remains a secondary action on the Markdown report page", () => {
test("the Markdown report keeps print layout support without a reader print button", () => {
assert.match(pageSource, /longformMarkdown/);
assert.match(pageSource, /PersonalReportMarkdownView/);
assert.doesNotMatch(pageSource, /PersonalReportDocumentView/);
assert.match(pageSource, /<style media="print">\{"@page \{ size: A4;/);
assert.match(pageSource, /<style media="print">\{REPORT_SHELL_PRINT_CSS\}/);
assert.doesNotMatch(actionsSource, /printPersonalReport|PERSONAL_REPORT_PRINT_LABEL|Printer|isPrintSupported/);
assert.doesNotMatch(documentViewSource, /dangerouslySetInnerHTML|srcDoc|<iframe/i);
assert.match(exportSource, /window\.print/);
});
+7 -8
View File
@@ -422,21 +422,20 @@ test("canonical contract rejects malformed documents", () => {
assert.equal(safeParseReportDocument(badRefs).ok, false, "dangling evidenceRefs must fail guards");
});
test("ready reports expose the browser print/PDF action with capability and hydration guards", () => {
test("ready reports expose only the back link and accessible export icon", () => {
const actionsSource = readFileSync(
new URL("../src/components/personal-report/report-actions.tsx", import.meta.url),
"utf8",
);
assert.match(actionsSource, /href="\/reports"/);
assert.match(actionsSource, /返回报告中心/);
assert.match(actionsSource, /PERSONAL_REPORT_EXPORT_LABEL/);
assert.match(actionsSource, /PERSONAL_REPORT_PRINT_LABEL/);
assert.match(actionsSource, /ReportExportDrawer/);
assert.doesNotMatch(actionsSource, /downloadPersonalReportLongformAppendix/);
assert.match(actionsSource, /printPersonalReport/);
assert.match(actionsSource, /Printer/);
assert.match(actionsSource, /useSyncExternalStore\(subscribePrintCapability, isPrintSupported, \(\) => false\)/);
assert.match(actionsSource, /disabled=\{printBusy \|\| exportOpen \|\| !printSupported\}/);
assert.match(actionsSource, /size="icon"/);
assert.doesNotMatch(actionsSource, /PERSONAL_REPORT_EXPORT_LABEL|#export|reportId|reportTitle/);
assert.match(actionsSource, /<Download aria-hidden="true" \/>/);
assert.match(actionsSource, /aria-label="导出报告"/);
assert.match(actionsSource, /title="导出"/);
assert.doesNotMatch(actionsSource, /RawAppendixDownload|printPersonalReport|PERSONAL_REPORT_PRINT_LABEL|Printer|useSyncExternalStore|detectPrintRestriction/);
});
test("the reader renders inside the app shell, in every phase", () => {
+26 -9
View File
@@ -99,22 +99,25 @@ test("download failure and WeChat restrictions leave drawer open without a succe
}
});
test("ready actions consume export hash once and keep print and raw appendix", async () => {
test("reader actions keep only the accessible export icon", async () => {
const h = createClientLifecycleHarness();
let replaces = 0;
const location = { hash: "#export", pathname: "/reports/fictional", search: "?view=1" };
Object.assign(window, { location, history: { state: { retained: true }, replaceState(state: unknown, _: string, url: string) { assert.deepEqual(state, { retained: true }); assert.equal(url, "/reports/fictional?view=1"); location.hash = ""; replaces += 1; } } });
try {
await h.render(<ReportActions reportId="fictional" markdown={reader.markdown} />);
Object.assign(window, { location: { hash: "#export" }, history: { replaceState() { assert.fail("retired export hash must not be consumed"); } } });
await h.render(<ReportActions markdown={reader.markdown} />);
assert.ok(!h.elements().some(node => node.tagName === "DIALOG"), "old export hash does not auto-open the dialog");
assert.equal(h.elements().filter(node => node.tagName === "BUTTON").length, 1);
assert.ok(h.elements().some(node => node.tagName === "A" && node.getAttribute("href") === "/reports"));
assert.deepEqual(h.errors, []);
assert.equal(replaces, 1);
const exportButton = h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "导出报告");
assert.ok(exportButton);
assert.equal(exportButton?.getAttribute("title"), "导出");
assert.ok(!h.container.text.includes("下载原始附录"));
assert.ok(!h.container.text.includes("打印"));
await h.event(exportButton!);
assert.ok(h.elements().some(node => node.tagName === "DIALOG"));
await h.event(button(h, "关闭"));
await h.idle();
assert.ok(!h.elements().some(node => node.tagName === "DIALOG"));
assert.equal(replaces, 1);
assert.ok(h.container.text.includes("下载原始附录"));
assert.ok(h.container.text.includes("打印"));
} finally { await h.close(); }
});
@@ -140,4 +143,18 @@ test("drawer contract uses native dialog, Escape/backdrop close and only allowed
assert.match(css, /min-width: 860px/);
assert.doesNotMatch(css, /var\(--report-(?:paper|rule|accent)\)/);
assert.match(css, /min-height: 44px/);
const narrow = css.slice(0, css.indexOf("@media (min-width: 860px)"));
assert.match(narrow, /inset: auto 0 0 var\(--report-export-sidebar\)/);
assert.match(narrow, /width: min\(760px, calc\(\(100% - var\(--report-export-sidebar\)\) \* \.92\)\)/);
assert.match(narrow, /margin: 0 auto/);
assert.match(narrow, /border-radius: var\(--radius-lg\) var\(--radius-lg\) 0 0/);
assert.match(narrow, /max-height: 70vh/);
assert.match(narrow, /@supports \(height: 1dvh\)\s*\{\s*\.report-export-drawer, \.report-export-panel \{ max-height: 70dvh;/);
const desktopStart = css.indexOf("@media (min-width: 860px)");
const desktop = css.slice(desktopStart);
assert.match(desktop, /\.report-export-drawer\s*\{\s*inset: 0;\s*width: min\(720px, 92vw\);\s*max-height: 80vh;\s*margin: auto;\s*border-radius: var\(--radius-lg\);/);
assert.match(desktop, /\.report-export-panel \{ max-height: calc\(80vh - 2px\); \}/);
assert.doesNotMatch(desktop, /max-height: 70(?:d)?vh|inset:.*--report-export-sidebar/);
assert.match(css, /\.report-export-list \{[^}]*overflow-y: auto;[^}]*min-height: 0;/);
assert.match(css, /\.report-export-header, \.report-export-footer \{[^}]*flex-shrink: 0;/);
});