// amountParser — characterization tests (#326), hardened (#325). // // `parseFrenchAmount` had no test at all, on a codebase of 871. It is called // from 11 sites (8 in `csvAutoDetect.ts`, 3 in `useSnapshotEditor.ts`) and sits // under every imported amount, so #326 pinned what it did before #325 touched // it. The `KNOWN DEFECT` blocks #326 left here have since flipped: the // expectations were updated in place and the markers dropped, per the standing // rule (update, never delete). // // The defect that motivated the whole chantier: `parseFrenchAmount` ended on // `parseFloat`, which stops at the first invalid character instead of rejecting // the string. A trailing unit or sign therefore yielded a magnitude off by a // factor of 100 — and it passed `isNaN`, so it counted as a VALID row // everywhere downstream. `"100,00 CAD"` did not fail; it imported as 10 000. // Validation is anchored now and such a cell is NaN, i.e. a visible row error. import { describe, it, expect } from "vitest"; import { detectDecimalSeparator, parseFrenchAmount } from "./amountParser"; import { autoDetectConfig } from "./csvAutoDetect"; import { buildDetailedLines, holdingsFromCsvRows, } from "../hooks/useSnapshotEditor"; import { BalanceServiceError } from "../services/balance.service"; import { readCsvFixture } from "../__fixtures__/csv"; describe("parseFrenchAmount — separator contract (#326)", () => { it("reads the French decimal comma", () => { expect(parseFrenchAmount("1234,56")).toBe(1234.56); expect(parseFrenchAmount("12,5")).toBe(12.5); expect(parseFrenchAmount("0,00")).toBe(0); }); it("reads French thousand separators (space, non-breaking space, dot)", () => { expect(parseFrenchAmount("1 234,56")).toBe(1234.56); expect(parseFrenchAmount("1\u00A0234,56")).toBe(1234.56); // non-breaking space expect(parseFrenchAmount("1.234,56")).toBe(1234.56); }); it("reads English notation", () => { expect(parseFrenchAmount("1234.56")).toBe(1234.56); expect(parseFrenchAmount("1,234.56")).toBe(1234.56); }); it("keeps the sign of a leading minus", () => { expect(parseFrenchAmount("-84,32")).toBe(-84.32); expect(parseFrenchAmount(" -84,32 ")).toBe(-84.32); }); it("strips currency symbols on either side", () => { expect(parseFrenchAmount("1 250,00 $")).toBe(1250); expect(parseFrenchAmount("$1,250.00")).toBe(1250); expect(parseFrenchAmount("€84,32")).toBe(84.32); expect(parseFrenchAmount("£84,32")).toBe(84.32); }); it("rejects blank and non-numeric input", () => { expect(parseFrenchAmount("")).toBeNaN(); expect(parseFrenchAmount(" ")).toBeNaN(); expect(parseFrenchAmount("abc")).toBeNaN(); expect(parseFrenchAmount("-")).toBeNaN(); expect(parseFrenchAmount("--5")).toBeNaN(); // Letter-leading labels are rejected — this is what keeps `detectHeader` // working on a header such as "Solde 2024" (see the twin defect below). expect(parseFrenchAmount("Solde 2024")).toBeNaN(); // Non-string input is guarded before any parsing. expect(parseFrenchAmount(undefined as unknown as string)).toBeNaN(); expect(parseFrenchAmount(42 as unknown as string)).toBeNaN(); }); }); describe("parseFrenchAmount — anchored validation (#325, was a KNOWN DEFECT of #326)", () => { // FIXED. `parseFloat` used to return the longest valid PREFIX instead of // rejecting the string; validation is anchored over the whole normalized // value now, so any residual character yields NaN. // // Note on the two currency/indicator cases: link 1 wrote "should be 1234.56" // and "should be 100" in the titles, while the block header it wrote just // above said "all of these then become NaN, except the accounting-parenthesis // and trailing-sign forms". The header is what #325 implements, for two // reasons the titles missed. `CR`/`DB` carry a DIRECTION, so returning a // magnitude for both would replace a loud failure with a silent SIGN error — // and the D/C-indicator shape is refused upstream by design (#328). And // "100,00 CAD" is structurally identical to "2025 Montant": rescuing the // first by whitelisting a trailing word re-blinds `detectHeader` on the // second, which is the very defect measured below. it("reads a trailing sign as a negative amount", () => { // "50,00-" is the trailing-minus convention of several bank exports. expect(parseFrenchAmount("50,00-")).toBe(-50); expect(parseFrenchAmount("1 234,56-")).toBe(-1234.56); expect(parseFrenchAmount("50,00+")).toBe(50); }); it("rejects a trailing direction indicator instead of guessing a sign", () => { expect(parseFrenchAmount("1 234,56 CR")).toBeNaN(); expect(parseFrenchAmount("1 234,56 DB")).toBeNaN(); }); it("rejects a trailing currency code", () => { expect(parseFrenchAmount("100,00 CAD")).toBeNaN(); expect(parseFrenchAmount("84,32 USD")).toBeNaN(); }); it("rejects the numeric prefix of a text label", () => { // This is what used to blind `detectHeader`: a header cell STARTING with // digits read as a number, so the header row was taken for data. expect(parseFrenchAmount("2024 Montant")).toBeNaN(); expect(parseFrenchAmount("5%")).toBeNaN(); }); it("rejects JavaScript number literals a bank never emits", () => { expect(parseFrenchAmount("1e3")).toBeNaN(); expect(parseFrenchAmount("Infinity")).toBeNaN(); expect(parseFrenchAmount("0x1F")).toBeNaN(); }); it("rejects a malformed number instead of keeping its first two groups", () => { expect(parseFrenchAmount("1,2,3")).toBeNaN(); expect(parseFrenchAmount("1.2.3")).toBeNaN(); expect(parseFrenchAmount("1,23,456")).toBeNaN(); // groups must be 3 digits }); it("rejects two signs, wherever they sit", () => { expect(parseFrenchAmount("-50,00-")).toBeNaN(); expect(parseFrenchAmount("(-50,00)")).toBeNaN(); }); }); describe("parseFrenchAmount — accounting forms (#325, was a KNOWN DEFECT of #326)", () => { it("reads accounting parentheses as a negative amount", () => { expect(parseFrenchAmount("(50,00)")).toBe(-50); expect(parseFrenchAmount("(1 234,56)")).toBe(-1234.56); expect(parseFrenchAmount("(1,234.56)")).toBe(-1234.56); }); it("still rejects an unbalanced parenthesis", () => { expect(parseFrenchAmount("(50,00")).toBeNaN(); expect(parseFrenchAmount("50,00)")).toBeNaN(); }); }); describe("parseFrenchAmount — column-level arbitration (#325, was a KNOWN DEFECT of #326)", () => { // The French-vs-English decision used to be taken on each cell in isolation // and NOTHING else, so two cells of the same column could be read under two // different conventions. The isolated readings below are unchanged — they // are the best a lone cell allows — but a caller that knows the column now // passes its verdict and settles the ambiguity. it("keeps the documented reading when no column context is given", () => { // "12,345" is 12.345 in a column of decimals, 12345 in a column of // thousands. Nothing in the cell ALONE can tell. expect(parseFrenchAmount("12,345")).toBe(12345); expect(parseFrenchAmount("1.234")).toBe(1.234); expect(parseFrenchAmount("1.234,56")).toBe(1234.56); // ...unless a comma follows }); it("obeys the column verdict when there is one", () => { expect(parseFrenchAmount("12,345", { decimalSeparator: "," })).toBe(12.345); expect(parseFrenchAmount("12,345", { decimalSeparator: "." })).toBe(12345); expect(parseFrenchAmount("1.234", { decimalSeparator: "," })).toBe(1234); expect(parseFrenchAmount("1.234", { decimalSeparator: "." })).toBe(1.234); }); it("still rejects a value that contradicts the column verdict", () => { // A grouping separator groups by three, always. expect(parseFrenchAmount("1.23", { decimalSeparator: "," })).toBeNaN(); expect(parseFrenchAmount("1,23", { decimalSeparator: "." })).toBeNaN(); }); it("arbitrates a column from a decisive sibling cell", () => { // "1.234" alone is ambiguous; "84,32" in the same column is not. expect(detectDecimalSeparator(["1.234", "84,32", "-6,95"])).toBe(","); expect(detectDecimalSeparator(["1,234", "84.32", "-6.95"])).toBe("."); // Mixed notation settles on the last separator of each decisive cell. expect(detectDecimalSeparator(["1.234,56"])).toBe(","); expect(detectDecimalSeparator(["1,234.56"])).toBe("."); }); it("returns no verdict when the column gives no evidence", () => { expect(detectDecimalSeparator([])).toBeUndefined(); expect(detectDecimalSeparator(["1.234", "5.678"])).toBeUndefined(); expect(detectDecimalSeparator(["", " ", "N/A"])).toBeUndefined(); // One vote each way is a tie, not a majority. expect(detectDecimalSeparator(["84,32", "84.32"])).toBeUndefined(); }); }); describe("parseFrenchAmount — call-site fallout (#326, hardened by #325)", () => { // The /review-spec revision of #326 asks the corpus to reach the holdings // call sites too, because #325 hardens the parser GLOBALLY. These pin what // the shared parser does to its two most exposed consumers. it("no longer blinds detectHeader when a header cell starts with digits", () => { // `detectHeader` (csvAutoDetect.ts:224) treats "parses as a number" as // proof of a data row. "2025 Montant" used to parse as 2025, so the header // was taken for data; anchoring makes it NaN and the header is recognised. const cfg = autoDetectConfig(readCsvFixture("header-numeric-label"))!; expect(cfg.hasHeader).toBe(true); }); it("no longer leaks a x100 magnitude into the holdings CSV import (#245)", () => { // `holdingsFromCsvRows` (useSnapshotEditor.ts:191-202) shares the parser. // A price column carrying its currency code used to multiply every // position by 100 — a $150.25 share stored at $15,025. It is refused now, // and an empty price is what `buildDetailedLines` rejects on save. const drafts = holdingsFromCsvRows( [ ["AAPL", "10", "150,25 CAD", "1 200,00"], ["MSFT", "5", "300,50", "1 400,00 CAD"], ], { symbol: 0, quantity: 1, unit_price: 2, book_cost: 3 } ); expect(drafts[0].unit_price).toBe(""); // refused, not 15025 expect(drafts[0].book_cost).toBe("1200"); // clean cell expect(drafts[1].unit_price).toBe("300.5"); // clean cell expect(drafts[1].book_cost).toBe(""); // refused, not 140000 }); it("reads an accounting-parenthesis price instead of dropping it", () => { const drafts = holdingsFromCsvRows( [["GOOG", "2", "(140,10)", "280,20"]], { symbol: 0, quantity: 1, unit_price: 2, book_cost: 3 } ); expect(drafts[0].unit_price).toBe("-140.1"); expect(drafts[0].quantity).toBe("2"); }); it("keeps an unreadable quantity verbatim so the save refuses it", () => { // It used to be coerced to 0, which SAVED a zero-value position in // silence. `buildDetailedLines` throws on the raw text instead. const drafts = holdingsFromCsvRows( [["GOOG", "2 parts", "140,10", "280,20"]], { symbol: 0, quantity: 1, unit_price: 2, book_cost: 3 } ); expect(drafts[0].quantity).toBe("2 parts"); expect(() => buildDetailedLines({ 7: drafts }, new Set([7])) ).toThrowError(BalanceServiceError); }); it("taints the merged quantity when one lot of a symbol is unreadable", () => { const drafts = holdingsFromCsvRows( [ ["AAPL", "6", "150,00", "700"], ["AAPL", "quatre", "151,00", "500"], ], { symbol: 0, quantity: 1, unit_price: 2, book_cost: 3 } ); expect(drafts).toHaveLength(1); expect(drafts[0].quantity).toBe("quatre"); // NOT "6" }); });