feat(reports): streamline reader actions and center export dialog
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user