Something went wrong. Try again.
A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
Something went wrong. Try again.
8.9 kB · 276 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277// Static run analysis: coded, attributed issues found before any search.
import { describe, expect, test } from "bun:test";
import { analyzeRun } from "../../src/level-core/analyzeRun.ts";import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts";import { serialSGS } from "../../src/level-core/search/serialSGS.ts";import type { Constraint, ResolvedProject, Scorer,} from "../../src/level-core/types.ts";import { Duration } from "../../src/model/Duration.ts";import { RelationType, TimeUnit } from "../../src/model/types.ts";
import { MON_JAN_5, SAT_JAN_10, makeAssignment, makeProject, makeResource, makeTask,} from "./fixtures.ts";
function resolvedFixture(): ResolvedProject { const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); const t2 = makeTask({ uniqueId: 2, start: MON_JAN_5, finish: SAT_JAN_10 }); const project = makeProject([t1, t2], { resources: [makeResource(100, "Crew")], assignments: [makeAssignment(1, 100), makeAssignment(2, 100)], }); return resolveCalendar(project, { workUnits: [ { id: 10, taskUniqueIds: [1] }, { id: 20, taskUniqueIds: [2] }, ], });}
function codes(issues: readonly { code: string }[]): string[] { return issues.map((i) => i.code);}
describe("analyzeRun — reference checks", () => { test("a clean run has no issues", () => { const analysis = analyzeRun(resolvedFixture(), [ { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1 }, { kind: "ConcurrentUnitsLimit", unitIds: [10, 20], max: 1 }, { kind: "Deadline", taskUniqueId: 1, latestFinish: 30 }, ]); expect(analysis.ok).toBe(true); expect(analysis.issues).toHaveLength(0); });
test("unknown resource / task / unit references are coded errors with attribution", () => { const analysis = analyzeRun(resolvedFixture(), [ { kind: "MaxConcurrentResource", resourceUniqueId: 999, max: 1, name: "ghost-cap", }, { kind: "Deadline", taskUniqueId: 999, latestFinish: 30 }, { kind: "ConcurrentUnitsLimit", unitIds: [999], max: 1 }, ]); expect(analysis.ok).toBe(false); expect(codes(analysis.issues)).toEqual( expect.arrayContaining([ "LEVELSET_E101", "LEVELSET_E102", "LEVELSET_E103", ]) as string[], ); const resourceIssue = analysis.issues.find( (i) => i.code === "LEVELSET_E101", )!; expect(resourceIssue.source).toBe("ghost-cap"); expect(resourceIssue.message).toContain("999"); });
test("unknown calendar reference is LEVELSET_E109", () => { const analysis = analyzeRun(resolvedFixture(), [ { kind: "Calendars", calendarUniqueId: 42 }, ]); expect(codes(analysis.issues)).toContain("LEVELSET_E109"); });
test("inverted PeakCap window is LEVELSET_E108", () => { const analysis = analyzeRun(resolvedFixture(), [ { kind: "PeakCap", resourceUniqueId: 100, cap: 2, window: { fromDay: 10, toDay: 10 }, }, ]); expect(codes(analysis.issues)).toContain("LEVELSET_E108"); });});
describe("analyzeRun — cross-constraint feasibility", () => { test("deadline earlier than release is LEVELSET_E104", () => { const analysis = analyzeRun(resolvedFixture(), [ { kind: "Deadline", taskUniqueId: 1, latestFinish: 3, name: "gate" }, { kind: "Release", taskUniqueId: 1, earliestStart: 10 }, ]); expect(analysis.ok).toBe(false); expect(codes(analysis.issues)).toContain("LEVELSET_E104"); expect( analysis.issues.find((i) => i.code === "LEVELSET_E104")!.source, ).toBe("gate"); });
test("deadline tighter than task duration is LEVELSET_E105", () => { // Tasks span Mon–Sat = 5 working days; a deadline 2 calendar days after // release day 0 cannot fit them. const analysis = analyzeRun(resolvedFixture(), [ { kind: "Deadline", taskUniqueId: 1, latestFinish: 2 }, ]); expect(codes(analysis.issues)).toContain("LEVELSET_E105"); });
test("disabled constraints are carried but not analyzed — no errors from the skipped set", () => { // Each of these would be an error if enabled; disabled they are exactly // the what-if ablation case and must not invalidate the run. const analysis = analyzeRun(resolvedFixture(), [ { kind: "Deadline", taskUniqueId: 1, latestFinish: 0, enabled: false, name: "ablated", }, { kind: "MaxConcurrentResource", resourceUniqueId: 999, max: 1, enabled: false, }, { kind: "Precedence", enabled: false, edges: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lagDays: 0, }, { predecessorUniqueId: 2, successorUniqueId: 1, type: RelationType.FinishToStart, lagDays: 0, }, ], }, ]); expect(analysis.ok).toBe(true); expect(analysis.issues).toHaveLength(0); });
test("milestones are exempt from the E105 duration bound", () => { // A milestone is placed with zero span whatever its nominal duration // carries (serialSGS's milestone branch), so a same-day release + // deadline pair is feasible and must not be reported as E105. const gate = { ...makeTask({ uniqueId: 3, start: MON_JAN_5, finish: MON_JAN_5, milestone: true, }), duration: Duration.from(5, TimeUnit.Days), }; const resolved = resolveCalendar(makeProject([gate]), {}); expect(resolved.tasks[0]!.durationDays).toBeGreaterThan(0); const analysis = analyzeRun(resolved, [ { kind: "Release", taskUniqueId: 3, earliestStart: 2 }, { kind: "Deadline", taskUniqueId: 3, latestFinish: 2 }, ]); expect(analysis.ok).toBe(true); expect(analysis.issues).toHaveLength(0); });
test("unit precedence cycles are LEVELSET_E106", () => { const analysis = analyzeRun(resolvedFixture(), [ { kind: "UnitPrecedence", unitId: 10, afterUnitIds: [20] }, { kind: "UnitPrecedence", unitId: 20, afterUnitIds: [10] }, ]); expect(codes(analysis.issues)).toContain("LEVELSET_E106"); });
test("precedence cycles from constraint edges are LEVELSET_E107", () => { const edges = [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lagDays: 0, }, { predecessorUniqueId: 2, successorUniqueId: 1, type: RelationType.FinishToStart, lagDays: 0, }, ]; const analysis = analyzeRun(resolvedFixture(), [ { kind: "Precedence", edges }, ]); expect(codes(analysis.issues)).toContain("LEVELSET_E107"); });});
describe("analyzeRun — warnings", () => { test("kinds the search records but does not enforce warn LEVELSET_W201", () => { const constraints: Constraint[] = [ { kind: "UnitPrecedence", unitId: 10, afterUnitIds: [20], name: "floors", }, ]; const withSearch = analyzeRun(resolvedFixture(), constraints, { search: serialSGS, }); expect(withSearch.ok).toBe(true); // warning, not error expect(codes(withSearch.issues)).toContain("LEVELSET_W201"); // Disabled constraints don't warn — they're not going to run. const disabled = analyzeRun( resolvedFixture(), [{ ...constraints[0]!, enabled: false }], { search: serialSGS, }, ); expect(codes(disabled.issues)).not.toContain("LEVELSET_W201"); });
test("empty governed sets warn LEVELSET_W202", () => { const analysis = analyzeRun(resolvedFixture(), [ { kind: "ConcurrentUnitsLimit", unitIds: [], max: 1 }, ]); expect(codes(analysis.issues)).toContain("LEVELSET_W202"); });
test("duplicate explicit names warn LEVELSET_W203", () => { const analysis = analyzeRun(resolvedFixture(), [ { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1, name: "cap", }, { kind: "PeakCap", resourceUniqueId: 100, cap: 2, name: "cap" }, ]); expect(codes(analysis.issues)).toContain("LEVELSET_W203"); });});
describe("analyzeRun — scorer reads", () => { test("a scorer's declared reads are checked like constraint references", () => { const scorer: Scorer = { name: "OpenUnitPenalty[all]", direction: "min", reads: { unitIds: [999] }, score: () => 0, }; const analysis = analyzeRun(resolvedFixture(), [], { scorers: [scorer] }); expect(analysis.ok).toBe(false); const issue = analysis.issues.find((i) => i.code === "LEVELSET_E103")!; expect(issue.source).toBe("OpenUnitPenalty[all]"); });});