chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,207 @@
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);
});
});