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.
7.4 kB · 231 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232import { test, expect, describe } from "bun:test";
import { concatStreams, ScheduleStreamImpl, streamFromFactory,} from "../../src/level-core/scheduleStream.ts";import type { ResolvedProject, Schedule, ScheduledTask, Scorer,} from "../../src/level-core/types.ts";
const fakeResolved = { source: { tasks: [] } as unknown, defaultCalendarUniqueId: null, calendars: new Map(), tasks: [], resources: [], assignments: [], precedences: [],} as unknown as ResolvedProject;
function fakeSchedule(makespan: number, peak = 0): Schedule { const tasks: ScheduledTask[] = [ { uniqueId: 1, startDay: 0, finishDay: makespan, modeId: null }, ]; // Stash peak in modeId of a fake second task purely for scoring tests. tasks.push({ uniqueId: 99, startDay: 0, finishDay: peak, modeId: null }); return { resolved: fakeResolved, tasks, makespan, annotations: new Map() };}
const makespanScorer: Scorer = { name: "makespan", direction: "min", score: (s) => s.tasks[0]!.finishDay,};
const peakScorer: Scorer = { name: "peak", direction: "min", score: (s) => s.tasks[1]!.finishDay,};
function streamOf(...schedules: Schedule[]) { return streamFromFactory( // `async` is what makes the factory return an AsyncGenerator, which is // what streamFromFactory takes; the fixtures are built synchronously. // oxlint-disable-next-line typescript/require-await async function* () { for (const s of schedules) yield s; }, );}
describe("ScheduleStream — lazy transforms", () => { test("filter keeps only matching schedules", async () => { const out = await streamOf( fakeSchedule(3), fakeSchedule(5), fakeSchedule(7), ) .filter((s) => s.tasks[0]!.finishDay > 4) .collect(); expect(out).toHaveLength(2); expect(out.map((s) => s.tasks[0]!.finishDay)).toEqual([5, 7]); });
test("map transforms each schedule", async () => { const out = await streamOf(fakeSchedule(3)) .map((s) => ({ ...s, annotations: new Map() })) .collect(); expect(out).toHaveLength(1); });
test("take(k) only pulls the first k", async () => { let pulled = 0; const stream = streamFromFactory( // `async` is what makes the factory return an AsyncGenerator, which is // what streamFromFactory takes; the fixtures are built synchronously. // oxlint-disable-next-line typescript/require-await async function* () { while (true) { pulled++; yield fakeSchedule(pulled); } }, ); const out = await stream.take(3).collect(); expect(out).toHaveLength(3); expect(pulled).toBe(3); });
test("take(0) is empty and never pulls", async () => { let pulled = 0; const stream = streamFromFactory( // `async` is what makes the factory return an AsyncGenerator, which is // what streamFromFactory takes; the fixtures are built synchronously. // oxlint-disable-next-line typescript/require-await async function* () { pulled++; yield fakeSchedule(1); }, ); const out = await stream.take(0).collect(); expect(out).toEqual([]); expect(pulled).toBe(0); });
test("branch composes — each input fans out into the sub-stream", async () => { const out = await streamOf(fakeSchedule(3), fakeSchedule(5)) .branch((s) => streamOf(s, { ...s, tasks: [{ ...s.tasks[0]!, finishDay: s.tasks[0]!.finishDay + 1 }], }), ) .collect(); expect(out).toHaveLength(4); expect(out.map((s) => s.tasks[0]!.finishDay)).toEqual([3, 4, 5, 6]); });
test("concatStreams creates later streams only after earlier streams are exhausted", async () => { const events: string[] = []; const concatenated = concatStreams([ () => { events.push("create-a"); return streamOf(fakeSchedule(1), fakeSchedule(2)); }, () => { events.push("create-b"); return streamOf(fakeSchedule(3)); }, ]);
expect(events).toEqual([]); expect( (await concatenated.take(2).collect()).map( (schedule) => schedule.makespan, ), ).toEqual([1, 2]); expect(events).toEqual(["create-a"]); });});
describe("ScheduleStream — materializing operations", () => { test("bestBy returns the min-scoring schedule", async () => { const stream = streamOf(fakeSchedule(7), fakeSchedule(3), fakeSchedule(5)); const best = await stream.bestBy(makespanScorer); expect(best?.tasks[0]!.finishDay).toBe(3); });
test("bestBy with direction=max returns the max-scoring schedule", async () => { const stream = streamOf(fakeSchedule(7), fakeSchedule(3), fakeSchedule(5)); const best = await stream.bestBy({ ...makespanScorer, direction: "max" }); expect(best?.tasks[0]!.finishDay).toBe(7); });
test("bestBy on empty stream returns null", async () => { expect(await streamOf().bestBy(makespanScorer)).toBeNull(); });
test("collect with limit stops early", async () => { const out = await streamOf( fakeSchedule(1), fakeSchedule(2), fakeSchedule(3), ).collect(2); expect(out).toHaveLength(2); });
test("paretoFrontier excludes dominated schedules", async () => { // (makespan, peak): (5, 2) dominates (5, 3) and (6, 3); (3, 5) is non-dominated. const a = fakeSchedule(5, 2); const b = fakeSchedule(5, 3); const c = fakeSchedule(6, 3); const d = fakeSchedule(3, 5); const frontier = await streamOf(a, b, c, d).paretoFrontier([ makespanScorer, peakScorer, ]); expect(frontier).toHaveLength(2); expect(frontier).toContain(a); expect(frontier).toContain(d); });
test("bestByLex breaks first-tier ties on the second tier", async () => { // makespan ties at 5; peak decides. (5,3) vs (5,2) vs (6,0) → (5,2). const a = fakeSchedule(5, 3); const b = fakeSchedule(5, 2); const c = fakeSchedule(6, 0); expect( await streamOf(a, b, c).bestByLex([makespanScorer, peakScorer]), ).toBe(b); });
test("bestByLex respects per-tier direction and keeps the first on full ties", async () => { const maxPeak: Scorer = { ...peakScorer, direction: "max" }; const a = fakeSchedule(5, 2); const b = fakeSchedule(5, 4); expect(await streamOf(a, b).bestByLex([makespanScorer, maxPeak])).toBe(b);
const dupA = fakeSchedule(5, 2); const dupB = fakeSchedule(5, 2); expect( await streamOf(dupA, dupB).bestByLex([makespanScorer, peakScorer]), ).toBe(dupA); });
test("bestByLex on an empty stream returns null", async () => { expect(await streamOf().bestByLex([makespanScorer])).toBeNull(); });});
describe("ScheduleStream — reusability", () => { test("calling Symbol.asyncIterator twice gives independent runs", async () => { const stream = streamOf(fakeSchedule(1), fakeSchedule(2)); const first: number[] = []; for await (const s of stream) first.push(s.tasks[0]!.finishDay); const second: number[] = []; for await (const s of stream) second.push(s.tasks[0]!.finishDay); expect(first).toEqual([1, 2]); expect(second).toEqual([1, 2]); });
test("ScheduleStreamImpl is the implementation type", () => { const stream = streamOf(fakeSchedule(1)); expect(stream).toBeInstanceOf(ScheduleStreamImpl); });});