// 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(); }); });