import { describe, expect, it, vi } from "vitest"; import type { ParsedScopeRow, ScopeImportResult } from "./scopeImportParser.js"; // parseScopeImport depends on parseSpreadsheet from "./excel.js", which does // real file I/O. We mock that module so the parser logic can be tested in // isolation with plain in-memory data. vi.mock("./excel.js", () => ({ parseSpreadsheet: vi.fn(), })); import { parseScopeImport } from "./scopeImportParser.js"; import { parseSpreadsheet } from "./excel.js"; const mockSpreadsheet = parseSpreadsheet as ReturnType; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeFile(): File { return new File(["dummy"], "test.xlsx"); } // --------------------------------------------------------------------------- // Empty / degenerate input // --------------------------------------------------------------------------- describe("parseScopeImport – empty input", () => { it("returns a warning and no rows when the spreadsheet is empty", async () => { mockSpreadsheet.mockResolvedValueOnce([]); const result: ScopeImportResult = await parseScopeImport(makeFile()); expect(result.rows).toHaveLength(0); expect(result.warnings).toContain("File contains no data rows."); expect(result.mapping.name).toBeNull(); }); it("returns a warning when no name column can be identified", async () => { mockSpreadsheet.mockResolvedValueOnce([{ quantity: "3", budget: "1000" }]); const result = await parseScopeImport(makeFile()); expect(result.rows).toHaveLength(0); expect(result.warnings.some((w) => w.includes('"Name" column'))).toBe(true); }); it("returns a warning when name column is present but all values are empty", async () => { mockSpreadsheet.mockResolvedValueOnce([{ name: " " }, { name: "" }]); const result = await parseScopeImport(makeFile()); expect(result.rows).toHaveLength(0); expect(result.warnings).toContain("No rows with a non-empty name found."); }); }); // --------------------------------------------------------------------------- // Column header resolution – aliases // --------------------------------------------------------------------------- describe("parseScopeImport – header alias resolution", () => { it("maps 'title' to the name field", async () => { mockSpreadsheet.mockResolvedValueOnce([{ title: "Shot A" }]); const result = await parseScopeImport(makeFile()); expect(result.mapping.name).toBe("title"); expect(result.rows[0]?.name).toBe("Shot A"); }); it("maps 'seq' to sequenceNo and uses its integer value", async () => { mockSpreadsheet.mockResolvedValueOnce([{ seq: "5", name: "Shot B" }]); const result = await parseScopeImport(makeFile()); expect(result.mapping.sequenceNo).toBe("seq"); expect(result.rows[0]?.sequenceNo).toBe(5); }); it("maps 'type' to scopeType", async () => { mockSpreadsheet.mockResolvedValueOnce([{ type: "ASSET", name: "Prop01" }]); const result = await parseScopeImport(makeFile()); expect(result.mapping.scopeType).toBe("type"); expect(result.rows[0]?.scopeType).toBe("ASSET"); }); it("maps 'pkg' to packageCode", async () => { mockSpreadsheet.mockResolvedValueOnce([{ pkg: "PKG-01", name: "Shot C" }]); const result = await parseScopeImport(makeFile()); expect(result.mapping.packageCode).toBe("pkg"); expect(result.rows[0]?.packageCode).toBe("PKG-01"); }); it("maps 'desc' to description", async () => { mockSpreadsheet.mockResolvedValueOnce([{ desc: "Some details", name: "Shot D" }]); const result = await parseScopeImport(makeFile()); expect(result.mapping.description).toBe("desc"); expect(result.rows[0]?.description).toBe("Some details"); }); it("treats header matching as case-insensitive", async () => { mockSpreadsheet.mockResolvedValueOnce([{ NAME: "Shot E", TYPE: "VFX" }]); const result = await parseScopeImport(makeFile()); expect(result.mapping.name).toBe("NAME"); expect(result.mapping.scopeType).toBe("TYPE"); }); it("handles headers with mixed casing and spaces", async () => { mockSpreadsheet.mockResolvedValueOnce([{ "Sequence No": "10", Name: "Shot F" }]); const result = await parseScopeImport(makeFile()); expect(result.mapping.sequenceNo).toBe("Sequence No"); expect(result.rows[0]?.sequenceNo).toBe(10); }); }); // --------------------------------------------------------------------------- // Sequence number handling // --------------------------------------------------------------------------- describe("parseScopeImport – sequence number auto-numbering", () => { it("auto-numbers rows when no sequenceNo column is present", async () => { mockSpreadsheet.mockResolvedValueOnce([ { name: "Shot A" }, { name: "Shot B" }, { name: "Shot C" }, ]); const result = await parseScopeImport(makeFile()); expect(result.rows[0]?.sequenceNo).toBe(1); expect(result.rows[1]?.sequenceNo).toBe(2); expect(result.rows[2]?.sequenceNo).toBe(3); expect(result.warnings).toContain("No sequence number column detected — auto-numbering."); }); it("falls back to auto-number when sequenceNo value is not a positive integer", async () => { mockSpreadsheet.mockResolvedValueOnce([ { seq: "0", name: "Shot A" }, { seq: "-5", name: "Shot B" }, { seq: "abc", name: "Shot C" }, ]); const result = await parseScopeImport(makeFile()); result.rows.forEach((row, i) => { expect(row.sequenceNo).toBe(i + 1); }); }); it("respects explicit positive integer sequence numbers", async () => { mockSpreadsheet.mockResolvedValueOnce([ { seq: "100", name: "Shot A" }, { seq: "200", name: "Shot B" }, ]); const result = await parseScopeImport(makeFile()); expect(result.rows[0]?.sequenceNo).toBe(100); expect(result.rows[1]?.sequenceNo).toBe(200); }); }); // --------------------------------------------------------------------------- // scopeType defaulting // --------------------------------------------------------------------------- describe("parseScopeImport – scopeType defaulting", () => { it("defaults scopeType to SHOT when no type column is present", async () => { mockSpreadsheet.mockResolvedValueOnce([{ name: "Shot A" }]); const result = await parseScopeImport(makeFile()); expect(result.rows[0]?.scopeType).toBe("SHOT"); expect(result.warnings).toContain("No scope type column detected — defaulting to SHOT."); }); it("defaults scopeType to SHOT when the type cell is empty", async () => { mockSpreadsheet.mockResolvedValueOnce([{ type: " ", name: "Shot A" }]); const result = await parseScopeImport(makeFile()); expect(result.rows[0]?.scopeType).toBe("SHOT"); }); it("preserves the provided scopeType when it is non-empty", async () => { mockSpreadsheet.mockResolvedValueOnce([{ type: "ASSET", name: "Prop01" }]); const result = await parseScopeImport(makeFile()); expect(result.rows[0]?.scopeType).toBe("ASSET"); }); }); // --------------------------------------------------------------------------- // Rows with empty names are skipped // --------------------------------------------------------------------------- describe("parseScopeImport – skipping empty-name rows", () => { it("skips rows where the name cell is blank", async () => { mockSpreadsheet.mockResolvedValueOnce([ { name: "Shot A" }, { name: "" }, { name: "Shot C" }, { name: " " }, ]); const result = await parseScopeImport(makeFile()); expect(result.rows).toHaveLength(2); expect(result.rows[0]?.name).toBe("Shot A"); expect(result.rows[1]?.name).toBe("Shot C"); }); }); // --------------------------------------------------------------------------- // Full happy-path scenario // --------------------------------------------------------------------------- describe("parseScopeImport – full integration scenario", () => { it("parses a complete row with all columns present", async () => { mockSpreadsheet.mockResolvedValueOnce([ { number: "42", type: "VFX", code: "PKG-VFX", title: "Explosion", notes: "Big boom", }, ]); const result = await parseScopeImport(makeFile()); expect(result.warnings).toHaveLength(0); expect(result.rows).toHaveLength(1); const row: ParsedScopeRow = result.rows[0]!; expect(row.sequenceNo).toBe(42); expect(row.scopeType).toBe("VFX"); expect(row.packageCode).toBe("PKG-VFX"); expect(row.name).toBe("Explosion"); expect(row.description).toBe("Big boom"); }); it("trims whitespace from all string fields", async () => { mockSpreadsheet.mockResolvedValueOnce([ { name: " Trimmed Name ", type: " SHOT ", code: " PKG-01 ", desc: " Some notes ", }, ]); const result = await parseScopeImport(makeFile()); const row = result.rows[0]!; expect(row.name).toBe("Trimmed Name"); expect(row.scopeType).toBe("SHOT"); expect(row.packageCode).toBe("PKG-01"); expect(row.description).toBe("Some notes"); }); it("returns the correct mapping alongside the rows", async () => { mockSpreadsheet.mockResolvedValueOnce([ { seq: "1", type: "VFX", package: "P1", name: "Shot A", description: "desc" }, ]); const result = await parseScopeImport(makeFile()); expect(result.mapping).toMatchObject({ sequenceNo: "seq", scopeType: "type", packageCode: "package", name: "name", description: "description", }); }); });