Files
Nexus/packages/engine/src/__tests__/version-compare.test.ts
T

408 lines
15 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
compareEstimateVersions,
type VersionCompareInput,
} from "../estimate/index.js";
function makeLine(overrides: Partial<VersionCompareInput["demandLines"][number]> & { id: string; name: string }) {
return {
hours: 0,
costRateCents: 0,
billRateCents: 0,
costTotalCents: 0,
priceTotalCents: 0,
lineType: "ROLE",
...overrides,
};
}
function makeVersion(overrides: Partial<VersionCompareInput> = {}): 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);
});
});
});