import { describe, expect, it } from 'vitest' import { type DependencyEdge, schedule, type SchedulableIssue, selectFocus } from './scheduler-v0.js' function issue(number: number, over: Partial = {}): SchedulableIssue { return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over } } describe('schedule', () => { it('lays open issues out serially with cumulative start/finish', () => { const plan = schedule([issue(1, { estimateDays: 2 }), issue(2, { estimateDays: 3 })], []) expect(plan.cycle).toBeNull() const byN = Object.fromEntries(plan.items.map((i) => [i.number, i])) // total span = sum of durations, no overlap (single serial worker) const spans = plan.items.map((i) => [i.startDay, i.endDay]) expect(spans).toContainEqual([0, expect.any(Number)]) expect(byN[1].durationDays + byN[2].durationDays).toBe(5) expect(Math.max(...plan.items.map((i) => i.endDay))).toBe(5) }) it('respects dependencies: a dependency is scheduled before its dependent', () => { // #2 depends on #1 const edges: DependencyEdge[] = [{ issue: 2, dependsOn: 1 }] const plan = schedule([issue(2, { priority: 1 }), issue(1, { priority: 4 })], edges) const order = plan.items.map((i) => i.number) expect(order.indexOf(1)).toBeLessThan(order.indexOf(2)) // #2 records #1 as a blocker; #1 records #2 as blocked const two = plan.items.find((i) => i.number === 2)! expect(two.blockedBy).toEqual([1]) const one = plan.items.find((i) => i.number === 1)! expect(one.blocks).toEqual([2]) }) it('breaks ties among ready roots by priority, then estimate', () => { const plan = schedule( [issue(1, { priority: 3 }), issue(2, { priority: 1 }), issue(3, { priority: 2 })], [], ) expect(plan.items.map((i) => i.number)).toEqual([2, 3, 1]) }) it('detects a dependency cycle instead of looping', () => { const edges: DependencyEdge[] = [ { issue: 1, dependsOn: 2 }, { issue: 2, dependsOn: 1 }, ] const plan = schedule([issue(1), issue(2)], edges) expect(plan.items).toHaveLength(0) expect(plan.cycle).not.toBeNull() expect(plan.cycle!.sort()).toEqual([1, 2]) }) it('drops dependencies on out-of-scope (closed) issues', () => { // #2 depends on #9 which isn't in scope → treated as satisfied const plan = schedule([issue(2)], [{ issue: 2, dependsOn: 9 }]) expect(plan.cycle).toBeNull() expect(plan.items.find((i) => i.number === 2)!.blockedBy).toEqual([]) }) it('marks the longest dependency chain critical', () => { // chain 1→2→3 (each 3d = 9d) vs standalone 4 (2d): 1,2,3 critical, 4 not const edges: DependencyEdge[] = [ { issue: 2, dependsOn: 1 }, { issue: 3, dependsOn: 2 }, ] const issues = [ issue(1, { estimateDays: 3 }), issue(2, { estimateDays: 3 }), issue(3, { estimateDays: 3 }), issue(4, { estimateDays: 2 }), ] const plan = schedule(issues, edges) const crit = plan.items.filter((i) => i.critical).map((i) => i.number).sort() expect(crit).toEqual([1, 2, 3]) expect(plan.items.find((i) => i.number === 4)!.critical).toBe(false) }) it('defaults a null estimate to DEFAULT_ESTIMATE_DAYS', () => { const plan = schedule([issue(1, { estimateDays: null })], []) expect(plan.items[0].durationDays).toBe(2) }) }) describe('selectFocus', () => { it('picks the first three of the ordered plan as now/next/later', () => { const plan = schedule([issue(1, { priority: 1 }), issue(2, { priority: 2 }), issue(3, { priority: 3 })], []) const focus = selectFocus(plan) expect(focus.now?.number).toBe(1) expect(focus.next?.number).toBe(2) expect(focus.later?.number).toBe(3) }) it('returns nulls past the end of a short plan', () => { const focus = selectFocus(schedule([issue(1)], [])) expect(focus.now?.number).toBe(1) expect(focus.next).toBeNull() expect(focus.later).toBeNull() }) })