Review finding on #325. The debit/credit rule tested isNaN(debit) && isNaN(credit), which only caught the case where BOTH sides fell. The unused column carries 0,00 in exactly the files this rule exists to fix, so an unreadable debit beside a 0,00 credit computed 0 - 0 = 0 and imported silently — the very bug, one cell over. Replayed on the PR's own unused-column-zero fixture with a currency suffix: 6 transactions imported at 0,00 with no error row. A mapped cell that is not empty but does not parse now fails the row whatever its sibling holds. An EMPTY cell keeps meaning 'this column does not apply to this row' and contributes zero, which is the normal shape of the format. Refs #325 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
998 lines
35 KiB
TypeScript
998 lines
35 KiB
TypeScript
// importFormat — the format codec (#324).
|
||
//
|
||
// The chantier's root bug was a format field that never reached the database
|
||
// and a restore that invented a value for it. These tests hold the two halves
|
||
// of the repair: the codec carries EVERY field in both directions, and the
|
||
// wizard restores what was saved instead of re-deriving it.
|
||
|
||
import { describe, it, expect } from "vitest";
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { resolve } from "node:path";
|
||
import type {
|
||
ImportFormat,
|
||
ImportFormatRow,
|
||
ImportFormatRowInput,
|
||
ParsedRow,
|
||
} from "../shared/types";
|
||
import {
|
||
AMOUNT_MODES,
|
||
FORMAT_FIELD_PAIRS,
|
||
ImportFormatError,
|
||
ROW_ERROR_KEYS,
|
||
SIGN_CONVENTIONS,
|
||
clearMappingForMode,
|
||
detectAmountSeparators,
|
||
flipSignFormat,
|
||
formatFromRow,
|
||
formatToRow,
|
||
isRowErrorKey,
|
||
mapRow,
|
||
pickFormatRow,
|
||
summarizeParsedRows,
|
||
} from "./importFormat";
|
||
import fr from "../i18n/locales/fr.json";
|
||
import en from "../i18n/locales/en.json";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Samples. Every field deliberately differs from the wizard's default config,
|
||
// so a dropped field surfaces as a mismatch rather than as an accidental pass.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const SAMPLE_FORMAT: ImportFormat = {
|
||
delimiter: ",",
|
||
encoding: "windows-1252",
|
||
dateFormat: "YYYY-MM-DD",
|
||
skipLines: 3,
|
||
hasHeader: false,
|
||
columnMapping: { date: 2, description: 5, amount: 7 },
|
||
amountMode: "single",
|
||
signConvention: "positive_expense",
|
||
};
|
||
|
||
const SAMPLE_ROW: ImportFormatRow = {
|
||
delimiter: ",",
|
||
encoding: "windows-1252",
|
||
date_format: "YYYY-MM-DD",
|
||
skip_lines: 3,
|
||
has_header: 0,
|
||
column_mapping: '{"date":2,"description":5,"amount":7}',
|
||
amount_mode: "single",
|
||
sign_convention: "positive_expense",
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Completeness — the property the codec exists to hold
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("codec completeness (#324)", () => {
|
||
// `FORMAT_FIELD_PAIRS` is typed `Record<keyof ImportFormat, keyof
|
||
// ImportFormatRow>`, so a field added to `ImportFormat` fails to BUILD until
|
||
// it is listed. These two assertions close the other half: a field listed but
|
||
// not wired through a codec body fails the TEST.
|
||
it("formatToRow emits exactly the persisted fields of the table", () => {
|
||
expect(Object.keys(formatToRow(SAMPLE_FORMAT)).sort()).toEqual(
|
||
Object.values(FORMAT_FIELD_PAIRS).sort()
|
||
);
|
||
});
|
||
|
||
it("formatFromRow emits exactly the domain fields of the table", () => {
|
||
expect(Object.keys(formatFromRow(SAMPLE_ROW)).sort()).toEqual(
|
||
Object.keys(FORMAT_FIELD_PAIRS).sort()
|
||
);
|
||
});
|
||
|
||
it("covers the eight format fields, no more and no fewer", () => {
|
||
expect(Object.keys(FORMAT_FIELD_PAIRS)).toHaveLength(8);
|
||
});
|
||
|
||
it("maps every domain field to a distinct persisted field", () => {
|
||
const persisted = Object.values(FORMAT_FIELD_PAIRS);
|
||
expect(new Set(persisted).size).toBe(persisted.length);
|
||
});
|
||
|
||
it("pickFormatRow emits exactly the persisted fields of the table", () => {
|
||
expect(Object.keys(pickFormatRow(SAMPLE_ROW)).sort()).toEqual(
|
||
Object.values(FORMAT_FIELD_PAIRS).sort()
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Projection — the direction the SREF export needs (#331)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("pickFormatRow (#331)", () => {
|
||
it("carries the eight columns of a row verbatim", () => {
|
||
expect(pickFormatRow({ ...SAMPLE_ROW, id: 7, name: "Visa" })).toEqual(
|
||
SAMPLE_ROW
|
||
);
|
||
});
|
||
|
||
it("normalizes has_header to the 0/1 the column stores", () => {
|
||
// `ImportSource` declares it a boolean; SQLite has no such type.
|
||
expect(pickFormatRow({ ...SAMPLE_ROW, has_header: true }).has_header).toBe(1);
|
||
expect(pickFormatRow({ ...SAMPLE_ROW, has_header: false }).has_header).toBe(0);
|
||
});
|
||
|
||
it("does NOT validate — an unreadable mapping still travels", () => {
|
||
// `formatFromRow` refuses this row; a backup must still be able to carry a
|
||
// source an older build left in that state.
|
||
const row = { ...SAMPLE_ROW, column_mapping: "{}" };
|
||
expect(() => formatFromRow(row)).toThrow(ImportFormatError);
|
||
expect(pickFormatRow(row).column_mapping).toBe("{}");
|
||
});
|
||
|
||
it("never names the drift metadata — it is not a format field", () => {
|
||
const picked = pickFormatRow({
|
||
...SAMPLE_ROW,
|
||
header_signature: "date|desc",
|
||
});
|
||
expect("header_signature" in picked).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Round trip — configure -> save -> reload
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("round trip (#324)", () => {
|
||
it("survives domain -> row -> domain field by field", () => {
|
||
const restored = formatFromRow(formatToRow(SAMPLE_FORMAT));
|
||
for (const field of Object.keys(FORMAT_FIELD_PAIRS) as Array<
|
||
keyof ImportFormat
|
||
>) {
|
||
expect(restored[field]).toEqual(SAMPLE_FORMAT[field]);
|
||
}
|
||
expect(restored).toEqual(SAMPLE_FORMAT);
|
||
});
|
||
|
||
it("survives row -> domain -> row field by field", () => {
|
||
expect(formatToRow(formatFromRow(SAMPLE_ROW))).toEqual(SAMPLE_ROW);
|
||
});
|
||
|
||
// THE regression: the credit-card case. Before v17 the convention was not a
|
||
// column, so restoring one wrote "negative_expense" and every expense of the
|
||
// next import landed as income.
|
||
it("keeps positive_expense across a save and a reload", () => {
|
||
const positive: ImportFormat = {
|
||
...SAMPLE_FORMAT,
|
||
signConvention: "positive_expense",
|
||
};
|
||
expect(formatFromRow(formatToRow(positive)).signConvention).toBe(
|
||
"positive_expense"
|
||
);
|
||
});
|
||
|
||
it("keeps negative_expense across a save and a reload", () => {
|
||
const negative: ImportFormat = {
|
||
...SAMPLE_FORMAT,
|
||
signConvention: "negative_expense",
|
||
};
|
||
expect(formatFromRow(formatToRow(negative)).signConvention).toBe(
|
||
"negative_expense"
|
||
);
|
||
});
|
||
|
||
// The mode used to be re-derived from `mapping.debitAmount !== undefined`. A
|
||
// debit/credit source whose debit column is not mapped yet came back as
|
||
// "single" — this is that shape.
|
||
it("keeps debit_credit even when the mapping carries no debit column", () => {
|
||
const partial: ImportFormat = {
|
||
...SAMPLE_FORMAT,
|
||
amountMode: "debit_credit",
|
||
columnMapping: { date: 0, description: 1, creditAmount: 3 },
|
||
};
|
||
const restored = formatFromRow(formatToRow(partial));
|
||
expect(restored.amountMode).toBe("debit_credit");
|
||
expect(restored.columnMapping).toEqual({
|
||
date: 0,
|
||
description: 1,
|
||
creditAmount: 3,
|
||
});
|
||
});
|
||
|
||
it("keeps single even when the mapping still carries debit/credit keys", () => {
|
||
const stale: ImportFormat = {
|
||
...SAMPLE_FORMAT,
|
||
amountMode: "single",
|
||
columnMapping: { date: 0, description: 1, amount: 2, debitAmount: 3 },
|
||
};
|
||
expect(formatFromRow(formatToRow(stale)).amountMode).toBe("single");
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// has_header normalization
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("has_header normalization", () => {
|
||
it("writes 0/1, never a boolean — SQLite has no boolean type", () => {
|
||
expect(formatToRow({ ...SAMPLE_FORMAT, hasHeader: true }).has_header).toBe(1);
|
||
expect(formatToRow({ ...SAMPLE_FORMAT, hasHeader: false }).has_header).toBe(0);
|
||
});
|
||
|
||
it("reads the integer an import_config_templates row carries", () => {
|
||
expect(formatFromRow({ ...SAMPLE_ROW, has_header: 1 }).hasHeader).toBe(true);
|
||
expect(formatFromRow({ ...SAMPLE_ROW, has_header: 0 }).hasHeader).toBe(false);
|
||
});
|
||
|
||
it("reads the boolean an import_sources row is declared with", () => {
|
||
const asBoolean: ImportFormatRowInput = { ...SAMPLE_ROW, has_header: true };
|
||
expect(formatFromRow(asBoolean).hasHeader).toBe(true);
|
||
expect(formatFromRow({ ...asBoolean, has_header: false }).hasHeader).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Validation — a value the app cannot map must raise, never fall back
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("formatFromRow validation", () => {
|
||
it("accepts every mode and convention the app implements", () => {
|
||
for (const amount_mode of AMOUNT_MODES) {
|
||
expect(formatFromRow({ ...SAMPLE_ROW, amount_mode }).amountMode).toBe(
|
||
amount_mode
|
||
);
|
||
}
|
||
for (const sign_convention of SIGN_CONVENTIONS) {
|
||
expect(
|
||
formatFromRow({ ...SAMPLE_ROW, sign_convention }).signConvention
|
||
).toBe(sign_convention);
|
||
}
|
||
});
|
||
|
||
// The v17 CHECK admits 'absolute_indicator' so the third mode ships without a
|
||
// migration. Until it is implemented, reading one must raise — a fall-back to
|
||
// the `single` branch would read the wrong column for every row.
|
||
it("rejects an amount mode the app cannot map", () => {
|
||
const row = {
|
||
...SAMPLE_ROW,
|
||
amount_mode: "absolute_indicator",
|
||
} as unknown as ImportFormatRowInput;
|
||
expect(() => formatFromRow(row)).toThrow(ImportFormatError);
|
||
try {
|
||
formatFromRow(row);
|
||
} catch (e) {
|
||
expect((e as ImportFormatError).i18nKey).toBe(
|
||
"import.errors.unsupportedAmountMode"
|
||
);
|
||
}
|
||
});
|
||
|
||
it("rejects an unknown sign convention rather than defaulting", () => {
|
||
const row = {
|
||
...SAMPLE_ROW,
|
||
sign_convention: "whatever",
|
||
} as unknown as ImportFormatRowInput;
|
||
expect(() => formatFromRow(row)).toThrow(ImportFormatError);
|
||
try {
|
||
formatFromRow(row);
|
||
} catch (e) {
|
||
expect((e as ImportFormatError).i18nKey).toBe(
|
||
"import.errors.unsupportedSignConvention"
|
||
);
|
||
}
|
||
});
|
||
|
||
it("rejects a column mapping that is not valid JSON", () => {
|
||
expect(() =>
|
||
formatFromRow({ ...SAMPLE_ROW, column_mapping: "{not json" })
|
||
).toThrow(ImportFormatError);
|
||
});
|
||
|
||
it("rejects a column mapping without usable date/description columns", () => {
|
||
for (const column_mapping of ["null", "[]", '{"date":"2"}', "{}"]) {
|
||
expect(() => formatFromRow({ ...SAMPLE_ROW, column_mapping })).toThrow(
|
||
ImportFormatError
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// clearMappingForMode — the mode owns the mapping
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("clearMappingForMode", () => {
|
||
const full = {
|
||
date: 0,
|
||
description: 1,
|
||
amount: 2,
|
||
debitAmount: 3,
|
||
creditAmount: 4,
|
||
};
|
||
|
||
it("drops the debit/credit columns when switching to single", () => {
|
||
expect(clearMappingForMode(full, "single")).toEqual({
|
||
date: 0,
|
||
description: 1,
|
||
amount: 2,
|
||
});
|
||
});
|
||
|
||
it("drops the amount column when switching to debit/credit", () => {
|
||
expect(clearMappingForMode(full, "debit_credit")).toEqual({
|
||
date: 0,
|
||
description: 1,
|
||
debitAmount: 3,
|
||
creditAmount: 4,
|
||
});
|
||
});
|
||
|
||
it("keeps date and description untouched in both directions", () => {
|
||
expect(clearMappingForMode({ date: 4, description: 6 }, "single")).toEqual({
|
||
date: 4,
|
||
description: 6,
|
||
});
|
||
expect(
|
||
clearMappingForMode({ date: 4, description: 6 }, "debit_credit")
|
||
).toEqual({ date: 4, description: 6 });
|
||
});
|
||
|
||
// #325 replaces the `?? 0` fallbacks with an explicit "amount column not
|
||
// mapped" row error. Materializing the column the <select> merely DISPLAYS
|
||
// would make that error unreachable, so an absent column stays absent.
|
||
it("does not invent the column the select displays by default", () => {
|
||
const cleared = clearMappingForMode({ date: 0, description: 1 }, "single");
|
||
expect("amount" in cleared).toBe(false);
|
||
const pair = clearMappingForMode({ date: 0, description: 1 }, "debit_credit");
|
||
expect("debitAmount" in pair).toBe(false);
|
||
expect("creditAmount" in pair).toBe(false);
|
||
});
|
||
|
||
it("emits no undefined-valued keys, so the persisted JSON stays clean", () => {
|
||
const row = formatToRow({
|
||
...SAMPLE_FORMAT,
|
||
columnMapping: clearMappingForMode(full, "single"),
|
||
});
|
||
expect(row.column_mapping).toBe('{"date":0,"description":1,"amount":2}');
|
||
});
|
||
|
||
it("is idempotent", () => {
|
||
const once = clearMappingForMode(full, "debit_credit");
|
||
expect(clearMappingForMode(once, "debit_credit")).toEqual(once);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// mapRow — the row rule, lifted out of the wizard (#325)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const DC_FORMAT: ImportFormat = {
|
||
delimiter: ";",
|
||
encoding: "utf-8",
|
||
dateFormat: "DD/MM/YYYY",
|
||
skipLines: 0,
|
||
hasHeader: true,
|
||
columnMapping: { date: 0, description: 1, debitAmount: 2, creditAmount: 3 },
|
||
amountMode: "debit_credit",
|
||
signConvention: "negative_expense",
|
||
};
|
||
|
||
const SINGLE_FORMAT: ImportFormat = {
|
||
...DC_FORMAT,
|
||
columnMapping: { date: 0, description: 1, amount: 2 },
|
||
amountMode: "single",
|
||
};
|
||
|
||
/** The amount of a row that parsed, or the error key of one that did not. */
|
||
function outcome(row: ReturnType<typeof mapRow>): number | string {
|
||
return row.parsed ? row.parsed.amount : row.error!;
|
||
}
|
||
|
||
describe("mapRow — debit/credit is a subtraction, not a nullity test (#325)", () => {
|
||
it("reads a debit as negative and a credit as positive", () => {
|
||
expect(
|
||
outcome(mapRow(["05/01/2025", "EPICERIE", "84,32", ""], DC_FORMAT))
|
||
).toBe(-84.32);
|
||
expect(
|
||
outcome(mapRow(["15/01/2025", "PAIE", "", "1250,00"], DC_FORMAT))
|
||
).toBe(1250);
|
||
});
|
||
|
||
it("treats a 0,00 cell in the unused column as absent", () => {
|
||
// The bug: `isNaN(credit)` was false for "0,00", so the credit won and
|
||
// every debit imported as zero.
|
||
expect(
|
||
outcome(mapRow(["05/01/2025", "EPICERIE", "84,32", "0,00"], DC_FORMAT))
|
||
).toBe(-84.32);
|
||
expect(
|
||
outcome(mapRow(["15/01/2025", "PAIE", "0,00", "1250,00"], DC_FORMAT))
|
||
).toBe(1250);
|
||
});
|
||
|
||
it("takes both columns as magnitudes, whatever sign they carry", () => {
|
||
expect(
|
||
outcome(mapRow(["05/01/2025", "EPICERIE", "-84,32", ""], DC_FORMAT))
|
||
).toBe(-84.32);
|
||
expect(
|
||
outcome(mapRow(["15/01/2025", "PAIE", "", "-1250,00"], DC_FORMAT))
|
||
).toBe(1250);
|
||
});
|
||
|
||
it("nets a row that fills both columns", () => {
|
||
expect(
|
||
outcome(mapRow(["05/01/2025", "AJUST", "40,00", "100,00"], DC_FORMAT))
|
||
).toBe(60);
|
||
});
|
||
|
||
it("errors on an unreadable cell even when its sibling parses", () => {
|
||
// The review finding on #336: testing `isNaN(debit) && isNaN(credit)` only
|
||
// caught the case where BOTH sides fell. But the unused column carries
|
||
// `0,00` in exactly the files this rule exists to fix, so an unreadable
|
||
// debit beside a `0,00` credit computed 0 − 0 = 0 and imported silently —
|
||
// the very bug, one cell over.
|
||
expect(
|
||
outcome(mapRow(["05/01/2025", "EPICERIE", "n/a", "0,00"], DC_FORMAT))
|
||
).toBe(ROW_ERROR_KEYS.invalidAmount);
|
||
expect(
|
||
outcome(mapRow(["05/01/2025", "EPICERIE", "84,32 CAD", "0,00"], DC_FORMAT))
|
||
).toBe(ROW_ERROR_KEYS.invalidAmount);
|
||
// Symmetric: an unreadable credit beside a readable debit.
|
||
expect(
|
||
outcome(mapRow(["15/01/2025", "PAIE", "0,00", "1 250 $ CAD"], DC_FORMAT))
|
||
).toBe(ROW_ERROR_KEYS.invalidAmount);
|
||
});
|
||
|
||
it("still treats an EMPTY cell as absent, not as unreadable", () => {
|
||
// The distinction the fix rests on: empty means "this column does not apply
|
||
// to this row", which is the normal shape of a debit/credit file.
|
||
expect(
|
||
outcome(mapRow(["05/01/2025", "EPICERIE", "84,32", ""], DC_FORMAT))
|
||
).toBe(-84.32);
|
||
expect(
|
||
outcome(mapRow(["05/01/2025", "EPICERIE", "84,32", " "], DC_FORMAT))
|
||
).toBe(-84.32);
|
||
});
|
||
|
||
it("errors when NEITHER column is readable, instead of importing 0", () => {
|
||
expect(outcome(mapRow(["05/01/2025", "X", "", ""], DC_FORMAT))).toBe(
|
||
ROW_ERROR_KEYS.invalidAmount
|
||
);
|
||
expect(outcome(mapRow(["05/01/2025", "X", "n/a", "-"], DC_FORMAT))).toBe(
|
||
ROW_ERROR_KEYS.invalidAmount
|
||
);
|
||
});
|
||
|
||
it("ignores the sign convention, which only applies to a single column", () => {
|
||
const flipped: ImportFormat = {
|
||
...DC_FORMAT,
|
||
signConvention: "positive_expense",
|
||
};
|
||
expect(
|
||
outcome(mapRow(["05/01/2025", "EPICERIE", "84,32", ""], flipped))
|
||
).toBe(-84.32);
|
||
});
|
||
});
|
||
|
||
describe("mapRow — an unmapped amount column is an error, not column 0 (#325)", () => {
|
||
// The `?? 0` fallbacks read column 0 — usually the date — for every row.
|
||
|
||
it("refuses a single-amount format with no amount column", () => {
|
||
const { amount: _drop, ...mapping } = SINGLE_FORMAT.columnMapping;
|
||
const row = mapRow(["05/01/2025", "EPICERIE", "-84,32"], {
|
||
...SINGLE_FORMAT,
|
||
columnMapping: mapping,
|
||
});
|
||
expect(row.parsed).toBeNull();
|
||
expect(row.error).toBe(ROW_ERROR_KEYS.amountColumnNotMapped);
|
||
});
|
||
|
||
it("refuses a debit/credit format with neither column mapped", () => {
|
||
const row = mapRow(["05/01/2025", "EPICERIE", "84,32", ""], {
|
||
...DC_FORMAT,
|
||
columnMapping: { date: 0, description: 1 },
|
||
});
|
||
expect(row.error).toBe(ROW_ERROR_KEYS.amountColumnNotMapped);
|
||
});
|
||
|
||
it("accepts a debit/credit format with only ONE column mapped", () => {
|
||
// Half a pair is a legitimate shape (a card statement with debits only).
|
||
expect(
|
||
outcome(
|
||
mapRow(["05/01/2025", "EPICERIE", "84,32"], {
|
||
...DC_FORMAT,
|
||
columnMapping: { date: 0, description: 1, debitAmount: 2 },
|
||
})
|
||
)
|
||
).toBe(-84.32);
|
||
});
|
||
|
||
it("reports the format error before any per-row problem", () => {
|
||
const row = mapRow(["not-a-date", "EPICERIE", "-84,32"], {
|
||
...SINGLE_FORMAT,
|
||
columnMapping: { date: 0, description: 1 },
|
||
});
|
||
expect(row.error).toBe(ROW_ERROR_KEYS.amountColumnNotMapped);
|
||
});
|
||
});
|
||
|
||
describe("mapRow — the rest of the contract (#325)", () => {
|
||
it("keeps the date check ahead of the amount check", () => {
|
||
expect(outcome(mapRow(["nope", "X", "abc"], SINGLE_FORMAT))).toBe(
|
||
ROW_ERROR_KEYS.invalidDate
|
||
);
|
||
expect(outcome(mapRow(["05/01/2025", "X", "abc"], SINGLE_FORMAT))).toBe(
|
||
ROW_ERROR_KEYS.invalidAmount
|
||
);
|
||
});
|
||
|
||
it("applies positive_expense to a single amount column", () => {
|
||
expect(
|
||
outcome(
|
||
mapRow(["05/01/2025", "EPICERIE", "84,32"], {
|
||
...SINGLE_FORMAT,
|
||
signConvention: "positive_expense",
|
||
})
|
||
)
|
||
).toBe(-84.32);
|
||
});
|
||
|
||
it("carries rowIndex, raw and sourceFilename through", () => {
|
||
const raw = ["05/01/2025", "EPICERIE", "-84,32"];
|
||
const row = mapRow(raw, SINGLE_FORMAT, {
|
||
rowIndex: 41,
|
||
sourceFilename: "releve.csv",
|
||
});
|
||
expect(row.rowIndex).toBe(41);
|
||
expect(row.raw).toBe(raw);
|
||
expect(row.sourceFilename).toBe("releve.csv");
|
||
expect(row.parsed).toEqual({
|
||
date: "2025-01-05",
|
||
description: "EPICERIE",
|
||
amount: -84.32,
|
||
});
|
||
});
|
||
|
||
it("defaults rowIndex to 0 and omits sourceFilename when not given", () => {
|
||
const row = mapRow(["05/01/2025", "X", "-1,00"], SINGLE_FORMAT);
|
||
expect(row.rowIndex).toBe(0);
|
||
expect("sourceFilename" in row).toBe(false);
|
||
});
|
||
|
||
it("reports every failure as an i18n key", () => {
|
||
for (const raw of [
|
||
["nope", "X", "-1,00"],
|
||
["05/01/2025", "X", "abc"],
|
||
]) {
|
||
const row = mapRow(raw, SINGLE_FORMAT);
|
||
expect(isRowErrorKey(row.error!)).toBe(true);
|
||
}
|
||
expect(isRowErrorKey("boom: sqlite is locked")).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("detectAmountSeparators — the column decides (#325)", () => {
|
||
const rows = [
|
||
["05/01/2025", "EPICERIE", "1.234", ""],
|
||
["15/01/2025", "PAIE", "", "84,32"],
|
||
];
|
||
|
||
it("arbitrates each amount column the format declares", () => {
|
||
const seps = detectAmountSeparators(rows, DC_FORMAT);
|
||
// Column 2 holds only the ambiguous "1.234" — no verdict of its own.
|
||
expect(seps.get(2)).toBeUndefined();
|
||
expect(seps.get(3)).toBe(",");
|
||
});
|
||
|
||
it("turns an ambiguous cell into the column's reading", () => {
|
||
const french = [
|
||
["05/01/2025", "A", "1.234"],
|
||
["15/01/2025", "B", "84,32"],
|
||
];
|
||
const seps = detectAmountSeparators(french, SINGLE_FORMAT);
|
||
expect(seps.get(2)).toBe(",");
|
||
expect(
|
||
outcome(mapRow(french[0], SINGLE_FORMAT, { decimalSeparators: seps }))
|
||
).toBe(1234);
|
||
// Without the column verdict, the same cell reads as 1.234.
|
||
expect(outcome(mapRow(french[0], SINGLE_FORMAT))).toBe(1.234);
|
||
});
|
||
|
||
it("looks at no column the format does not use", () => {
|
||
expect(detectAmountSeparators(rows, SINGLE_FORMAT).has(3)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Static guards on useImportWizard.
|
||
//
|
||
// The restore path and the config write point live inside `useCallback`s of a
|
||
// React hook and the repository has no jsdom (see FilterPanel.test.tsx), so the
|
||
// only way to assert on them is to read the source. Same technique as the guard
|
||
// link 1 left on `parseFilesInternal` in csvAutoDetect.test.ts.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const WIZARD_SRC = readFileSync(
|
||
resolve(import.meta.dirname, "..", "hooks", "useImportWizard.ts"),
|
||
"utf-8"
|
||
);
|
||
|
||
/** The body of a `const <name> = useCallback(...)` block, up to the next one. */
|
||
function callbackBody(name: string, nextName: string): string {
|
||
const start = WIZARD_SRC.indexOf(`const ${name} =`);
|
||
const end = WIZARD_SRC.indexOf(`const ${nextName} =`, start);
|
||
expect(start).toBeGreaterThan(-1);
|
||
expect(end).toBeGreaterThan(start);
|
||
return WIZARD_SRC.slice(start, end);
|
||
}
|
||
|
||
/**
|
||
* The block of `selectSource` that rebuilds a stored format. Scoped tightly on
|
||
* purpose: the fresh-source arm below it legitimately names `encoding` while
|
||
* auto-detecting one.
|
||
*/
|
||
function restoreBranch(): string {
|
||
const body = callbackBody("selectSource", "loadHeadersWithConfig");
|
||
const start = body.indexOf("let restored: SourceConfig | null = null;");
|
||
const end = body.indexOf("if (restored) {", start);
|
||
expect(start).toBeGreaterThan(-1);
|
||
expect(end).toBeGreaterThan(start);
|
||
return body.slice(start, end);
|
||
}
|
||
|
||
describe("useImportWizard — restore reads, never re-derives (#324)", () => {
|
||
it("routes the restore through the codec", () => {
|
||
expect(restoreBranch()).toContain("...formatFromRow(existing)");
|
||
});
|
||
|
||
it("no longer re-infers the amount mode from the mapping shape", () => {
|
||
expect(WIZARD_SRC).not.toContain("mapping.debitAmount !== undefined");
|
||
});
|
||
|
||
// The spread alone is not enough: a literal AFTER it silently wins, which is
|
||
// how `signConvention: "negative_expense"` overrode the stored value. The
|
||
// restore must name no format field of its own.
|
||
it("names no format field of its own in the restore", () => {
|
||
const branch = restoreBranch();
|
||
for (const field of Object.keys(FORMAT_FIELD_PAIRS)) {
|
||
expect(branch, `restore hardcodes ${field}`).not.toContain(`${field}:`);
|
||
}
|
||
});
|
||
|
||
it("restores the template provenance instead of blanking it", () => {
|
||
expect(callbackBody("selectSource", "loadHeadersWithConfig")).toContain(
|
||
"existing?.template_id ?? null"
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("useImportWizard — the config write point (#324)", () => {
|
||
it("writes nothing at the duplicate step", () => {
|
||
const body = callbackBody("checkDuplicatesInternal", "checkDuplicates");
|
||
expect(body).not.toContain("createSource(");
|
||
expect(body).not.toContain("updateSource(");
|
||
});
|
||
|
||
it("writes the format at import time, through the codec", () => {
|
||
const body = callbackBody("executeImport", "goToStep");
|
||
expect(body).toContain("formatToRow(config)");
|
||
expect(body).toContain("createSource(");
|
||
expect(body).toContain("updateSource(");
|
||
});
|
||
|
||
it("keeps a single write point in the whole hook", () => {
|
||
expect(WIZARD_SRC.match(/await createSource\(/g)).toHaveLength(1);
|
||
expect(WIZARD_SRC.match(/await updateSource\(/g)).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The signed recap and the sign flip (#329)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** A parsed row carrying an amount. */
|
||
function row(rowIndex: number, amount: number): ParsedRow {
|
||
return {
|
||
rowIndex,
|
||
raw: ["01/01/2025", "LABEL", String(amount)],
|
||
parsed: { date: "2025-01-01", description: "LABEL", amount },
|
||
};
|
||
}
|
||
|
||
/** A row that produced no amount at all. */
|
||
function errorRow(rowIndex: number): ParsedRow {
|
||
return {
|
||
rowIndex,
|
||
raw: ["", "LABEL", "x"],
|
||
parsed: null,
|
||
error: ROW_ERROR_KEYS.invalidDate,
|
||
};
|
||
}
|
||
|
||
describe("summarizeParsedRows — the recap that reads MEANING (#329)", () => {
|
||
it("counts and totals the two directions separately", () => {
|
||
expect(
|
||
summarizeParsedRows([row(0, -84.32), row(1, 1250), row(2, -56.75)])
|
||
).toEqual({
|
||
outflowCount: 2,
|
||
outflowTotal: -141.07,
|
||
inflowCount: 1,
|
||
inflowTotal: 1250,
|
||
errorCount: 0,
|
||
});
|
||
});
|
||
|
||
it("keeps the totals SIGNED, as they will reach the ledger", () => {
|
||
// Magnitudes would hide the one thing the recap exists to expose: a file
|
||
// read backwards looks identical once the signs are dropped.
|
||
const totals = summarizeParsedRows([row(0, -10), row(1, 40)]);
|
||
expect(totals.outflowTotal).toBeLessThan(0);
|
||
expect(totals.inflowTotal).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("counts rows with no parsed amount as errors, and nothing else", () => {
|
||
const totals = summarizeParsedRows([errorRow(0), row(1, -10), errorRow(2)]);
|
||
expect(totals.errorCount).toBe(2);
|
||
expect(totals.outflowCount).toBe(1);
|
||
expect(totals.inflowCount).toBe(0);
|
||
});
|
||
|
||
it("files a zero amount under neither direction", () => {
|
||
// Deliberate: a 0,00 row is its own anomaly. Filing it silently under
|
||
// outflows or inflows would be a small lie of the same family as the big
|
||
// one this recap exists to catch.
|
||
const totals = summarizeParsedRows([row(0, 0), row(1, -10)]);
|
||
expect(totals.outflowCount).toBe(1);
|
||
expect(totals.inflowCount).toBe(0);
|
||
expect(totals.errorCount).toBe(0);
|
||
});
|
||
|
||
it("returns all zeroes on an empty file", () => {
|
||
expect(summarizeParsedRows([])).toEqual({
|
||
outflowCount: 0,
|
||
outflowTotal: 0,
|
||
inflowCount: 0,
|
||
inflowTotal: 0,
|
||
errorCount: 0,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("flipSignFormat — the correction lands on the FORMAT (#329)", () => {
|
||
const SINGLE = {
|
||
...SAMPLE_FORMAT,
|
||
amountMode: "single" as const,
|
||
signConvention: "negative_expense" as const,
|
||
columnMapping: { date: 0, description: 1, amount: 2 },
|
||
};
|
||
const DEBIT_CREDIT = {
|
||
...SAMPLE_FORMAT,
|
||
amountMode: "debit_credit" as const,
|
||
columnMapping: { date: 0, description: 1, debitAmount: 2, creditAmount: 3 },
|
||
};
|
||
|
||
it("toggles the convention in single-amount mode", () => {
|
||
expect(flipSignFormat(SINGLE).signConvention).toBe("positive_expense");
|
||
expect(
|
||
flipSignFormat({ ...SINGLE, signConvention: "positive_expense" })
|
||
.signConvention
|
||
).toBe("negative_expense");
|
||
});
|
||
|
||
it("is its own inverse in single-amount mode", () => {
|
||
const once = { ...SINGLE, ...flipSignFormat(SINGLE) };
|
||
expect({ ...once, ...flipSignFormat(once) }).toEqual(SINGLE);
|
||
});
|
||
|
||
it("leaves the mapping alone in single-amount mode", () => {
|
||
expect(flipSignFormat(SINGLE).columnMapping).toEqual(SINGLE.columnMapping);
|
||
});
|
||
|
||
it("swaps the two columns in debit/credit mode", () => {
|
||
// `mapRow` ignores the sign convention there — toggling it would be inert,
|
||
// which is the review finding this branch answers.
|
||
expect(flipSignFormat(DEBIT_CREDIT).columnMapping).toEqual({
|
||
date: 0,
|
||
description: 1,
|
||
debitAmount: 3,
|
||
creditAmount: 2,
|
||
});
|
||
});
|
||
|
||
it("does not touch the convention in debit/credit mode", () => {
|
||
expect(flipSignFormat(DEBIT_CREDIT).signConvention).toBe(
|
||
DEBIT_CREDIT.signConvention
|
||
);
|
||
});
|
||
|
||
it("moves a half-mapped column to the other role", () => {
|
||
const half = {
|
||
...DEBIT_CREDIT,
|
||
columnMapping: { date: 0, description: 1, debitAmount: 2 },
|
||
};
|
||
expect(flipSignFormat(half).columnMapping).toEqual({
|
||
date: 0,
|
||
description: 1,
|
||
creditAmount: 2,
|
||
});
|
||
});
|
||
|
||
it("never materializes an unmapped column as a key", () => {
|
||
// `mapRow` distinguishes "not mapped" from "mapped to column 0"; an
|
||
// explicit `undefined` would make that distinction unreachable.
|
||
const half = {
|
||
...DEBIT_CREDIT,
|
||
columnMapping: { date: 0, description: 1, creditAmount: 3 },
|
||
};
|
||
const mapping = flipSignFormat(half).columnMapping;
|
||
expect(Object.keys(mapping)).not.toContain("creditAmount");
|
||
expect(mapping.debitAmount).toBe(3);
|
||
});
|
||
|
||
it("flips the sign of every row it is applied to (single)", () => {
|
||
const raw = ["05/01/2025", "EPICERIE", "84.32"];
|
||
const before = mapRow(raw, { ...SINGLE, dateFormat: "DD/MM/YYYY" });
|
||
const after = mapRow(raw, {
|
||
...SINGLE,
|
||
dateFormat: "DD/MM/YYYY",
|
||
...flipSignFormat(SINGLE),
|
||
});
|
||
expect(before.parsed!.amount).toBe(84.32);
|
||
expect(after.parsed!.amount).toBe(-84.32);
|
||
});
|
||
|
||
it("flips the sign of every row it is applied to (debit/credit)", () => {
|
||
const raw = ["05/01/2025", "EPICERIE", "84.32", ""];
|
||
const format = { ...DEBIT_CREDIT, dateFormat: "DD/MM/YYYY" };
|
||
expect(mapRow(raw, format).parsed!.amount).toBe(-84.32);
|
||
expect(
|
||
mapRow(raw, { ...format, ...flipSignFormat(format) }).parsed!.amount
|
||
).toBe(84.32);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Static guards: the preview step is traversed at EVERY import (#329).
|
||
//
|
||
// The step lives in a React page and the repository has no jsdom, so the wiring
|
||
// is asserted on the source — same technique as the guards above.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const PAGE_SRC = readFileSync(
|
||
resolve(import.meta.dirname, "..", "pages", "ImportPage.tsx"),
|
||
"utf-8"
|
||
);
|
||
|
||
describe("the preview step is mandatory (#329)", () => {
|
||
it("has a transition that targets it", () => {
|
||
// `file-preview` was declared in `ImportWizardStep` from the beginning and
|
||
// no dispatch ever aimed at it — the optional modal had supplanted it.
|
||
expect(callbackBody("parseAndPreview", "checkDuplicatesInternal")).toContain(
|
||
'dispatch({ type: "SET_STEP", payload: "file-preview" })'
|
||
);
|
||
});
|
||
|
||
it("leaves no callback that parses straight to the duplicate check", () => {
|
||
// `parseAndCheckDuplicates` jumped from the configuration to the duplicates
|
||
// ("skips preview step"). Keeping it exported would leave a live bypass.
|
||
expect(WIZARD_SRC).not.toContain("parseAndCheckDuplicates");
|
||
expect(WIZARD_SRC).not.toContain("skips preview step");
|
||
});
|
||
|
||
it("renders the step and reaches it from the configuration", () => {
|
||
expect(PAGE_SRC).toContain('state.step === "file-preview"');
|
||
expect(PAGE_SRC).toContain("onNext={parseAndPreview}");
|
||
expect(PAGE_SRC).toContain("onNext={checkDuplicates}");
|
||
});
|
||
|
||
it("walks back through the preview from the duplicate step", () => {
|
||
expect(PAGE_SRC).toContain('onBack={() => goToStep("file-preview")}');
|
||
});
|
||
|
||
it("shows the recap over the WHOLE file, not the displayed sample", () => {
|
||
// The table truncates its rows; a recap computed on the truncation would
|
||
// state a total that is not the file's.
|
||
expect(PAGE_SRC).toContain("rows={state.parsedPreview}");
|
||
expect(PAGE_SRC).not.toContain("state.parsedPreview.slice(");
|
||
});
|
||
|
||
it("no longer carries the optional preview modal", () => {
|
||
expect(PAGE_SRC).not.toContain("FilePreviewModal");
|
||
expect(
|
||
existsSync(
|
||
resolve(
|
||
import.meta.dirname,
|
||
"..",
|
||
"components",
|
||
"import",
|
||
"FilePreviewModal.tsx"
|
||
)
|
||
)
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("the sign flip is wired to the configuration (#329)", () => {
|
||
const FLIP = () => callbackBody("flipSignConvention", "executeImport");
|
||
|
||
it("rewrites the config and re-parses under the new one", () => {
|
||
const body = FLIP();
|
||
expect(body).toContain("flipSignFormat(state.sourceConfig)");
|
||
expect(body).toContain('dispatch({ type: "SET_SOURCE_CONFIG", payload: flipped })');
|
||
// The flipped format is PASSED to the parse: `state` has not re-rendered
|
||
// yet, so re-reading it would redisplay the table the user asked to fix.
|
||
expect(body).toContain("parseFilesInternal(flipped)");
|
||
});
|
||
|
||
it("drops the detection score, which never measured this format", () => {
|
||
expect(FLIP()).toContain(
|
||
'dispatch({ type: "SET_DETECTION_SCORE", payload: null })'
|
||
);
|
||
});
|
||
|
||
it("does not reload headers, which a flip cannot change", () => {
|
||
// `loadHeadersWithConfig` dispatches an empty row list; racing it against
|
||
// the re-parse is a coin toss between the corrected table and a blank one.
|
||
expect(FLIP()).not.toContain("loadHeadersWithConfig");
|
||
});
|
||
});
|
||
|
||
describe("the confirmation states what decides the amounts (#329)", () => {
|
||
const CONFIRM_SRC = readFileSync(
|
||
resolve(
|
||
import.meta.dirname,
|
||
"..",
|
||
"components",
|
||
"import",
|
||
"ImportConfirmation.tsx"
|
||
),
|
||
"utf-8"
|
||
);
|
||
|
||
it("shows the amount mode and the column mapping", () => {
|
||
expect(CONFIRM_SRC).toContain("import.config.amountMode");
|
||
expect(CONFIRM_SRC).toContain("import.config.columnMapping");
|
||
});
|
||
|
||
it("shows the sign convention, in the mode that applies it", () => {
|
||
expect(CONFIRM_SRC).toContain("import.config.signConvention");
|
||
expect(CONFIRM_SRC).toContain('config.amountMode === "single" && (');
|
||
});
|
||
|
||
it("names the columns the amount mode actually reads", () => {
|
||
// Naming a debit column on a single-amount format would describe an import
|
||
// that is not happening, so the list is chosen by mode.
|
||
const mapping = CONFIRM_SRC.slice(
|
||
CONFIRM_SRC.indexOf("const mappedColumns"),
|
||
CONFIRM_SRC.indexOf("return (")
|
||
);
|
||
expect(mapping).toContain('config.amountMode === "debit_credit"');
|
||
for (const key of [
|
||
"dateColumn",
|
||
"descriptionColumn",
|
||
"amountColumn",
|
||
"debitColumn",
|
||
"creditColumn",
|
||
]) {
|
||
expect(mapping, key).toContain(`import.config.${key}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("every new preview string exists in both languages (#329)", () => {
|
||
it("carries the recap and the flip button", () => {
|
||
for (const key of [
|
||
"outflowCount",
|
||
"inflowCount",
|
||
"errorRows",
|
||
"flipSigns",
|
||
"flipSignsHint",
|
||
] as const) {
|
||
expect(fr.import.preview[key].length, `fr.${key}`).toBeGreaterThan(0);
|
||
expect(en.import.preview[key].length, `en.${key}`).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
it("interpolates the two counts in both languages", () => {
|
||
for (const key of ["outflowCount", "inflowCount"] as const) {
|
||
expect(fr.import.preview[key], `fr.${key}`).toContain("{{count}}");
|
||
expect(en.import.preview[key], `en.${key}`).toContain("{{count}}");
|
||
}
|
||
});
|
||
|
||
it("carries the unmapped-column label of the confirmation", () => {
|
||
expect(fr.import.confirm.columnUnmapped.length).toBeGreaterThan(0);
|
||
expect(en.import.confirm.columnUnmapped.length).toBeGreaterThan(0);
|
||
});
|
||
});
|