A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(schema): schema subpath barrel and validation tests

A schema subpath barrel re-exporting every entity schema as a curated
public surface for consumers who want runtime validation, with tests that
exercise parse and round-trip on each schema.

Russ T. Fugal (Mar 9, 2026, 11:30 AM -0600) 06ac22b9 2ac14755

+395
+370
test/schema.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { existsSync } from "node:fs"; 3 + 4 + import { Duration } from "../src/model/Duration.ts"; 5 + import { TimeUnit, RelationType, ResourceType, ConstraintType } from "../src/model/types.ts"; 6 + import { 7 + ProjectFileSchema, 8 + TaskSchema, 9 + ResourceSchema, 10 + AssignmentSchema, 11 + CalendarSchema, 12 + RelationSchema, 13 + DurationSchema, 14 + NullableDurationSchema, 15 + DateStringSchema, 16 + NullableDateStringSchema, 17 + TimeUnitSchema, 18 + RelationTypeSchema, 19 + ResourceTypeSchema, 20 + ConstraintTypeSchema, 21 + } from "../src/schema/index.ts"; 22 + 23 + describe("enum schemas", () => { 24 + test("TimeUnitSchema accepts valid values", () => { 25 + expect(TimeUnitSchema.parse("hours")).toBe(TimeUnit.Hours); 26 + expect(TimeUnitSchema.parse("days")).toBe(TimeUnit.Days); 27 + expect(TimeUnitSchema.parse("minutes")).toBe(TimeUnit.Minutes); 28 + }); 29 + 30 + test("TimeUnitSchema rejects invalid values", () => { 31 + expect(() => TimeUnitSchema.parse("invalid")).toThrow(); 32 + expect(() => TimeUnitSchema.parse(42)).toThrow(); 33 + }); 34 + 35 + test("RelationTypeSchema accepts valid values", () => { 36 + expect(RelationTypeSchema.parse("FS")).toBe(RelationType.FinishToStart); 37 + expect(RelationTypeSchema.parse("SS")).toBe(RelationType.StartToStart); 38 + expect(RelationTypeSchema.parse("FF")).toBe(RelationType.FinishToFinish); 39 + expect(RelationTypeSchema.parse("SF")).toBe(RelationType.StartToFinish); 40 + }); 41 + 42 + test("RelationTypeSchema rejects invalid values", () => { 43 + expect(() => RelationTypeSchema.parse("XX")).toThrow(); 44 + }); 45 + 46 + test("ResourceTypeSchema accepts valid values", () => { 47 + expect(ResourceTypeSchema.parse("Work")).toBe(ResourceType.Work); 48 + expect(ResourceTypeSchema.parse("Material")).toBe(ResourceType.Material); 49 + expect(ResourceTypeSchema.parse("Cost")).toBe(ResourceType.Cost); 50 + }); 51 + 52 + test("ConstraintTypeSchema accepts valid values", () => { 53 + expect(ConstraintTypeSchema.parse("ASAP")).toBe(ConstraintType.AsSoonAsPossible); 54 + expect(ConstraintTypeSchema.parse("MFO")).toBe(ConstraintType.MustFinishOn); 55 + }); 56 + 57 + test("ConstraintTypeSchema rejects invalid values", () => { 58 + expect(() => ConstraintTypeSchema.parse("INVALID")).toThrow(); 59 + }); 60 + }); 61 + 62 + describe("DateStringSchema", () => { 63 + test("transforms valid date string to Date", () => { 64 + const result = DateStringSchema.parse("2024-06-15T09:00:00"); 65 + expect(result).toBeInstanceOf(Date); 66 + expect(result.getFullYear()).toBe(2024); 67 + }); 68 + 69 + test("rejects invalid date string", () => { 70 + expect(() => DateStringSchema.parse("not-a-date")).toThrow(); 71 + }); 72 + 73 + test("NullableDateStringSchema handles null", () => { 74 + expect(NullableDateStringSchema.parse(null)).toBeNull(); 75 + }); 76 + 77 + test("NullableDateStringSchema handles empty string", () => { 78 + expect(NullableDateStringSchema.parse("")).toBeNull(); 79 + }); 80 + 81 + test("NullableDateStringSchema transforms valid string", () => { 82 + const result = NullableDateStringSchema.parse("2024-01-01T00:00:00"); 83 + expect(result).toBeInstanceOf(Date); 84 + }); 85 + }); 86 + 87 + describe("DurationSchema", () => { 88 + test("transforms to Duration instance", () => { 89 + const result = DurationSchema.parse({ duration: 8, units: "hours" }); 90 + expect(result).toBeInstanceOf(Duration); 91 + expect(result.value).toBe(8); 92 + expect(result.unit).toBe(TimeUnit.Hours); 93 + }); 94 + 95 + test("rejects invalid units", () => { 96 + expect(() => DurationSchema.parse({ duration: 8, units: "invalid" })).toThrow(); 97 + }); 98 + 99 + test("rejects missing fields", () => { 100 + expect(() => DurationSchema.parse({ duration: 8 })).toThrow(); 101 + expect(() => DurationSchema.parse({ units: "hours" })).toThrow(); 102 + }); 103 + 104 + test("NullableDurationSchema handles null", () => { 105 + expect(NullableDurationSchema.parse(null)).toBeNull(); 106 + }); 107 + 108 + test("NullableDurationSchema transforms valid object", () => { 109 + const result = NullableDurationSchema.parse({ duration: 5, units: "days" }); 110 + expect(result).toBeInstanceOf(Duration); 111 + expect(result!.value).toBe(5); 112 + }); 113 + }); 114 + 115 + describe("RelationSchema", () => { 116 + test("parses a valid relation", () => { 117 + const result = RelationSchema.parse({ 118 + predecessorUniqueId: 1, 119 + successorUniqueId: 2, 120 + type: "FS", 121 + lag: { duration: 1, units: "days" }, 122 + }); 123 + expect(result.predecessorUniqueId).toBe(1); 124 + expect(result.type).toBe(RelationType.FinishToStart); 125 + expect(result.lag).toBeInstanceOf(Duration); 126 + }); 127 + 128 + test("accepts null lag", () => { 129 + const result = RelationSchema.parse({ 130 + predecessorUniqueId: 1, 131 + successorUniqueId: 2, 132 + type: "SS", 133 + lag: null, 134 + }); 135 + expect(result.lag).toBeNull(); 136 + }); 137 + 138 + test("rejects invalid relation type", () => { 139 + expect(() => 140 + RelationSchema.parse({ 141 + predecessorUniqueId: 1, 142 + successorUniqueId: 2, 143 + type: "INVALID", 144 + lag: null, 145 + }), 146 + ).toThrow(); 147 + }); 148 + }); 149 + 150 + describe("TaskSchema", () => { 151 + test("parses a full task", () => { 152 + const result = TaskSchema.parse({ 153 + id: 1, 154 + uniqueId: 1, 155 + name: "Task A", 156 + wbs: "1.1", 157 + outlineLevel: 2, 158 + start: "2024-06-01T08:00:00", 159 + finish: "2024-06-01T17:00:00", 160 + duration: { duration: 8, units: "hours" }, 161 + percentComplete: 50, 162 + summary: false, 163 + milestone: false, 164 + critical: true, 165 + notes: "Some notes", 166 + priority: 500, 167 + cost: 1000, 168 + work: { duration: 8, units: "hours" }, 169 + actualStart: null, 170 + actualFinish: null, 171 + baselineStart: null, 172 + baselineFinish: null, 173 + baselineDuration: null, 174 + actualWork: null, 175 + constraintType: "ASAP", 176 + freeSlack: { duration: 0, units: "minutes" }, 177 + totalSlack: { duration: 480, units: "minutes" }, 178 + earlyStart: "2024-06-01T08:00:00", 179 + earlyFinish: "2024-06-01T17:00:00", 180 + lateStart: null, 181 + lateFinish: null, 182 + levelingDelay: null, 183 + deadline: null, 184 + splits: null, 185 + predecessors: [{ predecessorUniqueId: 0, successorUniqueId: 1, type: "FS", lag: null }], 186 + }); 187 + expect(result.name).toBe("Task A"); 188 + expect(result.start).toBeInstanceOf(Date); 189 + expect(result.duration).toBeInstanceOf(Duration); 190 + expect(result.duration!.value).toBe(8); 191 + expect(result.freeSlack).toBeInstanceOf(Duration); 192 + expect(result.predecessors.length).toBe(1); 193 + expect(result.constraintType).toBe(ConstraintType.AsSoonAsPossible); 194 + }); 195 + 196 + test("rejects missing required fields", () => { 197 + expect(() => TaskSchema.parse({ id: 1 })).toThrow(); 198 + }); 199 + 200 + test("parses task with all nulls", () => { 201 + const result = TaskSchema.parse({ 202 + id: null, 203 + uniqueId: null, 204 + name: null, 205 + wbs: null, 206 + outlineLevel: null, 207 + start: null, 208 + finish: null, 209 + duration: null, 210 + percentComplete: null, 211 + summary: null, 212 + milestone: null, 213 + critical: null, 214 + notes: null, 215 + priority: null, 216 + cost: null, 217 + work: null, 218 + actualStart: null, 219 + actualFinish: null, 220 + baselineStart: null, 221 + baselineFinish: null, 222 + baselineDuration: null, 223 + actualWork: null, 224 + constraintType: null, 225 + freeSlack: null, 226 + totalSlack: null, 227 + earlyStart: null, 228 + earlyFinish: null, 229 + lateStart: null, 230 + lateFinish: null, 231 + levelingDelay: null, 232 + deadline: null, 233 + splits: null, 234 + predecessors: [], 235 + }); 236 + expect(result.id).toBeNull(); 237 + expect(result.duration).toBeNull(); 238 + expect(result.start).toBeNull(); 239 + }); 240 + }); 241 + 242 + describe("ResourceSchema", () => { 243 + test("parses a valid resource", () => { 244 + const result = ResourceSchema.parse({ 245 + id: 1, 246 + uniqueId: 1, 247 + name: "Engineer", 248 + type: "Work", 249 + email: "eng@example.com", 250 + group: "Dev", 251 + maxUnits: 100, 252 + cost: 500, 253 + work: { duration: 40, units: "hours" }, 254 + resourcePool: null, 255 + }); 256 + expect(result.name).toBe("Engineer"); 257 + expect(result.work).toBeInstanceOf(Duration); 258 + }); 259 + 260 + test("rejects invalid resource type", () => { 261 + expect(() => 262 + ResourceSchema.parse({ 263 + id: 1, 264 + uniqueId: 1, 265 + name: "X", 266 + type: "Invalid", 267 + email: null, 268 + group: null, 269 + maxUnits: null, 270 + cost: null, 271 + work: null, 272 + resourcePool: null, 273 + }), 274 + ).toThrow(); 275 + }); 276 + }); 277 + 278 + describe("AssignmentSchema", () => { 279 + test("parses a valid assignment", () => { 280 + const result = AssignmentSchema.parse({ 281 + taskUniqueId: 1, 282 + resourceUniqueId: 2, 283 + work: { duration: 16, units: "hours" }, 284 + units: 100, 285 + start: "2024-06-01T08:00:00", 286 + finish: "2024-06-02T17:00:00", 287 + actualWork: { duration: 8, units: "hours" }, 288 + remainingWork: { duration: 8, units: "hours" }, 289 + }); 290 + expect(result.work).toBeInstanceOf(Duration); 291 + expect(result.actualWork).toBeInstanceOf(Duration); 292 + expect(result.start).toBeInstanceOf(Date); 293 + }); 294 + }); 295 + 296 + describe("CalendarSchema", () => { 297 + test("parses a valid calendar", () => { 298 + const result = CalendarSchema.parse({ 299 + uniqueId: 1, 300 + name: "Standard", 301 + weekDays: [ 302 + { 303 + dayType: 2, 304 + working: true, 305 + workingTimes: [ 306 + { from: "08:00:00", to: "12:00:00" }, 307 + { from: "13:00:00", to: "17:00:00" }, 308 + ], 309 + }, 310 + ], 311 + exceptions: [ 312 + { 313 + name: "Holiday", 314 + fromDate: "2024-12-25T00:00:00", 315 + toDate: "2024-12-25T23:59:59", 316 + working: false, 317 + }, 318 + ], 319 + }); 320 + expect(result.name).toBe("Standard"); 321 + expect(result.weekDays.length).toBe(1); 322 + expect(result.weekDays[0]!.workingTimes.length).toBe(2); 323 + expect(result.exceptions[0]!.fromDate).toBeInstanceOf(Date); 324 + }); 325 + }); 326 + 327 + describe("ProjectFileSchema", () => { 328 + test("parses a minimal project", () => { 329 + const result = ProjectFileSchema.parse({ 330 + properties: { 331 + title: "Test", 332 + author: null, 333 + startDate: "2024-01-01T00:00:00", 334 + finishDate: null, 335 + statusDate: null, 336 + defaultCalendarUniqueId: null, 337 + minutesPerDay: 480, 338 + minutesPerWeek: 2400, 339 + daysPerMonth: 20, 340 + saveVersion: 14, 341 + }, 342 + tasks: [], 343 + resources: [], 344 + assignments: [], 345 + calendars: [], 346 + }); 347 + expect(result.properties.title).toBe("Test"); 348 + expect(result.properties.startDate).toBeInstanceOf(Date); 349 + expect(result.tasks).toEqual([]); 350 + }); 351 + 352 + test("rejects completely invalid input", () => { 353 + expect(() => ProjectFileSchema.parse("not an object")).toThrow(); 354 + expect(() => ProjectFileSchema.parse(42)).toThrow(); 355 + expect(() => ProjectFileSchema.parse(null)).toThrow(); 356 + }); 357 + 358 + test.skipIf(!existsSync("test/sample-schedule.mpp"))( 359 + "validates JSON round-trip from JsonWriter", 360 + async () => { 361 + const { readMpp, writeJson } = await import("../src/index.ts"); 362 + const data = await Bun.file("test/sample-schedule.mpp").arrayBuffer(); 363 + const project = readMpp(data); 364 + const json = writeJson(project); 365 + const parsed = JSON.parse(json) as unknown; 366 + const result = ProjectFileSchema.safeParse(parsed); 367 + expect(result.success).toBe(true); 368 + }, 369 + ); 370 + });
+25
src/schema/index.ts
··· 1 + export { ProjectFileSchema, ProjectPropertiesSchema } from "./project.ts"; 2 + export type { ProjectFile, ProjectProperties } from "./project.ts"; 3 + export { TaskSchema } from "./task.ts"; 4 + export type { Task } from "./task.ts"; 5 + export { ResourceSchema } from "./resource.ts"; 6 + export type { Resource } from "./resource.ts"; 7 + export { AssignmentSchema } from "./assignment.ts"; 8 + export type { Assignment } from "./assignment.ts"; 9 + export { 10 + CalendarSchema, 11 + CalendarWeekDaySchema, 12 + CalendarExceptionSchema, 13 + WorkingTimeRangeSchema, 14 + } from "./calendar.ts"; 15 + export type { Calendar, CalendarWeekDay, CalendarException, WorkingTimeRange } from "./calendar.ts"; 16 + export { RelationSchema } from "./relation.ts"; 17 + export type { Relation } from "./relation.ts"; 18 + export { DurationSchema, DurationRawSchema, NullableDurationSchema } from "./duration.ts"; 19 + export { DateStringSchema, NullableDateStringSchema } from "./date.ts"; 20 + export { 21 + TimeUnitSchema, 22 + RelationTypeSchema, 23 + ResourceTypeSchema, 24 + ConstraintTypeSchema, 25 + } from "./types.ts";