129 lines
6.6 KiB
TypeScript
129 lines
6.6 KiB
TypeScript
|
|
// Deterministic-parser tests.
|
||
|
|
//
|
||
|
|
// The parser reads figures by coordinate from a fixed report template, so the risk is
|
||
|
|
// silent drift: a column moves and it starts returning a plausible wrong number. These
|
||
|
|
// tests pin the behaviour that matters — the column mapping, the code/description split,
|
||
|
|
// date formats, and the fact that anything which does not reconcile is refused.
|
||
|
|
//
|
||
|
|
// Run: node --import tsx --test lib/invoice-parser.test.ts
|
||
|
|
import { test } from "node:test";
|
||
|
|
import assert from "node:assert/strict";
|
||
|
|
import { existsSync, readFileSync } from "node:fs";
|
||
|
|
import { resolve } from "node:path";
|
||
|
|
import { parseInvoicePdf, registerCodeFrom, siteFromDescription, isoDate, ParseError } from "./invoice-parser";
|
||
|
|
|
||
|
|
// --- unit level: the pieces that do not need a PDF -------------------------------
|
||
|
|
|
||
|
|
test("registerCodeFrom strips punctuation and suffix from an invoice code", () => {
|
||
|
|
assert.equal(registerCodeFrom("SS-01-CASE"), "SS01");
|
||
|
|
assert.equal(registerCodeFrom("TP601-TATA"), "TP601");
|
||
|
|
assert.equal(registerCodeFrom("MG-01-DEZI"), "MG01");
|
||
|
|
assert.equal(registerCodeFrom("RV-03-CAT"), "RV03");
|
||
|
|
assert.equal(registerCodeFrom("TLB07-CASE"), "TLB07");
|
||
|
|
// No register equivalent — must NOT be silently coerced into something plausible.
|
||
|
|
assert.equal(registerCodeFrom("MPLANT05"), "MPLANT05");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("site is read from the invoice description", () => {
|
||
|
|
assert.equal(siteFromDescription("Plant Hire Sep 2026 West Park"), "West Park");
|
||
|
|
assert.equal(siteFromDescription("Hire Plant Sep 2026 Halfway Gardens"), "Halfway Gardens");
|
||
|
|
assert.equal(siteFromDescription("Hire Plant Sep 2026 Rooihuiskraal"), "Rooihuiskraal");
|
||
|
|
assert.equal(siteFromDescription("Plant Hire SEp 2026 Cosmo City"), "Cosmo City");
|
||
|
|
assert.equal(siteFromDescription("something else entirely"), "");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("dates accept the report's written formats", () => {
|
||
|
|
assert.equal(isoDate("18-September-2026"), "2026-09-18");
|
||
|
|
assert.equal(isoDate("21/08/2026"), "2026-08-21");
|
||
|
|
assert.throws(() => isoDate("not a date"), ParseError);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a PDF with no recognisable content is refused, not guessed", async () => {
|
||
|
|
const notAPdf = new TextEncoder().encode("%PDF-1.4\nthis is not a real invoice\n");
|
||
|
|
await assert.rejects(() => parseInvoicePdf(notAPdf), /PDF|Could not/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- integration level: the real invoices -----------------------------------------
|
||
|
|
//
|
||
|
|
// The source PDFs are not committed (they are client documents), so these skip when
|
||
|
|
// they are absent. Point FIXTURE_DIR at a folder holding PHI*.pdf to run them.
|
||
|
|
|
||
|
|
const DIR = process.env.FIXTURE_DIR ?? "/tmp";
|
||
|
|
const REAL: [string, string][] = [
|
||
|
|
["PHI166.pdf", "invoice-phi166.json"],
|
||
|
|
["PHI167.pdf", "invoice-phi167.json"],
|
||
|
|
["PHI168.pdf", "invoice-phi168.json"],
|
||
|
|
["PHI169.pdf", "invoice-phi169.json"],
|
||
|
|
["PHI171.pdf", "invoice-phi171.json"],
|
||
|
|
];
|
||
|
|
|
||
|
|
const available = REAL.filter(([pdf]) => existsSync(resolve(DIR, pdf)));
|
||
|
|
|
||
|
|
test("parser reproduces the hand-extracted invoices exactly", { skip: available.length ? false : "no source PDFs present" }, async () => {
|
||
|
|
const failures: string[] = [];
|
||
|
|
for (const [pdfName, jsonName] of available) {
|
||
|
|
const src = JSON.parse(readFileSync(resolve("data/source", jsonName), "utf8"));
|
||
|
|
const got = await parseInvoicePdf(new Uint8Array(readFileSync(resolve(DIR, pdfName))));
|
||
|
|
|
||
|
|
const cmp = (label: string, a: unknown, b: unknown) => {
|
||
|
|
const ok = typeof a === "number" && typeof b === "number"
|
||
|
|
? Math.abs(a - b) < 0.01
|
||
|
|
: String(a ?? "") === String(b ?? "");
|
||
|
|
if (!ok) failures.push(`${jsonName} ${label}: parser=${a} source=${b}`);
|
||
|
|
};
|
||
|
|
|
||
|
|
cmp("invoice_no", got.invoice_no, src.invoice_no);
|
||
|
|
cmp("invoice_date", got.invoice_date, src.invoice_date);
|
||
|
|
cmp("order_no", got.order_no, src.order_no);
|
||
|
|
cmp("debtor", got.debtor, src.debtor);
|
||
|
|
cmp("debtor_no", got.debtor_no, src.debtor_no);
|
||
|
|
cmp("period_from", got.period_from, src.period_from);
|
||
|
|
cmp("period_to", got.period_to, src.period_to);
|
||
|
|
cmp("description", got.description, src.description);
|
||
|
|
cmp("site", got.site, src.site);
|
||
|
|
cmp("currency", got.currency, src.currency);
|
||
|
|
cmp("vat_pct", got.vat_pct, src.vat_pct);
|
||
|
|
cmp("subtotal", got.subtotal, src.subtotal);
|
||
|
|
cmp("escalation", got.escalation, src.escalation);
|
||
|
|
cmp("vat", got.vat, src.vat);
|
||
|
|
cmp("total_excl", got.total_excl, src.total_excl);
|
||
|
|
cmp("total_incl", got.total_incl, src.total_incl);
|
||
|
|
|
||
|
|
if (got.lines.length !== src.lines.length) {
|
||
|
|
failures.push(`${jsonName} line count: ${got.lines.length} vs ${src.lines.length}`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
got.lines.forEach((l, i) => {
|
||
|
|
const s = src.lines[i];
|
||
|
|
cmp(`line${i + 1}.amount_excl`, l.amount_excl, s.amount_excl);
|
||
|
|
cmp(`line${i + 1}.vat`, l.vat, s.vat);
|
||
|
|
cmp(`line${i + 1}.total_incl`, l.total_incl, s.total_incl);
|
||
|
|
cmp(`line${i + 1}.qty`, l.qty, s.qty);
|
||
|
|
cmp(`line${i + 1}.unit`, l.unit, s.unit);
|
||
|
|
cmp(`line${i + 1}.rate`, l.rate, s.rate);
|
||
|
|
// machine_code is compared through registerCodeFrom: one input ("RV-01-CATCB22")
|
||
|
|
// has no separator between code and description, so the parser's split differs
|
||
|
|
// cosmetically from the hand extraction. What must match is the plant item the
|
||
|
|
// line books to, which is derived from exactly this value.
|
||
|
|
cmp(`line${i + 1}.register code`, registerCodeFrom(l.machine_code), registerCodeFrom(s.machine_code));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
assert.deepEqual(failures, [], `parser disagreed with the hand extraction:\n${failures.join("\n")}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("the parser never returns an invoice whose totals do not reconcile", { skip: available.length ? false : "no source PDFs present" }, async () => {
|
||
|
|
// Every successful parse must satisfy the balance rules on its own — this is the
|
||
|
|
// guarantee the intake route relies on before it is allowed to write anything.
|
||
|
|
for (const [pdfName] of available) {
|
||
|
|
const got = await parseInvoicePdf(new Uint8Array(readFileSync(resolve(DIR, pdfName))));
|
||
|
|
const sumHire = Math.round(got.lines.reduce((s, l) => s + l.amount_excl, 0) * 100) / 100;
|
||
|
|
const sumVat = Math.round(got.lines.reduce((s, l) => s + l.vat, 0) * 100) / 100;
|
||
|
|
assert.ok(Math.abs(sumHire - got.subtotal) < 0.01, `${pdfName}: lines != subtotal`);
|
||
|
|
assert.ok(Math.abs(sumVat - got.vat) < 0.02, `${pdfName}: line VAT != header VAT`);
|
||
|
|
assert.ok(Math.abs(got.total_excl + got.vat - got.total_incl) < 0.01, `${pdfName}: totals do not balance`);
|
||
|
|
for (const l of got.lines) {
|
||
|
|
assert.ok(Math.abs(l.amount_excl + l.vat - l.total_incl) < 0.01, `${pdfName}: ${l.machine_code} does not balance`);
|
||
|
|
assert.ok(Math.abs(l.qty * l.rate - l.amount_excl) < 0.01, `${pdfName}: ${l.machine_code} qty x rate != amount`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|