import { describe, expect, test } from "bun:test"; import { fileURLToPath } from "node:url"; import { MspdiReader } from "../src/mspdi/MspdiReader.ts"; import { MspdiWriter } from "../src/mspdi/MspdiWriter.ts"; import { formatProjectDate } from "../src/dateTime.ts"; import { type TimeUnit } from "../src/model/types.ts"; import type { ProjectFile } from "../src/model/Project.ts"; import { parseCsv, fixtureExists } from "./helpers.ts"; async function getMppReader() { return import("../src/mpp/MppReader.ts"); } async function readMppFixture(path: string) { const { MppReader } = await getMppReader(); const data = await Bun.file(path).arrayBuffer(); return new MppReader().read(data); } async function loadMppContainer(path: string) { const { parseMppBuffer } = await getMppReader(); const data = await Bun.file(path).arrayBuffer(); return parseMppBuffer(data); } const FIXTURE_JSON_PATH = resolveFixturePath("./project_data.json"); const FIXTURE_MPP_PATH = resolveFixturePath("./sample-schedule.mpp"); const FIXTURE_CSV_PATH = resolveFixturePath("./project_schedule.csv"); const HAS_MPP_FIXTURE = fixtureExists(FIXTURE_MPP_PATH); describe("fixture mapping", () => { test.skipIf(!HAS_MPP_FIXTURE)( "extracts project data from the mpp binary matching the json baseline", async () => { const project = await readMppFixture(FIXTURE_MPP_PATH); const expected = JSON.parse( await Bun.file(FIXTURE_JSON_PATH).text(), ) as SerializedProject; expect(serializeProject(project)).toEqual(expected); }, ); test.skipIf(!HAS_MPP_FIXTURE)( "matches the exported CSV for leaf task schedule rows from the mpp binary", async () => { const project = await readMppFixture(FIXTURE_MPP_PATH); const leafTasks = project.tasks.filter( (task) => task.summary === false && task.id !== null, ); const csvRows = parseCsv(await Bun.file(FIXTURE_CSV_PATH).text()); expect(csvRows).toHaveLength(leafTasks.length); for (const [index, row] of csvRows.entries()) { const task = leafTasks[index]; expect(task).toBeDefined(); expect(String(task!.id)).toBe(row["ID"] ?? ""); expect(task!.name ?? "").toBe(row["Task Name"] ?? ""); expect(task!.wbs ?? "").toBe(row["WBS"] ?? ""); expect(formatMinuteDate(task!.start) ?? "").toBe(row["Start"] ?? ""); expect(formatMinuteDate(task!.finish) ?? "").toBe(row["Finish"] ?? ""); expect(task!.duration?.toSimpleString() ?? "").toBe( row["Duration"] ?? "", ); expect(formatDecimal(task!.percentComplete)).toBe( row["% Complete"] ?? "", ); expect(task!.critical ? "Yes" : "No").toBe(row["Critical"] ?? ""); expect(task!.milestone ? "Yes" : "No").toBe(row["Milestone"] ?? ""); } }, ); }); describe("mspdi", () => { test.skipIf(!HAS_MPP_FIXTURE)( "round-trips the fixture project through MSPDI XML", async () => { const project = await readMppFixture(FIXTURE_MPP_PATH); const xml = new MspdiWriter().write(project, { saveVersion: 14 }); const roundTripped = new MspdiReader().read(xml); expect(xml).toContain(""); expect(roundTripped.tasks).toHaveLength(project.tasks.length); expect(roundTripped.resources).toHaveLength(project.resources.length); expect(roundTripped.assignments).toHaveLength(project.assignments.length); expect(roundTripped.calendars).toHaveLength(project.calendars.length); expect(roundTripped.tasks[10]?.name).toBe(project.tasks[10]?.name); expect(formatMinuteDate(roundTripped.tasks[10]?.start ?? null)).toBe( formatMinuteDate(project.tasks[10]?.start ?? null), ); }, ); test("parses and writes working-time durations, lag, and units using MSPDI conventions", () => { const xml = ` 16 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"); }); }); describe("mpp inspection", () => { test.skipIf(!HAS_MPP_FIXTURE)( "inspects the sample MPP container and reads directly from the binary API", async () => { const { MppReader } = await getMppReader(); const data = await Bun.file(FIXTURE_MPP_PATH).arrayBuffer(); const reader = new MppReader(); const inspection = reader.inspect(data); const project = reader.read(data); expect(inspection.version).toBe(14); expect(inspection.rootPath).toBe("Root Entry/ 114"); expect(inspection.formatPropsPath).toBe("Root Entry/Props14"); expect(inspection.props14).not.toBeNull(); expect(inspection.taskTable.fixedDataSize).toBeGreaterThan(0); expect(inspection.taskTable.var2DataSize).toBeGreaterThan(0); expect(project.tasks).toHaveLength(70); }, ); test.skipIf(!HAS_MPP_FIXTURE)( "detects and reads equivalent modern variants when stream roots are renamed", async () => { const { MppReader } = await getMppReader(); const container = await loadMppContainer(FIXTURE_MPP_PATH); const variantContainer = { streams: new Map( [...container.streams.entries()].map(([path, value]) => [ path .replace("Root Entry/Props14", "Root Entry/Props16") .replace("Root Entry/ 114/", "Root Entry/ 116/"), value, ]), ), }; const reader = new MppReader(); const inspection = reader.inspectContainer(variantContainer); const project = reader.readContainer(variantContainer); expect(inspection.version).toBe(16); expect(inspection.rootPath).toBe("Root Entry/ 116"); expect(inspection.formatPropsPath).toBe("Root Entry/Props16"); expect(project.properties.saveVersion).toBe(16); expect(project.tasks).toHaveLength(70); expect(project.tasks[1]?.name).toBe("2-3 Modification"); }, ); }); function formatMinuteDate(value: Date | null): string | null { const formatted = formatProjectDate(value); return formatted ? formatted.slice(0, 16) : null; } function formatDecimal(value: number | null): string { return value === null ? "" : Number.isInteger(value) ? value.toFixed(1) : String(value); } function resolveFixturePath(relativePath: string): string { return fileURLToPath(new URL(relativePath, import.meta.url)); } function serializeProject(project: ProjectFile) { return { project: { title: project.properties.title, author: project.properties.author, start_date: formatMinuteDate(project.properties.startDate), finish_date: formatMinuteDate(project.properties.finishDate), status_date: formatMinuteDate(project.properties.statusDate), }, tasks: project.tasks.map((task) => ({ id: task.id, unique_id: task.uniqueId, name: task.name, wbs: task.wbs, outline_level: task.outlineLevel, start: formatMinuteDate(task.start), finish: formatMinuteDate(task.finish), duration: serializeDuration(task.duration), percent_complete: task.percentComplete === null ? null : formatDecimal(task.percentComplete), summary: task.summary, milestone: task.milestone, critical: task.critical, notes: task.notes, priority: task.priority === null ? null : `[Priority value=${task.priority}]`, actual_start: formatMinuteDate(task.actualStart), actual_finish: formatMinuteDate(task.actualFinish), baseline_start: formatMinuteDate(task.baselineStart), baseline_finish: formatMinuteDate(task.baselineFinish), baseline_duration: serializeDuration(task.baselineDuration), cost: task.cost === null ? null : formatDecimal(task.cost), work: serializeDuration(task.work), predecessors: task.predecessors.map((predecessor) => ({ predecessor_unique_id: predecessor.predecessorUniqueId, type: predecessor.type, lag: serializeDuration(predecessor.lag), })), })), resources: project.resources.map((resource) => ({ id: resource.id, unique_id: resource.uniqueId, name: resource.name, type: resource.type, email: resource.email, group: resource.group, max_units: resource.maxUnits === null ? null : formatDecimal(resource.maxUnits), cost: resource.cost === null ? null : formatDecimal(resource.cost), })), assignments: project.assignments.map((assignment) => ({ task_unique_id: assignment.taskUniqueId, resource_unique_id: assignment.resourceUniqueId, work: serializeDuration(assignment.work), units: assignment.units === null ? null : formatDecimal(assignment.units), start: formatMinuteDate(assignment.start), finish: formatMinuteDate(assignment.finish), })), calendars: project.calendars.map((calendar) => ({ unique_id: calendar.uniqueId, name: calendar.name, })), }; } function serializeDuration( value: | { value: number; unit: TimeUnit; toSimpleString(): string } | null | undefined, ): string | null { if (!value) { return null; } return value.toSimpleString(); } type SerializedProject = ReturnType;