208 lines
7.8 KiB
TypeScript
208 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|