import { describe, expect, it } from "vitest"; import { expandScopeToEffort, aggregateByDiscipline, type EffortRuleInput, type ScopeItemInput, } from "../estimate/effort-rules.js"; const STANDARD_RULES: EffortRuleInput[] = [ { scopeType: "SHOT", discipline: "3D Animation", chapter: "Animation", unitMode: "per_frame", hoursPerUnit: 0.5, sortOrder: 0 }, { scopeType: "SHOT", discipline: "3D Lighting", chapter: "Lighting", unitMode: "per_frame", hoursPerUnit: 0.2, sortOrder: 1 }, { scopeType: "SHOT", discipline: "Compositing", chapter: "Compositing", unitMode: "per_frame", hoursPerUnit: 0.15, sortOrder: 2 }, { scopeType: "ASSET", discipline: "3D Modeling", chapter: "Modeling", unitMode: "per_item", hoursPerUnit: 40, sortOrder: 0 }, { scopeType: "ASSET", discipline: "3D Rigging", chapter: "Rigging", unitMode: "per_item", hoursPerUnit: 24, sortOrder: 1 }, { scopeType: "ENVIRONMENT", discipline: "3D Environment", chapter: "Environment", unitMode: "flat", hoursPerUnit: 80, sortOrder: 0 }, ]; describe("expandScopeToEffort", () => { it("expands a single shot scope item into multiple discipline lines", () => { const items: ScopeItemInput[] = [ { name: "Shot 001", scopeType: "SHOT", frameCount: 120 }, ]; const result = expandScopeToEffort(items, STANDARD_RULES); expect(result.lines).toHaveLength(3); expect(result.warnings).toHaveLength(0); expect(result.unmatchedScopeItems).toHaveLength(0); const anim = result.lines.find((l) => l.discipline === "3D Animation")!; expect(anim.hours).toBe(60); // 120 * 0.5 expect(anim.unitCount).toBe(120); expect(anim.chapter).toBe("Animation"); const lighting = result.lines.find((l) => l.discipline === "3D Lighting")!; expect(lighting.hours).toBe(24); // 120 * 0.2 const comp = result.lines.find((l) => l.discipline === "Compositing")!; expect(comp.hours).toBe(18); // 120 * 0.15 }); it("expands asset scope items using per_item mode", () => { const items: ScopeItemInput[] = [ { name: "Hero Character", scopeType: "ASSET", itemCount: 3 }, ]; const result = expandScopeToEffort(items, STANDARD_RULES); expect(result.lines).toHaveLength(2); const modeling = result.lines.find((l) => l.discipline === "3D Modeling")!; expect(modeling.hours).toBe(120); // 3 * 40 const rigging = result.lines.find((l) => l.discipline === "3D Rigging")!; expect(rigging.hours).toBe(72); // 3 * 24 }); it("uses flat hours for environment scope items", () => { const items: ScopeItemInput[] = [ { name: "Forest Scene", scopeType: "ENVIRONMENT" }, ]; const result = expandScopeToEffort(items, STANDARD_RULES); expect(result.lines).toHaveLength(1); expect(result.lines[0]!.hours).toBe(80); // flat expect(result.lines[0]!.unitCount).toBe(1); }); it("defaults to 1 when frameCount is null in per_frame mode", () => { const items: ScopeItemInput[] = [ { name: "Shot X", scopeType: "SHOT", frameCount: null }, ]; const result = expandScopeToEffort(items, STANDARD_RULES); const anim = result.lines.find((l) => l.discipline === "3D Animation")!; expect(anim.unitCount).toBe(1); expect(anim.hours).toBe(0.5); // 1 * 0.5 }); it("handles multiple scope items", () => { const items: ScopeItemInput[] = [ { name: "Shot 001", scopeType: "SHOT", frameCount: 100 }, { name: "Shot 002", scopeType: "SHOT", frameCount: 200 }, { name: "Prop A", scopeType: "ASSET", itemCount: 1 }, ]; const result = expandScopeToEffort(items, STANDARD_RULES); // 2 shots * 3 rules + 1 asset * 2 rules = 8 lines expect(result.lines).toHaveLength(8); }); it("reports unmatched scope items", () => { const items: ScopeItemInput[] = [ { name: "Audio Track", scopeType: "AUDIO" }, ]; const result = expandScopeToEffort(items, STANDARD_RULES); expect(result.lines).toHaveLength(0); expect(result.unmatchedScopeItems).toContain("Audio Track"); expect(result.warnings.length).toBeGreaterThan(0); }); it("matches scope type case-insensitively", () => { const items: ScopeItemInput[] = [ { name: "Shot 001", scopeType: "shot", frameCount: 50 }, ]; const result = expandScopeToEffort(items, STANDARD_RULES); expect(result.lines).toHaveLength(3); }); it("warns when no rules provided", () => { const result = expandScopeToEffort( [{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 }], [], ); expect(result.lines).toHaveLength(0); expect(result.warnings).toContain("No effort rules provided."); }); it("skips lines with zero hours", () => { const rules: EffortRuleInput[] = [ { scopeType: "SHOT", discipline: "QC", unitMode: "per_frame", hoursPerUnit: 0, sortOrder: 0 }, ]; const result = expandScopeToEffort( [{ name: "Shot 001", scopeType: "SHOT", frameCount: 100 }], rules, ); expect(result.lines).toHaveLength(0); expect(result.warnings.some((w) => w.includes("Skipped"))).toBe(true); }); it("skips empty-name scope items", () => { const result = expandScopeToEffort( [{ name: "", scopeType: "SHOT", frameCount: 100 }], STANDARD_RULES, ); expect(result.lines).toHaveLength(0); }); it("rounds hours to 2 decimal places", () => { const rules: EffortRuleInput[] = [ { scopeType: "SHOT", discipline: "FX", unitMode: "per_frame", hoursPerUnit: 0.333, sortOrder: 0 }, ]; const result = expandScopeToEffort( [{ name: "Shot 001", scopeType: "SHOT", frameCount: 7 }], rules, ); // 7 * 0.333 = 2.331 expect(result.lines[0]!.hours).toBe(2.33); }); it("preserves sortOrder in output order", () => { const rules: EffortRuleInput[] = [ { scopeType: "SHOT", discipline: "Z-Last", unitMode: "flat", hoursPerUnit: 1, sortOrder: 10 }, { scopeType: "SHOT", discipline: "A-First", unitMode: "flat", hoursPerUnit: 1, sortOrder: 0 }, ]; const result = expandScopeToEffort( [{ name: "Shot 001", scopeType: "SHOT" }], rules, ); expect(result.lines[0]!.discipline).toBe("A-First"); expect(result.lines[1]!.discipline).toBe("Z-Last"); }); }); describe("aggregateByDiscipline", () => { it("sums hours across scope items per discipline", () => { const items: ScopeItemInput[] = [ { name: "Shot 001", scopeType: "SHOT", frameCount: 100 }, { name: "Shot 002", scopeType: "SHOT", frameCount: 200 }, ]; const { lines } = expandScopeToEffort(items, STANDARD_RULES); const aggregated = aggregateByDiscipline(lines); const anim = aggregated.find((a) => a.discipline === "3D Animation")!; expect(anim.totalHours).toBe(150); // (100+200) * 0.5 expect(anim.lineCount).toBe(2); }); it("sorts by totalHours descending", () => { const items: ScopeItemInput[] = [ { name: "Shot 001", scopeType: "SHOT", frameCount: 100 }, ]; const { lines } = expandScopeToEffort(items, STANDARD_RULES); const aggregated = aggregateByDiscipline(lines); expect(aggregated[0]!.totalHours).toBeGreaterThanOrEqual(aggregated[1]!.totalHours); }); it("keeps discipline+chapter pairs separate", () => { const rules: EffortRuleInput[] = [ { scopeType: "SHOT", discipline: "Animation", chapter: "2D", unitMode: "flat", hoursPerUnit: 10, sortOrder: 0 }, { scopeType: "SHOT", discipline: "Animation", chapter: "3D", unitMode: "flat", hoursPerUnit: 20, sortOrder: 1 }, ]; const { lines } = expandScopeToEffort( [{ name: "Shot 001", scopeType: "SHOT" }], rules, ); const aggregated = aggregateByDiscipline(lines); expect(aggregated).toHaveLength(2); const anim2d = aggregated.find((a) => a.chapter === "2D")!; expect(anim2d.totalHours).toBe(10); const anim3d = aggregated.find((a) => a.chapter === "3D")!; expect(anim3d.totalHours).toBe(20); }); it("handles empty input", () => { expect(aggregateByDiscipline([])).toHaveLength(0); }); });