diff --git a/src/level-core/resolveCalendar.ts b/src/level-core/resolveCalendar.ts new file mode 100644 index 0000000..54ce0ba --- /dev/null +++ b/src/level-core/resolveCalendar.ts @@ -0,0 +1,349 @@ +// Project ──resolveCalendar──> ResolvedProject. +// Pure: in goes the user-facing `ProjectFile` with Date objects, out comes +// the day-indexed working value the rest of the pipeline operates on. + +import type { Calendar } from "../model/Calendar.ts"; +import type { Duration } from "../model/Duration.ts"; +import { TimeUnit } from "../model/types.ts"; +import type { ProjectFile, ProjectProperties } from "../schema/project.ts"; +import type { Task } from "../schema/task.ts"; + +import { + addCalendarDays, + buildWorkingCalendar, + endOfLocalDayExclusive, + startOfLocalDay, +} from "./calendarDays.ts"; +import type { + PrecedenceEdge, + ResolveOptions, + ResolvedAssignment, + ResolvedProject, + ResolvedResource, + ResolvedTask, + WorkUnit, + WorkingCalendar, +} from "./types.ts"; + +const HORIZON_BUFFER_DAYS = 90; +const MS_PER_DAY = 86_400_000; +const TEN_YEARS_DAYS = 365 * 10; +const SYNTHETIC_CAL_ID = -1; + +function syntheticMonFriCalendar(): Calendar { + return { + uniqueId: SYNTHETIC_CAL_ID, + name: "Synthetic Mon–Fri", + weekDays: [ + { dayType: 1, working: false, workingTimes: [] }, // Sun + { 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: [] }, // Sat + ], + exceptions: [], + }; +} + +/** opts.epoch ?? properties.statusDate ?? min(task.start). Throws if none. */ +function pickEpoch(project: ProjectFile, opts: ResolveOptions): Date { + if (opts.epoch) return startOfLocalDay(opts.epoch); + if (project.properties.statusDate) return startOfLocalDay(project.properties.statusDate); + let earliest: Date | null = project.properties.startDate + ? startOfLocalDay(project.properties.startDate) + : null; + for (const t of project.tasks) { + if (t.start) { + const s = startOfLocalDay(t.start); + if (!earliest || s.getTime() < earliest.getTime()) earliest = s; + } + } + if (!earliest) { + throw new Error( + "resolveCalendar: cannot pick epoch — no opts.epoch, statusDate, startDate, or task starts available", + ); + } + return earliest; +} + +function pickHorizon(project: ProjectFile, epoch: Date, opts: ResolveOptions): number { + if (opts.horizonDays !== undefined) { + if (opts.horizonDays < 0) { + throw new Error(`resolveCalendar: horizonDays must be >= 0, got ${String(opts.horizonDays)}`); + } + return opts.horizonDays; + } + let latest: Date | null = project.properties.finishDate + ? endOfLocalDayExclusive(project.properties.finishDate) + : null; + for (const t of project.tasks) { + if (t.finish) { + const f = endOfLocalDayExclusive(t.finish); + if (!latest || f.getTime() > latest.getTime()) latest = f; + } + } + if (!latest) return 365 + HORIZON_BUFFER_DAYS; + const span = Math.max(0, Math.round((latest.getTime() - epoch.getTime()) / MS_PER_DAY)); + const scaled = Math.ceil(span * 1.25) + HORIZON_BUFFER_DAYS; + return Math.min(TEN_YEARS_DAYS, scaled); +} + +// Working-days-per-week derived from properties so non-Mon–Fri calendars +// are handled; the `5` below is only a degenerate fallback. +function workingDaysPerWeek(properties: ProjectProperties): number { + const wpd = properties.minutesPerDay; + const wpw = properties.minutesPerWeek; + if (wpd <= 0 || wpw <= 0) return 5; + return wpw / wpd; +} + +// Lag → working days. MSPDI doesn't distinguish elapsed vs. working lag, so we +// pick the working-day interpretation (matches MS Project default when no +// `e`-prefix is present). Elapsed-lag support is a future addition. +function lagToWorkingDays(lag: Duration | null, properties: ProjectProperties): number { + if (!lag) return 0; + const minutesPerDay = properties.minutesPerDay; + const wpw = workingDaysPerWeek(properties); + const dpm = properties.daysPerMonth; + switch (lag.unit) { + case TimeUnit.Days: + return Math.round(lag.value); + case TimeUnit.Weeks: + return Math.round(lag.value * wpw); + case TimeUnit.Hours: + return Math.round((lag.value * 60) / minutesPerDay); + case TimeUnit.Minutes: + return Math.round(lag.value / minutesPerDay); + case TimeUnit.Months: + return Math.round(lag.value * dpm); + case TimeUnit.Percent: + return 0; + } +} + +function durationToWorkingDays( + dur: Duration | null, + cal: WorkingCalendar, + startDay: number, + properties: ProjectProperties, +): number { + if (!dur) return 0; + const minutesPerDay = properties.minutesPerDay; + const wpw = workingDaysPerWeek(properties); + const dpm = properties.daysPerMonth; + switch (dur.unit) { + case TimeUnit.Days: + return Math.max(0, Math.round(dur.value)); + case TimeUnit.Weeks: + return Math.max(0, Math.round(dur.value * wpw)); + case TimeUnit.Hours: + return Math.max(0, Math.round((dur.value * 60) / minutesPerDay)); + case TimeUnit.Minutes: + return Math.max(0, Math.round(dur.value / minutesPerDay)); + case TimeUnit.Months: + return Math.max(0, Math.round(dur.value * dpm)); + case TimeUnit.Percent: + // Without a known reference, treat as zero. The pipeline can add a CPM + // stage later if percent durations need to be resolved. + void cal; + void startDay; + return 0; + } +} + +function calendarDayOffset(d: Date, epoch: Date): number { + const dStart = startOfLocalDay(d).getTime(); + const eStart = epoch.getTime(); + return Math.round((dStart - eStart) / MS_PER_DAY); +} + +function countWorkingDaysInRange(cal: WorkingCalendar, fromDay: number, toDay: number): number { + const lo = Math.max(0, fromDay); + const hi = Math.min(cal.horizonDays, toDay); + if (hi <= lo) return 0; + return cal.cumWorking[hi]! - cal.cumWorking[lo]!; +} + +function resolveTask( + task: Task, + epoch: Date, + cal: WorkingCalendar, + properties: ProjectProperties, +): ResolvedTask { + if (task.uniqueId === null) { + throw new Error("resolveCalendar: task missing uniqueId"); + } + if (!task.start || !task.finish) { + throw new Error(`resolveCalendar: task ${String(task.uniqueId)} missing start or finish`); + } + const startDay = calendarDayOffset(task.start, epoch); + const finishDay = calendarDayOffset(endOfLocalDayExclusive(task.finish), epoch); + const durationDays = + task.duration !== null + ? durationToWorkingDays(task.duration, cal, startDay, properties) + : countWorkingDaysInRange(cal, startDay, finishDay); + return { + uniqueId: task.uniqueId, + name: task.name, + durationDays, + outlineLevel: task.outlineLevel, + summary: task.summary ?? false, + milestone: task.milestone ?? false, + // Per-task calendar override would land here once the schema carries it. + calendarUniqueId: null, + }; +} + +function resolveEdges(tasks: ReadonlyArray, properties: ProjectProperties): PrecedenceEdge[] { + const edges: PrecedenceEdge[] = []; + for (const t of tasks) { + if (t.uniqueId === null) continue; + for (const r of t.predecessors) { + if (r.predecessorUniqueId === null) continue; + edges.push({ + predecessorUniqueId: r.predecessorUniqueId, + successorUniqueId: t.uniqueId, + type: r.type, + lagDays: lagToWorkingDays(r.lag, properties), + }); + } + } + return edges; +} + +function resolveAssignments(project: ProjectFile): ResolvedAssignment[] { + const out: ResolvedAssignment[] = []; + for (const a of project.assignments) { + if (a.taskUniqueId === null || a.resourceUniqueId === null) continue; + out.push({ + taskUniqueId: a.taskUniqueId, + resourceUniqueId: a.resourceUniqueId, + units: a.units ?? 1, + }); + } + return out; +} + +// capacityPerDay = (resource.maxUnits ?? 1) * (minutesPerDay / 60). +function resolveResources(project: ProjectFile): ResolvedResource[] { + const minutesPerDay = project.properties.minutesPerDay; + const out: ResolvedResource[] = []; + for (const r of project.resources) { + if (r.uniqueId === null) continue; + const maxUnits = r.maxUnits ?? 1; + out.push({ + uniqueId: r.uniqueId, + capacityPerDay: maxUnits * (minutesPerDay / 60), + // Per-resource calendar override would land here once the schema carries it. + calendarUniqueId: null, + }); + } + return out; +} + +function validateTasks(project: ProjectFile): void { + for (const t of project.tasks) { + if (t.uniqueId === null) { + throw new Error("resolveCalendar: task missing uniqueId"); + } + if (!t.start || !t.finish) { + throw new Error(`resolveCalendar: task ${String(t.uniqueId)} missing start or finish`); + } + } +} + +/** Build a WorkingCalendar per distinct calendarUniqueId referenced by + * the project, plus any synthetic fallback. */ +function buildCalendarMap( + project: ProjectFile, + defaultCalendar: Calendar, + defaultCalendarUniqueId: number | null, + epoch: Date, + horizonDays: number, +): Map { + const map = new Map(); + // Always include the default. + map.set( + defaultCalendarUniqueId ?? SYNTHETIC_CAL_ID, + buildWorkingCalendar(defaultCalendar, defaultCalendarUniqueId, epoch, horizonDays), + ); + // Any other named calendar with a uniqueId. + for (const c of project.calendars) { + if (c.uniqueId === null) continue; + if (map.has(c.uniqueId)) continue; + map.set(c.uniqueId, buildWorkingCalendar(c, c.uniqueId, epoch, horizonDays)); + } + return map; +} + +/** Canonicalize WorkUnits into a by-id registry, the single owner of unit + * definitions. Rejects duplicate ids so a unit id maps to exactly one + * definition across every consumer (JS scorers and the MiniZinc backend). */ +export function resolveWorkUnits( + units: ReadonlyArray = [], +): ReadonlyMap { + const map = new Map(); + for (const u of units) { + if (map.has(u.id)) { + throw new Error(`resolveWorkUnits: duplicate WorkUnit id ${String(u.id)}`); + } + map.set(u.id, u); + } + return map; +} + +function pickDefaultCalendar(project: ProjectFile): { + calendar: Calendar; + uniqueId: number | null; +} { + const id = project.properties.defaultCalendarUniqueId; + if (id !== null) { + const found = project.calendars.find((c) => c.uniqueId === id); + if (found) return { calendar: found, uniqueId: found.uniqueId }; + } + const first = project.calendars[0]; + if (first) return { calendar: first, uniqueId: first.uniqueId }; + return { calendar: syntheticMonFriCalendar(), uniqueId: null }; +} + +export function resolveCalendar(project: ProjectFile, opts: ResolveOptions = {}): ResolvedProject { + validateTasks(project); + const epoch = pickEpoch(project, opts); + const horizonDays = pickHorizon(project, epoch, opts); + const { calendar: defaultCalendar, uniqueId: defaultCalendarUniqueId } = + pickDefaultCalendar(project); + const calendars = buildCalendarMap( + project, + defaultCalendar, + defaultCalendarUniqueId, + epoch, + horizonDays, + ); + // The default calendar drives task/edge resolution. Per-task overrides + // would consult `calendars.get(task.calendarUniqueId)` here once the + // schema carries that field. + const defaultId = defaultCalendarUniqueId ?? SYNTHETIC_CAL_ID; + const defaultWorking = calendars.get(defaultId)!; + const tasks = project.tasks.map((t) => resolveTask(t, epoch, defaultWorking, project.properties)); + const precedences = resolveEdges(project.tasks, project.properties); + const assignments = resolveAssignments(project); + const resources = resolveResources(project); + const workUnits = resolveWorkUnits(opts.workUnits); + return { + source: project, + defaultCalendarUniqueId: defaultCalendarUniqueId, + calendars, + tasks, + resources, + assignments, + precedences, + workUnits, + }; +} + +// Unused-but-exported epoch arithmetic kept for symmetry with calendarDays — +// resolveCalendar uses these via calendarDays helpers, but downstream stages +// often need the offset too. +export { addCalendarDays, calendarDayOffset }; diff --git a/test/level-core/resolveCalendar.test.ts b/test/level-core/resolveCalendar.test.ts new file mode 100644 index 0000000..89d1d66 --- /dev/null +++ b/test/level-core/resolveCalendar.test.ts @@ -0,0 +1,441 @@ +import { test, expect, describe } from "bun:test"; + +import { currentSchedule } from "../../src/level-core/currentSchedule.ts"; +import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; +import { Duration } from "../../src/model/Duration.ts"; +import { RelationType, TimeUnit } from "../../src/model/types.ts"; +import type { Calendar, CalendarException } 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, exceptions: CalendarException[] = []): 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 | null; + finish: Date | null; + predecessors?: Relation[]; + milestone?: boolean; + duration?: Duration | null; +}): Task { + return { + id: null, + uniqueId: args.uniqueId, + name: `Task ${String(args.uniqueId)}`, + wbs: null, + outlineLevel: 0, + start: args.start, + finish: args.finish, + duration: args.duration ?? null, + percentComplete: null, + summary: false, + milestone: args.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[], calendars: Calendar[] = [monFriCalendar()]): ProjectFile { + return { + properties: { + title: "Test", + author: null, + startDate: null, + finishDate: null, + statusDate: null, + defaultCalendarUniqueId: calendars[0]?.uniqueId ?? null, + minutesPerDay: 480, + minutesPerWeek: 2400, + daysPerMonth: 20, + saveVersion: null, + }, + tasks, + resources: [], + assignments: [], + calendars, + }; +} + +function defaultCal(resolved: ReturnType) { + const id = resolved.defaultCalendarUniqueId; + return id !== null ? resolved.calendars.get(id)! : resolved.calendars.values().next().value!; +} + +const MON_JAN_5 = new Date(2026, 0, 5); +const SAT_JAN_10 = new Date(2026, 0, 10); +const TUE_JAN_13 = new Date(2026, 0, 13); + +describe("resolveCalendar — Mon–Fri calendar", () => { + test("1-week task: durationDays = 5, currentSchedule places at days 0..5", () => { + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + const resolved = resolveCalendar(project); + expect(resolved.tasks[0]!.durationDays).toBe(5); + const schedule = currentSchedule(resolved); + expect(schedule.tasks[0]).toEqual({ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }); + expect(schedule.makespan).toBe(5); + }); + + test("MSPDI Fri 17:00 finish rounds up to Sat midnight", () => { + const friAt17 = new Date(2026, 0, 9, 17, 0, 0); + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: friAt17 })]); + const resolved = resolveCalendar(project); + expect(currentSchedule(resolved).tasks[0]!.finishDay).toBe(5); + }); + + test("milestone (start === finish at midnight) has zero duration", () => { + const project = makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: MON_JAN_5, milestone: true }), + ]); + const resolved = resolveCalendar(project); + expect(resolved.tasks[0]!.durationDays).toBe(0); + expect(resolved.tasks[0]!.milestone).toBe(true); + expect(currentSchedule(resolved).tasks[0]!.startDay).toBe(0); + expect(currentSchedule(resolved).tasks[0]!.finishDay).toBe(0); + }); + + test("epoch is start-of-day even when source uses non-midnight times", () => { + const monAt9 = new Date(2026, 0, 5, 9, 0, 0); + const project = makeProject([makeTask({ uniqueId: 1, start: monAt9, finish: SAT_JAN_10 })]); + const resolved = resolveCalendar(project); + const cal = defaultCal(resolved); + expect(cal.epoch.getHours()).toBe(0); + expect(cal.epoch.getDate()).toBe(5); + }); + + test("Sat/Sun gap is non-working in the bits", () => { + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: TUE_JAN_13 })]); + const cal = defaultCal(resolveCalendar(project)); + expect(cal.bits[5]).toBe(0); + expect(cal.bits[6]).toBe(0); + expect(cal.bits[7]).toBe(1); + }); +}); + +describe("resolveCalendar — exceptions", () => { + test("mid-week non-working exception lengthens span", () => { + const wedJan7 = new Date(2026, 0, 7); + const calendar = monFriCalendar(1, [ + { name: "Plant maintenance", fromDate: wedJan7, toDate: wedJan7, working: false }, + ]); + const project = makeProject( + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: TUE_JAN_13 })], + [calendar], + ); + const resolved = resolveCalendar(project); + expect(defaultCal(resolved).bits[2]).toBe(0); + // 5 working days with one mid-week non-working day → start 0, finish 8. + const sched = currentSchedule(resolved).tasks[0]!; + expect(sched.startDay).toBe(0); + expect(sched.finishDay).toBe(8); + }); + + test("exception with `working: null` is treated as non-working", () => { + const wedJan7 = new Date(2026, 0, 7); + const calendar = monFriCalendar(1, [ + { name: "Holiday", fromDate: wedJan7, toDate: wedJan7, working: null }, + ]); + const project = makeProject( + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })], + [calendar], + ); + expect(defaultCal(resolveCalendar(project)).bits[2]).toBe(0); + }); +}); + +describe("resolveCalendar — precedence edges", () => { + test("FS edge with 2-day lag carries lagDays through", () => { + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); + const t2 = makeTask({ + uniqueId: 2, + start: TUE_JAN_13, + finish: new Date(2026, 0, 20), + predecessors: [ + { + predecessorUniqueId: 1, + successorUniqueId: 2, + type: RelationType.FinishToStart, + lag: Duration.from(2, TimeUnit.Days), + }, + ], + }); + const resolved = resolveCalendar(makeProject([t1, t2])); + expect(resolved.precedences[0]).toEqual({ + predecessorUniqueId: 1, + successorUniqueId: 2, + type: RelationType.FinishToStart, + lagDays: 2, + }); + }); + + test("week-unit lag uses minutesPerWeek/minutesPerDay (not hardcoded 5)", () => { + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); + const t2 = makeTask({ + uniqueId: 2, + start: TUE_JAN_13, + finish: new Date(2026, 0, 20), + predecessors: [ + { + predecessorUniqueId: 1, + successorUniqueId: 2, + type: RelationType.StartToStart, + lag: Duration.from(1, TimeUnit.Weeks), + }, + ], + }); + // 480 min/day, 2400 min/week → 5 working days/week. + expect(resolveCalendar(makeProject([t1, t2])).precedences[0]!.lagDays).toBe(5); + }); + + test("week-unit lag honors a 6-day workweek (minutesPerWeek = 2880)", () => { + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); + const t2 = makeTask({ + uniqueId: 2, + start: TUE_JAN_13, + finish: new Date(2026, 0, 20), + predecessors: [ + { + predecessorUniqueId: 1, + successorUniqueId: 2, + type: RelationType.StartToStart, + lag: Duration.from(1, TimeUnit.Weeks), + }, + ], + }); + const project: ProjectFile = { + ...makeProject([t1, t2]), + properties: { + title: null, + author: null, + startDate: null, + finishDate: null, + statusDate: null, + defaultCalendarUniqueId: 1, + minutesPerDay: 480, + minutesPerWeek: 2880, // 6 working days/week + daysPerMonth: 20, + saveVersion: null, + }, + }; + expect(resolveCalendar(project).precedences[0]!.lagDays).toBe(6); + }); +}); + +describe("resolveCalendar — assignments, resources, source preservation", () => { + test("assignments pass through with default units = 1 when null", () => { + const project: ProjectFile = { + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), + assignments: [ + { + taskUniqueId: 1, + resourceUniqueId: 10, + work: null, + units: null, + start: null, + finish: null, + actualWork: null, + remainingWork: null, + }, + ], + }; + expect(resolveCalendar(project).assignments).toEqual([ + { taskUniqueId: 1, resourceUniqueId: 10, units: 1 }, + ]); + }); + + test("capacityPerDay = (maxUnits ?? 1) * (minutesPerDay/60)", () => { + // 480 min/day → 8 hours/day; maxUnits = 2 → capacity = 16. + const project: ProjectFile = { + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), + resources: [ + { + id: 10, + uniqueId: 10, + name: "Crew", + type: "Work" as never, + email: null, + group: null, + maxUnits: 2, + cost: null, + work: null, + resourcePool: null, + }, + ], + }; + expect(resolveCalendar(project).resources).toEqual([ + { uniqueId: 10, capacityPerDay: 16, calendarUniqueId: null }, + ]); + }); + + test("`source` field is the input project (identity)", () => { + const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + expect(resolveCalendar(project).source).toBe(project); + }); +}); + +describe("resolveCalendar — epoch", () => { + test("opts.epoch wins over statusDate and task starts", () => { + const explicitEpoch = new Date(2025, 11, 29); // Mon Dec 29 2025 + const project: ProjectFile = { + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), + properties: { + title: null, + author: null, + startDate: null, + finishDate: null, + statusDate: new Date(2026, 0, 1), + defaultCalendarUniqueId: 1, + minutesPerDay: 480, + minutesPerWeek: 2400, + daysPerMonth: 20, + saveVersion: null, + }, + }; + const resolved = resolveCalendar(project, { epoch: explicitEpoch }); + expect(defaultCal(resolved).epoch.getDate()).toBe(29); + expect(defaultCal(resolved).epoch.getMonth()).toBe(11); + }); + + test("statusDate is used when opts.epoch is absent", () => { + const project: ProjectFile = { + ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), + properties: { + title: null, + author: null, + startDate: null, + finishDate: null, + statusDate: new Date(2025, 11, 30), + defaultCalendarUniqueId: 1, + minutesPerDay: 480, + minutesPerWeek: 2400, + daysPerMonth: 20, + saveVersion: null, + }, + }; + expect(defaultCal(resolveCalendar(project)).epoch.getDate()).toBe(30); + }); + + test("throws when neither opts.epoch, statusDate, startDate, nor task starts exist", () => { + const project: ProjectFile = { + ...makeProject([]), + }; + expect(() => resolveCalendar(project)).toThrow(/cannot pick epoch/); + }); +}); + +describe("resolveCalendar — multi-calendar map", () => { + test("default calendar lives in the map under its uniqueId", () => { + const c1 = monFriCalendar(7); + const project = makeProject( + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })], + [c1], + ); + const resolved = resolveCalendar(project); + expect(resolved.defaultCalendarUniqueId).toBe(7); + expect(resolved.calendars.get(7)).toBeDefined(); + }); + + test("non-default calendars also build in the map", () => { + const c1 = monFriCalendar(1); + const c2 = monFriCalendar(2); + const project = makeProject( + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })], + [c1, c2], + ); + const resolved = resolveCalendar(project); + expect(resolved.calendars.has(1)).toBe(true); + expect(resolved.calendars.has(2)).toBe(true); + }); + + test("missing calendars synthesizes a Mon–Fri fallback", () => { + const project = makeProject( + [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })], + [], + ); + const resolved = resolveCalendar(project); + expect(resolved.defaultCalendarUniqueId).toBeNull(); + expect(resolved.calendars.size).toBe(1); + const cal = resolved.calendars.values().next().value!; + expect(cal.bits[0]).toBe(1); // Mon + expect(cal.bits[5]).toBe(0); // Sat + }); +}); + +describe("resolveCalendar — error paths", () => { + test("task missing start throws", () => { + const broken = makeTask({ uniqueId: 1, start: null, finish: SAT_JAN_10 }); + expect(() => resolveCalendar(makeProject([broken]))).toThrow(/missing start or finish/); + }); + + test("task missing uniqueId throws", () => { + const broken = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); + expect(() => resolveCalendar(makeProject([{ ...broken, uniqueId: null }]))).toThrow( + /missing uniqueId/, + ); + }); +}); + +describe("resolveCalendar — WorkUnit registry", () => { + const task = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); + + test("resolves WorkUnits into a by-id registry", () => { + const resolved = resolveCalendar(makeProject([task]), { + workUnits: [ + { id: 10, taskUniqueIds: [1] }, + { id: 20, taskUniqueIds: [2] }, + ], + }); + expect(resolved.workUnits.size).toBe(2); + expect(resolved.workUnits.get(10)!.taskUniqueIds).toEqual([1]); + }); + + test("defaults to an empty registry when no workUnits are given", () => { + expect(resolveCalendar(makeProject([task])).workUnits.size).toBe(0); + }); + + test("rejects duplicate WorkUnit ids", () => { + expect(() => + resolveCalendar(makeProject([task]), { + workUnits: [ + { id: 10, taskUniqueIds: [1] }, + { id: 10, taskUniqueIds: [2] }, + ], + }), + ).toThrow(/duplicate WorkUnit id 10/); + }); +});