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.
34 kB · 1009 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010import { test, expect, describe } from "bun:test";
import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts";import { ScheduleStreamImpl } from "../../src/level-core/scheduleStream.ts";import { serialSGS } from "../../src/level-core/search/serialSGS.ts";import { buildResourceLoad } from "../../src/level-core/weeklyProfile.ts";import { Duration } from "../../src/model/Duration.ts";import { RelationType, ResourceType, TimeUnit } from "../../src/model/types.ts";import type { Constraint, Explanation, Failure, Schedule, WorkUnit,} from "../../src/level-core/types.ts";import type { Calendar } from "../../src/schema/calendar.ts";import type { ProjectFile } from "../../src/schema/project.ts";import type { Relation } from "../../src/schema/relation.ts";import type { Task } from "../../src/schema/task.ts";
function monFriCalendar(uniqueId = 1): Calendar { return { uniqueId, name: "Standard", weekDays: [ { dayType: 1, working: false, workingTimes: [] }, { dayType: 2, working: true, workingTimes: [] }, { dayType: 3, working: true, workingTimes: [] }, { dayType: 4, working: true, workingTimes: [] }, { dayType: 5, working: true, workingTimes: [] }, { dayType: 6, working: true, workingTimes: [] }, { dayType: 7, working: false, workingTimes: [] }, ], exceptions: [], };}
function makeTask(args: { uniqueId: number; start: Date; finish: Date; predecessors?: Relation[];}): Task { return { id: null, uniqueId: args.uniqueId, name: `Task ${String(args.uniqueId)}`, wbs: null, outlineLevel: 0, start: args.start, finish: args.finish, duration: null, percentComplete: null, summary: false, milestone: false, critical: null, notes: null, priority: null, cost: null, work: null, actualStart: null, actualFinish: null, baselineStart: null, baselineFinish: null, baselineDuration: null, actualWork: null, constraintType: null, freeSlack: null, totalSlack: null, earlyStart: null, earlyFinish: null, lateStart: null, lateFinish: null, levelingDelay: null, deadline: null, splits: null, predecessors: args.predecessors ?? [], };}
function makeProject( tasks: Task[], options: { calendars?: Calendar[]; resources?: ProjectFile["resources"]; assignments?: ProjectFile["assignments"]; } = {},): ProjectFile { const calendars = options.calendars ?? [monFriCalendar()]; return { properties: { title: null, author: null, startDate: null, finishDate: null, statusDate: null, defaultCalendarUniqueId: calendars[0]?.uniqueId ?? null, minutesPerDay: 480, minutesPerWeek: 2400, daysPerMonth: 20, saveVersion: null, }, tasks, resources: options.resources ?? [], assignments: options.assignments ?? [], calendars, };}
const MON_JAN_5 = new Date(2026, 0, 5);const FRI_JAN_9 = new Date(2026, 0, 9);const SAT_JAN_10 = new Date(2026, 0, 10);
async function runOnce( project: ProjectFile, constraints: readonly Constraint[], workUnits?: readonly WorkUnit[],): Promise<Schedule> { const resolved = resolveCalendar(project, { workUnits }); const stream = new ScheduleStreamImpl({ [Symbol.asyncIterator]: () => serialSGS.run(resolved, constraints), }); const collected = await stream.collect(); expect(collected).toHaveLength(1); return collected[0]!;}
describe("serialSGS — precedence", () => { test("3-task FS chain serializes back-to-back when no resources", async () => { 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, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lag: null, }, ], }); const t3 = makeTask({ uniqueId: 3, start: MON_JAN_5, finish: SAT_JAN_10, predecessors: [ { predecessorUniqueId: 2, successorUniqueId: 3, type: RelationType.FinishToStart, lag: null, }, ], }); const schedule = await runOnce(makeProject([t1, t2, t3]), []); const byId = new Map(schedule.tasks.map((t) => [t.uniqueId, t])); expect(byId.get(1)!.startDay).toBe(0); expect(byId.get(1)!.finishDay).toBe(5); // T2 starts day 7 (Mon Jan 12) — Sat/Sun bridge from Sat Jan 10. expect(byId.get(2)!.startDay).toBe(7); expect(byId.get(2)!.finishDay).toBe(12); // T3 starts day 14 (Mon Jan 19) after another weekend bridge. expect(byId.get(3)!.startDay).toBe(14); expect(byId.get(3)!.finishDay).toBe(19); });
test("FS lag of 2 working days delays successor", async () => { 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, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lag: Duration.from(2, TimeUnit.Days), }, ], }); const schedule = await runOnce(makeProject([t1, t2]), []); const t2sched = schedule.tasks.find((t) => t.uniqueId === 2)!; // T1 finishDay=5 (Sat). +2 working days = Mon Jan 12 + Tue Jan 13 → start Wed Jan 14 = day 9. // Then 5 working days (Wed/Thu/Fri/Mon/Tue) lands finish at day 16 (Wed Jan 21). expect(t2sched.startDay).toBe(9); expect(t2sched.finishDay).toBe(16); });
test("cycle in precedence throws", async () => { const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10, predecessors: [ { predecessorUniqueId: 2, successorUniqueId: 1, type: RelationType.FinishToStart, lag: null, }, ], }); const t2 = makeTask({ uniqueId: 2, start: MON_JAN_5, finish: SAT_JAN_10, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lag: null, }, ], }); // bun-types declares `expect().rejects` as Matchers<unknown>, whose methods // return void — but they return a promise at runtime, so the await is what // makes this assertion observable. // oxlint-disable-next-line typescript/await-thenable await expect(runOnce(makeProject([t1, t2]), [])).rejects.toThrow(/cycle/); });});
describe("serialSGS — resource caps", () => { test("MaxConcurrentResource{max:1} serializes two parallel tasks", async () => { 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: [ { id: 1, uniqueId: 100, name: "Crew", type: ResourceType.Work, email: null, group: null, maxUnits: null, cost: null, work: null, resourcePool: null, }, ], assignments: [ { taskUniqueId: 1, resourceUniqueId: 100, work: null, units: 1, start: null, finish: null, actualWork: null, remainingWork: null, }, { taskUniqueId: 2, resourceUniqueId: 100, work: null, units: 1, start: null, finish: null, actualWork: null, remainingWork: null, }, ], }); const schedule = await runOnce(project, [ { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1 }, ]); const t1sched = schedule.tasks.find((t) => t.uniqueId === 1)!; const t2sched = schedule.tasks.find((t) => t.uniqueId === 2)!; expect(t1sched.startDay).toBe(0); expect(t1sched.finishDay).toBe(5); // T2 must wait until T1's resource window is clear — next working day after 5. expect(t2sched.startDay).toBe(7); // skip Sat/Sun expect(t2sched.finishDay).toBe(12); });
test("MaxConcurrentResource{max:2} permits both tasks in parallel", async () => { 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: [ { id: 1, uniqueId: 100, name: "Crew", type: ResourceType.Work, email: null, group: null, maxUnits: null, cost: null, work: null, resourcePool: null, }, ], assignments: [ { taskUniqueId: 1, resourceUniqueId: 100, work: null, units: 1, start: null, finish: null, actualWork: null, remainingWork: null, }, { taskUniqueId: 2, resourceUniqueId: 100, work: null, units: 1, start: null, finish: null, actualWork: null, remainingWork: null, }, ], }); const schedule = await runOnce(project, [ { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 2 }, ]); const t1sched = schedule.tasks.find((t) => t.uniqueId === 1)!; const t2sched = schedule.tasks.find((t) => t.uniqueId === 2)!; expect(t1sched.startDay).toBe(0); expect(t2sched.startDay).toBe(0); });
test("MaxConcurrentResource counts tasks, not units (matches MiniZinc bool2int)", async () => { // Three tasks each at 0.5 units. Under unit-sum semantics with max=2, // up to 4 could run concurrently (4 * 0.5 = 2.0). Under task-count // semantics (matching MiniZinc), only 2 may run concurrently. 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 t3 = makeTask({ uniqueId: 3, start: MON_JAN_5, finish: SAT_JAN_10 }); const project = makeProject([t1, t2, t3], { resources: [ { id: 1, uniqueId: 100, name: "Crew", type: ResourceType.Work, email: null, group: null, maxUnits: null, cost: null, work: null, resourcePool: null, }, ], assignments: [1, 2, 3].map((taskUniqueId) => ({ taskUniqueId, resourceUniqueId: 100, work: null, units: 0.5, start: null, finish: null, actualWork: null, remainingWork: null, })), }); const schedule = await runOnce(project, [ { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 2 }, ]); const t1sched = schedule.tasks.find((t) => t.uniqueId === 1)!; const t2sched = schedule.tasks.find((t) => t.uniqueId === 2)!; const t3sched = schedule.tasks.find((t) => t.uniqueId === 3)!; // T1 and T2 may run together (2 tasks ≤ 2). T3 must wait. expect(t1sched.startDay).toBe(0); expect(t2sched.startDay).toBe(0); expect(t3sched.startDay).toBe(7); });
test("PeakCap sums fractional units (distinct from MaxConcurrentResource)", async () => { // Same three half-unit tasks under PeakCap{cap:2}: 4 tasks at 0.5 each // sum to 2.0 — all three should run together, since 1.5 ≤ 2. 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 t3 = makeTask({ uniqueId: 3, start: MON_JAN_5, finish: SAT_JAN_10 }); const project = makeProject([t1, t2, t3], { resources: [ { id: 1, uniqueId: 100, name: "Crew", type: ResourceType.Work, email: null, group: null, maxUnits: null, cost: null, work: null, resourcePool: null, }, ], assignments: [1, 2, 3].map((taskUniqueId) => ({ taskUniqueId, resourceUniqueId: 100, work: null, units: 0.5, start: null, finish: null, actualWork: null, remainingWork: null, })), }); const schedule = await runOnce(project, [ { kind: "PeakCap", resourceUniqueId: 100, cap: 2 }, ]); expect(schedule.tasks.find((t) => t.uniqueId === 1)!.startDay).toBe(0); expect(schedule.tasks.find((t) => t.uniqueId === 2)!.startDay).toBe(0); expect(schedule.tasks.find((t) => t.uniqueId === 3)!.startDay).toBe(0); });
test("PeakCap with window only constrains inside the window", async () => { const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: FRI_JAN_9 }); const t2 = makeTask({ uniqueId: 2, start: MON_JAN_5, finish: FRI_JAN_9 }); const project = makeProject([t1, t2], { resources: [ { id: 1, uniqueId: 200, name: "Specialist", type: ResourceType.Work, email: null, group: null, maxUnits: null, cost: null, work: null, resourcePool: null, }, ], assignments: [ { taskUniqueId: 1, resourceUniqueId: 200, work: null, units: 1, start: null, finish: null, actualWork: null, remainingWork: null, }, { taskUniqueId: 2, resourceUniqueId: 200, work: null, units: 1, start: null, finish: null, actualWork: null, remainingWork: null, }, ], }); // Cap of 1 applies only days 100–200 — completely outside our scheduling // horizon, so tasks should run in parallel. const schedule = await runOnce(project, [ { kind: "PeakCap", resourceUniqueId: 200, cap: 1, window: { fromDay: 100, toDay: 200 }, }, ]); expect(schedule.tasks.find((t) => t.uniqueId === 1)!.startDay).toBe(0); expect(schedule.tasks.find((t) => t.uniqueId === 2)!.startDay).toBe(0); });});
describe("serialSGS — unit caps (ConcurrentUnitsLimit)", () => { const crewResource = { id: 1, uniqueId: 100, name: "Crew", type: ResourceType.Work, email: null, group: null, maxUnits: null, cost: null, work: null, resourcePool: null, }; const elecResource = { ...crewResource, id: 2, uniqueId: 200, name: "Electrical", };
function assignment(taskUniqueId: number, resourceUniqueId: number) { return { taskUniqueId, resourceUniqueId, work: null, units: 1, start: null, finish: null, actualWork: null, remainingWork: null, }; }
test("whole-unit limit max=1 serializes two single-task units", async () => { 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 schedule = await runOnce( makeProject([t1, t2]), [{ kind: "ConcurrentUnitsLimit", unitIds: [10, 20], max: 1 }], [ { id: 10, taskUniqueIds: [1] }, { id: 20, taskUniqueIds: [2] }, ], ); const t1s = schedule.tasks.find((t) => t.uniqueId === 1)!; const t2s = schedule.tasks.find((t) => t.uniqueId === 2)!; expect(t1s.startDay).toBe(0); expect(t1s.finishDay).toBe(5); // Unit 20 can't be open while unit 10 is — next working day after 5. expect(t2s.startDay).toBe(7); });
test("whole-unit limit max=2 permits both units in parallel", async () => { 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 schedule = await runOnce( makeProject([t1, t2]), [{ kind: "ConcurrentUnitsLimit", unitIds: [10, 20], max: 2 }], [ { id: 10, taskUniqueIds: [1] }, { id: 20, taskUniqueIds: [2] }, ], ); expect(schedule.tasks.find((t) => t.uniqueId === 1)!.startDay).toBe(0); expect(schedule.tasks.find((t) => t.uniqueId === 2)!.startDay).toBe(0); });
test("two tasks in the SAME unit do not count twice against the cap", async () => { // Unit 10 holds both tasks; unit 20 is empty. max=1 must still allow the // two tasks of unit 10 to run together — one open unit, not two. 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 schedule = await runOnce( makeProject([t1, t2]), [{ kind: "ConcurrentUnitsLimit", unitIds: [10], max: 1 }], [{ id: 10, taskUniqueIds: [1, 2] }], ); expect(schedule.tasks.find((t) => t.uniqueId === 1)!.startDay).toBe(0); expect(schedule.tasks.find((t) => t.uniqueId === 2)!.startDay).toBe(0); });
test("discipline-scoped limit serializes only the discipline's tasks", async () => { // Each unit has one electrical task (res 200) and one general task (res 100). // ConcurrentUnitsLimit{discipline:200,max:1} may not have both units' // electrical work active together → electrical serializes; general work, // uncounted, stays parallel. const elecA = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10, }); const genA = makeTask({ uniqueId: 2, start: MON_JAN_5, finish: SAT_JAN_10, }); const elecB = makeTask({ uniqueId: 3, start: MON_JAN_5, finish: SAT_JAN_10, }); const genB = makeTask({ uniqueId: 4, start: MON_JAN_5, finish: SAT_JAN_10, }); const project = makeProject([elecA, genA, elecB, genB], { resources: [crewResource, elecResource], assignments: [ assignment(1, 200), assignment(2, 100), assignment(3, 200), assignment(4, 100), ], }); const schedule = await runOnce( project, [ { kind: "ConcurrentUnitsLimit", discipline: 200, unitIds: [10, 20], max: 1, }, ], [ { id: 10, taskUniqueIds: [1, 2] }, { id: 20, taskUniqueIds: [3, 4] }, ], ); const by = new Map(schedule.tasks.map((t) => [t.uniqueId, t])); // Electrical work serializes across the two units. expect(by.get(1)!.startDay).toBe(0); expect(by.get(3)!.startDay).toBe(7); // General work is not counted by the discipline cap — both at day 0. expect(by.get(2)!.startDay).toBe(0); expect(by.get(4)!.startDay).toBe(0); });});
describe("serialSGS — ModeSelection (multi-mode RCPSP)", () => { const homeRes = { id: 1, uniqueId: 100, name: "Home", type: ResourceType.Work, email: null, group: null, maxUnits: null, cost: null, work: null, resourcePool: null, }; const borrowRes = { ...homeRes, id: 2, uniqueId: 200, name: "Borrow" };
function assignment( taskUniqueId: number, resourceUniqueId: number, units = 1, ) { return { taskUniqueId, resourceUniqueId, units, work: null, start: null, finish: null, actualWork: null, remainingWork: null, }; }
// Two equal-duration modes; mode 0 = home resource 100, mode 1 = borrow 200. const twoModes = (taskUniqueId: number): Constraint => ({ kind: "ModeSelection", taskUniqueId, modes: [ { modeId: 0, durationDays: 5, resourceDemand: [{ resourceUniqueId: 100, units: 1 }], }, { modeId: 1, durationDays: 5, resourceDemand: [{ resourceUniqueId: 200, units: 1 }], }, ], });
test("prefers the home mode (listed first) when it is feasible at the earliest day", async () => { const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); const project = makeProject([t1], { resources: [homeRes, borrowRes], assignments: [assignment(1, 100)], }); const schedule = await runOnce(project, [ twoModes(1), { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1 }, { kind: "MaxConcurrentResource", resourceUniqueId: 200, max: 1 }, ]); const s = schedule.tasks.find((t) => t.uniqueId === 1)!; expect(s.startDay).toBe(0); expect(s.modeId).toBe(0); // home crew, not borrowed expect(schedule.realizedAssignments).toEqual([ { taskUniqueId: 1, resourceUniqueId: 100, units: 1 }, ]); });
test("borrows the second mode to start earlier when the home resource is saturated", async () => { // Two tasks contend for home(100,max1). Without modes T2 waits for day 7. // With a borrow mode on resource 200 (free), T2 should run at day 0 on 200. 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: [homeRes, borrowRes], assignments: [assignment(1, 100), assignment(2, 100)], }); const schedule = await runOnce(project, [ twoModes(1), twoModes(2), { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1 }, { kind: "MaxConcurrentResource", resourceUniqueId: 200, max: 1 }, ]); const by = new Map(schedule.tasks.map((t) => [t.uniqueId, t])); // T1 takes the home crew at day 0. expect(by.get(1)!.startDay).toBe(0); expect(by.get(1)!.modeId).toBe(0); // T2 can't get home at day 0 (saturated) but borrows 200 → also day 0. expect(by.get(2)!.startDay).toBe(0); expect(by.get(2)!.modeId).toBe(1); });
test("one-way: borrowed load counts against the borrowed resource's cap", async () => { // Placement is id-order. T1 = real borrow-resource(200) task; T2 = home(100) // task; T3 = home task WITH a borrow mode. At day 0 both 100 and 200 are // full (T2, T1), so T3 cannot use home OR borrow → must wait to day 7. This // proves a borrow-mode task is capped by the borrowed resource (one-way). 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 t3 = makeTask({ uniqueId: 3, start: MON_JAN_5, finish: SAT_JAN_10 }); const project = makeProject([t1, t2, t3], { resources: [homeRes, borrowRes], assignments: [assignment(1, 200), assignment(2, 100), assignment(3, 100)], }); const schedule = await runOnce(project, [ twoModes(3), { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1 }, { kind: "MaxConcurrentResource", resourceUniqueId: 200, max: 1 }, ]); const by = new Map(schedule.tasks.map((t) => [t.uniqueId, t])); expect(by.get(1)!.startDay).toBe(0); // real borrow-resource(200) task expect(by.get(2)!.startDay).toBe(0); // home(100) task expect(by.get(3)!.startDay).toBe(7); // blocked at day 0 on BOTH caps });
test("buildResourceLoad reflects the realized (borrowed) resource, not the static home", async () => { 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: [homeRes, borrowRes], assignments: [assignment(1, 100), assignment(2, 100)], }); const schedule = await runOnce(project, [ twoModes(1), twoModes(2), { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1 }, { kind: "MaxConcurrentResource", resourceUniqueId: 200, max: 1 }, ]); const load = buildResourceLoad(schedule); // Day 0: T1 on 100, T2 borrowed onto 200 — one unit each, not two on 100. expect(load.get(100)![0]).toBe(1); expect(load.get(200)![0]).toBe(1); });});
describe("serialSGS — unimplemented constraints", () => { test("UnimodalProfile records an Explanation in annotations but does not block emission", async () => { const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); const schedule = await runOnce(makeProject([t1]), [ { kind: "UnimodalProfile", resourceUniqueId: 100, tolerance: 0.1, }, ]); expect(schedule.tasks).toHaveLength(1); const unsupported = schedule.annotations.get("unsupportedConstraints") as | readonly Explanation[] | undefined; expect(unsupported).toBeDefined(); expect(unsupported).toHaveLength(1); expect(unsupported![0]!.message).toMatch( /UnimodalProfile.*not implemented/, ); });
test("UnitPrecedence is recorded as unsupported", async () => { const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); const schedule = await runOnce(makeProject([t1]), [ { kind: "UnitPrecedence", unitId: 20, afterUnitIds: [10] }, ]); const unsupported = schedule.annotations.get("unsupportedConstraints") as | readonly Explanation[] | undefined; expect(unsupported).toBeDefined(); expect(unsupported![0]!.message).toMatch(/UnitPrecedence.*not implemented/); });});
describe("serialSGS — milestones", () => { test("zero-duration task is placed at the precedence lower bound", async () => { const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); const milestone = makeTask({ uniqueId: 2, start: SAT_JAN_10, finish: SAT_JAN_10, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lag: null, }, ], }); milestone.milestone = true; const schedule = await runOnce(makeProject([t1, milestone]), []); const m = schedule.tasks.find((t) => t.uniqueId === 2)!; expect(m.startDay).toBe(5); expect(m.finishDay).toBe(5); });});
describe("serialSGS — FF/SF approximation", () => { test("FF relation does not over-delay successor and emits approximatedRelations", async () => { 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, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToFinish, lag: null, }, ], }); const schedule = await runOnce(makeProject([t1, t2]), []); const t1sched = schedule.tasks.find((t) => t.uniqueId === 1)!; const t2sched = schedule.tasks.find((t) => t.uniqueId === 2)!; // T1 occupies days 0–5. Under FS-degradation, T2 would have started at // day 7 (next working day). Under proper FF semantics with lag=0 and // duration=5, T2 can start at day 0 since its finish (day 5) >= T1's // finish (day 5). Verify we no longer over-delay. expect(t1sched.finishDay).toBe(5); expect(t2sched.startDay).toBe(0); const approximated = schedule.annotations.get("approximatedRelations") as | readonly Explanation[] | undefined; expect(approximated).toBeDefined(); expect(approximated!.length).toBe(1); expect(approximated![0]!.message).toMatch(/FF.*approximated/); });
test("SF relation emits approximatedRelations", async () => { 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, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.StartToFinish, lag: null, }, ], }); const schedule = await runOnce(makeProject([t1, t2]), []); const approximated = schedule.annotations.get("approximatedRelations") as | readonly Explanation[] | undefined; expect(approximated).toBeDefined(); expect(approximated!.length).toBe(1); expect(approximated![0]!.message).toMatch(/SF.*approximated/); });
test("FS and SS relations do not emit approximatedRelations", async () => { 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, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lag: null, }, ], }); const schedule = await runOnce(makeProject([t1, t2]), []); expect(schedule.annotations.has("approximatedRelations")).toBe(false); });});
describe("serialSGS — horizon exhaustion", () => { test("emits Failure instead of throwing when FS lag overflows horizon", async () => { // Predecessor finishes near horizon end; FS edge lag pushes the // successor's earliestStart past the bitmap. earliestStart's // advanceWorkingDays must not throw — fall through to Failure. 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, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lag: Duration.from(20, TimeUnit.Days), }, ], }); const project = makeProject([t1, t2]); const resolved = resolveCalendar(project, { horizonDays: 8 }); const generator = serialSGS.run(resolved, []); const first = await generator.next(); expect(first.done).toBe(true); const failure = first.value as Failure | undefined; expect(failure).toBeDefined(); expect(failure!.kind).toBe("failure"); });
test("emits Failure instead of throwing when SS lag overflows horizon", async () => { 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, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.StartToStart, lag: Duration.from(30, TimeUnit.Days), }, ], }); const project = makeProject([t1, t2]); const resolved = resolveCalendar(project, { horizonDays: 8 }); const generator = serialSGS.run(resolved, []); const first = await generator.next(); expect(first.done).toBe(true); const failure = first.value as Failure | undefined; expect(failure).toBeDefined(); expect(failure!.kind).toBe("failure"); });
test("emits Failure instead of throwing when FF lag overflows horizon", async () => { 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, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToFinish, lag: Duration.from(20, TimeUnit.Days), }, ], }); const project = makeProject([t1, t2]); const resolved = resolveCalendar(project, { horizonDays: 8 }); const generator = serialSGS.run(resolved, []); const first = await generator.next(); expect(first.done).toBe(true); const failure = first.value as Failure | undefined; expect(failure).toBeDefined(); expect(failure!.kind).toBe("failure"); });
test("emits Failure instead of throwing when task can't fit before horizon", async () => { // Task with 4 working days at a project-end deadline — fits in the // horizon when standalone but not when serialized after a precedessor // that consumes all the working days. 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, predecessors: [ { predecessorUniqueId: 1, successorUniqueId: 2, type: RelationType.FinishToStart, lag: null, }, ], }); // Horizon = 8 days (Mon–Mon). T1 takes 5 working days, T2 needs 5 more — // T2 will run out of horizon. Should yield a Failure, not throw. const project = makeProject([t1, t2]); const resolved = resolveCalendar(project, { horizonDays: 8 }); const generator = serialSGS.run(resolved, []); const first = await generator.next(); expect(first.done).toBe(true); const failure = first.value as Failure | undefined; expect(failure).toBeDefined(); expect(failure!.kind).toBe("failure"); expect(failure!.explanations.length).toBeGreaterThan(0); });});
describe("serialSGS — bestBy integration", () => { test("ScheduleStream.bestBy works with serialSGS output", async () => { const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); const project = makeProject([t1]); const resolved = resolveCalendar(project); const stream = new ScheduleStreamImpl({ [Symbol.asyncIterator]: () => serialSGS.run(resolved, []), }); const best = await stream.bestBy({ name: "makespan", direction: "min", score: (s) => Math.max(...s.tasks.map((t) => t.finishDay)), }); expect(best?.tasks[0]!.finishDay).toBe(5); });});