Add searchable and flattened export, history re-export and diagnostics self-test
issueAndArchive keeps an issued invoice even when rendering or archiving fails (Issued - not archived, with a Render & archive action). The export menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is the invoice number alone; flatten failures offer Retry or searchable export and never silently lower the resolution. History re-exports the original as issued by default and warns when a re-render's layout fingerprint differs. The render client gains a FIFO assemble lane that previews cannot supersede. A Diagnostics panel runs fixtures in the real worker and compares against goldens generated from Node.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CanvasLike } from "../pdf/flatten";
|
||||
import { runAssembleJob } from "../pdf/render/assembleJob";
|
||||
import { renderInvoicePdf } from "../pdf/render/invoice";
|
||||
import { ensureSetup } from "../pdf/templates/harness";
|
||||
import type { GoldenMap } from "../pdf/testing/goldens";
|
||||
import goldens from "../pdf/testing/goldens.json";
|
||||
import { loadCanvas } from "../pdf/testing/rasterize";
|
||||
import { selfTestFixtures } from "../pdf/testing/selfTestFixtures";
|
||||
import { reportToText, runSelfTest, type SelfTestDeps, type SelfTestDoc } from "./selfTest";
|
||||
|
||||
const canvasLib = await loadCanvas();
|
||||
if (!canvasLib) console.warn("[selfTest.test] @napi-rs/canvas is not installed: skipping the self-test run under Node");
|
||||
|
||||
function nodeDeps(over: Partial<SelfTestDeps> = {}): SelfTestDeps {
|
||||
ensureSetup();
|
||||
return {
|
||||
env: () => ({ userAgent: "node", isSecureContext: true, renderMode: "worker" }),
|
||||
fixtures: selfTestFixtures(),
|
||||
goldens: goldens as GoldenMap,
|
||||
render: async (f) => {
|
||||
const r = await renderInvoicePdf({ model: f.model, prefs: f.prefs });
|
||||
return { bytes: r.bytes, pages: r.pages, issues: r.issues, fingerprint: r.fingerprint, ms: 1 };
|
||||
},
|
||||
openPdf: async (bytes) => {
|
||||
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
|
||||
const doc = await task.promise;
|
||||
return Object.assign(doc, { close: () => task.destroy() }) as unknown as SelfTestDoc;
|
||||
},
|
||||
createCanvas: (w, h) => canvasLib!.createCanvas(w, h) as unknown as CanvasLike,
|
||||
encodeJpeg: async (c, q) => new Uint8Array(await (c as unknown as { encode(f: "jpeg", q: number): Promise<Buffer> }).encode("jpeg", Math.round(q * 100))),
|
||||
assemble: async (pages, title) => (await runAssembleJob({ id: 1, pages, title })).bytes,
|
||||
startFrameMonitor: () => () => 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!canvasLib)("runSelfTest under Node", () => {
|
||||
it("passes every check against the committed goldens", async () => {
|
||||
const updates: number[] = [];
|
||||
const report = await runSelfTest(nodeDeps(), (r) => updates.push(r.sections.length));
|
||||
const failed = report.sections.flatMap((s) => s.checks.filter((c) => c.status === "fail").map((c) => `${s.title}: ${c.label}: ${c.detail}`));
|
||||
expect(failed).toEqual([]);
|
||||
expect(report.passed && report.done).toBe(true);
|
||||
expect(updates.length).toBeGreaterThan(10);
|
||||
const labels = report.sections.flatMap((s) => s.checks.map((c) => c.label));
|
||||
for (const l of ["Page count", "Layout audit", "Rasterise page 1", "Text extraction", "Layout fingerprint matches the Node golden", "Longest main-thread stall", "Assemble the image-only PDF"]) {
|
||||
expect(labels).toContain(l);
|
||||
}
|
||||
expect(reportToText(report)).toContain("RESULT: all checks passed");
|
||||
});
|
||||
|
||||
it("reports a fingerprint difference clearly, with both values", async () => {
|
||||
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
|
||||
const id = Object.keys(tampered)[0];
|
||||
tampered[id].fingerprint = "0".repeat(64);
|
||||
const report = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
|
||||
const check = report.sections.flatMap((s) => s.checks).find((c) => c.label.startsWith("Layout fingerprint"))!;
|
||||
expect(check.status).toBe("fail");
|
||||
expect(check.detail).toContain("0".repeat(64));
|
||||
expect(check.detail).toContain("this webview");
|
||||
expect(report.passed).toBe(false);
|
||||
expect(reportToText(report)).toContain("RESULT: FAILED");
|
||||
});
|
||||
|
||||
it("fails the text check when the page does not contain the expected strings, and a render error stops that fixture", async () => {
|
||||
const tampered: GoldenMap = JSON.parse(JSON.stringify(goldens));
|
||||
const id = Object.keys(tampered)[0];
|
||||
tampered[id].totalText = "₹9,999,999.99";
|
||||
const a = await runSelfTest(nodeDeps({ goldens: tampered, fixtures: selfTestFixtures().slice(0, 1) }));
|
||||
expect(a.sections.flatMap((s) => s.checks).find((c) => c.label === "Text extraction")).toMatchObject({ status: "fail" });
|
||||
|
||||
const b = await runSelfTest(nodeDeps({ render: async () => Promise.reject(new Error("worker could not start")), fixtures: selfTestFixtures().slice(0, 1) }));
|
||||
const sec = b.sections.find((s) => s.title.startsWith("Classic, A4"))!;
|
||||
expect(sec.checks.map((c) => c.status)).toEqual(["fail"]);
|
||||
expect(sec.checks[0].detail).toContain("worker could not start");
|
||||
expect(b.sections.some((s) => s.title.startsWith("300 DPI"))).toBe(false);
|
||||
});
|
||||
|
||||
it("fails the stall check when the longest frame gap exceeds the budget", async () => {
|
||||
const report = await runSelfTest(nodeDeps({ startFrameMonitor: () => () => 900 }));
|
||||
const stall = report.sections.flatMap((s) => s.checks).find((c) => c.label === "Longest main-thread stall")!;
|
||||
expect(stall.status).toBe("fail");
|
||||
expect(stall.detail).toContain("900 ms");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user