// 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(" { // `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(""); expect(componentSrc("FormatDriftPanel.tsx")).toContain(""); }); }); 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); } }); });