408 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|