import { describe, expect, it } from "vitest"; import { compareEstimateVersions, type VersionCompareInput, } from "../estimate/index.js"; function makeLine(overrides: Partial & { id: string; name: string }) { return { hours: 0, costRateCents: 0, billRateCents: 0, costTotalCents: 0, priceTotalCents: 0, lineType: "ROLE", ...overrides, }; } function makeVersion(overrides: Partial = {}): VersionCompareInput { return { versionNumber: 1, demandLines: [], assumptions: [], scopeItems: [], ...overrides, }; } describe("compareEstimateVersions", () => { it("returns zeroed diff for identical versions", () => { const lines = [ makeLine({ id: "l1", name: "Designer", hours: 100, costTotalCents: 500_00, priceTotalCents: 800_00 }), ]; const assumptions = [{ key: "rate", label: "Day rate", value: 500 }]; const scopeItems = [{ name: "Shot A", sequenceNo: 1, scopeType: "SHOT" }]; const v = makeVersion({ demandLines: lines, assumptions, scopeItems }); const diff = compareEstimateVersions(v, v); expect(diff.summary.totalHoursDelta).toBe(0); expect(diff.summary.totalCostDelta).toBe(0); expect(diff.summary.totalPriceDelta).toBe(0); expect(diff.summary.linesAdded).toBe(0); expect(diff.summary.linesRemoved).toBe(0); expect(diff.summary.linesChanged).toBe(0); expect(diff.summary.assumptionsChanged).toBe(0); expect(diff.summary.scopeItemsAdded).toBe(0); expect(diff.summary.scopeItemsRemoved).toBe(0); expect(diff.demandLineDiffs).toHaveLength(1); expect(diff.demandLineDiffs[0]!.status).toBe("unchanged"); expect(diff.assumptionDiffs).toHaveLength(1); expect(diff.assumptionDiffs[0]!.status).toBe("unchanged"); }); it("detects added demand lines", () => { const a = makeVersion({ demandLines: [] }); const b = makeVersion({ demandLines: [ makeLine({ id: "l1", name: "Animator", hours: 40, costTotalCents: 200_00, priceTotalCents: 320_00 }), ], }); const diff = compareEstimateVersions(a, b); expect(diff.summary.linesAdded).toBe(1); expect(diff.summary.linesRemoved).toBe(0); expect(diff.summary.totalHoursDelta).toBe(40); expect(diff.summary.totalCostDelta).toBe(200_00); expect(diff.summary.totalPriceDelta).toBe(320_00); expect(diff.demandLineDiffs).toHaveLength(1); expect(diff.demandLineDiffs[0]).toEqual( expect.objectContaining({ name: "Animator", status: "added", hoursDelta: 40, costDelta: 200_00, priceDelta: 320_00, }), ); }); it("detects removed demand lines", () => { const a = makeVersion({ demandLines: [ makeLine({ id: "l1", name: "Lighter", hours: 20, costTotalCents: 100_00, priceTotalCents: 160_00 }), ], }); const b = makeVersion({ demandLines: [] }); const diff = compareEstimateVersions(a, b); expect(diff.summary.linesAdded).toBe(0); expect(diff.summary.linesRemoved).toBe(1); expect(diff.summary.totalHoursDelta).toBe(-20); expect(diff.demandLineDiffs[0]).toEqual( expect.objectContaining({ name: "Lighter", status: "removed", hoursDelta: -20, }), ); }); it("detects changed hours and rates", () => { const a = makeVersion({ demandLines: [ makeLine({ id: "l1", name: "TD", hours: 50, costTotalCents: 300_00, priceTotalCents: 500_00 }), ], }); const b = makeVersion({ demandLines: [ makeLine({ id: "l1", name: "TD", hours: 80, costTotalCents: 480_00, priceTotalCents: 800_00 }), ], }); const diff = compareEstimateVersions(a, b); expect(diff.summary.linesChanged).toBe(1); expect(diff.summary.linesAdded).toBe(0); expect(diff.summary.linesRemoved).toBe(0); expect(diff.summary.totalHoursDelta).toBe(30); expect(diff.summary.totalCostDelta).toBe(180_00); expect(diff.summary.totalPriceDelta).toBe(300_00); const lineDiff = diff.demandLineDiffs[0]!; expect(lineDiff.status).toBe("changed"); expect(lineDiff.hoursDelta).toBe(30); expect(lineDiff.costDelta).toBe(180_00); expect(lineDiff.priceDelta).toBe(300_00); expect(lineDiff.a).toEqual({ hours: 50, costTotalCents: 300_00, priceTotalCents: 500_00 }); expect(lineDiff.b).toEqual({ hours: 80, costTotalCents: 480_00, priceTotalCents: 800_00 }); }); it("fuzzy-matches demand lines by name+lineType when ids differ", () => { const a = makeVersion({ demandLines: [ makeLine({ id: "old-id", name: "Compositor", lineType: "ROLE", hours: 10, costTotalCents: 50_00, priceTotalCents: 80_00 }), ], }); const b = makeVersion({ demandLines: [ makeLine({ id: "new-id", name: "Compositor", lineType: "ROLE", hours: 20, costTotalCents: 100_00, priceTotalCents: 160_00 }), ], }); const diff = compareEstimateVersions(a, b); expect(diff.summary.linesAdded).toBe(0); expect(diff.summary.linesRemoved).toBe(0); expect(diff.summary.linesChanged).toBe(1); expect(diff.demandLineDiffs[0]!.hoursDelta).toBe(10); }); it("detects added and removed assumptions", () => { const a = makeVersion({ assumptions: [ { key: "overhead", label: "Overhead %", value: 15 }, { key: "old_key", label: "Old", value: "x" }, ], }); const b = makeVersion({ assumptions: [ { key: "overhead", label: "Overhead %", value: 20 }, { key: "new_key", label: "New", value: "y" }, ], }); const diff = compareEstimateVersions(a, b); expect(diff.summary.assumptionsChanged).toBe(3); const overheadDiff = diff.assumptionDiffs.find((d) => d.key === "overhead")!; expect(overheadDiff.status).toBe("changed"); expect(overheadDiff.aValue).toBe(15); expect(overheadDiff.bValue).toBe(20); expect(diff.assumptionDiffs.find((d) => d.key === "old_key")!.status).toBe("removed"); expect(diff.assumptionDiffs.find((d) => d.key === "new_key")!.status).toBe("added"); }); it("detects scope item additions and removals", () => { const a = makeVersion({ scopeItems: [ { name: "Shot A", sequenceNo: 1, scopeType: "SHOT" }, { name: "Asset X", sequenceNo: 2, scopeType: "ASSET" }, ], }); const b = makeVersion({ scopeItems: [ { name: "Shot A", sequenceNo: 1, scopeType: "SHOT" }, { name: "Shot B", sequenceNo: 2, scopeType: "SHOT" }, ], }); const diff = compareEstimateVersions(a, b); expect(diff.summary.scopeItemsAdded).toBe(1); expect(diff.summary.scopeItemsRemoved).toBe(1); }); it("handles empty versions", () => { const diff = compareEstimateVersions(makeVersion(), makeVersion()); expect(diff.summary.totalHoursDelta).toBe(0); expect(diff.summary.linesAdded).toBe(0); expect(diff.demandLineDiffs).toHaveLength(0); expect(diff.assumptionDiffs).toHaveLength(0); expect(diff.scopeItemDiffs).toHaveLength(0); expect(diff.resourceSnapshotDiffs).toHaveLength(0); expect(diff.chapterSubtotals).toHaveLength(0); expect(diff.summary.marginPercentA).toBe(0); expect(diff.summary.marginPercentB).toBe(0); }); it("compares complex assumption values by deep equality", () => { const a = makeVersion({ assumptions: [{ key: "config", label: "Config", value: { fps: 24, resolution: "4K" } }], }); const b = makeVersion({ assumptions: [{ key: "config", label: "Config", value: { fps: 24, resolution: "4K" } }], }); const diff = compareEstimateVersions(a, b); expect(diff.assumptionDiffs[0]!.status).toBe("unchanged"); const b2 = makeVersion({ assumptions: [{ key: "config", label: "Config", value: { fps: 30, resolution: "4K" } }], }); const diff2 = compareEstimateVersions(a, b2); expect(diff2.assumptionDiffs[0]!.status).toBe("changed"); }); describe("scope item diffs (detailed)", () => { it("detects changed scope item fields", () => { const a = makeVersion({ scopeItems: [{ name: "Shot 010", sequenceNo: 10, scopeType: "SHOT", frameCount: 120 }], }); const b = makeVersion({ scopeItems: [{ name: "Shot 010", sequenceNo: 10, scopeType: "SHOT", frameCount: 200 }], }); const diff = compareEstimateVersions(a, b); expect(diff.scopeItemDiffs).toHaveLength(1); expect(diff.scopeItemDiffs[0]!.status).toBe("changed"); expect(diff.scopeItemDiffs[0]!.changedFields).toContain("frameCount"); expect(diff.summary.scopeItemsChanged).toBe(1); }); it("detects description changes in scope items", () => { const diff = compareEstimateVersions( makeVersion({ scopeItems: [{ name: "Env", sequenceNo: 1, scopeType: "ENV", description: "Old" }] }), makeVersion({ scopeItems: [{ name: "Env", sequenceNo: 1, scopeType: "ENV", description: "New" }] }), ); expect(diff.scopeItemDiffs[0]!.status).toBe("changed"); expect(diff.scopeItemDiffs[0]!.changedFields).toContain("description"); }); it("marks unchanged scope items", () => { const scope = { name: "Asset Hero", sequenceNo: 1, scopeType: "ASSET", frameCount: 50 }; const diff = compareEstimateVersions( makeVersion({ scopeItems: [scope] }), makeVersion({ scopeItems: [scope] }), ); expect(diff.scopeItemDiffs[0]!.status).toBe("unchanged"); }); }); describe("resource snapshot diffs", () => { it("detects added resources", () => { const diff = compareEstimateVersions( makeVersion(), makeVersion({ resourceSnapshots: [{ displayName: "John", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }], }), ); expect(diff.resourceSnapshotDiffs).toHaveLength(1); expect(diff.resourceSnapshotDiffs[0]!.status).toBe("added"); expect(diff.summary.resourceSnapshotsChanged).toBe(1); }); it("detects rate changes", () => { const diff = compareEstimateVersions( makeVersion({ resourceSnapshots: [{ resourceId: "r1", displayName: "Jane", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }], }), makeVersion({ resourceSnapshots: [{ resourceId: "r1", displayName: "Jane", currency: "EUR", lcrCents: 5500, ucrCents: 8500 }], }), ); expect(diff.resourceSnapshotDiffs[0]!.status).toBe("changed"); expect(diff.resourceSnapshotDiffs[0]!.lcrDelta).toBe(500); expect(diff.resourceSnapshotDiffs[0]!.ucrDelta).toBe(500); }); it("detects removed resources", () => { const diff = compareEstimateVersions( makeVersion({ resourceSnapshots: [{ displayName: "Max", currency: "EUR", lcrCents: 4000, ucrCents: 7000 }], }), makeVersion(), ); expect(diff.resourceSnapshotDiffs[0]!.status).toBe("removed"); }); it("matches by resourceId for unchanged rates", () => { const diff = compareEstimateVersions( makeVersion({ resourceSnapshots: [{ resourceId: "r1", displayName: "Old Name", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }], }), makeVersion({ resourceSnapshots: [{ resourceId: "r1", displayName: "New Name", currency: "EUR", lcrCents: 5000, ucrCents: 8000 }], }), ); expect(diff.resourceSnapshotDiffs[0]!.status).toBe("unchanged"); }); it("detects location changes", () => { const diff = compareEstimateVersions( makeVersion({ resourceSnapshots: [{ resourceId: "r1", displayName: "Alex", currency: "EUR", lcrCents: 5000, ucrCents: 8000, location: "Munich" }], }), makeVersion({ resourceSnapshots: [{ resourceId: "r1", displayName: "Alex", currency: "EUR", lcrCents: 5000, ucrCents: 8000, location: "Berlin" }], }), ); expect(diff.resourceSnapshotDiffs[0]!.status).toBe("changed"); }); }); describe("chapter subtotals", () => { it("groups demand lines by chapter and computes deltas", () => { const diff = compareEstimateVersions( makeVersion({ demandLines: [ makeLine({ id: "d1", name: "Anim Lead", hours: 80, costTotalCents: 480_00, priceTotalCents: 720_00, chapter: "Animation" }), makeLine({ id: "d2", name: "Modeler", hours: 60, costTotalCents: 300_00, priceTotalCents: 480_00, chapter: "Modeling" }), ], }), makeVersion({ demandLines: [ makeLine({ id: "d1", name: "Anim Lead", hours: 100, costTotalCents: 600_00, priceTotalCents: 900_00, chapter: "Animation" }), makeLine({ id: "d2", name: "Modeler", hours: 60, costTotalCents: 300_00, priceTotalCents: 480_00, chapter: "Modeling" }), ], }), ); expect(diff.chapterSubtotals).toHaveLength(2); const anim = diff.chapterSubtotals.find((c) => c.chapter === "Animation")!; expect(anim.hoursDelta).toBe(20); expect(anim.costDelta).toBe(120_00); }); it("uses (no chapter) fallback", () => { const diff = compareEstimateVersions( makeVersion({ demandLines: [makeLine({ id: "d1", name: "Misc", hours: 10, costTotalCents: 30_00, priceTotalCents: 50_00 })], }), makeVersion(), ); expect(diff.chapterSubtotals[0]!.chapter).toBe("(no chapter)"); }); it("sorts by absolute cost delta descending", () => { const diff = compareEstimateVersions( makeVersion({ demandLines: [ makeLine({ id: "d1", name: "A", hours: 10, costTotalCents: 100_00, priceTotalCents: 200_00, chapter: "Small" }), makeLine({ id: "d2", name: "B", hours: 100, costTotalCents: 5000_00, priceTotalCents: 8000_00, chapter: "Big" }), ], }), makeVersion({ demandLines: [ makeLine({ id: "d1", name: "A", hours: 12, costTotalCents: 120_00, priceTotalCents: 240_00, chapter: "Small" }), makeLine({ id: "d2", name: "B", hours: 200, costTotalCents: 10000_00, priceTotalCents: 16000_00, chapter: "Big" }), ], }), ); expect(diff.chapterSubtotals[0]!.chapter).toBe("Big"); }); }); describe("margin calculation", () => { it("computes margin percent and delta", () => { const diff = compareEstimateVersions( makeVersion({ demandLines: [makeLine({ id: "d1", name: "A", hours: 100, costTotalCents: 500_00, priceTotalCents: 800_00 })], }), makeVersion({ demandLines: [makeLine({ id: "d1", name: "A", hours: 100, costTotalCents: 500_00, priceTotalCents: 1000_00 })], }), ); // A: (80000-50000)/80000 = 37.5% expect(diff.summary.marginPercentA).toBeCloseTo(37.5, 1); // B: (100000-50000)/100000 = 50% expect(diff.summary.marginPercentB).toBeCloseTo(50, 1); expect(diff.summary.marginPercentDelta).toBeCloseTo(12.5, 1); }); it("handles zero price gracefully", () => { const diff = compareEstimateVersions(makeVersion(), makeVersion()); expect(diff.summary.marginPercentA).toBe(0); expect(diff.summary.marginPercentB).toBe(0); }); }); });