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>
123 lines
4 KiB
TypeScript
123 lines
4 KiB
TypeScript
// 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();
|
||
});
|
||
});
|