Simpl-Resultat/src/utils/csvAutoDetect.test.ts
le king fu c8c765e2f0
All checks were successful
PR Check / rust (pull_request) Successful in 22m45s
PR Check / frontend (pull_request) Successful in 2m25s
feat(balance): CSV import of holdings in detailed snapshot
A detailed (by-security) account can now import its positions from a CSV
instead of adding each security one by one. An "Import CSV" button next to
"Add a title" opens a native picker; the delimiter, encoding and columns
(symbol, quantity, optional price + book_cost) are auto-detected via new
csvAutoDetect helpers and adjustable in a small mapping editor. Rows sharing
a symbol are merged (SUM qty + book_cost, first price) and the batch is merged
into the basket by normalized symbol, so no UNIQUE(snapshot_line_id,
security_id) violation can occur at save. A CSV without a price column imports
quantities with an empty unit_price (fetch/type later) — the existing atomic
save path is unchanged (no SQL/Rust change).

- csvAutoDetect: autoDetectHoldingColumns + analyzeHoldingsCsv (+ tests)
- useSnapshotEditor: holdingsFromCsvRows + IMPORT_HOLDINGS action + importHoldings (+ tests)
- HoldingsCsvImportModal: file pick -> parse -> mapping editor + preview
- i18n FR/EN under balance.snapshot.detailed.importCsv.*
- CHANGELOG (Added / Ajouté)

Resolves #245

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:04:12 -04:00

123 lines
4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// csvAutoDetect — holdings-CSV detection tests (Issue #245).
//
// Covers `autoDetectHoldingColumns` (column detection) and `analyzeHoldingsCsv`
// (delimiter/header + full analysis). These helpers are pure and DOM-free, in
// line with the project's "test the extracted pure pieces" convention. The
// numeric parsing + duplicate-merge path lives in `holdingsFromCsvRows`
// (tested in useSnapshotEditor.test.ts).
import { describe, it, expect } from "vitest";
import {
autoDetectHoldingColumns,
analyzeHoldingsCsv,
} from "./csvAutoDetect";
describe("autoDetectHoldingColumns (#245)", () => {
it("detects symbol/quantity/price/book_cost from EN headers", () => {
const data = [
["Symbol", "Quantity", "Price", "Book Cost"],
["AAPL", "10", "150.25", "1200.00"],
["MSFT", "5", "300.50", "1400.00"],
];
expect(autoDetectHoldingColumns(data, true)).toEqual({
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
});
});
it("leaves unit_price + book_cost null when the CSV has no such columns", () => {
const data = [
["Symbol", "Shares"],
["AAPL", "10"],
["MSFT", "5"],
];
const m = autoDetectHoldingColumns(data, true)!;
expect(m.symbol).toBe(0);
expect(m.quantity).toBe(1);
expect(m.unit_price).toBeNull();
expect(m.book_cost).toBeNull();
});
it("never maps a Value (qty×price) column to price or book_cost", () => {
const data = [
["Symbol", "Quantity", "Price", "Value"],
["AAPL", "10", "150.25", "1502.50"],
["MSFT", "4", "300.50", "1202.00"],
];
const m = autoDetectHoldingColumns(data, true)!;
expect(m.symbol).toBe(0);
expect(m.quantity).toBe(1);
expect(m.unit_price).toBe(2);
// The Value column (col 3) must NOT be picked up as the cost basis.
expect(m.book_cost).toBeNull();
});
it("detects headerless CSVs positionally (symbol / int qty / decimal price)", () => {
const data = [
["AAPL", "10", "150.25"],
["MSFT", "5", "300.50"],
["GOOG", "2", "140.10"],
];
const m = autoDetectHoldingColumns(data, false)!;
expect(m.symbol).toBe(0);
expect(m.quantity).toBe(1);
expect(m.unit_price).toBe(2);
});
it("returns null when there are no data rows", () => {
expect(autoDetectHoldingColumns([], true)).toBeNull();
expect(autoDetectHoldingColumns([["Symbol", "Qty"]], true)).toBeNull();
});
});
describe("analyzeHoldingsCsv (#245)", () => {
it("parses an EN comma CSV with a header row", () => {
const csv = "Symbol,Quantity,Price\nAAPL,10,150.25\nMSFT,5,300.50\n";
const a = analyzeHoldingsCsv(csv)!;
expect(a.delimiter).toBe(",");
expect(a.hasHeader).toBe(true);
expect(a.headers).toEqual(["Symbol", "Quantity", "Price"]);
expect(a.rows).toEqual([
["AAPL", "10", "150.25"],
["MSFT", "5", "300.50"],
]);
expect(a.mapping).toEqual({
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: null,
});
});
it("handles FR headers (accents), a semicolon delimiter and FR numbers", () => {
const csv =
"Symbole;Quantité;Cours;Coût\nAAPL;10;150,25;1 200,00\nMSFT;5;300,50;1 400,00\n";
const a = analyzeHoldingsCsv(csv)!;
expect(a.delimiter).toBe(";");
expect(a.hasHeader).toBe(true);
expect(a.mapping).toEqual({
symbol: 0,
quantity: 1,
unit_price: 2,
book_cost: 3,
});
expect(a.rows[0]).toEqual(["AAPL", "10", "150,25", "1 200,00"]);
});
it("returns generated headers + all rows for a headerless CSV", () => {
const csv = "AAPL,10,150.25\nMSFT,5,300.50\nGOOG,2,140.10\n";
const a = analyzeHoldingsCsv(csv)!;
expect(a.hasHeader).toBe(false);
expect(a.headers).toEqual(["Col 0", "Col 1", "Col 2"]);
expect(a.rows).toHaveLength(3);
expect(a.mapping.symbol).toBe(0);
expect(a.mapping.quantity).toBe(1);
});
it("returns null on empty / whitespace-only content", () => {
expect(analyzeHoldingsCsv("")).toBeNull();
expect(analyzeHoldingsCsv(" \n \n")).toBeNull();
});
});