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,26 @@
|
||||
{
|
||||
"classic-a4-tax-3": {
|
||||
"pages": 1,
|
||||
"fingerprint": "35b03b20aeec3dcbb808300b6e8f0b1867dfd69d5fd6f69aaee891c354955092",
|
||||
"invoiceNumber": "ST/2026-001",
|
||||
"totalText": "₹8,625.80",
|
||||
"pageLabel": "Page 1 of 1",
|
||||
"textHashes": [
|
||||
"855dd2d104e80ff599210d69f643be0ccb281e6583cbcea2dafa5fdcb09f03fc"
|
||||
],
|
||||
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1"
|
||||
},
|
||||
"classic-letter-unreg-60": {
|
||||
"pages": 3,
|
||||
"fingerprint": "78d60e1b72dac0e40fea96cbf34c35cfeb637dbe0bc9754dcecba72231197e75",
|
||||
"invoiceNumber": "ST/2026-002",
|
||||
"totalText": "₹1,56,000.00",
|
||||
"pageLabel": "Page 1 of 3",
|
||||
"textHashes": [
|
||||
"1728b53a177e385584d2dc33b2e71607f860886632f32a5911364f86f345c950",
|
||||
"870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b",
|
||||
"fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0"
|
||||
],
|
||||
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per hour 3 ₹3,600.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per hour 2 ₹2,400.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per hour 3 ₹3,600.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per hour 2 ₹2,400.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per hour 3 ₹3,600.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per hour 2 ₹2,400.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderInvoicePdf } from "../render/invoice";
|
||||
import { ensureSetup } from "../templates/harness";
|
||||
import type { Golden } from "./goldens";
|
||||
import { selfTestFixtures } from "./selfTestFixtures";
|
||||
import { parsePdfText } from "./pdfText";
|
||||
|
||||
/**
|
||||
* goldens.json is what the Diagnostics self-test compares the webview's render against (page count,
|
||||
* layout fingerprint, key strings). This test keeps it in step with the Node render:
|
||||
* `UPDATE_GOLDENS=1 npx vitest run src/pdf/testing/goldens.test.ts` rewrites it.
|
||||
*/
|
||||
|
||||
const FILE = path.resolve(process.cwd(), "src/pdf/testing/goldens.json");
|
||||
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
|
||||
|
||||
async function current(): Promise<Record<string, Golden>> {
|
||||
ensureSetup();
|
||||
const out: Record<string, Golden> = {};
|
||||
for (const f of selfTestFixtures()) {
|
||||
const r = await renderInvoicePdf({ model: f.model, prefs: f.prefs });
|
||||
const text = await parsePdfText(r.bytes);
|
||||
const pages = text.pages.map((p) => p.items.map((i) => i.str).join(" "));
|
||||
out[f.id] = {
|
||||
pages: r.pages,
|
||||
fingerprint: r.fingerprint,
|
||||
invoiceNumber: f.invoiceNumber,
|
||||
totalText: f.totalText,
|
||||
pageLabel: `Page 1 of ${r.pages}`,
|
||||
textHashes: pages.map(sha),
|
||||
firstPageText: pages[0],
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("self-test goldens", () => {
|
||||
it("match the Node render of the self-test fixtures", async () => {
|
||||
const now = await current();
|
||||
if (process.env.UPDATE_GOLDENS) {
|
||||
writeFileSync(FILE, `${JSON.stringify(now, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
const stored = JSON.parse(readFileSync(FILE, "utf8")) as Record<string, Golden>;
|
||||
expect(Object.keys(stored).sort()).toEqual(Object.keys(now).sort());
|
||||
for (const [id, golden] of Object.entries(now)) {
|
||||
const s = stored[id];
|
||||
// Compare the readable parts first so a failure says what moved.
|
||||
expect(s.pages, `${id}: page count`).toBe(golden.pages);
|
||||
expect(s.firstPageText, `${id}: page 1 text`).toBe(golden.firstPageText);
|
||||
expect(s.textHashes, `${id}: text of every page`).toEqual(golden.textHashes);
|
||||
expect(s.invoiceNumber).toBe(golden.invoiceNumber);
|
||||
expect(s.totalText).toBe(golden.totalText);
|
||||
expect(s.pageLabel).toBe(golden.pageLabel);
|
||||
expect(s.fingerprint, `${id}: layout fingerprint (run with UPDATE_GOLDENS=1 if the layout change is intended)`).toBe(golden.fingerprint);
|
||||
}
|
||||
});
|
||||
|
||||
it("every golden carries the strings the self-test looks for", async () => {
|
||||
const stored = JSON.parse(readFileSync(FILE, "utf8")) as Record<string, Golden>;
|
||||
for (const [id, g] of Object.entries(stored)) {
|
||||
expect(g.totalText, id).toMatch(/^₹/);
|
||||
expect(g.firstPageText, id).toContain(g.invoiceNumber);
|
||||
expect(g.firstPageText.replace(/\s+/g, " "), id).toContain(g.pageLabel);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/** One entry of goldens.json: what the Node render of a self-test fixture produced. */
|
||||
export interface Golden {
|
||||
pages: number;
|
||||
fingerprint: string;
|
||||
invoiceNumber: string;
|
||||
totalText: string;
|
||||
/** Page-1 label the footer must show. */
|
||||
pageLabel: string;
|
||||
/** Per page: sha256 of the extracted text (items joined with a space). */
|
||||
textHashes: string[];
|
||||
/** Page 1's text, readable in a diff. */
|
||||
firstPageText: string;
|
||||
}
|
||||
|
||||
export type GoldenMap = Record<string, Golden>;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { computeTotals } from "../../lib/invoice";
|
||||
import { amountInWords } from "../../lib/numberToWords";
|
||||
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "../../lib/types";
|
||||
import { buildRenderModel, type RenderModel } from "../model/build";
|
||||
import { DEFAULT_PREFS, type RenderPrefsV1 } from "../model/prefs";
|
||||
|
||||
/**
|
||||
* Pure data for the Diagnostics self-test and the goldens test (no Node or DOM imports, so the
|
||||
* webview and Node build the very same models). Change a fixture and regenerate the goldens.
|
||||
*/
|
||||
|
||||
export interface SelfTestFixture {
|
||||
id: string;
|
||||
label: string;
|
||||
model: RenderModel;
|
||||
prefs: RenderPrefsV1;
|
||||
invoiceNumber: string;
|
||||
/** The grand total as the template prints it, e.g. "₹8,260.00". */
|
||||
totalText: string;
|
||||
}
|
||||
|
||||
const VENDOR: Settings = {
|
||||
vendorName: "Selftest Studio",
|
||||
vendorAddress: "1 Test Lane, Test Town, Bangalore - 560005",
|
||||
vendorEmail: "selftest@example.com",
|
||||
vendorPhone: "9000000000",
|
||||
vendorPan: "AAAAA0000A",
|
||||
vendorGstin: "",
|
||||
vendorStateCode: "29",
|
||||
logoPath: null,
|
||||
signaturePath: null,
|
||||
defaultBankId: 1,
|
||||
defaultTaxRate: 18,
|
||||
defaultTaxType: "cgst_sgst",
|
||||
paymentTermsDays: 30,
|
||||
currency: "INR",
|
||||
onboarded: true,
|
||||
theme: "g10",
|
||||
gstRegistration: "unregistered",
|
||||
defaultHsnSac: "",
|
||||
signatoryName: "",
|
||||
signatoryDesignation: "",
|
||||
renderPrefs: "",
|
||||
};
|
||||
|
||||
const BANK: BankAccount = {
|
||||
id: 1,
|
||||
label: "Primary",
|
||||
bankName: "State Bank of India",
|
||||
accountName: "Selftest Studio",
|
||||
accountNo: "00000000000",
|
||||
branch: "000000",
|
||||
ifsc: "SBIN0000000",
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
const items = (n: number, hsn: boolean): InvoiceItem[] =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: i + 1,
|
||||
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
|
||||
mode: i % 2 === 0 ? "rate" : "fixed",
|
||||
rate: 1200,
|
||||
unit: "hour",
|
||||
quantity: 1 + (i % 3),
|
||||
amount: 2500 + i * 10,
|
||||
sortOrder: i,
|
||||
...(hsn ? { hsnSac: "998397" } : {}),
|
||||
}));
|
||||
|
||||
function build(o: { id: string; label: string; number: string; rows: number; registered: boolean; prefs: Partial<RenderPrefsV1> }): SelfTestFixture {
|
||||
const taxType: TaxType = o.registered ? "cgst_sgst" : "none";
|
||||
const taxRate = o.registered ? 18 : 0;
|
||||
const rows = items(o.rows, o.registered);
|
||||
const totals = computeTotals(rows, 0, taxType, taxRate);
|
||||
const vendor: Settings = o.registered
|
||||
? { ...VENDOR, vendorGstin: "29AAAAA0000A1Z5", gstRegistration: "regular", signatoryName: "Selftest Studio", signatoryDesignation: "Proprietor" }
|
||||
: VENDOR;
|
||||
const invoice: Invoice = {
|
||||
id: 1,
|
||||
number: o.number,
|
||||
seriesId: 1,
|
||||
invoiceDate: "2026-09-15",
|
||||
dueDate: "2026-10-15",
|
||||
clientId: null,
|
||||
clientName: "Acme Learning Pvt Ltd",
|
||||
clientAddress: "42 MG Road\nBangalore 560001",
|
||||
clientGstin: o.registered ? "29ABCDE1234F1Z5" : "",
|
||||
poNumber: "",
|
||||
placeOfSupplyStateCode: o.registered ? "29" : "",
|
||||
subtotal: totals.subtotal,
|
||||
discount: totals.discount,
|
||||
taxType,
|
||||
taxRate,
|
||||
cgstAmount: totals.cgst,
|
||||
sgstAmount: totals.sgst,
|
||||
igstAmount: totals.igst,
|
||||
total: totals.total,
|
||||
amountInWords: amountInWords(totals.total),
|
||||
bankAccountId: 1,
|
||||
bankSnapshot: "",
|
||||
signaturePath: null,
|
||||
notes: "",
|
||||
status: "issued",
|
||||
createdAt: "2026-09-15T00:00:00Z",
|
||||
updatedAt: "2026-09-15T00:00:00Z",
|
||||
docType: o.registered ? "tax_invoice" : "invoice",
|
||||
reverseCharge: false,
|
||||
vendorSnapshot: "",
|
||||
snapshotOrigin: "",
|
||||
cancelledAt: null,
|
||||
cancelReason: "",
|
||||
archivedPdfSha256: null,
|
||||
renderPrefs: "",
|
||||
items: rows,
|
||||
};
|
||||
const model = buildRenderModel({ invoice, vendor, bank: BANK, logo: null, signatureDataUri: null, appVersion: "0.0.0-selftest" });
|
||||
return {
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
model,
|
||||
prefs: { ...DEFAULT_PREFS, ...o.prefs },
|
||||
invoiceNumber: o.number,
|
||||
totalText: model.totals.totalText,
|
||||
};
|
||||
}
|
||||
|
||||
export const SELF_TEST_FIXTURE_IDS = ["classic-a4-tax-3", "classic-letter-unreg-60"] as const;
|
||||
|
||||
/** Classic A4 tax invoice with 3 rows, and Classic Letter plain invoice with 60 rows (a multi-page table). */
|
||||
export function selfTestFixtures(): SelfTestFixture[] {
|
||||
return [
|
||||
build({ id: "classic-a4-tax-3", label: "Classic, A4, tax invoice, 3 rows", number: "ST/2026-001", rows: 3, registered: true, prefs: { pageSize: "A4" } }),
|
||||
build({ id: "classic-letter-unreg-60", label: "Classic, Letter, unregistered, 60 rows", number: "ST/2026-002", rows: 60, registered: false, prefs: { pageSize: "LETTER" } }),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user