a3d75973ee
Lib utilities: scopeImportParser (31), status-styles (58), planningEntryIds (10), uuid (11). Hooks: useFilters (28), useRowOrder (18), usePermissions (30), useViewPrefs (24). Components: AnimatedModal (14), DateInput (22), InfoTooltip (13), ProgressRing (19). Web test suite: 75 → 87 files, 553 → 844 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
9.6 KiB
TypeScript
276 lines
9.6 KiB
TypeScript
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<typeof vi.fn>;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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",
|
||
});
|
||
});
|
||
});
|