From ba17dc08943f52b35fa21212efddee8aa5117857 Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Sun, 26 Apr 2026 09:00:00 -0600 Subject: [PATCH] feat(level-core): resolveCalendar pipeline entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveCalendar turns a ProjectFile into a ResolvedProject — the entry point of the leveling pipeline. The epoch is opts.epoch ?? statusDate ?? earliest task start, and resolving with no available origin throws rather than silently defaulting. capacityPerDay is derived as work-unit-hours per day; calendars are built into a per-id map with a synthetic Mon–Fri fallback; durations and lags are converted to working-day units from the project's minutes-per-day and minutes-per-week. WorkUnit definitions are resolved into the by-id registry here, and duplicate unit ids are rejected at resolve time so a duplicate cannot diverge the greedy and MiniZinc objectives downstream. --- src/level-core/resolveCalendar.ts | 349 +++++++++++++++++++ test/level-core/resolveCalendar.test.ts | 441 ++++++++++++++++++++++++ 2 files changed, 790 insertions(+) create mode 100644 src/level-core/resolveCalendar.ts create mode 100644 test/level-core/resolveCalendar.test.ts 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/); + }); +}); -- 2.54.0