Simpl-Resultat/src/utils/bankSignatures.test.ts
le king fu 37b832e084
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
fix(import): stop a generic bank signature from claiming, and misreading, a richer file
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>
2026-08-14 12:03:07 -04:00

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);
}
});
});