import { describe, expect, it } from 'vitest' import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js' import { makespan, scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js' import { capacityPerWorkday, parseCapacityConfig } from './capacity-v0.js' describe('capacity model', () => { it('capacityPerWorkday = focusFactor × allocation', () => { expect(capacityPerWorkday({ person: 'a', focusFactor: 0.8, allocation: 0.5 })).toBeCloseTo(0.4) }) it('parses + clamps config, drops invalid members', () => { const members = parseCapacityConfig({ members: [ { person: 'christian', focusFactor: 0.8, allocation: 1 }, { person: 'ak', focusFactor: 1.5, allocation: -1 }, // clamps to 1 / 0 → zero capacity → dropped { focusFactor: 0.8 }, // no person → dropped ], }) expect(members).toEqual([{ person: 'christian', focusFactor: 0.8, allocation: 1 }]) }) it('returns [] for a non-array/absent members field', () => { expect(parseCapacityConfig({})).toEqual([]) expect(parseCapacityConfig({ members: 'nope' })).toEqual([]) }) }) function issue(number: number, over: Partial = {}): SchedulableIssue { return { number, title: `#${number}`, labels: [], estimateDays: 4, priority: 2, ...over } } describe('scheduleWithCapacity', () => { it('with no workers, falls back to the single serial plan', () => { const issues = [issue(1), issue(2)] const plan = scheduleWithCapacity(issues, [], []) expect(makespan(plan)).toBe(8) // 4 + 4 serial }) it('parallelizes independent work across lanes (makespan shrinks)', () => { const issues = [issue(1), issue(2), issue(3), issue(4)] // 4×4d = 16d serial const one: Worker[] = [{ person: 'a', speed: 1 }] const two: Worker[] = [ { person: 'a', speed: 1 }, { person: 'b', speed: 1 }, ] expect(makespan(scheduleWithCapacity(issues, [], one))).toBe(16) expect(makespan(scheduleWithCapacity(issues, [], two))).toBe(8) // two lanes → half }) it('scales duration by a lane speed (slower lane takes longer)', () => { const plan = scheduleWithCapacity([issue(1, { estimateDays: 4 })], [], [{ person: 'a', speed: 0.5 }]) expect(plan.items[0].durationDays).toBe(8) // 4 / 0.5 expect(plan.items[0].worker).toBe('a') }) it('routes an issue to its assignee lane', () => { const issues = [issue(1, { assignee: 'ak' }), issue(2, { assignee: 'sm' })] const workers: Worker[] = [ { person: 'ak', speed: 1 }, { person: 'sm', speed: 1 }, ] const plan = scheduleWithCapacity(issues, [], workers) const byN = Object.fromEntries(plan.items.map((i) => [i.number, i])) expect(byN[1].worker).toBe('ak') expect(byN[2].worker).toBe('sm') // both start at 0 (different lanes) → parallel expect(byN[1].startDay).toBe(0) expect(byN[2].startDay).toBe(0) }) it('adds a lane for an assignee not in the config (mean speed)', () => { const plan = scheduleWithCapacity([issue(1, { assignee: 'newbie' })], [], [{ person: 'a', speed: 0.5 }]) expect(plan.items[0].worker).toBe('newbie') expect(plan.items[0].durationDays).toBe(8) // mean speed 0.5 → 4/0.5 }) it('respects dependencies across lanes (a blocker finishes before its dependent starts)', () => { const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }] const workers: Worker[] = [ { person: 'a', speed: 1 }, { person: 'b', speed: 1 }, ] const plan = scheduleWithCapacity([issue(1), issue(2)], edges, workers) const byN = Object.fromEntries(plan.items.map((i) => [i.number, i])) expect(byN[2].startDay).toBeGreaterThanOrEqual(byN[1].endDay) // #2 waits for #1 even on another lane }) })