All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
Review finding on #330, two defects with one root cause. Desjardins' fingerprint is date/description/montant/solde — four labels any Canadian bank could emit — and MIN_SIGNATURE_LABELS = 4 did not deliver the property it promised, because matching was by SUBSET. Any Date;Description;Montant;Solde file was announced 'Format Desjardins reconnu'. A variant built only from generic labels must now describe the header exactly; one carrying a discriminating label (chequenumber, categorie, memo) keeps subset matching, so extra columns stay fine once something identifies the bank. Worse, a matched signature's single amount column short-circuited the sparse-complementary scan instead of being arbitrated against it. A Date;Description;Debit;Credit;Montant;Solde file reads correctly as debit/credit before #330 and became one unsigned column after, importing every deposit as an expense. The scan now runs first; a signature's amount column only wins when the pair contains it — which is what RBC's genuine Cheque Number / CAD$ case needs, and it still passes. Refs #330 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
764 lines
28 KiB
TypeScript
764 lines
28 KiB
TypeScript
// Bank signatures and format drift (#330).
|
|
//
|
|
// Two contracts are frozen here.
|
|
//
|
|
// THE SIGNATURES. Each of the four banks has a fixture carrying its documented
|
|
// header layout. The test that matters is not "the signature matches" — it is
|
|
// the pair: the same file WITH its bank header and WITHOUT it. Three of the
|
|
// four layouts are read wrong by the generic dictionary, and the counterfactual
|
|
// is what proves the signature earns its place instead of merely agreeing with
|
|
// the heuristic. The other half of that pair is the fall-back: every fixture of
|
|
// the #326 corpus must still be detected with no bank at all.
|
|
//
|
|
// THE DRIFT. `header_signature` records the labels of the last successful
|
|
// import; a later file whose header normalizes differently is drift. The
|
|
// interesting cases are the ones that must NOT fire: no stored signature, a
|
|
// headerless file, a stored value that will not parse, and a cosmetic rename
|
|
// (`Montant` -> `MONTANT ($)`) that normalizes to the same label.
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "fs";
|
|
import { resolve } from "path";
|
|
import {
|
|
BANK_SIGNATURES,
|
|
MIN_SIGNATURE_LABELS,
|
|
bankSignatureById,
|
|
buildHeaderSignature,
|
|
detectHeaderDrift,
|
|
matchBankSignature,
|
|
parseHeaderSignature,
|
|
type BankSignatureId,
|
|
} from "./bankSignatures";
|
|
import { normalizeHeaderCell } from "./headerDictionary";
|
|
import { detectImportFormat } from "./csvAutoDetect";
|
|
import { CSV_FIXTURE_NAMES, readCsvFixture } from "../__fixtures__/csv";
|
|
import fr from "../i18n/locales/fr.json";
|
|
import en from "../i18n/locales/en.json";
|
|
|
|
/** The bank a file is recognised as, or null. Throws on a file detection refuses. */
|
|
function bankOf(content: string): BankSignatureId | null {
|
|
const outcome = detectImportFormat(content);
|
|
if (outcome.status !== "ok") {
|
|
throw new Error(`detection returned ${outcome.status}`);
|
|
}
|
|
return outcome.bank;
|
|
}
|
|
|
|
/** The configuration detection settles on. Throws on a file it refuses. */
|
|
function configOf(content: string) {
|
|
const outcome = detectImportFormat(content);
|
|
if (outcome.status !== "ok") {
|
|
throw new Error(`detection returned ${outcome.status}`);
|
|
}
|
|
return outcome.config;
|
|
}
|
|
|
|
/** The same file with its header row replaced — every data row untouched. */
|
|
function withHeader(raw: string, header: string): string {
|
|
const lines = raw.split("\n");
|
|
lines[0] = header;
|
|
return lines.join("\n");
|
|
}
|
|
|
|
describe("the signature table is well formed (#330)", () => {
|
|
it("gives every bank a distinct id", () => {
|
|
const ids = BANK_SIGNATURES.map((s) => s.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it("declares every label already normalized", () => {
|
|
// A label written `Montant` would never match: the header cell is
|
|
// normalized before the comparison and the table's side is not.
|
|
for (const signature of BANK_SIGNATURES) {
|
|
for (const variant of signature.variants) {
|
|
for (const label of variant.labels) {
|
|
expect(normalizeHeaderCell(label), `${signature.id}: ${label}`).toBe(
|
|
label
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps every role's label inside the fingerprint it projects", () => {
|
|
// A role naming a label absent from `labels` would resolve to a column the
|
|
// match never checked for.
|
|
for (const signature of BANK_SIGNATURES) {
|
|
for (const variant of signature.variants) {
|
|
for (const [role, label] of Object.entries(variant.roles)) {
|
|
expect(variant.labels, `${signature.id}.${role}`).toContain(label);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("refuses a fingerprint too small to identify anyone", () => {
|
|
// `Date;Description;Montant` is the shape of half the corpus and of any
|
|
// hand-made export. A three-label variant would put a bank's name over
|
|
// files nobody can attribute to it.
|
|
for (const signature of BANK_SIGNATURES) {
|
|
for (const variant of signature.variants) {
|
|
expect(
|
|
variant.labels.length,
|
|
`${signature.id}: ${variant.labels.join(",")}`
|
|
).toBeGreaterThanOrEqual(MIN_SIGNATURE_LABELS);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("resolves a bank from its id, and nothing from an unknown one", () => {
|
|
expect(bankSignatureById("desjardins")?.label).toBe("Desjardins");
|
|
expect(bankSignatureById(null)).toBeNull();
|
|
expect(bankSignatureById(undefined)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("each bank is recognised on its own fixture (#330)", () => {
|
|
it("reads the Desjardins layout and names it", () => {
|
|
expect(bankOf(readCsvFixture("bank-desjardins"))).toBe("desjardins");
|
|
expect(configOf(readCsvFixture("bank-desjardins"))).toEqual({
|
|
delimiter: ";",
|
|
hasHeader: true,
|
|
skipLines: 0,
|
|
dateFormat: "DD/MM/YYYY",
|
|
// Solde (column 3) is the balance the signature names, and it stays out
|
|
// of the amount candidates.
|
|
columnMapping: { date: 0, description: 1, amount: 2 },
|
|
amountMode: "single",
|
|
signConvention: "negative_expense",
|
|
});
|
|
});
|
|
|
|
it("reads the English Desjardins header too", () => {
|
|
const content =
|
|
"Date;Description;Amount;Balance\n" +
|
|
"05/01/2025;EPICERIE METRO;-84,32;915,68\n" +
|
|
"15/01/2025;DEPOT PAIE;1250,00;2165,68\n" +
|
|
"18/01/2025;HYDRO QUEBEC;-142,18;2023,50\n" +
|
|
"22/01/2025;RESTAURANT;-56,75;1966,75\n";
|
|
expect(bankOf(content)).toBe("desjardins");
|
|
});
|
|
|
|
it("reads a whole-line-quoted Desjardins export", () => {
|
|
// `preprocessQuotedCSV` unwraps it into a comma-separated file; the
|
|
// signature declares the quirk and the comma, so the match survives it.
|
|
const content =
|
|
'"Date,""Description"",Montant,Solde"\n' +
|
|
'"05/01/2025,""EPICERIE METRO"",-84.32,915.68"\n' +
|
|
'"15/01/2025,""DEPOT PAIE"",1250.00,2165.68"\n' +
|
|
'"18/01/2025,""HYDRO QUEBEC"",-142.18,2023.50"\n' +
|
|
'"22/01/2025,""RESTAURANT"",-56.75,1966.75"\n';
|
|
expect(bankOf(content)).toBe("desjardins");
|
|
});
|
|
|
|
it("reads the RBC layout and maps CAD$ as the amount", () => {
|
|
expect(bankOf(readCsvFixture("bank-rbc"))).toBe("rbc");
|
|
expect(configOf(readCsvFixture("bank-rbc"))).toEqual({
|
|
delimiter: ",",
|
|
hasHeader: true,
|
|
skipLines: 0,
|
|
dateFormat: "DD/MM/YYYY",
|
|
columnMapping: { date: 2, description: 4, amount: 6 },
|
|
amountMode: "single",
|
|
signConvention: "negative_expense",
|
|
});
|
|
});
|
|
|
|
it("reads the Banque Nationale layout as a debit/credit pair", () => {
|
|
expect(bankOf(readCsvFixture("bank-bnc"))).toBe("bnc");
|
|
expect(configOf(readCsvFixture("bank-bnc"))).toEqual({
|
|
delimiter: ";",
|
|
hasHeader: true,
|
|
skipLines: 0,
|
|
dateFormat: "DD/MM/YYYY",
|
|
columnMapping: {
|
|
date: 0,
|
|
description: 1,
|
|
debitAmount: 3,
|
|
creditAmount: 4,
|
|
},
|
|
amountMode: "debit_credit",
|
|
signConvention: "negative_expense",
|
|
});
|
|
});
|
|
|
|
it("reads the Tangerine layout and maps Name as the description", () => {
|
|
expect(bankOf(readCsvFixture("bank-tangerine"))).toBe("tangerine");
|
|
expect(configOf(readCsvFixture("bank-tangerine"))).toEqual({
|
|
delimiter: ",",
|
|
hasHeader: true,
|
|
skipLines: 0,
|
|
dateFormat: "DD/MM/YYYY",
|
|
columnMapping: { date: 0, description: 2, amount: 4 },
|
|
amountMode: "single",
|
|
signConvention: "negative_expense",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("what the signatures actually buy (#330)", () => {
|
|
it("keeps RBC's cheque number out of the amounts", () => {
|
|
// The SAME rows, under a header no signature knows. `CAD$` and
|
|
// `Cheque Number` are sparse-complementary — one cheque number in six rows
|
|
// — so the shape scan pairs them as debit/credit and the row carrying a
|
|
// cheque number imports as -247.95 instead of -6.95. Nothing in the file
|
|
// can tell that apart from a genuine debit/credit pair; a documented layout
|
|
// can.
|
|
const anonymised = withHeader(
|
|
readCsvFixture("bank-rbc"),
|
|
"Type,Numero,Date,Cheque,Libelle,Note,Montant,Devise"
|
|
);
|
|
expect(bankOf(anonymised)).toBeNull();
|
|
expect(configOf(anonymised).amountMode).toBe("debit_credit");
|
|
expect(configOf(anonymised).columnMapping).toEqual({
|
|
date: 2,
|
|
description: 4,
|
|
debitAmount: 3,
|
|
creditAmount: 6,
|
|
});
|
|
|
|
// With the header the bank actually prints, the same rows read as one
|
|
// signed column.
|
|
expect(configOf(readCsvFixture("bank-rbc")).amountMode).toBe("single");
|
|
});
|
|
|
|
it("keeps Tangerine's direction column out of the description, signature or not", () => {
|
|
// `Transaction` is a description keyword of the generic dictionary, and
|
|
// Tangerine's `Transaction` column holds DEBIT / CREDIT. Read as the
|
|
// description, every transaction of the file is labelled `DEBIT`.
|
|
//
|
|
// This used to be rescued by the Tangerine signature alone, leaving any
|
|
// unrecognised file with a `Transaction` column broken. The cardinality
|
|
// veto in `detectDescriptionColumn` fixes the cause instead: a labelled
|
|
// column that behaves like an enum is not the description, whether or not a
|
|
// bank was identified.
|
|
const anonymised = withHeader(
|
|
readCsvFixture("bank-tangerine"),
|
|
"Date,Transaction,Nom,Note,Montant"
|
|
);
|
|
expect(bankOf(anonymised)).toBeNull();
|
|
expect(configOf(anonymised).columnMapping.description).toBe(2);
|
|
|
|
expect(configOf(readCsvFixture("bank-tangerine")).columnMapping.description).toBe(
|
|
2
|
|
);
|
|
});
|
|
|
|
it("does not let a generic variant claim a richer header", () => {
|
|
// Review finding on #340: Desjardins' fingerprint is four labels any
|
|
// Canadian bank could emit, so matching them as a SUBSET announced
|
|
// "Format Desjardins reconnu" over files that have nothing to do with it.
|
|
// A variant made only of generic labels now has to describe the header
|
|
// exactly.
|
|
const richer =
|
|
"Date;Description;Débit;Crédit;Montant;Solde\n" +
|
|
"05/01/2025;EPICERIE;84,32;;-84,32;1000,00\n" +
|
|
"15/01/2025;DEPOT PAIE;;1250,00;1250,00;2250,00\n" +
|
|
"20/01/2025;LOYER;900,00;;-900,00;1350,00\n" +
|
|
"25/01/2025;REMBOURSEMENT;;45,00;45,00;1395,00\n";
|
|
expect(bankOf(richer)).toBeNull();
|
|
|
|
// The exact header still is Desjardins.
|
|
const exact =
|
|
"Date;Description;Montant;Solde\n" +
|
|
"05/01/2025;EPICERIE;-84,32;1000,00\n" +
|
|
"15/01/2025;DEPOT PAIE;1250,00;2250,00\n";
|
|
expect(bankOf(exact)).toBe("desjardins");
|
|
});
|
|
|
|
it("does not let a signature's amount column displace a pair it is not in", () => {
|
|
// The measured regression: with the false Desjardins match above, the
|
|
// signature's `Montant` short-circuited the sparse-complementary scan, so a
|
|
// file that reads correctly as debit/credit became one unsigned column and
|
|
// every deposit imported as an expense. The scan now runs first and the
|
|
// signature only wins when the pair contains its column — which is what
|
|
// RBC's genuine `Cheque Number` / `CAD$` case needs.
|
|
const richer =
|
|
"Date;Description;Débit;Crédit;Montant;Solde\n" +
|
|
"05/01/2025;EPICERIE;84,32;;-84,32;1000,00\n" +
|
|
"15/01/2025;DEPOT PAIE;;1250,00;1250,00;2250,00\n" +
|
|
"20/01/2025;LOYER;900,00;;-900,00;1350,00\n" +
|
|
"25/01/2025;REMBOURSEMENT;;45,00;45,00;1395,00\n";
|
|
const config = configOf(richer);
|
|
expect(config.amountMode).toBe("debit_credit");
|
|
expect(config.columnMapping.debitAmount).toBe(2);
|
|
expect(config.columnMapping.creditAmount).toBe(3);
|
|
|
|
// RBC keeps its override: there the declared amount column IS in the pair.
|
|
expect(configOf(readCsvFixture("bank-rbc")).amountMode).toBe("single");
|
|
});
|
|
|
|
it("holds even for a legitimately recognised bank whose file grew columns", () => {
|
|
// The guard above is only reachable through a signature that really matches,
|
|
// so it needs a discriminating one. Tangerine is identified by `memo`, so it
|
|
// still matches as a subset when the export gains Débit/Crédit columns — and
|
|
// then its declared `Amount` must not displace that genuine pair either.
|
|
const grown =
|
|
"Date,Transaction,Name,Memo,Amount,Débit,Crédit\n" +
|
|
"05/01/2025,DEBIT,EPICERIE METRO,,-84.32,84.32,\n" +
|
|
"15/01/2025,CREDIT,DEPOT PAIE,Paie,1250.00,,1250.00\n" +
|
|
"20/01/2025,DEBIT,LOYER,,-900.00,900.00,\n" +
|
|
"25/01/2025,CREDIT,REMBOURSEMENT,,45.00,,45.00\n";
|
|
expect(bankOf(grown)).toBe("tangerine");
|
|
|
|
const config = configOf(grown);
|
|
expect(config.amountMode).toBe("debit_credit");
|
|
expect(config.columnMapping.debitAmount).toBe(5);
|
|
expect(config.columnMapping.creditAmount).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe("an unknown file falls back to the generic dictionary (#330)", () => {
|
|
it("names no bank on any of the shape fixtures", () => {
|
|
// The corpus frozen by #326 is synthetic and belongs to no bank. A single
|
|
// false positive here is a banner claiming a bank the file is not from.
|
|
for (const name of CSV_FIXTURE_NAMES) {
|
|
if (name.startsWith("bank-")) continue;
|
|
if (name === "absolute-indicator") continue; // refused before any bank
|
|
expect(bankOf(readCsvFixture(name)), name).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("leaves the pre-#330 configurations untouched", () => {
|
|
// The signatures must not have moved a single mapping of the corpus.
|
|
expect(configOf(readCsvFixture("signed-amount"))).toEqual({
|
|
delimiter: ";",
|
|
hasHeader: true,
|
|
skipLines: 0,
|
|
dateFormat: "DD/MM/YYYY",
|
|
columnMapping: { date: 0, description: 1, amount: 2 },
|
|
amountMode: "single",
|
|
signConvention: "negative_expense",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("the preconditions of a match (#330)", () => {
|
|
const rows =
|
|
"05/01/2025;EPICERIE METRO;-84,32;915,68\n" +
|
|
"15/01/2025;DEPOT PAIE;1250,00;2165,68\n" +
|
|
"18/01/2025;HYDRO QUEBEC;-142,18;2023,50\n" +
|
|
"22/01/2025;RESTAURANT;-56,75;1966,75\n";
|
|
|
|
it("refuses a delimiter the bank does not use", () => {
|
|
const tabbed = ("Date;Description;Montant;Solde\n" + rows).replace(
|
|
/;/g,
|
|
"\t"
|
|
);
|
|
expect(bankOf(tabbed)).toBeNull();
|
|
});
|
|
|
|
it("refuses an export carrying more preamble than the bank prints", () => {
|
|
const content =
|
|
"RELEVE DE COMPTE\nCompte 12345\nDate;Description;Montant;Solde\n" + rows;
|
|
expect(bankOf(content)).toBeNull();
|
|
});
|
|
|
|
it("refuses a whole-line-quoted file for a bank that does not quote", () => {
|
|
// Tangerine's labels, wrapped the way Desjardins wraps its exports. The
|
|
// quirk is what says this is not a Tangerine file.
|
|
const content =
|
|
'"Date,""Transaction"",Name,Memo,Amount"\n' +
|
|
'"05/01/2025,""DEBIT"",EPICERIE,,-84.32"\n' +
|
|
'"15/01/2025,""CREDIT"",PAIE,,1250.00"\n' +
|
|
'"18/01/2025,""DEBIT"",HYDRO,,-142.18"\n' +
|
|
'"22/01/2025,""DEBIT"",RESTO,,-56.75"\n';
|
|
expect(bankOf(content)).toBeNull();
|
|
});
|
|
|
|
it("names no bank for a headerless file, whatever its shape", () => {
|
|
// There is no label to read. This is the same boundary that leaves
|
|
// `header_signature` null on those sources.
|
|
expect(bankOf(readCsvFixture("no-header"))).toBeNull();
|
|
});
|
|
|
|
it("matches on the whole label, never on a substring", () => {
|
|
// `matchHeaderColumn` matches substrings — that is the generic dictionary's
|
|
// rule and the reason it mis-reads these layouts. A fingerprint compared
|
|
// loosely would inherit the same problem.
|
|
expect(
|
|
matchBankSignature({
|
|
headerRow: ["Date", "Description", "Montant net", "Solde"],
|
|
delimiter: ";",
|
|
skipLines: 0,
|
|
wholeLineQuoted: false,
|
|
})
|
|
).toBeNull();
|
|
});
|
|
|
|
it("resolves the roles to the columns of THIS file, not to the declared order", () => {
|
|
const match = matchBankSignature({
|
|
headerRow: ["Solde", "Montant", "Description", "Date"],
|
|
delimiter: ";",
|
|
skipLines: 0,
|
|
wholeLineQuoted: false,
|
|
});
|
|
expect(match?.signature.id).toBe("desjardins");
|
|
expect(match?.roles).toEqual({
|
|
date: 3,
|
|
description: 2,
|
|
amount: 1,
|
|
debit: null,
|
|
credit: null,
|
|
balance: 0,
|
|
roleCount: 4,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("the stored signature (#330)", () => {
|
|
it("stores the normalized labels, in order", () => {
|
|
expect(buildHeaderSignature(["Date", "Description", "Montant", "Solde"])).toBe(
|
|
'["date","description","montant","solde"]'
|
|
);
|
|
});
|
|
|
|
it("stores nothing for a file with no header row", () => {
|
|
// The whole point of the boundary: `Col 0`, `Col 1` … is not a signature,
|
|
// and inventing one from the data would fire on every change of content.
|
|
expect(buildHeaderSignature(null)).toBeNull();
|
|
expect(buildHeaderSignature([])).toBeNull();
|
|
});
|
|
|
|
it("reads back what it wrote", () => {
|
|
const stored = buildHeaderSignature(["Date", "Montant"]);
|
|
expect(parseHeaderSignature(stored)).toEqual(["date", "montant"]);
|
|
});
|
|
|
|
it("returns null rather than throwing on anything it did not write", () => {
|
|
// A hash left by an older build, a truncated row, hand-edited SQL: drift
|
|
// detection switches off, an import never blows up.
|
|
expect(parseHeaderSignature(null)).toBeNull();
|
|
expect(parseHeaderSignature("")).toBeNull();
|
|
expect(parseHeaderSignature("d41d8cd98f00b204")).toBeNull();
|
|
expect(parseHeaderSignature("{}")).toBeNull();
|
|
expect(parseHeaderSignature("[1,2,3]")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("drift detection stays silent when it should (#330)", () => {
|
|
const stored = buildHeaderSignature(["Date", "Description", "Montant"]);
|
|
|
|
it("says nothing about a source that never recorded a signature", () => {
|
|
expect(detectHeaderDrift(null, ["Date", "Solde"])).toBeNull();
|
|
expect(detectHeaderDrift(undefined, ["Date", "Solde"])).toBeNull();
|
|
});
|
|
|
|
it("says nothing about a headerless file", () => {
|
|
expect(detectHeaderDrift(stored, null)).toBeNull();
|
|
expect(detectHeaderDrift(stored, [])).toBeNull();
|
|
});
|
|
|
|
it("says nothing about an identical header", () => {
|
|
expect(
|
|
detectHeaderDrift(stored, ["Date", "Description", "Montant"])
|
|
).toBeNull();
|
|
});
|
|
|
|
it("says nothing about a cosmetic rename", () => {
|
|
// Accents, case, punctuation and spacing are stripped before the
|
|
// comparison: none of them changes which column holds what.
|
|
expect(
|
|
detectHeaderDrift(stored, ["DATE", "Description ", "MONTANT ($)"])
|
|
).toBeNull();
|
|
});
|
|
|
|
it("says nothing when the stored value cannot be read", () => {
|
|
expect(detectHeaderDrift("not json", ["Date"])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("drift detection names the columns that moved (#330)", () => {
|
|
it("reports a column that changed position", () => {
|
|
const stored = buildHeaderSignature([
|
|
"Date",
|
|
"Description",
|
|
"Montant",
|
|
"Solde",
|
|
]);
|
|
expect(
|
|
detectHeaderDrift(stored, ["Date", "Description", "Solde", "Montant"])
|
|
).toEqual([
|
|
{
|
|
kind: "moved",
|
|
normalizedLabel: "solde",
|
|
label: "Solde",
|
|
previousIndex: 3,
|
|
currentIndex: 2,
|
|
},
|
|
{
|
|
kind: "moved",
|
|
normalizedLabel: "montant",
|
|
label: "Montant",
|
|
previousIndex: 2,
|
|
currentIndex: 3,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reports a new column and the shift it causes", () => {
|
|
const stored = buildHeaderSignature(["Date", "Description", "Montant"]);
|
|
expect(
|
|
detectHeaderDrift(stored, ["Date", "Devise", "Description", "Montant"])
|
|
).toEqual([
|
|
{
|
|
kind: "added",
|
|
normalizedLabel: "devise",
|
|
label: "Devise",
|
|
previousIndex: null,
|
|
currentIndex: 1,
|
|
},
|
|
{
|
|
kind: "moved",
|
|
normalizedLabel: "description",
|
|
label: "Description",
|
|
previousIndex: 1,
|
|
currentIndex: 2,
|
|
},
|
|
{
|
|
kind: "moved",
|
|
normalizedLabel: "montant",
|
|
label: "Montant",
|
|
previousIndex: 2,
|
|
currentIndex: 3,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reports a column that disappeared, under its stored label", () => {
|
|
// The raw spelling of a removed column is recorded nowhere — only the
|
|
// normalized label survives, and that is what is shown.
|
|
const stored = buildHeaderSignature(["Date", "Description", "Solde"]);
|
|
expect(detectHeaderDrift(stored, ["Date", "Description"])).toEqual([
|
|
{
|
|
kind: "removed",
|
|
normalizedLabel: "solde",
|
|
label: "solde",
|
|
previousIndex: 2,
|
|
currentIndex: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("survives a bank swapping its amount column for a debit/credit pair", () => {
|
|
const stored = buildHeaderSignature([
|
|
"Date",
|
|
"Description",
|
|
"Montant",
|
|
"Solde",
|
|
]);
|
|
const drift = detectHeaderDrift(stored, [
|
|
"Date",
|
|
"Description",
|
|
"Débit",
|
|
"Crédit",
|
|
"Solde",
|
|
])!;
|
|
expect(drift.map((e) => [e.kind, e.normalizedLabel])).toEqual([
|
|
["added", "debit"],
|
|
["added", "credit"],
|
|
["moved", "solde"],
|
|
["removed", "montant"],
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Static guards. The wiring lives in a React hook and two components, and the
|
|
// repository has no jsdom — same technique as the guards of #324 to #329.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const WIZARD_SRC = readFileSync(
|
|
resolve(import.meta.dirname, "..", "hooks", "useImportWizard.ts"),
|
|
"utf-8"
|
|
);
|
|
const PAGE_SRC = readFileSync(
|
|
resolve(import.meta.dirname, "..", "pages", "ImportPage.tsx"),
|
|
"utf-8"
|
|
);
|
|
const componentSrc = (name: string) =>
|
|
readFileSync(
|
|
resolve(import.meta.dirname, "..", "components", "import", name),
|
|
"utf-8"
|
|
);
|
|
|
|
describe("the signature is written at every successful import (#330)", () => {
|
|
it("builds it from the header row the import actually read", () => {
|
|
expect(WIZARD_SRC).toContain("buildHeaderSignature(");
|
|
expect(WIZARD_SRC).toContain(
|
|
"config.hasHeader ? state.previewHeaders : null"
|
|
);
|
|
});
|
|
|
|
it("writes it on both arms of the single write point", () => {
|
|
const body = WIZARD_SRC.slice(
|
|
WIZARD_SRC.indexOf("const executeImport ="),
|
|
WIZARD_SRC.indexOf("const goToStep =")
|
|
);
|
|
expect(body.match(/header_signature: headerSignature/g)).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps it out of the format codec", () => {
|
|
// It is drift metadata, not one of the eight fields that decide how a row
|
|
// is read. `formatToRow` naming it would put it back in the format.
|
|
const CODEC = readFileSync(
|
|
resolve(import.meta.dirname, "importFormat.ts"),
|
|
"utf-8"
|
|
);
|
|
expect(CODEC).not.toContain("header_signature");
|
|
expect(CODEC).not.toContain("headerSignature");
|
|
});
|
|
});
|
|
|
|
describe("drift is computed on the way to the preview (#330)", () => {
|
|
const body = () =>
|
|
WIZARD_SRC.slice(
|
|
WIZARD_SRC.indexOf("const parseAndPreview ="),
|
|
WIZARD_SRC.indexOf("const checkDuplicatesInternal =")
|
|
);
|
|
|
|
it("compares the stored signature to the headers just parsed", () => {
|
|
expect(body()).toContain("detectHeaderDrift(");
|
|
expect(body()).toContain("state.existingSource?.header_signature");
|
|
});
|
|
|
|
it("does not compare anything on a headerless file", () => {
|
|
expect(body()).toContain("state.sourceConfig.hasHeader");
|
|
});
|
|
|
|
it("still reaches the preview step", () => {
|
|
expect(body()).toContain(
|
|
'dispatch({ type: "SET_STEP", payload: "file-preview" })'
|
|
);
|
|
});
|
|
|
|
it("drops the score the re-detection produced, which describes another format", () => {
|
|
// The re-detection measures the format the PANEL offers, not the one in
|
|
// use. Keeping its score would show "Format reconnu — 6 of 6 rows read"
|
|
// beside the stored mapping as soon as the user steps back.
|
|
expect(body()).toContain(
|
|
'dispatch({ type: "SET_DETECTION_SCORE", payload: null })'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the drift panel offers two outcomes and writes neither (#330)", () => {
|
|
const PANEL = componentSrc("FormatDriftPanel.tsx");
|
|
|
|
it("is rendered on the preview step, above the table", () => {
|
|
expect(PAGE_SRC).toContain("{state.formatDrift && (");
|
|
expect(PAGE_SRC).toContain("<FormatDriftPanel");
|
|
expect(PAGE_SRC).toContain("onAdopt={adoptDriftFormat}");
|
|
expect(PAGE_SRC).toContain("onKeep={keepCurrentFormat}");
|
|
expect(PAGE_SRC.indexOf("<FormatDriftPanel")).toBeLessThan(
|
|
PAGE_SRC.indexOf("<FilePreviewTable")
|
|
);
|
|
});
|
|
|
|
it("re-parses under the adopted format instead of re-reading state", () => {
|
|
// `state` has not re-rendered when the parse starts, exactly as for the
|
|
// sign flip: reading it back would redisplay the table just replaced.
|
|
const body = WIZARD_SRC.slice(
|
|
WIZARD_SRC.indexOf("const adoptDriftFormat ="),
|
|
WIZARD_SRC.indexOf("const keepCurrentFormat =")
|
|
);
|
|
expect(body).toContain("parseFilesInternal(adopted)");
|
|
expect(body).not.toContain("createSource(");
|
|
expect(body).not.toContain("updateSource(");
|
|
});
|
|
|
|
it("renders the three kinds of change through i18n", () => {
|
|
for (const key of ["columnMoved", "columnAdded", "columnRemoved"]) {
|
|
expect(PANEL, key).toContain(`import.drift.${key}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the repair path is stated in the interface (#330)", () => {
|
|
const NOTICE = componentSrc("RepairPathNotice.tsx");
|
|
|
|
it("names the history deletion rather than a vague warning", () => {
|
|
expect(NOTICE).toContain("import.repairPath.title");
|
|
expect(NOTICE).toContain("import.repairPath.body");
|
|
for (const locale of [fr, en]) {
|
|
expect(locale.import.repairPath.body.length).toBeGreaterThan(0);
|
|
}
|
|
// The two facts a user needs: duplicates are matched on the amount, and the
|
|
// faulty import has to go first.
|
|
expect(fr.import.repairPath.body).toContain("historique");
|
|
expect(en.import.repairPath.body).toContain("history");
|
|
});
|
|
|
|
it("appears in the preview and in the drift panel, not in one of the two", () => {
|
|
expect(componentSrc("FilePreviewTable.tsx")).toContain("<RepairPathNotice />");
|
|
expect(componentSrc("FormatDriftPanel.tsx")).toContain("<RepairPathNotice />");
|
|
});
|
|
});
|
|
|
|
describe("the recognised-bank banner (#330)", () => {
|
|
const PANEL = componentSrc("SourceConfigPanel.tsx");
|
|
|
|
it("names the bank only when the rows actually read", () => {
|
|
// A bank label over a file two thirds of whose rows fail is a claim the app
|
|
// cannot back; the uncertain wording is the one that helps there.
|
|
expect(PANEL).toContain("detectionScore?.confident");
|
|
expect(PANEL).toContain("import.config.detectionBank");
|
|
});
|
|
|
|
it("interpolates the bank name instead of translating it", () => {
|
|
// A bank is a proper noun. `Desjardins` is `Desjardins` in both languages.
|
|
for (const locale of [fr, en]) {
|
|
expect(locale.import.config.detectionBank).toContain("{{bank}}");
|
|
expect(locale.import.config.detectionBank).toContain("{{read}}");
|
|
expect(locale.import.config.detectionBank).toContain("{{total}}");
|
|
}
|
|
});
|
|
|
|
it("clears the bank with the score, in one place", () => {
|
|
// Three sites drop the score (a hand edit, a template, a sign flip). The
|
|
// reducer clearing the bank alongside it is what keeps a fourth from
|
|
// forgetting.
|
|
const reducerCase = WIZARD_SRC.slice(
|
|
WIZARD_SRC.indexOf('case "SET_DETECTION_SCORE":'),
|
|
WIZARD_SRC.indexOf('case "SET_DETECTED_BANK":')
|
|
);
|
|
expect(reducerCase).toContain("detectedBank: action.payload === null");
|
|
});
|
|
});
|
|
|
|
describe("every new string exists in both languages (#330)", () => {
|
|
it("carries the drift panel", () => {
|
|
for (const key of [
|
|
"title",
|
|
"intro",
|
|
"columnMoved",
|
|
"columnAdded",
|
|
"columnRemoved",
|
|
"adopt",
|
|
"adoptHint",
|
|
"adoptUnavailable",
|
|
"keep",
|
|
"keepHint",
|
|
] as const) {
|
|
expect(fr.import.drift[key].length, `fr.${key}`).toBeGreaterThan(0);
|
|
expect(en.import.drift[key].length, `en.${key}`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("interpolates the column positions in both languages", () => {
|
|
for (const locale of [fr, en]) {
|
|
expect(locale.import.drift.columnMoved).toContain("{{label}}");
|
|
expect(locale.import.drift.columnMoved).toContain("{{from}}");
|
|
expect(locale.import.drift.columnMoved).toContain("{{to}}");
|
|
expect(locale.import.drift.columnAdded).toContain("{{to}}");
|
|
expect(locale.import.drift.columnRemoved).toContain("{{from}}");
|
|
}
|
|
});
|
|
|
|
it("carries the repair path", () => {
|
|
for (const key of ["title", "body"] as const) {
|
|
expect(fr.import.repairPath[key].length, `fr.${key}`).toBeGreaterThan(0);
|
|
expect(en.import.repairPath[key].length, `en.${key}`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|