import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { MspdiReader } from "../src/mspdi/MspdiReader.ts"; import { MspdiWriter } from "../src/mspdi/MspdiWriter.ts"; import { parseXsdDuration, formatXsdDuration, parseLinkLag, formatLinkLag, durationContextFromProject, } from "../src/mspdi/XsdDuration.ts"; import { Duration } from "../src/model/Duration.ts"; import { TimeUnit, RelationType, ResourceType } from "../src/model/types.ts"; import { createEmptyProject } from "../src/model/Project.ts"; // --------------------------------------------------------------------------- // XsdDuration standalone tests // --------------------------------------------------------------------------- describe("XsdDuration", () => { const defaultContext = { minutesPerDay: 480, minutesPerWeek: 2400, daysPerMonth: 20, }; describe("parseXsdDuration", () => { test("returns null for null/undefined/empty", () => { expect(parseXsdDuration(null)).toBeNull(); expect(parseXsdDuration(undefined)).toBeNull(); expect(parseXsdDuration("")).toBeNull(); }); test("returns null for invalid format", () => { expect(parseXsdDuration("not a duration")).toBeNull(); expect(parseXsdDuration("12345")).toBeNull(); }); test("parses weeks", () => { const d = parseXsdDuration("P2W"); expect(d).not.toBeNull(); expect(d!.value).toBe(2); expect(d!.unit).toBe(TimeUnit.Weeks); }); test("parses pure days", () => { const d = parseXsdDuration("P5D"); expect(d).not.toBeNull(); expect(d!.value).toBe(5); expect(d!.unit).toBe(TimeUnit.Days); }); test("parses hours as days when it is a whole multiple of minutesPerDay", () => { // PT8H0M0S = 480 minutes = 1 day with default context const d = parseXsdDuration("PT8H0M0S", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(1); expect(d!.unit).toBe(TimeUnit.Days); }); test("parses hours when not a day multiple", () => { // PT4H0M0S = 240 minutes = 4 hours const d = parseXsdDuration("PT4H0M0S", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(4); expect(d!.unit).toBe(TimeUnit.Hours); }); test("parses minutes", () => { const d = parseXsdDuration("PT0H30M0S", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(30); expect(d!.unit).toBe(TimeUnit.Minutes); }); test("parses weeks from hours when whole multiple of minutesPerWeek", () => { // PT40H0M0S = 2400 minutes = 1 week with default context const d = parseXsdDuration("PT40H0M0S", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(1); expect(d!.unit).toBe(TimeUnit.Weeks); }); test("parses seconds", () => { // PT0H0M120S = 2 minutes const d = parseXsdDuration("PT0H0M120S", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(2); expect(d!.unit).toBe(TimeUnit.Minutes); }); test("uses custom context", () => { // 600 min/day context: PT10H = 600 min = 1 day const d = parseXsdDuration("PT10H0M0S", { minutesPerDay: 600, minutesPerWeek: 3000, daysPerMonth: 20, }); expect(d).not.toBeNull(); expect(d!.value).toBe(1); expect(d!.unit).toBe(TimeUnit.Days); }); }); describe("formatXsdDuration", () => { test("returns null for null/undefined", () => { expect(formatXsdDuration(null)).toBeNull(); expect(formatXsdDuration(undefined)).toBeNull(); }); test("formats days", () => { const d = Duration.from(1, TimeUnit.Days); expect(formatXsdDuration(d, defaultContext)).toBe("PT8H0M0S"); }); test("formats hours", () => { const d = Duration.from(4, TimeUnit.Hours); expect(formatXsdDuration(d, defaultContext)).toBe("PT4H0M0S"); }); test("formats minutes", () => { const d = Duration.from(30, TimeUnit.Minutes); expect(formatXsdDuration(d, defaultContext)).toBe("PT0H30M0S"); }); test("formats weeks", () => { const d = Duration.from(1, TimeUnit.Weeks); expect(formatXsdDuration(d, defaultContext)).toBe("PT40H0M0S"); }); }); describe("parseLinkLag", () => { test("returns null for null/undefined/empty", () => { expect(parseLinkLag(null, null)).toBeNull(); expect(parseLinkLag(undefined, undefined)).toBeNull(); expect(parseLinkLag("", "7")).toBeNull(); }); test("returns null for non-numeric lag", () => { expect(parseLinkLag("abc", "7")).toBeNull(); }); test("parses day lag (format 7)", () => { // 4800 tenths = 480 minutes = 1 day const d = parseLinkLag("4800", "7", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(1); expect(d!.unit).toBe(TimeUnit.Days); }); test("parses hour lag (format 5)", () => { // 600 tenths = 60 minutes = 1 hour const d = parseLinkLag("600", "5", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(1); expect(d!.unit).toBe(TimeUnit.Hours); }); test("parses minute lag (format 3)", () => { // 300 tenths = 30 minutes const d = parseLinkLag("300", "3", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(30); expect(d!.unit).toBe(TimeUnit.Minutes); }); test("parses week lag (format 9)", () => { // 24000 tenths = 2400 minutes = 1 week const d = parseLinkLag("24000", "9", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(1); expect(d!.unit).toBe(TimeUnit.Weeks); }); test("parses percent lag (format 19)", () => { // Percent unit has 0 unitMinutes, so result is 0 const d = parseLinkLag("500", "19", defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(0); expect(d!.unit).toBe(TimeUnit.Percent); }); test("defaults to day format when lagFormat is undefined", () => { const d = parseLinkLag("4800", undefined, defaultContext); expect(d).not.toBeNull(); expect(d!.value).toBe(1); expect(d!.unit).toBe(TimeUnit.Days); }); }); describe("formatLinkLag", () => { test("returns nulls for null/undefined", () => { expect(formatLinkLag(null)).toEqual({ linkLag: null, lagFormat: null }); expect(formatLinkLag(undefined)).toEqual({ linkLag: null, lagFormat: null, }); }); test("formats day lag", () => { const d = Duration.from(1, TimeUnit.Days); const result = formatLinkLag(d, defaultContext); expect(result.linkLag).toBe("4800"); expect(result.lagFormat).toBe("7"); }); test("formats hour lag", () => { const d = Duration.from(2, TimeUnit.Hours); const result = formatLinkLag(d, defaultContext); expect(result.linkLag).toBe("1200"); expect(result.lagFormat).toBe("5"); }); test("formats week lag", () => { const d = Duration.from(1, TimeUnit.Weeks); const result = formatLinkLag(d, defaultContext); expect(result.linkLag).toBe("24000"); expect(result.lagFormat).toBe("9"); }); }); describe("durationContextFromProject", () => { test("extracts correct fields from project properties", () => { const ctx = durationContextFromProject({ minutesPerDay: 480, minutesPerWeek: 2400, daysPerMonth: 20, }); expect(ctx.minutesPerDay).toBe(480); expect(ctx.minutesPerWeek).toBe(2400); expect(ctx.daysPerMonth).toBe(20); }); }); }); // --------------------------------------------------------------------------- // MspdiReader / MspdiWriter standalone tests // --------------------------------------------------------------------------- describe("MspdiReader", () => { test("throws when Project root element is missing", () => { const reader = new MspdiReader(); expect(() => reader.read("")).toThrow( "MSPDI XML is missing the Project root element", ); }); test("reads a minimal project", () => { const xml = ` 14 Test Project Tester 480 2400 20 `; const reader = new MspdiReader(); const project = reader.read(xml); expect(project.properties.saveVersion).toBe(14); expect(project.properties.title).toBe("Test Project"); expect(project.properties.author).toBe("Tester"); expect(project.properties.minutesPerDay).toBe(480); expect(project.properties.minutesPerWeek).toBe(2400); expect(project.properties.daysPerMonth).toBe(20); expect(project.tasks).toHaveLength(0); expect(project.resources).toHaveLength(0); expect(project.assignments).toHaveLength(0); expect(project.calendars).toHaveLength(0); }); test("uses defaults for missing minutesPerDay/minutesPerWeek/daysPerMonth", () => { const xml = ` Minimal `; const project = new MspdiReader().read(xml); expect(project.properties.minutesPerDay).toBe(480); expect(project.properties.minutesPerWeek).toBe(2400); expect(project.properties.daysPerMonth).toBe(20); }); test("reads tasks with predecessors", () => { const xml = ` 480 2400 20 1 1 First 1 2 2 Second 1 1 0 0 7 `; const project = new MspdiReader().read(xml); expect(project.tasks).toHaveLength(2); expect(project.tasks[0]?.name).toBe("First"); expect(project.tasks[1]?.name).toBe("Second"); expect(project.tasks[1]?.predecessors).toHaveLength(1); expect(project.tasks[1]?.predecessors[0]?.predecessorUniqueId).toBe(1); expect(project.tasks[1]?.predecessors[0]?.successorUniqueId).toBe(2); expect(project.tasks[1]?.predecessors[0]?.type).toBe( RelationType.FinishToStart, ); }); test("reads resources with type parsing", () => { const xml = ` 1 1 Work Resource 1 1.0 2 2 Material Resource 0 3 3 Cost Resource 2 `; const project = new MspdiReader().read(xml); expect(project.resources).toHaveLength(3); expect(project.resources[0]?.type).toBe(ResourceType.Work); expect(project.resources[0]?.maxUnits).toBe(100); expect(project.resources[1]?.type).toBe(ResourceType.Material); expect(project.resources[2]?.type).toBe(ResourceType.Cost); }); test("reads calendars with week days and exceptions", () => { const xml = ` 1 Standard 2 1 08:00:00 12:00:00 13:00:00 17:00:00 1 0 Holiday 2026-12-25T00:00:00 2026-12-25T00:00:00 0 `; const project = new MspdiReader().read(xml); expect(project.calendars).toHaveLength(1); expect(project.calendars[0]?.name).toBe("Standard"); expect(project.calendars[0]?.weekDays).toHaveLength(2); expect(project.calendars[0]?.weekDays[0]?.dayType).toBe(2); expect(project.calendars[0]?.weekDays[0]?.working).toBe(true); expect(project.calendars[0]?.weekDays[0]?.workingTimes).toHaveLength(2); expect(project.calendars[0]?.weekDays[0]?.workingTimes[0]?.from).toBe( "08:00:00", ); expect(project.calendars[0]?.weekDays[0]?.workingTimes[0]?.to).toBe( "12:00:00", ); expect(project.calendars[0]?.weekDays[1]?.working).toBe(false); expect(project.calendars[0]?.exceptions).toHaveLength(1); expect(project.calendars[0]?.exceptions[0]?.name).toBe("Holiday"); expect(project.calendars[0]?.exceptions[0]?.working).toBe(false); }); test("reads assignments with units conversion", () => { const xml = ` 1 1 0.75 PT4H0M0S 2026-04-06T08:00:00 2026-04-06T12:00:00 `; const project = new MspdiReader().read(xml); expect(project.assignments).toHaveLength(1); expect(project.assignments[0]?.taskUniqueId).toBe(1); expect(project.assignments[0]?.resourceUniqueId).toBe(1); expect(project.assignments[0]?.units).toBe(75); expect(project.assignments[0]?.work?.value).toBe(4); expect(project.assignments[0]?.work?.unit).toBe(TimeUnit.Hours); }); test("handles single-element arrays (no array wrapping by parser)", () => { const xml = ` 1 Only Task 1 Only Resource `; const project = new MspdiReader().read(xml); expect(project.tasks).toHaveLength(1); expect(project.resources).toHaveLength(1); }); }); describe("MspdiWriter", () => { test("writes a minimal project", () => { const project = createEmptyProject(); project.properties.title = "Test"; project.properties.saveVersion = 14; const writer = new MspdiWriter(); const xml = writer.write(project); expect(xml).toContain(''); expect(xml).toContain( '', ); expect(xml).toContain("14"); expect(xml).toContain("Test"); expect(xml).toContain(""); expect(xml).toContain(""); expect(xml).toContain(""); expect(xml).toContain(""); }); test("uses saveVersion from options", () => { const project = createEmptyProject(); const xml = new MspdiWriter().write(project, { saveVersion: 16 }); expect(xml).toContain("16"); }); test("defaults saveVersion to 14", () => { const project = createEmptyProject(); const xml = new MspdiWriter().write(project); expect(xml).toContain("14"); }); test("escapes XML special characters", () => { const project = createEmptyProject(); project.properties.title = 'Test & "Name"'; const xml = new MspdiWriter().write(project); expect(xml).toContain("Test & <Project> "Name""); }); test("omits null/empty fields", () => { const project = createEmptyProject(); project.properties.title = null; project.properties.author = null; project.properties.statusDate = null; const xml = new MspdiWriter().write(project); expect(xml).not.toContain(""); expect(xml).not.toContain("<Author>"); expect(xml).not.toContain("<StatusDate>"); }); }); // --------------------------------------------------------------------------- // MSPDI round-trip tests (no MPP dependency) // --------------------------------------------------------------------------- describe("MSPDI round-trip", () => { test("parses and writes working-time durations, lag, and units using MSPDI conventions", () => { const xml = `<?xml version="1.0" encoding="UTF-8"?> <Project xmlns="http://schemas.microsoft.com/project"> <SaveVersion>16</SaveVersion> <Title>Variant Test 480 2400 20 1 1 1 Task 1 1 2026-04-06T06:00:00 2026-04-06T14:00:00 PT8H0M0S PT8H0M0S 2 2 Task 2 1 2026-04-07T06:00:00 2026-04-07T14:00:00 PT8H0M0S 1 0 4800 7 1 1 Worker 1 1 1.5 1 1 0.5 PT4H0M0S 2026-04-06T06:00:00 2026-04-06T10:00:00 `; const project = new MspdiReader().read(xml); expect(project.properties.saveVersion).toBe(16); expect(project.properties.defaultCalendarUniqueId).toBe(1); expect(project.tasks[0]?.duration?.toSimpleString()).toBe("1.0d"); expect(project.tasks[0]?.work?.toSimpleString()).toBe("1.0d"); expect(project.tasks[1]?.predecessors[0]?.lag?.toSimpleString()).toBe( "1.0d", ); expect(project.resources[0]?.maxUnits).toBe(150); expect(project.assignments[0]?.units).toBe(50); expect(project.assignments[0]?.work?.toSimpleString()).toBe("4.0h"); const written = new MspdiWriter().write(project, { saveVersion: 16 }); expect(written).toContain("16"); expect(written).toContain("PT8H0M0S"); expect(written).toContain("4800"); expect(written).toContain("7"); expect(written).toContain("1.5"); expect(written).toContain("0.5"); }); test("round-trips a project with calendars, tasks, resources, and assignments", () => { const xml = ` 14 Round Trip Test Test Author 2026-04-01T08:00:00 2026-04-30T17:00:00 480 2400 20 1 1 Standard 2 1 08:00:00 17:00:00 0 0 Summary 0 1 0 0 1 1 Design Phase 1 1 2026-04-01T08:00:00 2026-04-05T17:00:00 PT40H0M0S 50 0 0 1 500 2 2 Build Phase 2 1 2026-04-06T08:00:00 2026-04-20T17:00:00 PT80H0M0S 0 0 0 1 1 0 0 7 1 1 Developer 1 dev@example.com 1.0 1 1 PT40H0M0S 1.0 2026-04-01T08:00:00 2026-04-05T17:00:00 `; const reader = new MspdiReader(); const writer = new MspdiWriter(); const project = reader.read(xml); const written = writer.write(project, { saveVersion: 14 }); const roundTripped = reader.read(written); // Verify structure is preserved expect(roundTripped.properties.title).toBe("Round Trip Test"); expect(roundTripped.properties.author).toBe("Test Author"); expect(roundTripped.properties.saveVersion).toBe(14); expect(roundTripped.properties.defaultCalendarUniqueId).toBe(1); expect(roundTripped.calendars).toHaveLength(1); expect(roundTripped.calendars[0]?.name).toBe("Standard"); expect(roundTripped.tasks).toHaveLength(3); expect(roundTripped.tasks[0]?.name).toBe("Summary"); expect(roundTripped.tasks[0]?.summary).toBe(true); expect(roundTripped.tasks[1]?.name).toBe("Design Phase"); expect(roundTripped.tasks[1]?.percentComplete).toBe(50); expect(roundTripped.tasks[1]?.critical).toBe(true); expect(roundTripped.tasks[1]?.duration?.toSimpleString()).toBe("1.0w"); expect(roundTripped.tasks[2]?.name).toBe("Build Phase"); expect(roundTripped.tasks[2]?.predecessors).toHaveLength(1); expect(roundTripped.tasks[2]?.predecessors[0]?.type).toBe( RelationType.FinishToStart, ); expect(roundTripped.resources).toHaveLength(1); expect(roundTripped.resources[0]?.name).toBe("Developer"); expect(roundTripped.resources[0]?.email).toBe("dev@example.com"); expect(roundTripped.resources[0]?.maxUnits).toBe(100); expect(roundTripped.assignments).toHaveLength(1); expect(roundTripped.assignments[0]?.units).toBe(100); expect(roundTripped.assignments[0]?.work?.toSimpleString()).toBe("1.0w"); }); test("round-trips all relation types", () => { const xml = ` 480 2400 20 11A 22B 1007 33C 1107 44D 1207 55E 1307 `; const project = new MspdiReader().read(xml); expect(project.tasks[1]?.predecessors[0]?.type).toBe( RelationType.FinishToStart, ); expect(project.tasks[2]?.predecessors[0]?.type).toBe( RelationType.StartToStart, ); expect(project.tasks[3]?.predecessors[0]?.type).toBe( RelationType.FinishToFinish, ); expect(project.tasks[4]?.predecessors[0]?.type).toBe( RelationType.StartToFinish, ); const written = new MspdiWriter().write(project); expect(written).toContain("0"); expect(written).toContain("1"); expect(written).toContain("2"); expect(written).toContain("3"); }); test("round-trips calendar exceptions", () => { const xml = ` 1 Standard Christmas 2026-12-25T00:00:00 2026-12-25T00:00:00 0 `; const project = new MspdiReader().read(xml); const written = new MspdiWriter().write(project); const roundTripped = new MspdiReader().read(written); expect(roundTripped.calendars[0]?.exceptions).toHaveLength(1); expect(roundTripped.calendars[0]?.exceptions[0]?.name).toBe("Christmas"); expect(roundTripped.calendars[0]?.exceptions[0]?.working).toBe(false); }); }); // --------------------------------------------------------------------------- // Tests that require an MPP file // --------------------------------------------------------------------------- const HAS_MPP_FIXTURE = existsSync( fileURLToPath(new URL("./sample-schedule.mpp", import.meta.url)), ); describe("MSPDI with MPP fixture", () => { test.skipIf(!HAS_MPP_FIXTURE)( "round-trips the fixture project through MSPDI XML", async () => { const { MppReader } = await import("../src/mpp/MppReader.ts"); const fixturePath = new URL("./sample-schedule.mpp", import.meta.url); const { fileURLToPath } = await import("node:url"); const data = await Bun.file(fileURLToPath(fixturePath)).arrayBuffer(); const project = new MppReader().read(data); const xml = new MspdiWriter().write(project); const roundTripped = new MspdiReader().read(xml); expect(roundTripped.tasks.length).toBe(project.tasks.length); expect(roundTripped.resources.length).toBe(project.resources.length); expect(roundTripped.assignments.length).toBe(project.assignments.length); for (const [i, task] of roundTripped.tasks.entries()) { const original = project.tasks[i]!; expect(task.name ?? null).toBe(original.name?.trimEnd() ?? null); expect(task.id).toBe(original.id); } }, ); });