diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 2413761..c7b2caf 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,11 +1,12 @@ { - // Matches the Prettier settings this replaces. printWidth in particular: - // oxfmt defaults to 80, which would reflow every file in the repo for no - // reason. + // Aligned with the downstream workspace's .oxfmtrc.json, which formats this + // library as packages/levelset when it is vendored there. printWidth was 100 + // (carried from the Prettier config oxfmt replaced); at 80 a `bun run format` + // in that workspace no longer reflows the vendored copy. "semi": true, "singleQuote": false, "trailingComma": "all", - "printWidth": 100, + "printWidth": 80, "tabWidth": 2, // Off, as `oxfmt --migrate=prettier` sets it. oxfmt would otherwise reorder // package.json's keys and sort the keywords array — churn Prettier never diff --git a/.oxlintrc.json b/.oxlintrc.json index 85c2512..7cd4890 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -123,7 +123,10 @@ "react/no-unsafe": "off", "react/react-in-jsx-scope": "off", "import/no-anonymous-default-export": "warn", - "jsx-a11y/alt-text": ["warn", { "elements": ["img"], "img": ["Image"] }], + "jsx-a11y/alt-text": [ + "warn", + { "elements": ["img"], "img": ["Image"] } + ], "jsx-a11y/aria-props": "warn", "jsx-a11y/aria-proptypes": "warn", "jsx-a11y/aria-unsupported-elements": "warn", @@ -167,7 +170,10 @@ // packages/config/oxlint.base.jsonc, which a test there pins. "files": ["**/*.ts", "**/*.tsx"], "rules": { - "no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "ignoreRestSiblings": true }], + "no-unused-vars": [ + "warn", + { "argsIgnorePattern": "^_", "ignoreRestSiblings": true } + ], "typescript/consistent-type-imports": [ "warn", { "prefer": "type-imports", "fixStyle": "inline-type-imports" } diff --git a/README.md b/README.md index 8340e4c..0e0af33 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,15 @@ bun install ## Usage ```ts -import { readMpp, readMspdi, readJson, writeMspdi, writeJson, writeCsv, writeXlsx } from "levelset"; +import { + readMpp, + readMspdi, + readJson, + writeMspdi, + writeJson, + writeCsv, + writeXlsx, +} from "levelset"; // Read a binary MPP buffer (MPP14+ / Microsoft Project 2010+) // Accepts Uint8Array or ArrayBuffer — no filesystem access required @@ -35,7 +43,10 @@ const xml = writeMspdi(project); // Write CSV (leaf tasks by default, with resource names from assignments) const csv = writeCsv(project); -const csvAll = writeCsv(project, { includeSummaryTasks: true, includeResources: false }); +const csvAll = writeCsv(project, { + includeSummaryTasks: true, + includeResources: false, +}); // Write Excel (returns a Uint8Array) const xlsx = await writeXlsx(project); @@ -146,7 +157,12 @@ ProjectFile ──resolveCalendar──▶ ResolvedProject ──serialSGS.run(c - **`materialize`** writes a chosen `Schedule` back into a `ProjectFile` (round-tripping dates), ready for `writeMspdi` / `writeXlsx` / etc. ```ts -import { resolveCalendar, serialSGS, streamFromFactory, materialize } from "levelset/leveling"; +import { + resolveCalendar, + serialSGS, + streamFromFactory, + materialize, +} from "levelset/leveling"; import type { Constraint, Scorer } from "levelset/leveling"; // 1. Resolve calendars → day-indexed working time @@ -170,7 +186,11 @@ const constraints: Constraint[] = [ const stream = streamFromFactory(() => serialSGS.run(resolved, constraints)); // 4. Pick the best by a scorer -const makespan: Scorer = { name: "makespan", direction: "min", score: (s) => s.makespan }; +const makespan: Scorer = { + name: "makespan", + direction: "min", + score: (s) => s.makespan, +}; const best = await stream.bestBy(makespan); // 5. Materialize back to a ProjectFile (then write it out however you like) @@ -222,7 +242,11 @@ import { OpenUnitPenaltyBlock } from "levelset/leveling"; // Soft WIP limit: leave 2 units open for free, then penalize each extra // open-unit-day. Unit definitions live in ResolveOptions.workUnits; blocks // reference them by id. -const wipPenalty = OpenUnitPenaltyBlock.apply({ unitIds: [10, 20], softMax: 2, weight: 50 }); +const wipPenalty = OpenUnitPenaltyBlock.apply({ + unitIds: [10, 20], + softMax: 2, + weight: 50, +}); const best = await stream.bestBy(wipPenalty); ``` @@ -289,7 +313,10 @@ import { runFromConfig, standardRegistry } from "levelset/leveling"; const registry = standardRegistry(); const parsed = parseConf(text, { registry }); -if (!parsed.ok) throw new Error(parsed.issues.map((i) => `[${i.code}] ${i.message}`).join("\n")); +if (!parsed.ok) + throw new Error( + parsed.issues.map((i) => `[${i.code}] ${i.message}`).join("\n"), + ); const run = runFromConfig(project, parsed.config, { registry }); const canonical = serializeConf(run.config); // the filled config, as .conf text ``` diff --git a/src/conf/index.ts b/src/conf/index.ts index e7cd5e5..7fc0e02 100644 --- a/src/conf/index.ts +++ b/src/conf/index.ts @@ -17,4 +17,8 @@ export { type ParseConfResult, type ParseConfOptions, } from "./parse.ts"; -export { serializeConf, lintRegistryForConf, type SerializeConfOptions } from "./serialize.ts"; +export { + serializeConf, + lintRegistryForConf, + type SerializeConfOptions, +} from "./serialize.ts"; diff --git a/src/conf/parse.ts b/src/conf/parse.ts index b1c2294..9699878 100644 --- a/src/conf/parse.ts +++ b/src/conf/parse.ts @@ -35,7 +35,12 @@ export interface ParseConfOptions { /** Keys peeled off a `[blocks.]` table as instance fields rather than * params. A block whose schema declares a param of one of these names can't * round-trip through conf (the reserved reading always wins) — E305. */ -export const RESERVED_BLOCK_KEYS = ["code", "enabled", "provenance", "shadow"] as const; +export const RESERVED_BLOCK_KEYS = [ + "code", + "enabled", + "provenance", + "shadow", +] as const; const ALLOWED_SECTIONS = new Set([ "levelset", @@ -93,7 +98,10 @@ interface BlockInstanceDraft { function isPlainObject(value: unknown): value is Record { return ( - typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !(value instanceof Date) ); } @@ -165,7 +173,10 @@ function scanValues(value: unknown, path: string, issues: ConfIssue[]): void { /** Translate a zod issue path (over the assembled RunConfig) into a * conf-oriented dotted path, so a param error reads `blocks..max` * rather than `pipeline.blocks.0.params.max`. */ -function confPath(path: readonly PropertyKey[], blocks: readonly { instanceId: string }[]): string { +function confPath( + path: readonly PropertyKey[], + blocks: readonly { instanceId: string }[], +): string { const seg = path.map(String); if (seg[0] === "pipeline" && seg[1] === "blocks") { const idx = Number(seg[2]); @@ -184,7 +195,10 @@ function confPath(path: readonly PropertyKey[], blocks: readonly { instanceId: s * the text is a well-formed config, else `{ ok: false, issues }`. Pass a * registry to additionally validate the graph and its scorer references. */ -export function parseConf(text: string, opts?: ParseConfOptions): ParseConfResult { +export function parseConf( + text: string, + opts?: ParseConfOptions, +): ParseConfResult { let doc: ConfDoc; try { doc = parseToml(text); @@ -216,7 +230,11 @@ export function parseConf(text: string, opts?: ParseConfOptions): ParseConfResul // Strict top level: unknown sections are errors. for (const key of Object.keys(doc)) { if (!ALLOWED_SECTIONS.has(key)) { - issues.push({ code: "LEVELSET_E302", path: key, message: unknownSectionMessage(key) }); + issues.push({ + code: "LEVELSET_E302", + path: key, + message: unknownSectionMessage(key), + }); } } @@ -254,7 +272,8 @@ export function parseConf(text: string, opts?: ParseConfOptions): ParseConfResul // so the `format` marker's own validation (E303) is the sole authority and // zod never double-reports it. const assembled: AssembledConfig = { formatVersion: 1 }; - if (levelsetTbl?.version !== undefined) assembled.levelset = levelsetTbl.version; + if (levelsetTbl?.version !== undefined) + assembled.levelset = levelsetTbl.version; if (levelsetTbl?.seed !== undefined) assembled.seed = levelsetTbl.seed; if (doc.resolve !== undefined) assembled.resolve = doc.resolve; @@ -325,7 +344,9 @@ export function parseConf(text: string, opts?: ParseConfOptions): ParseConfResul } assembled.pipeline = { formatVersion: 1, - ...(pipelineTbl?.systemCode !== undefined ? { systemCode: pipelineTbl.systemCode } : {}), + ...(pipelineTbl?.systemCode !== undefined + ? { systemCode: pipelineTbl.systemCode } + : {}), blocks, }; @@ -333,7 +354,11 @@ export function parseConf(text: string, opts?: ParseConfOptions): ParseConfResul if (doc.weights !== undefined) { const wTbl = isPlainObject(doc.weights) ? doc.weights : undefined; if (!wTbl) { - issues.push({ code: "LEVELSET_E302", path: "weights", message: `[weights] must be a table` }); + issues.push({ + code: "LEVELSET_E302", + path: "weights", + message: `[weights] must be a table`, + }); } else { const weights: Record = {}; for (const [name, value] of Object.entries(wTbl)) { @@ -376,7 +401,10 @@ export function parseConf(text: string, opts?: ParseConfOptions): ParseConfResul if (opts?.registry) { const validated = validateGraph(opts.registry, config.pipeline); if (!validated.ok) { - return { ok: false, issues: validated.issues.map((i) => graphIssueToConf(i)) }; + return { + ok: false, + issues: validated.issues.map((i) => graphIssueToConf(i)), + }; } const refIssues = checkRunConfigRefs(config, opts.registry, validated); if (refIssues.length > 0) { @@ -400,6 +428,8 @@ function graphIssueToConf(issue: { code: "LEVELSET_E308", message: issue.message, ...(issue.instanceId !== undefined ? { instanceId: issue.instanceId } : {}), - ...(issue.path !== undefined ? { path: issue.path.map(String).join(".") } : {}), + ...(issue.path !== undefined + ? { path: issue.path.map(String).join(".") } + : {}), }; } diff --git a/src/conf/serialize.ts b/src/conf/serialize.ts index 3518e13..33ef5d7 100644 --- a/src/conf/serialize.ts +++ b/src/conf/serialize.ts @@ -128,7 +128,10 @@ function emitTable( /** Substitute `{param}` placeholders in an assumptionTemplate with the block's * param values; unresolved placeholders are left as-is. */ -function renderAssumption(template: string, params: Record): string { +function renderAssumption( + template: string, + params: Record, +): string { return template.replace(/\{([A-Za-z0-9_.]+)\}/g, (whole, path: string) => { let cur: unknown = params; for (const seg of path.split(".")) { @@ -143,7 +146,10 @@ function renderAssumption(template: string, params: Record): st }); } -function serializeBlock(instance: BlockInstance, annotate: BlockRegistry | undefined): string[] { +function serializeBlock( + instance: BlockInstance, + annotate: BlockRegistry | undefined, +): string[] { const lead: string[] = [`code = ${tomlString(instance.code)}`]; // Reserved flags, in canonical order; only the non-default values. if (instance.enabled === false) lead.push(`enabled = false`); @@ -153,7 +159,13 @@ function serializeBlock(instance: BlockInstance, annotate: BlockRegistry | undef if (instance.shadow === true) lead.push(`shadow = true`); const sections: string[] = []; - emitTable(["blocks", instance.instanceId], lead, instance.params, false, sections); + emitTable( + ["blocks", instance.instanceId], + lead, + instance.params, + false, + sections, + ); const def = annotate?.get(instance.code); if (def?.assumptionTemplate !== undefined && sections.length > 0) { @@ -169,7 +181,10 @@ function serializeBlock(instance: BlockInstance, annotate: BlockRegistry | undef * are omitted. Pass `annotate` to comment each block with its rendered * assumption, or `header` for top-of-file comment lines. */ -export function serializeConf(config: RunConfig, opts?: SerializeConfOptions): string { +export function serializeConf( + config: RunConfig, + opts?: SerializeConfOptions, +): string { const sections: string[] = []; if (opts?.header && opts.header.length > 0) { @@ -178,8 +193,10 @@ export function serializeConf(config: RunConfig, opts?: SerializeConfOptions): s // [levelset] const levelsetLines = [`format = 1`]; - if (config.levelset !== undefined) levelsetLines.push(`version = ${tomlString(config.levelset)}`); - if (config.seed !== undefined) levelsetLines.push(`seed = ${tomlScalar(config.seed)}`); + if (config.levelset !== undefined) + levelsetLines.push(`version = ${tomlString(config.levelset)}`); + if (config.seed !== undefined) + levelsetLines.push(`seed = ${tomlScalar(config.seed)}`); sections.push([`[levelset]`, ...levelsetLines].join("\n")); // [resolve] @@ -190,7 +207,10 @@ export function serializeConf(config: RunConfig, opts?: SerializeConfOptions): s // [pipeline] — systemCode only; formatVersion is implied by [levelset]. if (config.pipeline.systemCode !== undefined) { sections.push( - [`[pipeline]`, `systemCode = ${tomlString(config.pipeline.systemCode)}`].join("\n"), + [ + `[pipeline]`, + `systemCode = ${tomlString(config.pipeline.systemCode)}`, + ].join("\n"), ); } @@ -202,7 +222,8 @@ export function serializeConf(config: RunConfig, opts?: SerializeConfOptions): s // [weights] if (config.weights !== undefined) { const lines = Object.entries(config.weights).map( - ([name, weight]) => `${tomlKey(name)} = ${weight === null ? '"report"' : tomlScalar(weight)}`, + ([name, weight]) => + `${tomlKey(name)} = ${weight === null ? '"report"' : tomlScalar(weight)}`, ); sections.push([`[weights]`, ...lines].join("\n")); } @@ -210,7 +231,10 @@ export function serializeConf(config: RunConfig, opts?: SerializeConfOptions): s // [objective] if (config.objective !== undefined) { sections.push( - [`[objective]`, `lexicographic = ${inlineArray(config.objective.lexicographic)}`].join("\n"), + [ + `[objective]`, + `lexicographic = ${inlineArray(config.objective.lexicographic)}`, + ].join("\n"), ); } @@ -267,7 +291,9 @@ export function lintRegistryForConf(registry: BlockRegistry): ConfIssue[] { } let schema: SchemaNode; try { - schema = z.toJSONSchema(def.block.schema.input, { io: "input" }) as SchemaNode; + schema = z.toJSONSchema(def.block.schema.input, { + io: "input", + }) as SchemaNode; } catch { issues.push({ code: "LEVELSET_E302", @@ -286,7 +312,10 @@ export function lintRegistryForConf(registry: BlockRegistry): ConfIssue[] { // a non-bare reserved key would need quoting everywhere). for (const key of RESERVED_BLOCK_KEYS) { if (!BARE_KEY.test(key)) { - issues.push({ code: "LEVELSET_E302", message: `reserved key "${key}" is not bare-safe` }); + issues.push({ + code: "LEVELSET_E302", + message: `reserved key "${key}" is not bare-safe`, + }); } } return issues; diff --git a/src/csv/CsvWriter.ts b/src/csv/CsvWriter.ts index 8e7853c..3ccfcfa 100644 --- a/src/csv/CsvWriter.ts +++ b/src/csv/CsvWriter.ts @@ -25,7 +25,9 @@ export class CsvWriter { const includeSummary = options?.includeSummaryTasks === true; const includeResources = options?.includeResources !== false; - const headers = includeResources ? [...HEADERS_BASE, "Resources"] : [...HEADERS_BASE]; + const headers = includeResources + ? [...HEADERS_BASE, "Resources"] + : [...HEADERS_BASE]; const resourceNames = new Map(); for (const resource of project.resources) { @@ -36,7 +38,11 @@ export class CsvWriter { const taskResources = new Map(); for (const assignment of project.assignments) { - if (assignment.taskUniqueId === null || assignment.resourceUniqueId === null) continue; + if ( + assignment.taskUniqueId === null || + assignment.resourceUniqueId === null + ) + continue; const name = resourceNames.get(assignment.resourceUniqueId); if (!name) continue; const existing = taskResources.get(assignment.taskUniqueId); @@ -67,7 +73,8 @@ export class CsvWriter { ]; if (includeResources) { - const names = task.uniqueId !== null ? taskResources.get(task.uniqueId) : undefined; + const names = + task.uniqueId !== null ? taskResources.get(task.uniqueId) : undefined; fields.push(names ? names.join(",") : null); } @@ -93,7 +100,12 @@ function escapeField(value: unknown): string { if (s.length > 0 && FORMULA_TRIGGERS.has(s[0]!)) { s = "'" + s; } - if (s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r")) { + if ( + s.includes(",") || + s.includes('"') || + s.includes("\n") || + s.includes("\r") + ) { return `"${s.replace(/"/g, '""')}"`; } return s; diff --git a/src/dateTime.ts b/src/dateTime.ts index 836a8a6..0328645 100644 --- a/src/dateTime.ts +++ b/src/dateTime.ts @@ -4,7 +4,9 @@ export function parseProjectDate(raw: string | null | undefined): Date | null { return Number.isNaN(date.getTime()) ? null : date; } -export function formatProjectDate(value: Date | null | undefined): string | null { +export function formatProjectDate( + value: Date | null | undefined, +): string | null { if (!value) { return null; } diff --git a/src/exceljs.d.ts b/src/exceljs.d.ts index 4ab6dd2..422aa91 100644 --- a/src/exceljs.d.ts +++ b/src/exceljs.d.ts @@ -2,7 +2,10 @@ import "exceljs"; declare module "exceljs" { interface Xlsx { - load(data: Uint8Array | ArrayBuffer, options?: Partial): Promise; + load( + data: Uint8Array | ArrayBuffer, + options?: Partial, + ): Promise; writeBuffer(): Promise; } } diff --git a/src/fixture.ts b/src/fixture.ts index f1201f4..5b49723 100644 --- a/src/fixture.ts +++ b/src/fixture.ts @@ -6,7 +6,11 @@ import type { Relation } from "./model/Relation.ts"; import type { Resource } from "./model/Resource.ts"; import type { Task } from "./model/Task.ts"; import { parseProjectDate } from "./dateTime.ts"; -import { RelationType, ResourceType, type ProjectProperties } from "./model/types.ts"; +import { + RelationType, + ResourceType, + type ProjectProperties, +} from "./model/types.ts"; interface FixtureProjectData { project: { @@ -92,7 +96,9 @@ export function projectFromFixtureJson(json: string): ProjectFile { return projectFromFixtureData(JSON.parse(json) as FixtureProjectData); } -function mapProperties(input: FixtureProjectData["project"]): ProjectProperties { +function mapProperties( + input: FixtureProjectData["project"], +): ProjectProperties { return { title: input.title, author: input.author, @@ -147,7 +153,10 @@ function mapTask(input: FixtureTask): Task { }; } -function mapRelation(input: FixtureRelation, successorUniqueId: number | null): Relation { +function mapRelation( + input: FixtureRelation, + successorUniqueId: number | null, +): Relation { return { predecessorUniqueId: input.predecessor_unique_id, successorUniqueId, diff --git a/src/index.ts b/src/index.ts index beefd44..7d65418 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,10 @@ export function readMspdi(xml: string): ProjectFile { return new MspdiReader().read(xml); } -export function writeMspdi(project: ProjectFile, options?: { saveVersion?: number }): string { +export function writeMspdi( + project: ProjectFile, + options?: { saveVersion?: number }, +): string { return new MspdiWriter().write(project, options); } @@ -25,11 +28,17 @@ export function readJson(json: string): ProjectFile { return new JsonReader().read(json); } -export function writeJson(project: ProjectFile, options?: { pretty?: boolean }): string { +export function writeJson( + project: ProjectFile, + options?: { pretty?: boolean }, +): string { return new JsonWriter().write(project, options); } -export function writeCsv(project: ProjectFile, options?: CsvWriterOptions): string { +export function writeCsv( + project: ProjectFile, + options?: CsvWriterOptions, +): string { return new CsvWriter().write(project, options); } @@ -69,7 +78,13 @@ export type { export type { MspdiWriterOptions } from "./mspdi/MspdiWriter.ts"; // Enums -export { TimeUnit, RelationType, ResourceType, ConstraintType, Priority } from "./model/types.ts"; +export { + TimeUnit, + RelationType, + ResourceType, + ConstraintType, + Priority, +} from "./model/types.ts"; // Date utilities export { parseProjectDate, formatProjectDate } from "./dateTime.ts"; diff --git a/src/json/JsonReader.ts b/src/json/JsonReader.ts index 04e810e..86f6807 100644 --- a/src/json/JsonReader.ts +++ b/src/json/JsonReader.ts @@ -332,7 +332,9 @@ function parseResourceType(raw: string | undefined): ResourceType { } } -function parseConstraintType(raw: string | null | undefined): ConstraintType | null { +function parseConstraintType( + raw: string | null | undefined, +): ConstraintType | null { if (!raw) { return null; } diff --git a/src/level-blocks/compile.ts b/src/level-blocks/compile.ts index 756666f..0bd4877 100644 --- a/src/level-blocks/compile.ts +++ b/src/level-blocks/compile.ts @@ -27,10 +27,16 @@ export interface CompileTarget { } export class GraphValidationError extends Error { - readonly issues: readonly { instanceId?: string; code?: string; message: string }[]; + readonly issues: readonly { + instanceId?: string; + code?: string; + message: string; + }[]; constructor(issues: GraphValidationError["issues"]) { - super(`Pipeline graph failed validation:\n${issues.map((i) => `- ${i.message}`).join("\n")}`); + super( + `Pipeline graph failed validation:\n${issues.map((i) => `- ${i.message}`).join("\n")}`, + ); this.name = "GraphValidationError"; this.issues = issues; } @@ -54,7 +60,9 @@ export function compileGraph( // params (defaults/transforms applied) — no second parse here. const def = registry.get(instance.code)!; const params = validated.parsedParams.get(instance.instanceId); - const output: unknown = (def.block as { apply(input: unknown): unknown }).apply(params); + const output: unknown = ( + def.block as { apply(input: unknown): unknown } + ).apply(params); const compiled: CompiledBlock = { def, instance, output }; switch (def.family) { diff --git a/src/level-blocks/composition.ts b/src/level-blocks/composition.ts index 3a2bbcd..13c4e50 100644 --- a/src/level-blocks/composition.ts +++ b/src/level-blocks/composition.ts @@ -14,7 +14,12 @@ import type { BlockFamily } from "./registry.ts"; /** Canonical band order — the reading order a stack is laid out in. */ -export const FAMILY_ORDER: readonly BlockFamily[] = ["constraint", "scorer", "search", "collector"]; +export const FAMILY_ORDER: readonly BlockFamily[] = [ + "constraint", + "scorer", + "search", + "collector", +]; /** Families allowed to sit directly below each family in a stack. The * strict-checker reading (null never matches non-null, otherwise @@ -64,7 +69,9 @@ export function sortIntoBandOrder( ): T[] { const rank = (item: T): number => { const family = familyOf(item); - return family === undefined ? FAMILY_ORDER.length : FAMILY_ORDER.indexOf(family); + return family === undefined + ? FAMILY_ORDER.length + : FAMILY_ORDER.indexOf(family); }; return [...items] .map((item, index) => ({ item, index })) diff --git a/src/level-blocks/concurrentResourceCost.ts b/src/level-blocks/concurrentResourceCost.ts index 7e97022..a0475c9 100644 --- a/src/level-blocks/concurrentResourceCost.ts +++ b/src/level-blocks/concurrentResourceCost.ts @@ -40,14 +40,17 @@ export const ConcurrentResourceCostInputSchema = z.object({ threshold: z.number().int().nonnegative().default(0), }); -export type ConcurrentResourceCostInput = z.infer; +export type ConcurrentResourceCostInput = z.infer< + typeof ConcurrentResourceCostInputSchema +>; const ScorerOutputSchema: z.ZodType = z.custom( (val): val is Scorer => typeof val === "object" && val !== null && typeof (val as Scorer).name === "string" && - ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && + ((val as Scorer).direction === "min" || + (val as Scorer).direction === "max") && typeof (val as Scorer).score === "function", ); @@ -61,42 +64,58 @@ export function dayCost( return basePrice * (Math.pow(growthBase, load - threshold) - 1); } -export const ConcurrentResourceCostBlock: ScoringBlock = { - id: "ConcurrentResourceCost", - schema: { input: ConcurrentResourceCostInputSchema, output: ScorerOutputSchema }, - apply: ({ resourceUniqueId, basePrice, growthBase, threshold }): Scorer => ({ - name: `ConcurrentResourceCost[r=${String(resourceUniqueId)}]`, - direction: "min", - reads: { resourceUniqueIds: [resourceUniqueId] }, - score(schedule: Schedule): number { - // Shared per-day histogram (realized-mode aware) — computed once per - // schedule for all scorers, not per block. - const daily = scheduleProfiles(schedule).resourceLoad.get(resourceUniqueId); - if (!daily) return 0; +export const ConcurrentResourceCostBlock: ScoringBlock = + { + id: "ConcurrentResourceCost", + schema: { + input: ConcurrentResourceCostInputSchema, + output: ScorerOutputSchema, + }, + apply: ({ + resourceUniqueId, + basePrice, + growthBase, + threshold, + }): Scorer => ({ + name: `ConcurrentResourceCost[r=${String(resourceUniqueId)}]`, + direction: "min", + reads: { resourceUniqueIds: [resourceUniqueId] }, + score(schedule: Schedule): number { + // Shared per-day histogram (realized-mode aware) — computed once per + // schedule for all scorers, not per block. + const daily = + scheduleProfiles(schedule).resourceLoad.get(resourceUniqueId); + if (!daily) return 0; - let total = 0; - for (const units of daily) { - if (units > 0) total += dayCost(units, basePrice, growthBase, threshold); - } - return total; + let total = 0; + for (const units of daily) { + if (units > 0) + total += dayCost(units, basePrice, growthBase, threshold); + } + return total; + }, + }), + toMiniZinc: ({ + resourceUniqueId, + basePrice, + growthBase, + threshold, + }): MiniZincFragment => ({ + // Exponential cost doesn't lower cleanly into CP-SAT integers; the + // canonical compile path is a piecewise-linear approximation applied at + // the compiler layer, parameterized off this fragment's growthBase. + text: + `var float: concurrent_resource_cost_${String(resourceUniqueId)} = ` + + `sum(d in DAYS) ` + + `( let { var int: load = ` + + `sum(t in tasks_demanding[${String(resourceUniqueId)}]) ` + + `(bool2int(active[t,d]) * units[t,${String(resourceUniqueId)}]) } in ` + + `if load <= ${String(threshold)} then 0.0 else ` + + `${String(basePrice)} * (pow(${String(growthBase)}, load - ${String(threshold)}) - 1.0) endif );`, + }), + doc: { + nl: "Exponential per-day penalty on concurrent demand: each marginal worker beyond threshold costs growthBase× more than the previous one.", + pseudocode: + "sum_d (load[d] > threshold ? basePrice * (growthBase^(load[d] - threshold) - 1) : 0)", }, - }), - toMiniZinc: ({ resourceUniqueId, basePrice, growthBase, threshold }): MiniZincFragment => ({ - // Exponential cost doesn't lower cleanly into CP-SAT integers; the - // canonical compile path is a piecewise-linear approximation applied at - // the compiler layer, parameterized off this fragment's growthBase. - text: - `var float: concurrent_resource_cost_${String(resourceUniqueId)} = ` + - `sum(d in DAYS) ` + - `( let { var int: load = ` + - `sum(t in tasks_demanding[${String(resourceUniqueId)}]) ` + - `(bool2int(active[t,d]) * units[t,${String(resourceUniqueId)}]) } in ` + - `if load <= ${String(threshold)} then 0.0 else ` + - `${String(basePrice)} * (pow(${String(growthBase)}, load - ${String(threshold)}) - 1.0) endif );`, - }), - doc: { - nl: "Exponential per-day penalty on concurrent demand: each marginal worker beyond threshold costs growthBase× more than the previous one.", - pseudocode: - "sum_d (load[d] > threshold ? basePrice * (growthBase^(load[d] - threshold) - 1) : 0)", - }, -}; + }; diff --git a/src/level-blocks/concurrentUnitsLimit.ts b/src/level-blocks/concurrentUnitsLimit.ts index 06e7a99..d1e9cf6 100644 --- a/src/level-blocks/concurrentUnitsLimit.ts +++ b/src/level-blocks/concurrentUnitsLimit.ts @@ -31,40 +31,45 @@ export const ConcurrentUnitsLimitInputSchema = z.object({ max: z.number().int().positive(), }); -export type ConcurrentUnitsLimitInput = z.infer; +export type ConcurrentUnitsLimitInput = z.infer< + typeof ConcurrentUnitsLimitInputSchema +>; -const ConcurrentUnitsLimitOutputSchema: z.ZodType = z.object({ - kind: z.literal("ConcurrentUnitsLimit"), - unitIds: z.array(z.number().int()), - discipline: z.number().int().optional(), - max: z.number().int().positive(), -}); +const ConcurrentUnitsLimitOutputSchema: z.ZodType = + z.object({ + kind: z.literal("ConcurrentUnitsLimit"), + unitIds: z.array(z.number().int()), + discipline: z.number().int().optional(), + max: z.number().int().positive(), + }); -export const ConcurrentUnitsLimitBlock: ConstraintBlock = { - id: "ConcurrentUnitsLimit", - schema: { - input: ConcurrentUnitsLimitInputSchema, - output: ConcurrentUnitsLimitOutputSchema, - }, - apply: ({ unitIds, discipline, max }): ConcurrentUnitsLimitConstraint => ({ - kind: "ConcurrentUnitsLimit", - unitIds, - discipline, - max, - }), - toMiniZinc: ({ unitIds, max }): MiniZincFragment => { - const unitIdList = unitIds.map(String).join(","); - return { - // `unit_open[u,d]` is supplied/derived by the harness as - // `exists(t in unit_tasks[u])(active[t,d])`, with unit_tasks already - // discipline-filtered. Cap the count of open units per day. - text: - `constraint forall(d in DAYS) ` + - `( sum(u in {${unitIdList}}) (bool2int(unit_open[u,d])) <= ${String(max)} );`, - }; - }, - doc: { - nl: "At most N work units may have active work on any single day (optionally scoped to one discipline) — a WIP cap that prioritizes finishing open units before starting more.", - pseudocode: "forall day d: count(u in units where any task of u active on d) <= max", - }, -}; +export const ConcurrentUnitsLimitBlock: ConstraintBlock = + { + id: "ConcurrentUnitsLimit", + schema: { + input: ConcurrentUnitsLimitInputSchema, + output: ConcurrentUnitsLimitOutputSchema, + }, + apply: ({ unitIds, discipline, max }): ConcurrentUnitsLimitConstraint => ({ + kind: "ConcurrentUnitsLimit", + unitIds, + discipline, + max, + }), + toMiniZinc: ({ unitIds, max }): MiniZincFragment => { + const unitIdList = unitIds.map(String).join(","); + return { + // `unit_open[u,d]` is supplied/derived by the harness as + // `exists(t in unit_tasks[u])(active[t,d])`, with unit_tasks already + // discipline-filtered. Cap the count of open units per day. + text: + `constraint forall(d in DAYS) ` + + `( sum(u in {${unitIdList}}) (bool2int(unit_open[u,d])) <= ${String(max)} );`, + }; + }, + doc: { + nl: "At most N work units may have active work on any single day (optionally scoped to one discipline) — a WIP cap that prioritizes finishing open units before starting more.", + pseudocode: + "forall day d: count(u in units where any task of u active on d) <= max", + }, + }; diff --git a/src/level-blocks/crewBorrow.ts b/src/level-blocks/crewBorrow.ts index df3ed8e..482f0cd 100644 --- a/src/level-blocks/crewBorrow.ts +++ b/src/level-blocks/crewBorrow.ts @@ -18,14 +18,20 @@ export const CrewBorrowInputSchema = z homeResourceUniqueId: z.number().int(), borrowResourceUniqueId: z.number().int(), }) - .refine((input) => new Set(input.taskUniqueIds).size === input.taskUniqueIds.length, { - message: "taskUniqueIds must be unique", - path: ["taskUniqueIds"], - }) - .refine((input) => input.homeResourceUniqueId !== input.borrowResourceUniqueId, { - message: "borrowResourceUniqueId must differ from homeResourceUniqueId", - path: ["borrowResourceUniqueId"], - }); + .refine( + (input) => new Set(input.taskUniqueIds).size === input.taskUniqueIds.length, + { + message: "taskUniqueIds must be unique", + path: ["taskUniqueIds"], + }, + ) + .refine( + (input) => input.homeResourceUniqueId !== input.borrowResourceUniqueId, + { + message: "borrowResourceUniqueId must differ from homeResourceUniqueId", + path: ["borrowResourceUniqueId"], + }, + ); export type CrewBorrowInput = z.infer; @@ -42,7 +48,9 @@ export const CrewBorrowBlock: ConstraintBlock = { apply: (input): CrewBorrowConstraint => ({ kind: "CrewBorrow", ...input }), toMiniZinc: ({ taskUniqueIds }): MiniZincFragment => ({ text: taskUniqueIds - .map((taskUniqueId) => `constraint mode[${String(taskUniqueId)}] in {0,1};`) + .map( + (taskUniqueId) => `constraint mode[${String(taskUniqueId)}] in {0,1};`, + ) .join("\n"), }), doc: { diff --git a/src/level-blocks/gate.ts b/src/level-blocks/gate.ts index ac30eb2..6c0a336 100644 --- a/src/level-blocks/gate.ts +++ b/src/level-blocks/gate.ts @@ -41,7 +41,8 @@ const GateLimitInputSchema = z min: z.number().optional(), }) .refine((l) => l.max !== undefined || l.min !== undefined, { - message: "a gate limit needs at least one of max or min (an unbounded side is simply absent)", + message: + "a gate limit needs at least one of max or min (an unbounded side is simply absent)", }); export const GateInputSchema = z.object({ @@ -67,7 +68,11 @@ const GateOutputSchema: z.ZodType = z.object({ export const GateBlock: CollectorBlock = { id: "Gate", schema: { input: GateInputSchema, output: GateOutputSchema }, - apply: ({ limits, fallback }): GateCollector => ({ kind: "Gate", limits, fallback }), + apply: ({ limits, fallback }): GateCollector => ({ + kind: "Gate", + limits, + fallback, + }), toMiniZinc: (): MiniZincFragment => ({ text: `% Gate — selection layer; keeps schedules satisfying every limit, emits no solver constraint.`, }), @@ -125,14 +130,18 @@ export function selectGate( limits: readonly ResolvedGateLimit[], fallback: "leastBad" | "none", ): readonly Schedule[] { - const passing = schedules.filter((s) => limits.every((l) => withinBounds(l.scorer.score(s), l))); + const passing = schedules.filter((s) => + limits.every((l) => withinBounds(l.scorer.score(s), l)), + ); if (passing.length > 0) return passing; if (fallback === "none" || schedules.length === 0) return []; let best = schedules[0]!; let bestViol = limits.map((l) => limitViolation(l.scorer.score(best), l)); for (let i = 1; i < schedules.length; i++) { - const viol = limits.map((l) => limitViolation(l.scorer.score(schedules[i]!), l)); + const viol = limits.map((l) => + limitViolation(l.scorer.score(schedules[i]!), l), + ); if (lexLessBad(viol, bestViol)) { best = schedules[i]!; bestViol = viol; diff --git a/src/level-blocks/graph.ts b/src/level-blocks/graph.ts index f9c9715..e97a524 100644 --- a/src/level-blocks/graph.ts +++ b/src/level-blocks/graph.ts @@ -9,7 +9,11 @@ import { z } from "zod"; -import { cardinalityMessage, FAMILY_CARDINALITY, FAMILY_ORDER } from "./composition.ts"; +import { + cardinalityMessage, + FAMILY_CARDINALITY, + FAMILY_ORDER, +} from "./composition.ts"; import type { BlockFamily, BlockRegistry } from "./registry.ts"; export const blockInstanceSchema = z.object({ @@ -75,7 +79,10 @@ export type ValidateGraphResult = * strict-connection checker mirrors visually — a canvas must not be able to * author a graph that fails here. */ -export function validateGraph(registry: BlockRegistry, graph: unknown): ValidateGraphResult { +export function validateGraph( + registry: BlockRegistry, + graph: unknown, +): ValidateGraphResult { const parsed = pipelineGraphSchema.safeParse(graph); if (!parsed.success) { return { diff --git a/src/level-blocks/hiringLagPenalty.ts b/src/level-blocks/hiringLagPenalty.ts index 3eafb06..e2d4aeb 100644 --- a/src/level-blocks/hiringLagPenalty.ts +++ b/src/level-blocks/hiringLagPenalty.ts @@ -35,7 +35,8 @@ const ScorerOutputSchema: z.ZodType = z.custom( typeof val === "object" && val !== null && typeof (val as Scorer).name === "string" && - ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && + ((val as Scorer).direction === "min" || + (val as Scorer).direction === "max") && typeof (val as Scorer).score === "function", ); @@ -69,10 +70,18 @@ export const HiringLagPenaltyBlock: ScoringBlock = { startDay, endDay, ); - return totalHires(profile.map((b) => b.value)) * trainingWeeks * costPerCrewWeek; + return ( + totalHires(profile.map((b) => b.value)) * + trainingWeeks * + costPerCrewWeek + ); }, }), - toMiniZinc: ({ resourceUniqueId, trainingWeeks, costPerCrewWeek }): MiniZincFragment => ({ + toMiniZinc: ({ + resourceUniqueId, + trainingWeeks, + costPerCrewWeek, + }): MiniZincFragment => ({ text: `var int: hiring_lag_penalty_${String(resourceUniqueId)} = ` + `sum(w in WEEKS where w > 1) ` + diff --git a/src/level-blocks/makespan.ts b/src/level-blocks/makespan.ts index 260cd97..550bf3a 100644 --- a/src/level-blocks/makespan.ts +++ b/src/level-blocks/makespan.ts @@ -21,7 +21,8 @@ const ScorerOutputSchema: z.ZodType = z.custom( typeof val === "object" && val !== null && typeof (val as Scorer).name === "string" && - ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && + ((val as Scorer).direction === "min" || + (val as Scorer).direction === "max") && typeof (val as Scorer).score === "function", ); diff --git a/src/level-blocks/maxConcurrentResource.ts b/src/level-blocks/maxConcurrentResource.ts index 628e77d..3f2915c 100644 --- a/src/level-blocks/maxConcurrentResource.ts +++ b/src/level-blocks/maxConcurrentResource.ts @@ -22,33 +22,37 @@ export const MaxConcurrentResourceInputSchema = z.object({ max: z.number().int().positive(), }); -export type MaxConcurrentResourceInput = z.infer; +export type MaxConcurrentResourceInput = z.infer< + typeof MaxConcurrentResourceInputSchema +>; -const MaxConcurrentResourceOutputSchema: z.ZodType = z.object({ - kind: z.literal("MaxConcurrentResource"), - resourceUniqueId: z.number().int(), - max: z.number().int().positive(), -}); +const MaxConcurrentResourceOutputSchema: z.ZodType = + z.object({ + kind: z.literal("MaxConcurrentResource"), + resourceUniqueId: z.number().int(), + max: z.number().int().positive(), + }); -export const MaxConcurrentResourceBlock: ConstraintBlock = { - id: "MaxConcurrentResource", - schema: { - input: MaxConcurrentResourceInputSchema, - output: MaxConcurrentResourceOutputSchema, - }, - apply: ({ resourceUniqueId, max }): MaxConcurrentResourceConstraint => ({ - kind: "MaxConcurrentResource", - resourceUniqueId, - max, - }), - toMiniZinc: ({ resourceUniqueId, max }): MiniZincFragment => ({ - text: - `constraint forall(d in DAYS) ` + - `( sum(t in tasks_demanding[${String(resourceUniqueId)}]) ` + - `(bool2int(active[t,d])) <= ${String(max)} );`, - }), - doc: { - nl: "At most N tasks may simultaneously demand the named resource on any working day.", - pseudocode: "forall day d: count(t demanding r and active(t,d)) <= max", - }, -}; +export const MaxConcurrentResourceBlock: ConstraintBlock = + { + id: "MaxConcurrentResource", + schema: { + input: MaxConcurrentResourceInputSchema, + output: MaxConcurrentResourceOutputSchema, + }, + apply: ({ resourceUniqueId, max }): MaxConcurrentResourceConstraint => ({ + kind: "MaxConcurrentResource", + resourceUniqueId, + max, + }), + toMiniZinc: ({ resourceUniqueId, max }): MiniZincFragment => ({ + text: + `constraint forall(d in DAYS) ` + + `( sum(t in tasks_demanding[${String(resourceUniqueId)}]) ` + + `(bool2int(active[t,d])) <= ${String(max)} );`, + }), + doc: { + nl: "At most N tasks may simultaneously demand the named resource on any working day.", + pseudocode: "forall day d: count(t demanding r and active(t,d)) <= max", + }, + }; diff --git a/src/level-blocks/meta.ts b/src/level-blocks/meta.ts index 398aacd..e6d9a1b 100644 --- a/src/level-blocks/meta.ts +++ b/src/level-blocks/meta.ts @@ -20,7 +20,12 @@ import { type FamilyCardinality, } from "./composition.ts"; import { blockInstanceSchema } from "./graph.ts"; -import type { BlockDef, BlockFamily, BlockTier, PanelBinding } from "./registry.ts"; +import type { + BlockDef, + BlockFamily, + BlockTier, + PanelBinding, +} from "./registry.ts"; import { lexicographicTierSchema } from "./runConfig.ts"; import { DEFAULT_MAX_COMBINATIONS, @@ -57,7 +62,12 @@ interface JsonSchemaNode { /** Editable scalar-field kinds. Variadic array *elements* can also be plain * text ("text"); a top-level scalar string with no field form stays complex, * so a field is never "text". */ -export type BlockFieldKind = "date" | "number" | "slider" | "dropdown" | "checkbox"; +export type BlockFieldKind = + | "date" + | "number" + | "slider" + | "dropdown" + | "checkbox"; /** Slider bounds for a param the registry binds to a slider control. */ export interface SliderSpec { @@ -165,10 +175,12 @@ export interface BlockMetaDump { } function authoringMeta(): BlockMetaDump["authoring"] { - const instanceSchema = z.toJSONSchema(blockInstanceSchema, { io: "input" }) as JsonSchemaNode; - const reservedParamNames = Object.keys(instanceSchema.properties ?? {}).filter( - (name) => name !== "instanceId" && name !== "params", - ); + const instanceSchema = z.toJSONSchema(blockInstanceSchema, { + io: "input", + }) as JsonSchemaNode; + const reservedParamNames = Object.keys( + instanceSchema.properties ?? {}, + ).filter((name) => name !== "instanceId" && name !== "params"); return { instance: { reservedParamNames, @@ -182,7 +194,9 @@ function authoringMeta(): BlockMetaDump["authoring"] { schema: z.toJSONSchema(sweepSchema, { io: "input" }), }, objective: { - lexicographicTierSchema: z.toJSONSchema(lexicographicTierSchema, { io: "input" }), + lexicographicTierSchema: z.toJSONSchema(lexicographicTierSchema, { + io: "input", + }), }, }; } @@ -250,7 +264,11 @@ function leafField( integer, }; if (binding?.control === "slider") { - const options = (binding.options ?? {}) as { min?: number; max?: number; step?: number }; + const options = (binding.options ?? {}) as { + min?: number; + max?: number; + step?: number; + }; const sliderMin = options.min ?? min; const sliderMax = options.max ?? max; const slider: SliderSpec = { @@ -387,8 +405,12 @@ export function deriveBlockMeta(def: BlockDef): DerivedBlockMeta { tier: def.tier, doc: def.block.doc, defaultShadow: def.defaultShadow, - ...(def.assumptionTemplate !== undefined ? { assumptionTemplate: def.assumptionTemplate } : {}), - ...(def.panelBindings !== undefined ? { panelBindings: def.panelBindings } : {}), + ...(def.assumptionTemplate !== undefined + ? { assumptionTemplate: def.assumptionTemplate } + : {}), + ...(def.panelBindings !== undefined + ? { panelBindings: def.panelBindings } + : {}), ...(def.projectAnchoredPaths !== undefined ? { projectAnchoredPaths: def.projectAnchoredPaths } : {}), diff --git a/src/level-blocks/modeSelection.ts b/src/level-blocks/modeSelection.ts index cb07d44..bee8fda 100644 --- a/src/level-blocks/modeSelection.ts +++ b/src/level-blocks/modeSelection.ts @@ -50,10 +50,15 @@ export const ModeSelectionInputSchema = z taskUniqueId: z.number().int(), modes: z.array(TaskModeSchema).min(1), }) - .refine((input) => new Set(input.modes.map((m) => m.modeId)).size === input.modes.length, { - message: "mode ids must be unique — the chosen modeId attributes the placement", - path: ["modes"], - }); + .refine( + (input) => + new Set(input.modes.map((m) => m.modeId)).size === input.modes.length, + { + message: + "mode ids must be unique — the chosen modeId attributes the placement", + path: ["modes"], + }, + ); export type ModeSelectionInput = z.infer; @@ -65,7 +70,10 @@ const ModeSelectionOutputSchema: z.ZodType = z.object({ export const ModeSelectionBlock: ConstraintBlock = { id: "ModeSelection", - schema: { input: ModeSelectionInputSchema, output: ModeSelectionOutputSchema }, + schema: { + input: ModeSelectionInputSchema, + output: ModeSelectionOutputSchema, + }, apply: ({ taskUniqueId, modes }): ModeSelectionConstraint => ({ kind: "ModeSelection", taskUniqueId, @@ -79,6 +87,7 @@ export const ModeSelectionBlock: ConstraintBlock = { }, doc: { nl: "The task runs in exactly one of the listed modes — alternative duration and resource-demand pairs. Order is preference: a later mode is chosen only when it starts the task strictly earlier.", - pseudocode: "(duration(task), demand(task)) = modes[chosen], chosen in modeIds", + pseudocode: + "(duration(task), demand(task)) = modes[chosen], chosen in modeIds", }, }; diff --git a/src/level-blocks/openUnitPenalty.ts b/src/level-blocks/openUnitPenalty.ts index c37a443..1b736a3 100644 --- a/src/level-blocks/openUnitPenalty.ts +++ b/src/level-blocks/openUnitPenalty.ts @@ -41,7 +41,8 @@ const ScorerOutputSchema: z.ZodType = z.custom( typeof val === "object" && val !== null && typeof (val as Scorer).name === "string" && - ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && + ((val as Scorer).direction === "min" || + (val as Scorer).direction === "max") && typeof (val as Scorer).score === "function", ); @@ -49,7 +50,8 @@ export const OpenUnitPenaltyBlock: ScoringBlock = { id: "OpenUnitPenalty", schema: { input: OpenUnitPenaltyInputSchema, output: ScorerOutputSchema }, apply: (input): Scorer => { - const { unitIds, discipline, softMax, weight } = OpenUnitPenaltyInputSchema.parse(input); + const { unitIds, discipline, softMax, weight } = + OpenUnitPenaltyInputSchema.parse(input); // MiniZinc treats the id list as a set, so iterate distinct ids here too — // a duplicate reference must not double-count a unit's open span. const distinctUnitIds = [...new Set(unitIds)]; @@ -76,7 +78,8 @@ export const OpenUnitPenaltyBlock: ScoringBlock = { }; }, toMiniZinc: (input): MiniZincFragment => { - const { unitIds, softMax, weight } = OpenUnitPenaltyInputSchema.parse(input); + const { unitIds, softMax, weight } = + OpenUnitPenaltyInputSchema.parse(input); const unitIdList = unitIds.map(String).join(","); return { text: diff --git a/src/level-blocks/pareto.ts b/src/level-blocks/pareto.ts index 0875f69..f2cdc20 100644 --- a/src/level-blocks/pareto.ts +++ b/src/level-blocks/pareto.ts @@ -41,6 +41,7 @@ export const ParetoBlock: CollectorBlock = { }), doc: { nl: "Keeps the non-dominated set across the referenced scorers — the Pareto frontier.", - pseudocode: "candidates.filter(c => !candidates.some(o => dominates(o, c, over)))", + pseudocode: + "candidates.filter(c => !candidates.some(o => dominates(o, c, over)))", }, }; diff --git a/src/level-blocks/peakCap.ts b/src/level-blocks/peakCap.ts index 2069f77..af7d4b8 100644 --- a/src/level-blocks/peakCap.ts +++ b/src/level-blocks/peakCap.ts @@ -61,6 +61,7 @@ export const PeakCapBlock: ConstraintBlock = { }, doc: { nl: "Summed fractional unit demand on the named resource may not exceed the cap on any working day (optionally within a day window).", - pseudocode: "forall day d [in window]: sum(units of active tasks demanding r) <= cap", + pseudocode: + "forall day d [in window]: sum(units of active tasks demanding r) <= cap", }, }; diff --git a/src/level-blocks/phaseEarliness.ts b/src/level-blocks/phaseEarliness.ts index ee7f83d..0c7947b 100644 --- a/src/level-blocks/phaseEarliness.ts +++ b/src/level-blocks/phaseEarliness.ts @@ -45,7 +45,8 @@ const ScorerOutputSchema: z.ZodType = z.custom( typeof val === "object" && val !== null && typeof (val as Scorer).name === "string" && - ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && + ((val as Scorer).direction === "min" || + (val as Scorer).direction === "max") && typeof (val as Scorer).score === "function", ); @@ -58,7 +59,8 @@ export const PhaseEarlinessBlock: ScoringBlock = { name: "PhaseEarliness", direction: "min", reads: { taskUniqueIds: phaseTaskIds(phases) }, - score: (schedule: Schedule): number => phaseDeviation(schedule, phases, "start"), + score: (schedule: Schedule): number => + phaseDeviation(schedule, phases, "start"), }; }, toMiniZinc: (input): MiniZincFragment => { diff --git a/src/level-blocks/phaseLateness.ts b/src/level-blocks/phaseLateness.ts index 7d9230d..832f420 100644 --- a/src/level-blocks/phaseLateness.ts +++ b/src/level-blocks/phaseLateness.ts @@ -43,7 +43,8 @@ const ScorerOutputSchema: z.ZodType = z.custom( typeof val === "object" && val !== null && typeof (val as Scorer).name === "string" && - ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && + ((val as Scorer).direction === "min" || + (val as Scorer).direction === "max") && typeof (val as Scorer).score === "function", ); @@ -56,7 +57,8 @@ export const PhaseLatenessBlock: ScoringBlock = { name: "PhaseLateness", direction: "min", reads: { taskUniqueIds: phaseTaskIds(phases) }, - score: (schedule: Schedule): number => phaseDeviation(schedule, phases, "finish"), + score: (schedule: Schedule): number => + phaseDeviation(schedule, phases, "finish"), }; }, toMiniZinc: (input): MiniZincFragment => { diff --git a/src/level-blocks/phaseRelease.ts b/src/level-blocks/phaseRelease.ts index fba5ba6..c7dfab7 100644 --- a/src/level-blocks/phaseRelease.ts +++ b/src/level-blocks/phaseRelease.ts @@ -14,10 +14,13 @@ export const PhaseReleaseInputSchema = z taskUniqueIds: z.array(z.number().int()).min(1), earliestStart: z.number().int().nonnegative(), }) - .refine((input) => new Set(input.taskUniqueIds).size === input.taskUniqueIds.length, { - message: "taskUniqueIds must be unique within a phase", - path: ["taskUniqueIds"], - }); + .refine( + (input) => new Set(input.taskUniqueIds).size === input.taskUniqueIds.length, + { + message: "taskUniqueIds must be unique within a phase", + path: ["taskUniqueIds"], + }, + ); export type PhaseReleaseInput = z.infer; @@ -29,9 +32,16 @@ const ReleaseOutputSchema: z.ZodType = z.object({ export const PhaseReleaseBlock: MultiConstraintBlock = { id: "PhaseRelease", - schema: { input: PhaseReleaseInputSchema, output: z.array(ReleaseOutputSchema) }, + schema: { + input: PhaseReleaseInputSchema, + output: z.array(ReleaseOutputSchema), + }, apply: ({ taskUniqueIds, earliestStart }): ReleaseConstraint[] => - taskUniqueIds.map((taskUniqueId) => ({ kind: "Release", taskUniqueId, earliestStart })), + taskUniqueIds.map((taskUniqueId) => ({ + kind: "Release", + taskUniqueId, + earliestStart, + })), toMiniZinc: ({ taskUniqueIds, earliestStart }): MiniZincFragment => ({ text: taskUniqueIds .map( diff --git a/src/level-blocks/phaseScoring.ts b/src/level-blocks/phaseScoring.ts index ceda5fd..af8faf9 100644 --- a/src/level-blocks/phaseScoring.ts +++ b/src/level-blocks/phaseScoring.ts @@ -17,7 +17,10 @@ import { z } from "zod"; -import { countWorkingDays, resolveWorkingCalendar } from "../level-core/calendarDays.ts"; +import { + countWorkingDays, + resolveWorkingCalendar, +} from "../level-core/calendarDays.ts"; import { scheduleProfiles } from "../level-core/profiles.ts"; import type { Schedule } from "../level-core/types.ts"; @@ -117,14 +120,18 @@ export function phaseDeviationMiniZinc( if (phase.taskUniqueIds.length === 0) return; // max/min over {} is undefined const ids = phase.taskUniqueIds.map(String).join(","); const boundary = `${boundaryPrefix}_${String(i)}`; - decls.push(`var int: ${boundary} = ${aggregate}(t in {${ids}})(${dayVar}[t]);`); + decls.push( + `var int: ${boundary} = ${aggregate}(t in {${ids}})(${dayVar}[t]);`, + ); // Working days on the wrong side of the target: after it for lateness, // before it for earliness. const predicate = edge === "finish" ? `d >= ${String(phase.targetDay)} /\\ d < ${boundary}` : `d >= ${boundary} /\\ d < ${String(phase.targetDay)}`; - terms.push(`${String(phase.weight)} * sum(d in DAYS)(bool2int(${predicate}))`); + terms.push( + `${String(phase.weight)} * sum(d in DAYS)(bool2int(${predicate}))`, + ); }); const rhs = terms.length === 0 ? "0.0" : terms.join(" + "); diff --git a/src/level-blocks/registry.ts b/src/level-blocks/registry.ts index 7a1a7b3..82c2591 100644 --- a/src/level-blocks/registry.ts +++ b/src/level-blocks/registry.ts @@ -66,7 +66,9 @@ export function createRegistry(defs?: BlockDef[]): BlockRegistry { } const parsed = def.block.schema.input.safeParse(def.defaultShadow); if (!parsed.success) { - throw new Error(`Invalid defaultShadow for block "${def.code}": ${parsed.error.message}`); + throw new Error( + `Invalid defaultShadow for block "${def.code}": ${parsed.error.message}`, + ); } // Dot-paths are consumed app-side (panel generation, template save); // a typo there would otherwise fail silently at first use. Check them @@ -78,7 +80,9 @@ export function createRegistry(defs?: BlockDef[]): BlockRegistry { ]; for (const path of paths) { if (resolveDotPath(parsed.data, path) === undefined) { - throw new Error(`Block "${def.code}": path "${path}" does not resolve in defaultShadow`); + throw new Error( + `Block "${def.code}": path "${path}" does not resolve in defaultShadow`, + ); } } byCode.set(def.code, def); diff --git a/src/level-blocks/runConfig.ts b/src/level-blocks/runConfig.ts index 279d465..135243f 100644 --- a/src/level-blocks/runConfig.ts +++ b/src/level-blocks/runConfig.ts @@ -19,7 +19,10 @@ import { analyzeRun, type RunAnalysis } from "../level-core/analyzeRun.ts"; import { RunAnalysisError } from "../level-core/errors.ts"; import { materialize } from "../level-core/materialize.ts"; import { resolveCalendar } from "../level-core/resolveCalendar.ts"; -import { concatStreams, streamFromFactory } from "../level-core/scheduleStream.ts"; +import { + concatStreams, + streamFromFactory, +} from "../level-core/scheduleStream.ts"; import type { Collector, Constraint, @@ -35,7 +38,11 @@ import { weightedScorer } from "../level-core/weightedScorer.ts"; import type { RunProvenance } from "../schema/provenance.ts"; import { LEVELSET_VERSION } from "../version.ts"; -import { compileGraph, GraphValidationError, type CompileTarget } from "./compile.ts"; +import { + compileGraph, + GraphValidationError, + type CompileTarget, +} from "./compile.ts"; import { selectGate, type ResolvedGateLimit } from "./gate.ts"; import { pipelineGraphSchema, @@ -77,7 +84,10 @@ export const weightedSumTierSchema = z.strictObject({ "weightedSum must contain at least one non-zero weight", ), }); -export const lexicographicTierSchema = z.union([z.string(), weightedSumTierSchema]); +export const lexicographicTierSchema = z.union([ + z.string(), + weightedSumTierSchema, +]); export type LexicographicTier = z.infer; // Framework-owned sections are strict (a typo'd key is an error, not a @@ -94,7 +104,10 @@ export const runConfigSchema = z.strictObject({ /** ISO date string for ResolveOptions.epoch. */ epoch: z .string() - .refine((s) => !Number.isNaN(new Date(s).getTime()), "not a parseable date") + .refine( + (s) => !Number.isNaN(new Date(s).getTime()), + "not a parseable date", + ) .optional(), horizonDays: z.number().int().positive().optional(), workUnits: z.array(workUnitSchema).optional(), @@ -205,7 +218,9 @@ export function checkRunConfigRefs( const params = validated.parsedParams.get(b.instanceId); let collector: Collector; try { - collector = (def.block as { apply(input: unknown): Collector }).apply(params); + collector = (def.block as { apply(input: unknown): Collector }).apply( + params, + ); } catch (err) { compileIssues.push({ instanceId: b.instanceId, @@ -224,7 +239,10 @@ export function checkRunConfigRefs( if (compileIssues.length > 0) return compileIssues; const badRefs = [ - ...Object.keys(config.weights ?? {}).map((k) => ({ k, where: "weights key" })), + ...Object.keys(config.weights ?? {}).map((k) => ({ + k, + where: "weights key", + })), ...(config.objective?.lexicographic ?? []).flatMap((tier) => lexicographicTierRefs(tier).map((k) => ({ k, @@ -242,7 +260,10 @@ export function checkRunConfigRefs( /** Validate a config and expand it: block params parsed through each * block's input schema (defaults applied), levelset version stamped. * Throws GraphValidationError / ZodError on invalid input. */ -export function fillRunConfig(config: unknown, registry?: BlockRegistry): RunConfig { +export function fillRunConfig( + config: unknown, + registry?: BlockRegistry, +): RunConfig { const reg = registry ?? standardRegistry(); const parsed = runConfigSchema.parse(config); const validated = validateGraph(reg, parsed.pipeline); @@ -258,7 +279,10 @@ export function fillRunConfig(config: unknown, registry?: BlockRegistry): RunCon ...validated.graph, blocks: validated.graph.blocks.map((instance) => ({ ...instance, - params: validated.parsedParams.get(instance.instanceId) as Record, + params: validated.parsedParams.get(instance.instanceId) as Record< + string, + unknown + >, })), }, }; @@ -275,7 +299,9 @@ export function validateSweepConfig( ): GraphIssue[] { if (!config.sweep) return []; const issues: GraphIssue[] = []; - const instanceById = new Map(config.pipeline.blocks.map((block) => [block.instanceId, block])); + const instanceById = new Map( + config.pipeline.blocks.map((block) => [block.instanceId, block]), + ); const axisIds = new Set(); for (const axis of config.sweep.axes) { @@ -284,17 +310,27 @@ export function validateSweepConfig( } axisIds.add(axis.id); - const keyedTargets = axis.targets.filter((target) => target.valueKey !== undefined); + const keyedTargets = axis.targets.filter( + (target) => target.valueKey !== undefined, + ); const isZip = keyedTargets.length === axis.targets.length; if (keyedTargets.length > 0 && !isZip) { - issues.push({ message: `sweep axis "${axis.id}" mixes keyed and unkeyed targets` }); + issues.push({ + message: `sweep axis "${axis.id}" mixes keyed and unkeyed targets`, + }); continue; } - const referencedKeys = new Set(keyedTargets.map((target) => target.valueKey!)); + const referencedKeys = new Set( + keyedTargets.map((target) => target.valueKey!), + ); if (isZip) { axis.values.forEach((point, pointIndex) => { - if (point === null || Array.isArray(point) || typeof point !== "object") { + if ( + point === null || + Array.isArray(point) || + typeof point !== "object" + ) { issues.push({ message: `sweep axis "${axis.id}" zip point ${pointIndex} must be a record`, }); @@ -352,7 +388,10 @@ export function validateSweepConfig( }); continue; } - if (def.family === "scorer" && target.paramPath === SWEEP_ENABLED_TARGET) { + if ( + def.family === "scorer" && + target.paramPath === SWEEP_ENABLED_TARGET + ) { issues.push({ instanceId: target.instanceId, code: instance.code, @@ -361,12 +400,17 @@ export function validateSweepConfig( continue; } axis.values.forEach((point, pointIndex) => { - if (isZip && (point === null || Array.isArray(point) || typeof point !== "object")) return; + if ( + isZip && + (point === null || Array.isArray(point) || typeof point !== "object") + ) + return; const value = target.valueKey === undefined ? point : (point as Record)[target.valueKey]; - const valueLabel = target.valueKey === undefined ? "" : ` valueKey "${target.valueKey}"`; + const valueLabel = + target.valueKey === undefined ? "" : ` valueKey "${target.valueKey}"`; if (target.paramPath === SWEEP_ENABLED_TARGET) { if (typeof value !== "boolean") { issues.push({ @@ -411,7 +455,11 @@ function resolveSweepDotPath(value: unknown, path: string): unknown { return current; } -function setSweepDotPath(target: Record, path: string, value: unknown): void { +function setSweepDotPath( + target: Record, + path: string, + value: unknown, +): void { const segments = path.split("."); let current = target; for (let i = 0; i < segments.length - 1; i++) { @@ -458,7 +506,9 @@ function runTarget(config: RunConfig): CompileTarget { scorer: compiled.output as Scorer, // `null` is a deliberate value (report, don't optimize) — only a // genuinely absent key falls back to weight 1. - weight: Object.hasOwn(weights, id) ? (weights[id] as number | null) : 1, + weight: Object.hasOwn(weights, id) + ? (weights[id] as number | null) + : 1, }); } @@ -466,7 +516,8 @@ function runTarget(config: RunConfig): CompileTarget { throw new GraphValidationError([ { instanceId: families.search.instance.instanceId, - message: "the search block cannot be disabled — a run needs exactly one active search", + message: + "the search block cannot be disabled — a run needs exactly one active search", }, ]); } @@ -518,7 +569,9 @@ export function runFromConfig( const filled = fillRunConfig(config, registry); const resolved = resolveCalendar(project, { - ...(filled.resolve?.epoch !== undefined ? { epoch: new Date(filled.resolve.epoch) } : {}), + ...(filled.resolve?.epoch !== undefined + ? { epoch: new Date(filled.resolve.epoch) } + : {}), ...(filled.resolve?.horizonDays !== undefined ? { horizonDays: filled.resolve.horizonDays } : {}), @@ -583,11 +636,18 @@ export function runFromConfig( const sweepCells = filled.sweep ? expandSweepCells(filled).map((cell) => ({ point: cell.point, - compiled: compileGraph(registry, cell.config.pipeline, runTarget(cell.config)), + compiled: compileGraph( + registry, + cell.config.pipeline, + runTarget(cell.config), + ), })) : []; const sweepStatsBySchedule = new WeakMap(); - const sweepScorersBySchedule = new WeakMap>(); + const sweepScorersBySchedule = new WeakMap< + Schedule, + ReadonlyMap + >(); const runScorers = filled.sweep ? scorers.map((entry): WeightedScorerEntry => ({ ...entry, @@ -595,7 +655,8 @@ export function runFromConfig( ...entry.scorer, score(schedule: Schedule): number { return ( - sweepScorersBySchedule.get(schedule)?.get(entry.instanceId) ?? entry.scorer + sweepScorersBySchedule.get(schedule)?.get(entry.instanceId) ?? + entry.scorer ).score(schedule); }, }, @@ -604,7 +665,11 @@ export function runFromConfig( const objective = runScorers.some((s) => s.weight !== null && s.weight !== 0) ? weightedScorer(runScorers, "objective") - : { name: "Makespan", direction: "min" as const, score: (s: Schedule) => s.makespan }; + : { + name: "Makespan", + direction: "min" as const, + score: (s: Schedule) => s.makespan, + }; const run: LevelingRun = { config: filled, @@ -647,13 +712,17 @@ export function runFromConfig( } }); } - return streamFromFactory(() => stripReturn(search.run(resolved, constraints))); + return streamFromFactory(() => + stripReturn(search.run(resolved, constraints)), + ); }, async best(): Promise { const lex = filled.objective?.lexicographic; if (lex) { const byId = new Map(runScorers.map((s) => [s.instanceId, s.scorer])); - return run.stream().bestByLex(lex.map((tier) => compileLexicographicTier(tier, byId))); + return run + .stream() + .bestByLex(lex.map((tier) => compileLexicographicTier(tier, byId))); } return run.stream().bestBy(objective); }, @@ -679,24 +748,35 @@ export function runFromConfig( case "Gate": { // Gate selects over a materialized set (its limits carry bounds, // not just scorers), so collect once, then filter/fall back. - const limits: ResolvedGateLimit[] = c.collector.limits.map((l) => ({ - scorer: byId.get(l.scorer)!, - max: l.max, - min: l.min, - })); - schedules = selectGate(await run.stream().collect(), limits, c.collector.fallback); + const limits: ResolvedGateLimit[] = c.collector.limits.map( + (l) => ({ + scorer: byId.get(l.scorer)!, + max: l.max, + min: l.min, + }), + ); + schedules = selectGate( + await run.stream().collect(), + limits, + c.collector.fallback, + ); break; } default: assertNeverCollector(c.collector); } - return { instanceId: c.instanceId, kind: c.collector.kind, schedules }; + return { + instanceId: c.instanceId, + kind: c.collector.kind, + schedules, + }; }), ); }, scores(schedule: Schedule): Record { const report: Record = {}; - for (const entry of runScorers) report[entry.instanceId] = entry.scorer.score(schedule); + for (const entry of runScorers) + report[entry.instanceId] = entry.scorer.score(schedule); return report; }, materialize(schedule: Schedule): Project { @@ -721,7 +801,10 @@ export function runFromConfig( combinations: sweepStats.combinations, feasibleCells: sweepStats.feasibleCells, winner, - skipped: sweepStats.skipped.map(({ point, reason }) => ({ point, reason })), + skipped: sweepStats.skipped.map(({ point, reason }) => ({ + point, + reason, + })), }, } : {}), @@ -768,7 +851,9 @@ function compileLexicographicTier( // streamFromFactory expects a plain AsyncGenerator. Re-yield to // drop the return value (the stream just ends, matching ScheduleStream's // documented contract). -async function* stripReturn(gen: AsyncGenerator): AsyncGenerator { +async function* stripReturn( + gen: AsyncGenerator, +): AsyncGenerator { for await (const schedule of gen) yield schedule; } diff --git a/src/level-blocks/serialSgsSearch.ts b/src/level-blocks/serialSgsSearch.ts index 4250c29..d5f7ac6 100644 --- a/src/level-blocks/serialSgsSearch.ts +++ b/src/level-blocks/serialSgsSearch.ts @@ -36,6 +36,7 @@ export const SerialSGSBlock: SearchBlock = { }), doc: { nl: "Greedy serial schedule-generation scheme: topological order, earliest feasible placement per task. Deterministic, single schedule per run.", - pseudocode: "for task in topo order: place at earliest day satisfying precedence + caps", + pseudocode: + "for task in topo order: place at earliest day satisfying precedence + caps", }, }; diff --git a/src/level-blocks/standard.ts b/src/level-blocks/standard.ts index 86bfdc2..a95fa4e 100644 --- a/src/level-blocks/standard.ts +++ b/src/level-blocks/standard.ts @@ -25,7 +25,11 @@ import { PhaseReleaseBlock } from "./phaseRelease.ts"; import { PeakCapBlock } from "./peakCap.ts"; import { PhaseEarlinessBlock } from "./phaseEarliness.ts"; import { PhaseLatenessBlock } from "./phaseLateness.ts"; -import { createRegistry, type BlockDef, type BlockRegistry } from "./registry.ts"; +import { + createRegistry, + type BlockDef, + type BlockRegistry, +} from "./registry.ts"; import { ReleaseBlock } from "./release.ts"; import { SerialSGSBlock } from "./serialSgsSearch.ts"; import { UnimodalDeviationBlock } from "./unimodalDeviation.ts"; @@ -61,7 +65,8 @@ export function standardBlockDefs(): BlockDef[] { tier: "estimator", block: MaxConcurrentResourceBlock, defaultShadow: { resourceUniqueId: 0, max: 1 }, - assumptionTemplate: "at most {max} concurrent tasks on resource {resourceUniqueId}", + assumptionTemplate: + "at most {max} concurrent tasks on resource {resourceUniqueId}", }, { code: STANDARD_CODES.peakCap, @@ -69,7 +74,8 @@ export function standardBlockDefs(): BlockDef[] { tier: "estimator", block: PeakCapBlock, defaultShadow: { resourceUniqueId: 0, cap: 1 }, - assumptionTemplate: "peak demand on resource {resourceUniqueId} capped at {cap} units", + assumptionTemplate: + "peak demand on resource {resourceUniqueId} capped at {cap} units", }, { code: STANDARD_CODES.concurrentUnitsLimit, @@ -93,7 +99,8 @@ export function standardBlockDefs(): BlockDef[] { tier: "estimator", block: ReleaseBlock, defaultShadow: { taskUniqueId: 0, earliestStart: 0 }, - assumptionTemplate: "task {taskUniqueId} starts no earlier than day {earliestStart}", + assumptionTemplate: + "task {taskUniqueId} starts no earlier than day {earliestStart}", }, { code: STANDARD_CODES.phaseRelease, @@ -101,7 +108,8 @@ export function standardBlockDefs(): BlockDef[] { tier: "estimator", block: PhaseReleaseBlock, defaultShadow: { taskUniqueIds: [0], earliestStart: 0 }, - assumptionTemplate: "phase tasks start no earlier than day {earliestStart}", + assumptionTemplate: + "phase tasks start no earlier than day {earliestStart}", }, { code: STANDARD_CODES.modeSelection, @@ -112,7 +120,8 @@ export function standardBlockDefs(): BlockDef[] { taskUniqueId: 0, modes: [{ modeId: 1, durationDays: 1, resourceDemand: [] }], }, - assumptionTemplate: "task {taskUniqueId} chooses among its listed duration/crew modes", + assumptionTemplate: + "task {taskUniqueId} chooses among its listed duration/crew modes", }, { code: STANDARD_CODES.crewBorrow, @@ -132,7 +141,12 @@ export function standardBlockDefs(): BlockDef[] { family: "scorer", tier: "estimator", block: ConcurrentResourceCostBlock, - defaultShadow: { resourceUniqueId: 0, basePrice: 100, growthBase: 2, threshold: 0 }, + defaultShadow: { + resourceUniqueId: 0, + basePrice: 100, + growthBase: 2, + threshold: 0, + }, }, { code: STANDARD_CODES.openUnitPenalty, @@ -146,7 +160,11 @@ export function standardBlockDefs(): BlockDef[] { family: "scorer", tier: "estimator", block: HiringLagPenaltyBlock, - defaultShadow: { resourceUniqueId: 0, trainingWeeks: 2, costPerCrewWeek: 1000 }, + defaultShadow: { + resourceUniqueId: 0, + trainingWeeks: 2, + costPerCrewWeek: 1000, + }, }, { code: STANDARD_CODES.phaseLateness, @@ -167,7 +185,11 @@ export function standardBlockDefs(): BlockDef[] { family: "scorer", tier: "engineer", block: UnimodalDeviationBlock, - defaultShadow: { resourceUniqueId: 0, allowSecondPeak: false, jitterTolerance: 1 }, + defaultShadow: { + resourceUniqueId: 0, + allowSecondPeak: false, + jitterTolerance: 1, + }, }, { code: STANDARD_CODES.makespan, @@ -207,13 +229,18 @@ export function standardBlockDefs(): BlockDef[] { block: GateBlock, // Placeholder scorer reference + bound — the app rewires `limits` to // real scorer instanceIds and caps before building a config. - defaultShadow: { limits: [{ scorer: "makespan", max: 0 }], fallback: "none" }, + defaultShadow: { + limits: [{ scorer: "makespan", max: 0 }], + fallback: "none", + }, }, ]; } /** A registry pre-loaded with the standard defs; pass extra defs to extend * (plugins, app-specific blocks, `.v2` revisions). */ -export function standardRegistry(extraDefs?: readonly BlockDef[]): BlockRegistry { +export function standardRegistry( + extraDefs?: readonly BlockDef[], +): BlockRegistry { return createRegistry([...standardBlockDefs(), ...(extraDefs ?? [])]); } diff --git a/src/level-blocks/sweep.ts b/src/level-blocks/sweep.ts index e8647df..21ec179 100644 --- a/src/level-blocks/sweep.ts +++ b/src/level-blocks/sweep.ts @@ -85,7 +85,12 @@ export function expandSweepCells(filled: RunConfig): SweepCell[] { target.valueKey === undefined ? structuredClone(axisPoint) : structuredClone( - (axisPoint as Record>>)[target.valueKey], + ( + axisPoint as Record< + string, + z.infer> + > + )[target.valueKey], ); if (target.paramPath === SWEEP_ENABLED_TARGET) { block.enabled = value as boolean; @@ -118,7 +123,11 @@ export function expandSweep(filled: RunConfig): RunConfig[] { return expandSweepCells(filled).map((cell) => cell.config); } -function setDotPath(target: Record, path: string, value: unknown): void { +function setDotPath( + target: Record, + path: string, + value: unknown, +): void { const segments = path.split("."); let current = target; for (let i = 0; i < segments.length - 1; i++) { diff --git a/src/level-blocks/unimodalDeviation.ts b/src/level-blocks/unimodalDeviation.ts index f3520a8..ab240b0 100644 --- a/src/level-blocks/unimodalDeviation.ts +++ b/src/level-blocks/unimodalDeviation.ts @@ -31,14 +31,17 @@ export const UnimodalDeviationInputSchema = z.object({ jitterTolerance: z.number().int().nonnegative().default(1), }); -export type UnimodalDeviationInput = z.infer; +export type UnimodalDeviationInput = z.infer< + typeof UnimodalDeviationInputSchema +>; const ScorerOutputSchema: z.ZodType = z.custom( (val): val is Scorer => typeof val === "object" && val !== null && typeof (val as Scorer).name === "string" && - ((val as Scorer).direction === "min" || (val as Scorer).direction === "max") && + ((val as Scorer).direction === "min" || + (val as Scorer).direction === "max") && typeof (val as Scorer).score === "function", ); diff --git a/src/level-core/analyzeRun.ts b/src/level-core/analyzeRun.ts index c7b689f..568ce99 100644 --- a/src/level-core/analyzeRun.ts +++ b/src/level-core/analyzeRun.ts @@ -90,7 +90,8 @@ export function analyzeRun( for (const c of constraints) { const source = constraintLabel(c); - if (c.name !== undefined) seenNames.set(c.name, (seenNames.get(c.name) ?? 0) + 1); + if (c.name !== undefined) + seenNames.set(c.name, (seenNames.get(c.name) ?? 0) + 1); // `enabled: false` constraints are carried in the run but not enforced — // searches skip them — so nothing about them can invalidate the run. @@ -143,7 +144,8 @@ export function analyzeRun( checkUnit(source, after); unitEdges.push({ from: after, to: c.unitId, source }); } - if (c.afterUnitIds.length === 0) warnEmpty(issues, source, "afterUnitIds"); + if (c.afterUnitIds.length === 0) + warnEmpty(issues, source, "afterUnitIds"); break; case "UnimodalProfile": checkResource(source, c.resourceUniqueId); @@ -152,7 +154,8 @@ export function analyzeRun( checkTask(source, c.taskUniqueId); claimModePolicy(c.taskUniqueId, source); for (const mode of c.modes) { - for (const demand of mode.resourceDemand) checkResource(source, demand.resourceUniqueId); + for (const demand of mode.resourceDemand) + checkResource(source, demand.resourceUniqueId); } if (c.modes.length === 0) warnEmpty(issues, source, "modes"); break; @@ -179,7 +182,8 @@ export function analyzeRun( }); } } - if (c.taskUniqueIds.length === 0) warnEmpty(issues, source, "taskUniqueIds"); + if (c.taskUniqueIds.length === 0) + warnEmpty(issues, source, "taskUniqueIds"); break; case "CrewFlowContinuity": checkResource(source, c.resourceUniqueId); @@ -244,7 +248,8 @@ export function analyzeRun( // (serialSGS's `task.milestone || durationDays === 0` branch), so the // duration bound only applies to real tasks. const task = taskById.get(taskUniqueId); - const duration = task === undefined || task.milestone ? undefined : task.durationDays; + const duration = + task === undefined || task.milestone ? undefined : task.durationDays; if (duration !== undefined && deadline.day - releaseDay < duration) { issues.push({ code: "LEVELSET_E105", @@ -258,7 +263,8 @@ export function analyzeRun( // Precedence cycle (project edges + constraint edges), restricted to known // tasks — the same graph serialSGS topo-sorts, checked before any search. const allEdges = [...resolved.precedences, ...constraintEdges].filter( - (e) => taskIds.has(e.predecessorUniqueId) && taskIds.has(e.successorUniqueId), + (e) => + taskIds.has(e.predecessorUniqueId) && taskIds.has(e.successorUniqueId), ); const taskCycle = findCycle( [...taskIds], @@ -276,7 +282,9 @@ export function analyzeRun( const unitCycle = findCycle( [...resolved.workUnits.keys()], unitEdges - .filter((e) => resolved.workUnits.has(e.from) && resolved.workUnits.has(e.to)) + .filter( + (e) => resolved.workUnits.has(e.from) && resolved.workUnits.has(e.to), + ) .map((e) => [e.from, e.to]), ); if (unitCycle) { @@ -292,7 +300,8 @@ export function analyzeRun( for (const scorer of opts?.scorers ?? []) { const reads = scorer.reads; if (!reads) continue; - for (const id of reads.resourceUniqueIds ?? []) checkResource(scorer.name, id); + for (const id of reads.resourceUniqueIds ?? []) + checkResource(scorer.name, id); for (const id of reads.taskUniqueIds ?? []) checkTask(scorer.name, id); for (const id of reads.unitIds ?? []) checkUnit(scorer.name, id); } diff --git a/src/level-core/calendarDays.ts b/src/level-core/calendarDays.ts index 73afe9f..b5fd334 100644 --- a/src/level-core/calendarDays.ts +++ b/src/level-core/calendarDays.ts @@ -75,7 +75,9 @@ export function buildWorkingCalendar( horizonDays: number, ): WorkingCalendar { if (horizonDays < 0) { - throw new Error(`buildWorkingCalendar: horizonDays must be >= 0, got ${String(horizonDays)}`); + throw new Error( + `buildWorkingCalendar: horizonDays must be >= 0, got ${String(horizonDays)}`, + ); } const epochAtMidnight = startOfLocalDay(epoch); const bits = new Uint8Array(horizonDays); @@ -102,7 +104,11 @@ export function isWorkingDay(cal: WorkingCalendar, day: DayIndex): boolean { } /** O(1) count of working days in `[fromDay, toDay)` clamped to the horizon. */ -export function countWorkingDays(cal: WorkingCalendar, fromDay: DayIndex, toDay: DayIndex): number { +export function countWorkingDays( + cal: WorkingCalendar, + fromDay: DayIndex, + toDay: DayIndex, +): number { const lo = Math.max(0, fromDay); const hi = Math.min(cal.horizonDays, toDay); if (hi <= lo) return 0; diff --git a/src/level-core/materialize.ts b/src/level-core/materialize.ts index 0a255e0..5e96d25 100644 --- a/src/level-core/materialize.ts +++ b/src/level-core/materialize.ts @@ -9,7 +9,11 @@ import { TimeUnit } from "../model/types.ts"; import type { ProjectFile, ProjectProperties } from "../schema/project.ts"; import type { Task } from "../schema/task.ts"; -import { countWorkingDays, dayToDate, resolveWorkingCalendar } from "./calendarDays.ts"; +import { + countWorkingDays, + dayToDate, + resolveWorkingCalendar, +} from "./calendarDays.ts"; import type { Schedule, ScheduledTask, WorkingCalendar } from "./types.ts"; function workingDaysPerWeek(properties: ProjectProperties): number { @@ -34,13 +38,19 @@ function workingDaysToDuration( case TimeUnit.Days: return Duration.from(workingDays, TimeUnit.Days); case TimeUnit.Weeks: - return Duration.from(wpw > 0 ? workingDays / wpw : workingDays, TimeUnit.Weeks); + return Duration.from( + wpw > 0 ? workingDays / wpw : workingDays, + TimeUnit.Weeks, + ); case TimeUnit.Hours: return Duration.from((workingDays * minutesPerDay) / 60, TimeUnit.Hours); case TimeUnit.Minutes: return Duration.from(workingDays * minutesPerDay, TimeUnit.Minutes); case TimeUnit.Months: - return Duration.from(dpm > 0 ? workingDays / dpm : workingDays, TimeUnit.Months); + return Duration.from( + dpm > 0 ? workingDays / dpm : workingDays, + TimeUnit.Months, + ); case TimeUnit.Percent: return original; } @@ -55,7 +65,11 @@ function applyScheduledTask( // A non-null modeId means a ModeSelection mode was chosen; the duration is // already encoded in start→finish, and the realized resource demand is // re-applied to project.assignments in `materialize` below. - const workingDays = countWorkingDays(cal, scheduled.startDay, scheduled.finishDay); + const workingDays = countWorkingDays( + cal, + scheduled.startDay, + scheduled.finishDay, + ); return { ...task, start: dayToDate(cal, scheduled.startDay), @@ -70,7 +84,9 @@ export function materialize(schedule: Schedule): ProjectFile { const byUniqueId = new Map(); for (const t of schedule.tasks) byUniqueId.set(t.uniqueId, t); - const resolvedTaskById = new Map(schedule.resolved.tasks.map((t) => [t.uniqueId, t])); + const resolvedTaskById = new Map( + schedule.resolved.tasks.map((t) => [t.uniqueId, t]), + ); const tasks = source.tasks.map((task) => { if (task.uniqueId === null) return task; @@ -90,7 +106,9 @@ export function materialize(schedule: Schedule): ProjectFile { if (!realized || realized.length === 0) return { ...source, tasks }; const overridden = new Set(realized.map((a) => a.taskUniqueId)); const assignments = [ - ...source.assignments.filter((a) => a.taskUniqueId === null || !overridden.has(a.taskUniqueId)), + ...source.assignments.filter( + (a) => a.taskUniqueId === null || !overridden.has(a.taskUniqueId), + ), ...realized.map((a) => ({ taskUniqueId: a.taskUniqueId, resourceUniqueId: a.resourceUniqueId, diff --git a/src/level-core/profiles.ts b/src/level-core/profiles.ts index 2101fea..c209d61 100644 --- a/src/level-core/profiles.ts +++ b/src/level-core/profiles.ts @@ -14,7 +14,11 @@ import { resolveWorkingCalendar } from "./calendarDays.ts"; import type { DayIndex, Schedule, ScheduledTask } from "./types.ts"; -import { buildResourceLoad, effectiveAssignments, type ResourceLoad } from "./weeklyProfile.ts"; +import { + buildResourceLoad, + effectiveAssignments, + type ResourceLoad, +} from "./weeklyProfile.ts"; export interface UnitSpan { readonly startDay: DayIndex; @@ -66,7 +70,10 @@ function computeUnitSpans( class ProfilesImpl implements ScheduleProfiles { private resourceLoadMemo: ResourceLoad | null = null; private taskByIdMemo: ReadonlyMap | null = null; - private readonly unitSpansMemo = new Map>(); + private readonly unitSpansMemo = new Map< + number | null, + ReadonlyMap + >(); private readonly schedule: Schedule; @@ -80,7 +87,9 @@ class ProfilesImpl implements ScheduleProfiles { } get taskById(): ReadonlyMap { - this.taskByIdMemo ??= new Map(this.schedule.tasks.map((t) => [t.uniqueId, t])); + this.taskByIdMemo ??= new Map( + this.schedule.tasks.map((t) => [t.uniqueId, t]), + ); return this.taskByIdMemo; } diff --git a/src/level-core/resolveCalendar.ts b/src/level-core/resolveCalendar.ts index a130278..931e15e 100644 --- a/src/level-core/resolveCalendar.ts +++ b/src/level-core/resolveCalendar.ts @@ -50,7 +50,8 @@ function syntheticMonFriCalendar(): Calendar { /** 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); + if (project.properties.statusDate) + return startOfLocalDay(project.properties.statusDate); let earliest: Date | null = project.properties.startDate ? startOfLocalDay(project.properties.startDate) : null; @@ -68,10 +69,16 @@ function pickEpoch(project: ProjectFile, opts: ResolveOptions): Date { return earliest; } -function pickHorizon(project: ProjectFile, epoch: Date, opts: ResolveOptions): number { +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)}`); + throw new Error( + `resolveCalendar: horizonDays must be >= 0, got ${String(opts.horizonDays)}`, + ); } return opts.horizonDays; } @@ -85,7 +92,10 @@ function pickHorizon(project: ProjectFile, epoch: Date, opts: ResolveOptions): n } } if (!latest) return 365 + HORIZON_BUFFER_DAYS; - const span = Math.max(0, Math.round((latest.getTime() - epoch.getTime()) / MS_PER_DAY)); + 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); } @@ -102,7 +112,10 @@ function workingDaysPerWeek(properties: ProjectProperties): number { // 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 { +function lagToWorkingDays( + lag: Duration | null, + properties: ProjectProperties, +): number { if (!lag) return 0; const minutesPerDay = properties.minutesPerDay; const wpw = workingDaysPerWeek(properties); @@ -159,7 +172,11 @@ function calendarDayOffset(d: Date, epoch: Date): number { return Math.round((dStart - eStart) / MS_PER_DAY); } -function countWorkingDaysInRange(cal: WorkingCalendar, fromDay: number, toDay: number): number { +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; @@ -176,10 +193,15 @@ function resolveTask( throw new Error("resolveCalendar: task missing uniqueId"); } if (!task.start || !task.finish) { - throw new Error(`resolveCalendar: task ${String(task.uniqueId)} missing start or 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 finishDay = calendarDayOffset( + endOfLocalDayExclusive(task.finish), + epoch, + ); const durationDays = task.duration !== null ? durationToWorkingDays(task.duration, cal, startDay, properties) @@ -196,7 +218,10 @@ function resolveTask( }; } -function resolveEdges(tasks: readonly Task[], properties: ProjectProperties): PrecedenceEdge[] { +function resolveEdges( + tasks: readonly Task[], + properties: ProjectProperties, +): PrecedenceEdge[] { const edges: PrecedenceEdge[] = []; for (const t of tasks) { if (t.uniqueId === null) continue; @@ -249,7 +274,9 @@ function validateTasks(project: ProjectFile): void { throw new Error("resolveCalendar: task missing uniqueId"); } if (!t.start || !t.finish) { - throw new Error(`resolveCalendar: task ${String(t.uniqueId)} missing start or finish`); + throw new Error( + `resolveCalendar: task ${String(t.uniqueId)} missing start or finish`, + ); } } } @@ -267,13 +294,21 @@ function buildCalendarMap( // Always include the default. map.set( defaultCalendarUniqueId ?? SYNTHETIC_CAL_ID, - buildWorkingCalendar(defaultCalendar, defaultCalendarUniqueId, epoch, horizonDays), + 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)); + map.set( + c.uniqueId, + buildWorkingCalendar(c, c.uniqueId, epoch, horizonDays), + ); } return map; } @@ -281,11 +316,15 @@ function buildCalendarMap( /** 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: readonly WorkUnit[] = []): ReadonlyMap { +export function resolveWorkUnits( + units: readonly WorkUnit[] = [], +): 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)}`); + throw new Error( + `resolveWorkUnits: duplicate WorkUnit id ${String(u.id)}`, + ); } map.set(u.id, u); } @@ -306,7 +345,10 @@ function pickDefaultCalendar(project: ProjectFile): { return { calendar: syntheticMonFriCalendar(), uniqueId: null }; } -export function resolveCalendar(project: ProjectFile, opts: ResolveOptions = {}): ResolvedProject { +export function resolveCalendar( + project: ProjectFile, + opts: ResolveOptions = {}, +): ResolvedProject { validateTasks(project); const epoch = pickEpoch(project, opts); const horizonDays = pickHorizon(project, epoch, opts); @@ -324,7 +366,9 @@ export function resolveCalendar(project: ProjectFile, opts: ResolveOptions = {}) // 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 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); diff --git a/src/level-core/scheduleStream.ts b/src/level-core/scheduleStream.ts index 794b024..cc315c5 100644 --- a/src/level-core/scheduleStream.ts +++ b/src/level-core/scheduleStream.ts @@ -90,7 +90,10 @@ export class ScheduleStreamImpl implements ScheduleStream { return bestSchedule; } - async kBestByLex(scorers: readonly Scorer[], k: number): Promise { + async kBestByLex( + scorers: readonly Scorer[], + k: number, + ): Promise { if (k <= 0) return []; const scored: { schedule: Schedule; scores: number[] }[] = []; for await (const s of this.source) { @@ -99,12 +102,18 @@ export class ScheduleStreamImpl implements ScheduleStream { // Stable sort (ES2019 guarantees it): equal-on-all-tiers keeps stream // order, matching bestByLex where first seen wins. scored.sort((a, b) => - lexBetter(a.scores, b.scores, scorers) ? -1 : lexBetter(b.scores, a.scores, scorers) ? 1 : 0, + lexBetter(a.scores, b.scores, scorers) + ? -1 + : lexBetter(b.scores, a.scores, scorers) + ? 1 + : 0, ); return scored.slice(0, k).map((x) => x.schedule); } - async paretoFrontier(scorers: readonly Scorer[]): Promise { + async paretoFrontier( + scorers: readonly Scorer[], + ): Promise { const collected: { schedule: Schedule; scores: number[] }[] = []; for await (const s of this.source) { collected.push({ @@ -173,7 +182,9 @@ function dominates( return strictlyBetter; } -export function streamFromFactory(factory: () => AsyncGenerator): ScheduleStream { +export function streamFromFactory( + factory: () => AsyncGenerator, +): ScheduleStream { return new ScheduleStreamImpl({ [Symbol.asyncIterator]: factory, }); @@ -181,7 +192,9 @@ export function streamFromFactory(factory: () => AsyncGenerator): Sche /** Lazily concatenate streams in declaration order. A later factory is not * invoked until every schedule from the preceding stream has been consumed. */ -export function concatStreams(factories: readonly (() => ScheduleStream)[]): ScheduleStream { +export function concatStreams( + factories: readonly (() => ScheduleStream)[], +): ScheduleStream { return new ScheduleStreamImpl({ async *[Symbol.asyncIterator]() { for (const factory of factories) { diff --git a/src/level-core/search/serialSGS.ts b/src/level-core/search/serialSGS.ts index 63cc100..a7a88b7 100644 --- a/src/level-core/search/serialSGS.ts +++ b/src/level-core/search/serialSGS.ts @@ -56,7 +56,10 @@ interface UnitCap { interface PlacementMode { readonly modeId: number | null; readonly durationDays: number; - readonly demand: readonly { readonly resourceUniqueId: number; readonly units: number }[]; + readonly demand: readonly { + readonly resourceUniqueId: number; + readonly units: number; + }[]; } interface Preprocessed { @@ -72,7 +75,10 @@ interface Preprocessed { readonly explanations: readonly Explanation[]; } -function preprocess(resolved: ResolvedProject, constraints: readonly Constraint[]): Preprocessed { +function preprocess( + resolved: ResolvedProject, + constraints: readonly Constraint[], +): Preprocessed { const edges: PrecedenceEdge[] = [...resolved.precedences]; const caps = new Map(); const unitCaps: UnitCap[] = []; @@ -100,7 +106,10 @@ function preprocess(resolved: ResolvedProject, constraints: readonly Constraint[ const loosenRelease = (taskId: number, earliestStart: number): void => { const prev = releases.get(taskId); - releases.set(taskId, prev === undefined ? earliestStart : Math.max(prev, earliestStart)); + releases.set( + taskId, + prev === undefined ? earliestStart : Math.max(prev, earliestStart), + ); }; for (const c of constraints) { @@ -118,7 +127,9 @@ function preprocess(resolved: ResolvedProject, constraints: readonly Constraint[ addCap(c.resourceUniqueId, { mode: "units", max: c.cap, - window: c.window ? { fromDay: c.window.fromDay, toDay: c.window.toDay } : null, + window: c.window + ? { fromDay: c.window.fromDay, toDay: c.window.toDay } + : null, }); break; case "Deadline": @@ -203,7 +214,9 @@ function preprocess(resolved: ResolvedProject, constraints: readonly Constraint[ const task = taskById.get(taskUniqueId); if (!task) continue; const homeDemand = mergeDemand( - resolved.assignments.filter((assignment) => assignment.taskUniqueId === taskUniqueId), + resolved.assignments.filter( + (assignment) => assignment.taskUniqueId === taskUniqueId, + ), ); const borrowDemand = mergeDemand( homeDemand.map((demand) => @@ -219,11 +232,22 @@ function preprocess(resolved: ResolvedProject, constraints: readonly Constraint[ } } - return { edges, caps, unitCaps, deadlines, releases, modesByTask, explanations }; + return { + edges, + caps, + unitCaps, + deadlines, + releases, + modesByTask, + explanations, + }; } function mergeDemand( - demand: readonly { readonly resourceUniqueId: number; readonly units: number }[], + demand: readonly { + readonly resourceUniqueId: number; + readonly units: number; + }[], ): { resourceUniqueId: number; units: number }[] { const unitsByResource = new Map(); for (const item of demand) { @@ -232,7 +256,10 @@ function mergeDemand( (unitsByResource.get(item.resourceUniqueId) ?? 0) + item.units, ); } - return [...unitsByResource].map(([resourceUniqueId, units]) => ({ resourceUniqueId, units })); + return [...unitsByResource].map(([resourceUniqueId, units]) => ({ + resourceUniqueId, + units, + })); } function topoSort( @@ -247,8 +274,15 @@ function topoSort( successors.set(t.uniqueId, []); } for (const e of edges) { - if (!taskById.has(e.successorUniqueId) || !taskById.has(e.predecessorUniqueId)) continue; - indegree.set(e.successorUniqueId, (indegree.get(e.successorUniqueId) ?? 0) + 1); + if ( + !taskById.has(e.successorUniqueId) || + !taskById.has(e.predecessorUniqueId) + ) + continue; + indegree.set( + e.successorUniqueId, + (indegree.get(e.successorUniqueId) ?? 0) + 1, + ); successors.get(e.predecessorUniqueId)!.push(e.successorUniqueId); } @@ -288,7 +322,8 @@ function topoSort( function safeAdvance(cal: WorkingCalendar, from: number, lag: number): number { if (lag <= 0) return Math.max(0, from); if (from >= cal.horizonDays) return cal.horizonDays; - if (countWorkingDays(cal, from, cal.horizonDays) < lag) return cal.horizonDays; + if (countWorkingDays(cal, from, cal.horizonDays) < lag) + return cal.horizonDays; return advanceWorkingDays(cal, from, lag); } @@ -318,11 +353,17 @@ function earliestStart( candidate = safeAdvance(cal, pred.startDay, e.lagDays); break; case RelationType.FinishToFinish: - candidate = Math.max(0, safeAdvance(cal, pred.finishDay, e.lagDays) - task.durationDays); + candidate = Math.max( + 0, + safeAdvance(cal, pred.finishDay, e.lagDays) - task.durationDays, + ); approximated.push(e); break; case RelationType.StartToFinish: - candidate = Math.max(0, safeAdvance(cal, pred.startDay, e.lagDays) - task.durationDays); + candidate = Math.max( + 0, + safeAdvance(cal, pred.startDay, e.lagDays) - task.durationDays, + ); approximated.push(e); break; } @@ -345,7 +386,10 @@ function isFeasible( taskUniqueId: number, startDay: number, finishDay: number, - demand: readonly { readonly resourceUniqueId: number; readonly units: number }[], + demand: readonly { + readonly resourceUniqueId: number; + readonly units: number; + }[], cal: WorkingCalendar, caps: ReadonlyMap, load: ResourceLoad, @@ -361,9 +405,11 @@ function isFeasible( const currentUnits = dayLoad?.units ?? 0; const alreadyActive = dayLoad?.taskIds.has(taskUniqueId) ?? false; const newUnits = currentUnits + a.units; - const newTaskCount = (dayLoad?.taskIds.size ?? 0) + (alreadyActive ? 0 : 1); + const newTaskCount = + (dayLoad?.taskIds.size ?? 0) + (alreadyActive ? 0 : 1); for (const cap of resourceCaps) { - if (cap.window && (d < cap.window.fromDay || d >= cap.window.toDay)) continue; + if (cap.window && (d < cap.window.fromDay || d >= cap.window.toDay)) + continue; if (cap.mode === "units" && newUnits > cap.max) return false; if (cap.mode === "tasks" && newTaskCount > cap.max) return false; } @@ -387,7 +433,10 @@ function applyLoad( taskUniqueId: number, startDay: number, finishDay: number, - demand: readonly { readonly resourceUniqueId: number; readonly units: number }[], + demand: readonly { + readonly resourceUniqueId: number; + readonly units: number; + }[], cal: WorkingCalendar, load: ResourceLoad, unitCaps: readonly UnitCap[], @@ -443,10 +492,15 @@ function placeTask( ): PlaceResult { const cal = resolveWorkingCalendar(resolved, task.calendarUniqueId); const release = releases.get(task.uniqueId) ?? 0; - const lowerBound = Math.max(release, earliestStart(task, edges, scheduled, cal, approximated)); + const lowerBound = Math.max( + release, + earliestStart(task, edges, scheduled, cal, approximated), + ); const deadlineConstraint = deadlines.get(task.uniqueId) ?? null; - const deadline = deadlineConstraint === null ? null : deadlineConstraint.latestFinish; - const deadlineLabel = deadlineConstraint === null ? "" : constraintLabel(deadlineConstraint); + const deadline = + deadlineConstraint === null ? null : deadlineConstraint.latestFinish; + const deadlineLabel = + deadlineConstraint === null ? "" : constraintLabel(deadlineConstraint); // Candidate modes: the ModeSelection alternatives if any (tried in list order, // so a borrow mode only wins when it lets the task start strictly earlier than @@ -459,7 +513,9 @@ function placeTask( { modeId: null, durationDays: task.durationDays, - demand: resolved.assignments.filter((a) => a.taskUniqueId === task.uniqueId), + demand: resolved.assignments.filter( + (a) => a.taskUniqueId === task.uniqueId, + ), }, ]; @@ -527,7 +583,16 @@ function placeTask( unitLoad, ) ) { - applyLoad(task.uniqueId, startDay, finishDay, mode.demand, cal, load, unitCaps, unitLoad); + applyLoad( + task.uniqueId, + startDay, + finishDay, + mode.demand, + cal, + load, + unitCaps, + unitLoad, + ); scheduled.set(task.uniqueId, { uniqueId: task.uniqueId, startDay, @@ -598,10 +663,15 @@ export const serialSGS: Search = { // recorded on the schedule so reports can tell "absent" from "disabled". const active = constraints.filter((c) => c.enabled !== false); const disabled = constraints.filter((c) => c.enabled === false); - const { edges, caps, unitCaps, deadlines, releases, modesByTask, explanations } = preprocess( - resolved, - active, - ); + const { + edges, + caps, + unitCaps, + deadlines, + releases, + modesByTask, + explanations, + } = preprocess(resolved, active); const order = topoSort(resolved.tasks, edges); const scheduled = new Map(); const load: ResourceLoad = new Map(); @@ -633,21 +703,27 @@ export const serialSGS: Search = { } } - const approximationExplanations: Explanation[] = approximatedEdges.map((e) => ({ - violated: { kind: "Precedence", edges: [e] }, - involvedTaskIds: [e.predecessorUniqueId, e.successorUniqueId], - atDays: null, - message: - `serialSGS: ${e.type === RelationType.FinishToFinish ? "FF" : "SF"} relation ` + - `${String(e.predecessorUniqueId)}→${String(e.successorUniqueId)} approximated ` + - `(start lower bound under-delays vs strict working-day retreat); use a CP-SAT or ` + - `MiniZinc backend for exact semantics.`, - })); + const approximationExplanations: Explanation[] = approximatedEdges.map( + (e) => ({ + violated: { kind: "Precedence", edges: [e] }, + involvedTaskIds: [e.predecessorUniqueId, e.successorUniqueId], + atDays: null, + message: + `serialSGS: ${e.type === RelationType.FinishToFinish ? "FF" : "SF"} relation ` + + `${String(e.predecessorUniqueId)}→${String(e.successorUniqueId)} approximated ` + + `(start lower bound under-delays vs strict working-day retreat); use a CP-SAT or ` + + `MiniZinc backend for exact semantics.`, + }), + ); if (placementFailed) { return { kind: "failure", - explanations: [...explanations, ...approximationExplanations, ...placementExplanations], + explanations: [ + ...explanations, + ...approximationExplanations, + ...placementExplanations, + ], } satisfies Failure; } @@ -658,9 +734,13 @@ export const serialSGS: Search = { } const annotationEntries: [string, unknown][] = []; - if (explanations.length > 0) annotationEntries.push(["unsupportedConstraints", explanations]); + if (explanations.length > 0) + annotationEntries.push(["unsupportedConstraints", explanations]); if (approximationExplanations.length > 0) { - annotationEntries.push(["approximatedRelations", approximationExplanations]); + annotationEntries.push([ + "approximatedRelations", + approximationExplanations, + ]); } if (disabled.length > 0) { annotationEntries.push([ diff --git a/src/level-core/types.ts b/src/level-core/types.ts index 0d1dad7..f597620 100644 --- a/src/level-core/types.ts +++ b/src/level-core/types.ts @@ -179,7 +179,9 @@ export interface PeakCapConstraint extends ConstraintMeta { readonly cap: number; // `| undefined` so zod's `.optional()` round-trips under // exactOptionalPropertyTypes (same convention as WorkUnit). - readonly window?: { readonly fromDay: DayIndex; readonly toDay: DayIndex } | undefined; + readonly window?: + | { readonly fromDay: DayIndex; readonly toDay: DayIndex } + | undefined; } // WIP cap on whole work units. Prioritizes *completion*: caps how many units @@ -376,7 +378,10 @@ export function assertNeverCollector(c: never): never { export interface Explanation { readonly violated: Constraint; readonly involvedTaskIds: readonly number[]; - readonly atDays: { readonly fromDay: DayIndex; readonly toDay: DayIndex } | null; + readonly atDays: { + readonly fromDay: DayIndex; + readonly toDay: DayIndex; + } | null; readonly message: string; } @@ -422,7 +427,10 @@ export interface TaskDelta { readonly uniqueId: number; readonly startDelta: number; readonly finishDelta: number; - readonly modeChange: { readonly from: number | null; readonly to: number | null } | null; + readonly modeChange: { + readonly from: number | null; + readonly to: number | null; + } | null; } export interface ResourceDayDelta { @@ -465,7 +473,10 @@ export interface ScheduleStream { /** The best `k` schedules by those same ordered tiers, best first. Yields * fewer than `k` when the stream is shorter, and none when `k <= 0`. * Ties keep first-seen order (matching `bestByLex`'s determinism). */ - kBestByLex(scorers: readonly Scorer[], k: number): Promise; + kBestByLex( + scorers: readonly Scorer[], + k: number, + ): Promise; paretoFrontier(scorers: readonly Scorer[]): Promise; /** Bounded materialization — caller asserts the stream terminates. */ collect(limit?: number): Promise; @@ -544,7 +555,10 @@ export interface ResolveOptions { readonly workUnits?: readonly WorkUnit[] | undefined; } -export declare function resolveCalendar(project: Project, opts?: ResolveOptions): ResolvedProject; +export declare function resolveCalendar( + project: Project, + opts?: ResolveOptions, +): ResolvedProject; /** Bridge for the no-search-yet case. Reads pre-existing dates from * `resolved.source` into Schedule shape so round-trip identity holds. */ @@ -552,7 +566,10 @@ export declare function currentSchedule(resolved: ResolvedProject): Schedule; export declare function materialize(schedule: Schedule): Project; -export declare function run(pipeline: Pipeline, project: Project): ScheduleStream; +export declare function run( + pipeline: Pipeline, + project: Project, +): ScheduleStream; export declare function buildWorkingCalendar( cal: Calendar | null, diff --git a/src/level-core/weeklyProfile.ts b/src/level-core/weeklyProfile.ts index 82c41ed..7aa5d81 100644 --- a/src/level-core/weeklyProfile.ts +++ b/src/level-core/weeklyProfile.ts @@ -29,7 +29,9 @@ export function effectiveAssignments( if (!realized || realized.length === 0) return schedule.resolved.assignments; const overridden = new Set(realized.map((a) => a.taskUniqueId)); return [ - ...schedule.resolved.assignments.filter((a) => !overridden.has(a.taskUniqueId)), + ...schedule.resolved.assignments.filter( + (a) => !overridden.has(a.taskUniqueId), + ), ...realized, ]; } @@ -66,8 +68,13 @@ export function peakLoad(load: ResourceLoad, resourceId: number): number { return m; } -export function combinedPeakLoad(load: ResourceLoad, resourceIds: readonly number[]): number { - const arrs = resourceIds.map((id) => load.get(id)).filter((a): a is Int16Array => !!a); +export function combinedPeakLoad( + load: ResourceLoad, + resourceIds: readonly number[], +): number { + const arrs = resourceIds + .map((id) => load.get(id)) + .filter((a): a is Int16Array => !!a); if (arrs.length === 0) return 0; let m = 0; const n = arrs[0]!.length; @@ -80,7 +87,10 @@ export function combinedPeakLoad(load: ResourceLoad, resourceIds: readonly numbe } /** Snap `day` back to the Sunday on-or-before in the resolved calendar's epoch frame. */ -export function snapToSunday(resolved: ResolvedProject, day: DayIndex): DayIndex { +export function snapToSunday( + resolved: ResolvedProject, + day: DayIndex, +): DayIndex { const cal = resolveWorkingCalendar(resolved, null); const d = dayToDate(cal, day); return day - d.getDay(); @@ -137,7 +147,10 @@ export function weeklyProfileCombined( } /** Derive a [start, end] day-index range from a Schedule's actual placements. */ -export function scheduleSpan(schedule: Schedule): { startDay: DayIndex; endDay: DayIndex } { +export function scheduleSpan(schedule: Schedule): { + startDay: DayIndex; + endDay: DayIndex; +} { if (schedule.tasks.length === 0) return { startDay: 0, endDay: 0 }; let s = Infinity; let e = 0; diff --git a/src/level-core/weightedScorer.ts b/src/level-core/weightedScorer.ts index 7494763..92fde14 100644 --- a/src/level-core/weightedScorer.ts +++ b/src/level-core/weightedScorer.ts @@ -16,9 +16,13 @@ export interface WeightedEntry { * contribute negated, so a higher raw value still improves the combined * score. Entries with null/zero weight are excluded from the objective * (use `scoreReport` to still see their values). */ -export function weightedScorer(entries: readonly WeightedEntry[], name = "objective"): Scorer { +export function weightedScorer( + entries: readonly WeightedEntry[], + name = "objective", +): Scorer { const active = entries.filter( - (e): e is WeightedEntry & { weight: number } => e.weight !== null && e.weight !== 0, + (e): e is WeightedEntry & { weight: number } => + e.weight !== null && e.weight !== 0, ); return { name, diff --git a/src/model/Duration.ts b/src/model/Duration.ts index 7b30346..f5b6f69 100644 --- a/src/model/Duration.ts +++ b/src/model/Duration.ts @@ -104,7 +104,9 @@ export class Duration { } toSimpleString(): string { - const value = Number.isInteger(this.value) ? this.value.toFixed(1) : String(this.value); + const value = Number.isInteger(this.value) + ? this.value.toFixed(1) + : String(this.value); switch (this.unit) { case TimeUnit.Minutes: return `${value}m`; diff --git a/src/model/types.ts b/src/model/types.ts index e04f565..42e2ea8 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -33,7 +33,8 @@ export const ConstraintType = { FinishNoEarlierThan: "FNET", FinishNoLaterThan: "FNLT", } as const; -export type ConstraintType = (typeof ConstraintType)[keyof typeof ConstraintType]; +export type ConstraintType = + (typeof ConstraintType)[keyof typeof ConstraintType]; export const Priority = { Lowest: 100, @@ -45,4 +46,8 @@ export const Priority = { export type Priority = (typeof Priority)[keyof typeof Priority]; export type { ProjectProperties } from "../schema/project.ts"; -export type { WorkingTimeRange, CalendarWeekDay, CalendarException } from "../schema/calendar.ts"; +export type { + WorkingTimeRange, + CalendarWeekDay, + CalendarException, +} from "../schema/calendar.ts"; diff --git a/src/mpp/FixedData.ts b/src/mpp/FixedData.ts index ed06727..22558cc 100644 --- a/src/mpp/FixedData.ts +++ b/src/mpp/FixedData.ts @@ -6,7 +6,11 @@ export class FixedData { public readonly raw: Uint8Array; private readonly records: (Uint8Array | null)[]; - constructor(raw: Uint8Array, records: (Uint8Array | null)[], offsets: number[]) { + constructor( + raw: Uint8Array, + records: (Uint8Array | null)[], + offsets: number[], + ) { this.raw = raw; this.records = records; this.offsetIndex = new Map(); @@ -30,7 +34,12 @@ export class FixedData { return this.offsetIndex.get(offset) ?? -1; } - static fromMeta(meta: FixedMeta, raw: Uint8Array, maxExpectedSize = 0, minSize = 0): FixedData { + static fromMeta( + meta: FixedMeta, + raw: Uint8Array, + maxExpectedSize = 0, + minSize = 0, + ): FixedData { const records: (Uint8Array | null)[] = []; const offsets: number[] = []; @@ -54,7 +63,9 @@ export class FixedData { itemSize = raw.length - itemOffset; } else { const nextMetaData = meta.getByteArrayValue(index + 1); - const nextOffset = nextMetaData ? MppUtility.getInt(nextMetaData, 4) : raw.length; + const nextOffset = nextMetaData + ? MppUtility.getInt(nextMetaData, 4) + : raw.length; itemSize = nextOffset - itemOffset; } @@ -64,20 +75,29 @@ export class FixedData { const available = raw.length - itemOffset; if (itemSize < 0 || itemSize > available) { - itemSize = maxExpectedSize === 0 ? available : Math.min(maxExpectedSize, available); + itemSize = + maxExpectedSize === 0 + ? available + : Math.min(maxExpectedSize, available); } if (maxExpectedSize !== 0 && itemSize > maxExpectedSize) { itemSize = maxExpectedSize; } - records.push(itemSize > 0 ? raw.subarray(itemOffset, itemOffset + itemSize) : null); + records.push( + itemSize > 0 ? raw.subarray(itemOffset, itemOffset + itemSize) : null, + ); } return new FixedData(raw, records, offsets); } - static fromFixedSize(raw: Uint8Array, itemSize: number, readRemainderBlock = false): FixedData { + static fromFixedSize( + raw: Uint8Array, + itemSize: number, + readRemainderBlock = false, + ): FixedData { const count = Math.floor(raw.length / itemSize) + (readRemainderBlock && raw.length % itemSize !== 0 ? 1 : 0); diff --git a/src/mpp/FixedMeta.ts b/src/mpp/FixedMeta.ts index ed7c878..a2ac3df 100644 --- a/src/mpp/FixedMeta.ts +++ b/src/mpp/FixedMeta.ts @@ -75,9 +75,13 @@ export class FixedMeta { const version = raw.length >= 8 ? MppUtility.getUInt(raw, 4) : 0; const itemCount = raw.length >= 12 ? MppUtility.getUInt(raw, 8) : 0; const itemSize = itemSizeProvider(raw.length, itemCount); - const adjustedItemCount = itemSize > 0 ? Math.floor((raw.length - HEADER_SIZE) / itemSize) : 0; + const adjustedItemCount = + itemSize > 0 ? Math.floor((raw.length - HEADER_SIZE) / itemSize) : 0; const items = Array.from({ length: adjustedItemCount }, (_, index) => - raw.subarray(HEADER_SIZE + index * itemSize, HEADER_SIZE + (index + 1) * itemSize), + raw.subarray( + HEADER_SIZE + index * itemSize, + HEADER_SIZE + (index + 1) * itemSize, + ), ); return new FixedMeta( diff --git a/src/mpp/Mpp14Reader.ts b/src/mpp/Mpp14Reader.ts index 04b094b..fb65e14 100644 --- a/src/mpp/Mpp14Reader.ts +++ b/src/mpp/Mpp14Reader.ts @@ -64,10 +64,12 @@ export class Mpp14Reader { } inspect(): MppInspection { - const streams = Array.from(this.container.streams.entries()).map(([path, raw]) => ({ - path, - size: raw.length, - })); + const streams = Array.from(this.container.streams.entries()).map( + ([path, raw]) => ({ + path, + size: raw.length, + }), + ); return { family: this.variant.family, @@ -93,7 +95,9 @@ export class Mpp14Reader { const project = createEmptyProject(); const projectProps = this.requireProps(this.variant.projectPropsPath); const summaryInformation = this.variant.summaryInformationPath - ? Props.parseSummaryInformation(this.requireStream(this.variant.summaryInformationPath)) + ? Props.parseSummaryInformation( + this.requireStream(this.variant.summaryInformationPath), + ) : { title: null, author: null }; project.properties = { @@ -117,7 +121,9 @@ export class Mpp14Reader { this.attachRelations(taskRecords); this.populateTaskHierarchy(taskRecords, project.properties.finishDate); - project.tasks = taskRecords.map((record) => record.task).sort(compareTaskOrder); + project.tasks = taskRecords + .map((record) => record.task) + .sort(compareTaskOrder); project.resources = resources.sort(compareResourceOrder); project.calendars = calendars.sort(compareCalendarOrder); project.assignments = this.readAssignments( @@ -130,7 +136,10 @@ export class Mpp14Reader { private inspectTable(prefix: string): MppTableInspection { return { props: this.readProps(`${prefix}/Props`), - fixedMeta: this.readFixedMeta(`${prefix}/FixedMeta`, itemSizeForPrefix(prefix)), + fixedMeta: this.readFixedMeta( + `${prefix}/FixedMeta`, + itemSizeForPrefix(prefix), + ), varMeta: this.readVarMeta(`${prefix}/VarMeta`), fixedDataSize: this.getSize(`${prefix}/FixedData`), fixed2DataSize: this.getSize(`${prefix}/Fixed2Data`), @@ -139,7 +148,9 @@ export class Mpp14Reader { } private readTasks(projectFinish: Date | null): TaskRecord[] { - const taskVarMeta = VarMeta.fromBuffer(this.requireStream(this.path("task", "VarMeta"))); + const taskVarMeta = VarMeta.fromBuffer( + this.requireStream(this.path("task", "VarMeta")), + ); const taskVarData = Var2Data.fromMeta( taskVarMeta, this.requireStream(this.path("task", "Var2Data")), @@ -164,7 +175,11 @@ export class Mpp14Reader { ); const taskMap = new Map(); - for (let index = taskFixedMeta.adjustedItemCount - 1; index > 2; index -= 1) { + for ( + let index = taskFixedMeta.adjustedItemCount - 1; + index > 2; + index -= 1 + ) { const data = taskFixedData.getByteArrayValue(index); const data2 = taskFixed2Data.getByteArrayValue(index); const metaData = taskFixedMeta.getByteArrayValue(index); @@ -262,7 +277,10 @@ export class Mpp14Reader { const freeSlackTenths = safeInt(data, 24); const startSlackTenths = safeInt(data, 28); const finishSlackTenths = safeInt(data, 32); - const totalSlackTenths = computeTotalSlack(startSlackTenths, finishSlackTenths); + const totalSlackTenths = computeTotalSlack( + startSlackTenths, + finishSlackTenths, + ); const levelingDelayTenths = safeInt(data, 58); const actualWorkRaw = safeDouble(data, 134); @@ -276,16 +294,25 @@ export class Mpp14Reader { name: taskVarData.getUnicodeStringById(uniqueId, 14), wbs: null, outlineLevel: safeShort(data, 172), - start: MppUtility.getTimestampValue(data, 104) ?? MppUtility.getTimestampValue(data, 96), + start: + MppUtility.getTimestampValue(data, 104) ?? + MppUtility.getTimestampValue(data, 96), finish: - MppUtility.getTimestampValue(data, 108) ?? MppUtility.getTimestampValue(data, 100), - duration: MppUtility.durationFromTenthsOfMinutes(durationTenths, TimeUnit.Minutes), + MppUtility.getTimestampValue(data, 108) ?? + MppUtility.getTimestampValue(data, 100), + duration: MppUtility.durationFromTenthsOfMinutes( + durationTenths, + TimeUnit.Minutes, + ), percentComplete: 0, summary: false, milestone: durationTenths === 0, critical: projectFinish !== null && - MppUtility.isSameMinute(MppUtility.getTimestampValue(data, 100), projectFinish), + MppUtility.isSameMinute( + MppUtility.getTimestampValue(data, 100), + projectFinish, + ), notes: taskVarData.getUnicodeStringById(uniqueId, 15), priority: safeShort(data, 78), cost: 0, @@ -297,8 +324,14 @@ export class Mpp14Reader { baselineFinish: null, baselineDuration: null, constraintType: null, - freeSlack: MppUtility.durationFromTenthsOfMinutes(freeSlackTenths, TimeUnit.Minutes), - totalSlack: MppUtility.durationFromTenthsOfMinutes(totalSlackTenths, TimeUnit.Minutes), + freeSlack: MppUtility.durationFromTenthsOfMinutes( + freeSlackTenths, + TimeUnit.Minutes, + ), + totalSlack: MppUtility.durationFromTenthsOfMinutes( + totalSlackTenths, + TimeUnit.Minutes, + ), earlyStart: MppUtility.getTimestampValue(data, 106), earlyFinish: MppUtility.getTimestampValue(data, 8), lateStart: MppUtility.getTimestampValue(data, 12), @@ -415,7 +448,9 @@ export class Mpp14Reader { const name = calendarVarData.getUnicodeStringById(calendarId, 1) ?? - (resourceId > 0 ? (resourceNames.get(resourceId) ?? null) : "Unnamed Resource"); + (resourceId > 0 + ? (resourceNames.get(resourceId) ?? null) + : "Unnamed Resource"); calendars.set(calendarId, { uniqueId: calendarId, @@ -441,7 +476,11 @@ export class Mpp14Reader { const assignments: Assignment[] = []; - for (let index = 0; index < assignmentFixedMeta.adjustedItemCount; index += 1) { + for ( + let index = 0; + index < assignmentFixedMeta.adjustedItemCount; + index += 1 + ) { const metaData = assignmentFixedMeta.getByteArrayValue(index); if (!metaData || metaData[0] !== 0) { continue; @@ -496,13 +535,19 @@ export class Mpp14Reader { ); let lastConstraintId = -1; - for (let index = 0; index < relationFixedMeta.adjustedItemCount; index += 1) { + for ( + let index = 0; + index < relationFixedMeta.adjustedItemCount; + index += 1 + ) { const metaData = relationFixedMeta.getByteArrayValue(index); if (!metaData || MppUtility.getShort(metaData, 0) !== 0) { continue; } - const recordIndex = relationFixedData.getIndexFromOffset(MppUtility.getInt(metaData, 4)); + const recordIndex = relationFixedData.getIndexFromOffset( + MppUtility.getInt(metaData, 4), + ); if (recordIndex === -1) { continue; } @@ -539,7 +584,10 @@ export class Mpp14Reader { } } - private populateTaskHierarchy(taskRecords: TaskRecord[], projectFinish: Date | null): void { + private populateTaskHierarchy( + taskRecords: TaskRecord[], + projectFinish: Date | null, + ): void { const childrenByParent = new Map(); for (const record of taskRecords) { if (record.parentRawUniqueId === null) { @@ -564,11 +612,16 @@ export class Mpp14Reader { } else if (record.parentRawUniqueId === null) { record.task.wbs = null; } else { - const nextIndex = (counterByParent.get(record.parentRawUniqueId) ?? 0) + 1; + const nextIndex = + (counterByParent.get(record.parentRawUniqueId) ?? 0) + 1; counterByParent.set(record.parentRawUniqueId, nextIndex); const parentWbs = wbsByUniqueId.get(record.parentRawUniqueId); record.task.wbs = - parentWbs === "0" ? `${nextIndex}` : parentWbs ? `${parentWbs}.${nextIndex}` : null; + parentWbs === "0" + ? `${nextIndex}` + : parentWbs + ? `${parentWbs}.${nextIndex}` + : null; if (record.task.wbs) { wbsByUniqueId.set(record.rawUniqueId, record.task.wbs); } @@ -577,7 +630,9 @@ export class Mpp14Reader { if (record.durationTenths !== null) { record.task.duration = MppUtility.durationFromTenthsOfMinutes( record.durationTenths, - record.task.summary || record.durationTenths >= 4800 ? TimeUnit.Days : TimeUnit.Minutes, + record.task.summary || record.durationTenths >= 4800 + ? TimeUnit.Days + : TimeUnit.Minutes, ); } @@ -683,7 +738,9 @@ function normalizeResourceId(value: number): number | null { } function safeShort(buffer: Uint8Array, offset: number): number | null { - return offset + 2 <= buffer.length ? MppUtility.getUShort(buffer, offset) : null; + return offset + 2 <= buffer.length + ? MppUtility.getUShort(buffer, offset) + : null; } function safeInt(buffer: Uint8Array, offset: number): number | null { @@ -691,7 +748,9 @@ function safeInt(buffer: Uint8Array, offset: number): number | null { } function safeDouble(buffer: Uint8Array, offset: number): number | null { - return offset + 8 <= buffer.length ? MppUtility.getDouble(buffer, offset) : null; + return offset + 8 <= buffer.length + ? MppUtility.getDouble(buffer, offset) + : null; } /** @@ -726,11 +785,17 @@ function computeTotalSlack( return Math.min(startSlackTenths, finishSlackTenths); } -function elapsedHoursDuration(start: Date | null, finish: Date | null): Duration | null { +function elapsedHoursDuration( + start: Date | null, + finish: Date | null, +): Duration | null { if (!start || !finish) { return null; } - return Duration.from((finish.getTime() - start.getTime()) / 3_600_000, TimeUnit.Hours); + return Duration.from( + (finish.getTime() - start.getTime()) / 3_600_000, + TimeUnit.Hours, + ); } function parseRelationType(value: number): RelationType { diff --git a/src/mpp/MppReader.ts b/src/mpp/MppReader.ts index ab92241..492c00c 100644 --- a/src/mpp/MppReader.ts +++ b/src/mpp/MppReader.ts @@ -1,7 +1,11 @@ import CFB from "cfb"; import type { ProjectFile } from "../model/Project.ts"; -import { Mpp14Reader, type MppContainer, type MppInspection } from "./Mpp14Reader.ts"; +import { + Mpp14Reader, + type MppContainer, + type MppInspection, +} from "./Mpp14Reader.ts"; import { detectMppVariant } from "./MppVariant.ts"; export class MppReader { @@ -30,10 +34,15 @@ export function parseMppBuffer(data: Uint8Array | ArrayBuffer): MppContainer { const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); if (bytes.byteLength === 0) { - throw new Error("Failed to read MPP data: the buffer is empty and not a valid MPP document."); + throw new Error( + "Failed to read MPP data: the buffer is empty and not a valid MPP document.", + ); } - let cfb: { FullPaths: string[]; FileIndex: { type: number; content?: Uint8Array }[] }; + let cfb: { + FullPaths: string[]; + FileIndex: { type: number; content?: Uint8Array }[]; + }; try { cfb = CFB.read(bytes, { type: "array" }) as typeof cfb; } catch (error: unknown) { diff --git a/src/mpp/MppUtility.ts b/src/mpp/MppUtility.ts index 331789b..3624870 100644 --- a/src/mpp/MppUtility.ts +++ b/src/mpp/MppUtility.ts @@ -3,35 +3,50 @@ import { TimeUnit } from "../model/types.ts"; export class MppUtility { static getInt(buffer: Uint8Array, offset: number): number { - return new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getInt32(offset, true); + return new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ).getInt32(offset, true); } static getUInt(buffer: Uint8Array, offset: number): number { - return new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32( - offset, - true, - ); + return new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ).getUint32(offset, true); } static getShort(buffer: Uint8Array, offset: number): number { - return new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getInt16(offset, true); + return new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ).getInt16(offset, true); } static getUShort(buffer: Uint8Array, offset: number): number { - return new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint16( - offset, - true, - ); + return new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ).getUint16(offset, true); } static getDouble(buffer: Uint8Array, offset: number): number { - return new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getFloat64( - offset, - true, - ); + return new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ).getFloat64(offset, true); } - static getUnicodeString(buffer: Uint8Array, offset: number, byteLength: number): string { + static getUnicodeString( + buffer: Uint8Array, + offset: number, + byteLength: number, + ): string { const slice = buffer.subarray(offset, offset + byteLength); const decoded = Buffer.from(slice).toString("utf16le"); // NUL is the padding byte in MPP's fixed-width UTF-16LE fields, so @@ -45,10 +60,17 @@ export class MppUtility { if (offset >= buffer.length) { return ""; } - return Buffer.from(buffer.subarray(offset)).toString("utf16le").split("\0")[0] ?? ""; + return ( + Buffer.from(buffer.subarray(offset)).toString("utf16le").split("\0")[0] ?? + "" + ); } - static getAnsiString(buffer: Uint8Array, offset = 0, byteLength?: number): string { + static getAnsiString( + buffer: Uint8Array, + offset = 0, + byteLength?: number, + ): string { const slice = byteLength === undefined ? buffer.subarray(offset) @@ -88,7 +110,10 @@ export class MppUtility { return result; } - static durationFromTenthsOfMinutes(value: number | null, unit: TimeUnit): Duration | null { + static durationFromTenthsOfMinutes( + value: number | null, + unit: TimeUnit, + ): Duration | null { if (value === null) { return null; } diff --git a/src/mpp/MppVariant.ts b/src/mpp/MppVariant.ts index e19f3eb..bca91a9 100644 --- a/src/mpp/MppVariant.ts +++ b/src/mpp/MppVariant.ts @@ -18,7 +18,9 @@ export function detectMppVariant(container: MppContainer): MppVariant { const formatPropsPath = detectFormatPropsPath(container); const rootPath = detectRootPath(container); const majorVersion = - parseVersionSuffix(formatPropsPath) ?? inferVersionFromRootPath(rootPath) ?? 14; + parseVersionSuffix(formatPropsPath) ?? + inferVersionFromRootPath(rootPath) ?? + 14; if (majorVersion < 14) { throw new Error( @@ -57,7 +59,8 @@ function detectRootPath(container: MppContainer): string { const candidates = new Map(); for (const path of container.streams.keys()) { - const match = /^(.*)\/TBknd(Task|Rsc|Assn|Cal|Cons)\/Fixed(?:2)?Data$/u.exec(path); + const match = + /^(.*)\/TBknd(Task|Rsc|Assn|Cal|Cons)\/Fixed(?:2)?Data$/u.exec(path); if (!match) { continue; } @@ -94,7 +97,13 @@ function detectRootPath(container: MppContainer): string { ); } - const requiredTables = ["TBkndTask", "TBkndRsc", "TBkndAssn", "TBkndCal", "TBkndCons"]; + const requiredTables = [ + "TBkndTask", + "TBkndRsc", + "TBkndAssn", + "TBkndCal", + "TBkndCons", + ]; for (const table of requiredTables) { const fixedDataPath = `${rootPath}/${table}/FixedData`; if (!container.streams.has(fixedDataPath)) { diff --git a/src/mpp/Props.ts b/src/mpp/Props.ts index 435c98b..a2d3e09 100644 --- a/src/mpp/Props.ts +++ b/src/mpp/Props.ts @@ -13,7 +13,11 @@ export class Props { public readonly summary: PropsSummary; private readonly values: Map; - constructor(raw: Uint8Array, summary: PropsSummary, values: Map) { + constructor( + raw: Uint8Array, + summary: PropsSummary, + values: Map, + ) { this.raw = raw; this.summary = summary; this.values = values; diff --git a/src/mspdi/MspdiReader.ts b/src/mspdi/MspdiReader.ts index f4adb8b..231b286 100644 --- a/src/mspdi/MspdiReader.ts +++ b/src/mspdi/MspdiReader.ts @@ -13,7 +13,11 @@ import { type CalendarException, type CalendarWeekDay, } from "../model/types.ts"; -import { durationContextFromProject, parseLinkLag, parseXsdDuration } from "./XsdDuration.ts"; +import { + durationContextFromProject, + parseLinkLag, + parseXsdDuration, +} from "./XsdDuration.ts"; interface ProjectNode { SaveVersion?: string; @@ -154,12 +158,14 @@ export class MspdiReader { project.calendars = arrayify(root.Calendars?.Calendar).map((calendar) => this.parseCalendar(calendar), ); - project.tasks = arrayify(root.Tasks?.Task).map((task) => this.parseTask(task, durationContext)); + project.tasks = arrayify(root.Tasks?.Task).map((task) => + this.parseTask(task, durationContext), + ); project.resources = arrayify(root.Resources?.Resource).map((resource) => this.parseResource(resource, durationContext), ); - project.assignments = arrayify(root.Assignments?.Assignment).map((assignment) => - this.parseAssignment(assignment, durationContext), + project.assignments = arrayify(root.Assignments?.Assignment).map( + (assignment) => this.parseAssignment(assignment, durationContext), ); return project; @@ -169,7 +175,9 @@ export class MspdiReader { return { uniqueId: parseNumber(node.UID), name: nullIfEmpty(node.Name), - weekDays: arrayify(node.WeekDays?.WeekDay).map((weekDay) => this.parseWeekDay(weekDay)), + weekDays: arrayify(node.WeekDays?.WeekDay).map((weekDay) => + this.parseWeekDay(weekDay), + ), exceptions: arrayify(node.Exceptions?.Exception).map((exception) => this.parseException(exception), ), @@ -180,10 +188,12 @@ export class MspdiReader { return { dayType: parseNumber(node.DayType) ?? 0, working: parseBool(node.DayWorking) ?? false, - workingTimes: arrayify(node.WorkingTimes?.WorkingTime).map((workingTime) => ({ - from: nullIfEmpty(workingTime.FromTime) ?? "08:00:00", - to: nullIfEmpty(workingTime.ToTime) ?? "17:00:00", - })), + workingTimes: arrayify(node.WorkingTimes?.WorkingTime).map( + (workingTime) => ({ + from: nullIfEmpty(workingTime.FromTime) ?? "08:00:00", + to: nullIfEmpty(workingTime.ToTime) ?? "17:00:00", + }), + ), }; } @@ -201,12 +211,14 @@ export class MspdiReader { durationContext: ReturnType, ): Task { const uid = parseNumber(node.UID); - const predecessors: Relation[] = arrayify(node.PredecessorLink).map((link) => ({ - predecessorUniqueId: parseNumber(link.PredecessorUID), - successorUniqueId: uid, - type: parseRelationType(link.Type), - lag: parseLinkLag(link.LinkLag, link.LagFormat, durationContext), - })); + const predecessors: Relation[] = arrayify(node.PredecessorLink).map( + (link) => ({ + predecessorUniqueId: parseNumber(link.PredecessorUID), + successorUniqueId: uid, + type: parseRelationType(link.Type), + lag: parseLinkLag(link.LinkLag, link.LagFormat, durationContext), + }), + ); return { id: parseNumber(node.ID), @@ -230,7 +242,10 @@ export class MspdiReader { actualFinish: parseDate(node.ActualFinish), baselineStart: parseDate(node.BaselineStart), baselineFinish: parseDate(node.BaselineFinish), - baselineDuration: parseXsdDuration(node.BaselineDuration, durationContext), + baselineDuration: parseXsdDuration( + node.BaselineDuration, + durationContext, + ), constraintType: null, freeSlack: parseXsdDuration(node.FreeSlack, durationContext), totalSlack: parseXsdDuration(node.TotalSlack, durationContext), diff --git a/src/mspdi/MspdiWriter.ts b/src/mspdi/MspdiWriter.ts index 84471c8..81a4649 100644 --- a/src/mspdi/MspdiWriter.ts +++ b/src/mspdi/MspdiWriter.ts @@ -5,7 +5,11 @@ import type { Resource } from "../model/Resource.ts"; import type { Task } from "../model/Task.ts"; import { formatProjectDate } from "../dateTime.ts"; import { RelationType, ResourceType } from "../model/types.ts"; -import { durationContextFromProject, formatLinkLag, formatXsdDuration } from "./XsdDuration.ts"; +import { + durationContextFromProject, + formatLinkLag, + formatXsdDuration, +} from "./XsdDuration.ts"; export interface MspdiWriterOptions { saveVersion?: number; @@ -25,9 +29,17 @@ export class MspdiWriter { tag("FinishDate", formatProjectDate(project.properties.finishDate), 1), tag("StatusDate", formatProjectDate(project.properties.statusDate), 1), tag("MinutesPerDay", nullableNumber(project.properties.minutesPerDay), 1), - tag("MinutesPerWeek", nullableNumber(project.properties.minutesPerWeek), 1), + tag( + "MinutesPerWeek", + nullableNumber(project.properties.minutesPerWeek), + 1, + ), tag("DaysPerMonth", nullableNumber(project.properties.daysPerMonth), 1), - tag("CalendarUID", nullableNumber(project.properties.defaultCalendarUniqueId), 1), + tag( + "CalendarUID", + nullableNumber(project.properties.defaultCalendarUniqueId), + 1, + ), " ", ...project.calendars.flatMap((calendar) => this.writeCalendar(calendar)), " ", @@ -35,7 +47,9 @@ export class MspdiWriter { ...project.tasks.flatMap((task) => this.writeTask(task, durationContext)), " ", " ", - ...project.resources.flatMap((resource) => this.writeResource(resource, durationContext)), + ...project.resources.flatMap((resource) => + this.writeResource(resource, durationContext), + ), " ", " ", ...project.assignments.flatMap((assignment) => @@ -105,9 +119,13 @@ export class MspdiWriter { lines.push(tag("OutlineLevel", nullableNumber(task.outlineLevel), 3)); lines.push(tag("Start", formatProjectDate(task.start), 3)); lines.push(tag("Finish", formatProjectDate(task.finish), 3)); - lines.push(tag("Duration", formatXsdDuration(task.duration, durationContext), 3)); + lines.push( + tag("Duration", formatXsdDuration(task.duration, durationContext), 3), + ); lines.push(tag("Work", formatXsdDuration(task.work, durationContext), 3)); - lines.push(tag("ActualWork", formatXsdDuration(task.actualWork, durationContext), 3)); + lines.push( + tag("ActualWork", formatXsdDuration(task.actualWork, durationContext), 3), + ); lines.push(tag("PercentComplete", nullableNumber(task.percentComplete), 3)); lines.push(tag("Summary", formatBoolean(task.summary), 3)); lines.push(tag("Milestone", formatBoolean(task.milestone), 3)); @@ -118,22 +136,44 @@ export class MspdiWriter { lines.push(tag("ActualStart", formatProjectDate(task.actualStart), 3)); lines.push(tag("ActualFinish", formatProjectDate(task.actualFinish), 3)); lines.push(tag("BaselineStart", formatProjectDate(task.baselineStart), 3)); - lines.push(tag("BaselineFinish", formatProjectDate(task.baselineFinish), 3)); lines.push( - tag("BaselineDuration", formatXsdDuration(task.baselineDuration, durationContext), 3), + tag("BaselineFinish", formatProjectDate(task.baselineFinish), 3), + ); + lines.push( + tag( + "BaselineDuration", + formatXsdDuration(task.baselineDuration, durationContext), + 3, + ), + ); + lines.push( + tag("FreeSlack", formatXsdDuration(task.freeSlack, durationContext), 3), + ); + lines.push( + tag("TotalSlack", formatXsdDuration(task.totalSlack, durationContext), 3), ); - lines.push(tag("FreeSlack", formatXsdDuration(task.freeSlack, durationContext), 3)); - lines.push(tag("TotalSlack", formatXsdDuration(task.totalSlack, durationContext), 3)); lines.push(tag("EarlyStart", formatProjectDate(task.earlyStart), 3)); lines.push(tag("EarlyFinish", formatProjectDate(task.earlyFinish), 3)); lines.push(tag("LateStart", formatProjectDate(task.lateStart), 3)); lines.push(tag("LateFinish", formatProjectDate(task.lateFinish), 3)); - lines.push(tag("LevelingDelay", formatXsdDuration(task.levelingDelay, durationContext), 3)); + lines.push( + tag( + "LevelingDelay", + formatXsdDuration(task.levelingDelay, durationContext), + 3, + ), + ); lines.push(tag("Deadline", formatProjectDate(task.deadline), 3)); for (const predecessor of task.predecessors) { const lag = formatLinkLag(predecessor.lag, durationContext); lines.push(" "); - lines.push(tag("PredecessorUID", nullableNumber(predecessor.predecessorUniqueId), 4)); + lines.push( + tag( + "PredecessorUID", + nullableNumber(predecessor.predecessorUniqueId), + 4, + ), + ); lines.push(tag("Type", formatRelationType(predecessor.type), 4)); lines.push(tag("LinkLag", lag.linkLag, 4)); lines.push(tag("LagFormat", lag.lagFormat, 4)); @@ -174,8 +214,16 @@ export class MspdiWriter { tag("Units", formatUnits(assignment.units), 3), tag("Start", formatProjectDate(assignment.start), 3), tag("Finish", formatProjectDate(assignment.finish), 3), - tag("ActualWork", formatXsdDuration(assignment.actualWork, durationContext), 3), - tag("RemainingWork", formatXsdDuration(assignment.remainingWork, durationContext), 3), + tag( + "ActualWork", + formatXsdDuration(assignment.actualWork, durationContext), + 3, + ), + tag( + "RemainingWork", + formatXsdDuration(assignment.remainingWork, durationContext), + 3, + ), " ", ]; } diff --git a/src/mspdi/XsdDuration.ts b/src/mspdi/XsdDuration.ts index 25effe0..21789cc 100644 --- a/src/mspdi/XsdDuration.ts +++ b/src/mspdi/XsdDuration.ts @@ -37,7 +37,10 @@ export function parseXsdDuration( } const totalMinutes = - hours * 60 + minutes + seconds / 60 + toMinutes(days, TimeUnit.Days, context); + hours * 60 + + minutes + + seconds / 60 + + toMinutes(days, TimeUnit.Days, context); return fromWorkingMinutes(totalMinutes, context); } @@ -97,7 +100,10 @@ export function formatLinkLag( } export function durationContextFromProject( - project: Pick, + project: Pick< + ProjectProperties, + "minutesPerDay" | "minutesPerWeek" | "daysPerMonth" + >, ): DurationContext { return { minutesPerDay: project.minutesPerDay, @@ -106,14 +112,23 @@ export function durationContextFromProject( }; } -function fromWorkingMinutes(totalMinutes: number, context?: DurationContext): Duration { +function fromWorkingMinutes( + totalMinutes: number, + context?: DurationContext, +): Duration { const minutesPerWeek = normalizeMinutesPerWeek(context); const minutesPerDay = normalizeMinutesPerDay(context); - if (isWholeMultiple(totalMinutes, minutesPerWeek) && totalMinutes >= minutesPerWeek) { + if ( + isWholeMultiple(totalMinutes, minutesPerWeek) && + totalMinutes >= minutesPerWeek + ) { return Duration.from(totalMinutes / minutesPerWeek, TimeUnit.Weeks); } - if (isWholeMultiple(totalMinutes, minutesPerDay) && totalMinutes >= minutesPerDay) { + if ( + isWholeMultiple(totalMinutes, minutesPerDay) && + totalMinutes >= minutesPerDay + ) { return Duration.from(totalMinutes / minutesPerDay, TimeUnit.Days); } if (isWholeMultiple(totalMinutes, 60) && totalMinutes >= 60) { @@ -122,7 +137,11 @@ function fromWorkingMinutes(totalMinutes: number, context?: DurationContext): Du return Duration.from(totalMinutes, TimeUnit.Minutes); } -function toMinutes(value: number, unit: TimeUnit, context?: DurationContext): number { +function toMinutes( + value: number, + unit: TimeUnit, + context?: DurationContext, +): number { switch (unit) { case TimeUnit.Weeks: return value * normalizeMinutesPerWeek(context); @@ -131,7 +150,9 @@ function toMinutes(value: number, unit: TimeUnit, context?: DurationContext): nu case TimeUnit.Hours: return value * 60; case TimeUnit.Months: - return value * normalizeDaysPerMonth(context) * normalizeMinutesPerDay(context); + return ( + value * normalizeDaysPerMonth(context) * normalizeMinutesPerDay(context) + ); case TimeUnit.Percent: return 0; case TimeUnit.Minutes: @@ -141,7 +162,9 @@ function toMinutes(value: number, unit: TimeUnit, context?: DurationContext): nu } function normalizeMinutesPerDay(context?: DurationContext): number { - return context?.minutesPerDay && context.minutesPerDay > 0 ? context.minutesPerDay : 480; + return context?.minutesPerDay && context.minutesPerDay > 0 + ? context.minutesPerDay + : 480; } function normalizeMinutesPerWeek(context?: DurationContext): number { @@ -151,7 +174,9 @@ function normalizeMinutesPerWeek(context?: DurationContext): number { } function normalizeDaysPerMonth(context?: DurationContext): number { - return context?.daysPerMonth && context.daysPerMonth > 0 ? context.daysPerMonth : 20; + return context?.daysPerMonth && context.daysPerMonth > 0 + ? context.daysPerMonth + : 20; } function isWholeMultiple(value: number, base: number): boolean { diff --git a/src/schema/date.ts b/src/schema/date.ts index 57b46ae..547c96c 100644 --- a/src/schema/date.ts +++ b/src/schema/date.ts @@ -2,16 +2,20 @@ import { z } from "zod"; export const DateStringSchema = z .string() - .refine((val) => !Number.isNaN(Date.parse(val)), { message: "Invalid date string" }) + .refine((val) => !Number.isNaN(Date.parse(val)), { + message: "Invalid date string", + }) .transform((val) => new Date(val)); -export const NullableDateStringSchema = z.nullable(z.string()).transform((val) => { - if (val === null || val === "") { - return null; - } - const parsed = new Date(val); - if (Number.isNaN(parsed.getTime())) { - return null; - } - return parsed; -}); +export const NullableDateStringSchema = z + .nullable(z.string()) + .transform((val) => { + if (val === null || val === "") { + return null; + } + const parsed = new Date(val); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed; + }); diff --git a/src/schema/index.ts b/src/schema/index.ts index f269fa8..820c95a 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -1,6 +1,10 @@ export { ProjectFileSchema, ProjectPropertiesSchema } from "./project.ts"; export type { ProjectFile, ProjectProperties } from "./project.ts"; -export { RunProvenanceSchema, RunIssueJsonSchema, SweepProvenanceSchema } from "./provenance.ts"; +export { + RunProvenanceSchema, + RunIssueJsonSchema, + SweepProvenanceSchema, +} from "./provenance.ts"; export type { RunProvenance, SweepProvenance } from "./provenance.ts"; export { TaskSchema } from "./task.ts"; export type { Task } from "./task.ts"; @@ -14,10 +18,19 @@ export { CalendarExceptionSchema, WorkingTimeRangeSchema, } from "./calendar.ts"; -export type { Calendar, CalendarWeekDay, CalendarException, WorkingTimeRange } from "./calendar.ts"; +export type { + Calendar, + CalendarWeekDay, + CalendarException, + WorkingTimeRange, +} from "./calendar.ts"; export { RelationSchema } from "./relation.ts"; export type { Relation } from "./relation.ts"; -export { DurationSchema, DurationRawSchema, NullableDurationSchema } from "./duration.ts"; +export { + DurationSchema, + DurationRawSchema, + NullableDurationSchema, +} from "./duration.ts"; export { DateStringSchema, NullableDateStringSchema } from "./date.ts"; export { TimeUnitSchema, diff --git a/src/schema/types.ts b/src/schema/types.ts index 5a2a398..bca84fd 100644 --- a/src/schema/types.ts +++ b/src/schema/types.ts @@ -1,5 +1,10 @@ import { z } from "zod"; -import { TimeUnit, RelationType, ResourceType, ConstraintType } from "../model/types.ts"; +import { + TimeUnit, + RelationType, + ResourceType, + ConstraintType, +} from "../model/types.ts"; export const TimeUnitSchema = z.enum(TimeUnit); diff --git a/src/xlsx/XlsxWriter.ts b/src/xlsx/XlsxWriter.ts index 5e9510a..a0bf144 100644 --- a/src/xlsx/XlsxWriter.ts +++ b/src/xlsx/XlsxWriter.ts @@ -37,7 +37,10 @@ const HEADER_FILL: ExcelJS.Fill = { pattern: "solid", fgColor: { argb: "FF4472C4" }, }; -const HEADER_FONT: Partial = { bold: true, color: { argb: "FFFFFFFF" } }; +const HEADER_FONT: Partial = { + bold: true, + color: { argb: "FFFFFFFF" }, +}; /** Visual style hooks for spec-driven sheets. The semantics (e.g. muted rows * placed outside SUM ranges, INTERNAL distribution warnings) are the @@ -125,7 +128,10 @@ function durationUnitLabel(dur: Duration): string { } export class XlsxWriter { - async write(project: ProjectFile, options?: XlsxWriterOptions): Promise { + async write( + project: ProjectFile, + options?: XlsxWriterOptions, + ): Promise { return toBuffer(this.buildWorkbook(project, options)); } @@ -235,7 +241,10 @@ export class XlsxWriter { return this.composeGanttWorkbook(project, gantt, options, sheets); } - private buildWorkbook(project: ProjectFile, options?: XlsxWriterOptions): ExcelJS.Workbook { + private buildWorkbook( + project: ProjectFile, + options?: XlsxWriterOptions, + ): ExcelJS.Workbook { const sheetName = options?.sheetName ?? "Schedule"; const workbook = new ExcelJS.Workbook(); @@ -318,16 +327,28 @@ function applyCellStyle(cell: ExcelJS.Cell, style: CellStyle): void { cell.font = HEADER_FONT; break; case "input": - cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFFF2CC" } }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFF2CC" }, + }; break; case "derived": - cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF2F2F2" } }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFF2F2F2" }, + }; break; case "muted": cell.font = { color: { argb: "FF808080" }, strike: true }; break; case "internal": - cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFCE4D6" } }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFCE4D6" }, + }; break; } } @@ -396,13 +417,19 @@ async function injectGanttChart( const chartPath = "xl/charts/chart1.xml"; const contentTypesPath = "[Content_Types].xml"; - zip.file(chartPath, buildChartXml({ items, origin, end, title, dataSheetName })); + zip.file( + chartPath, + buildChartXml({ items, origin, end, title, dataSheetName }), + ); zip.file(drawingPath, buildDrawingXml(items.length)); zip.file(drawingRelsPath, buildDrawingRelsXml()); zip.file(ganttRelsPath, buildSheetRelsXml()); const sheetXml = await zip.file(ganttSheetPath)!.async("string"); - zip.file(ganttSheetPath, sheetXml.replace("", '')); + zip.file( + ganttSheetPath, + sheetXml.replace("", ''), + ); const ctXml = await zip.file(contentTypesPath)!.async("string"); const ctPatched = ctXml.replace( @@ -447,7 +474,13 @@ function buildPlotShadingXml(n: number): string { ); } -function buildChartXml({ items, origin, end, title, dataSheetName }: InjectArgs): string { +function buildChartXml({ + items, + origin, + end, + title, + dataSheetName, +}: InjectArgs): string { const n = items.length; const lastRow = n + 1; const sheetRef = `'${dataSheetName.replace(/'/g, "''")}'`; diff --git a/test/conf/parse.test.ts b/test/conf/parse.test.ts index a6c7328..798bc38 100644 --- a/test/conf/parse.test.ts +++ b/test/conf/parse.test.ts @@ -7,7 +7,10 @@ import lexicographicRaw from "./fixtures/lexicographic.config.json"; import minimalRaw from "./fixtures/minimal.config.json"; import variadicRaw from "./fixtures/variadic-nested-disabled.config.json"; import { parseConf, type ConfIssue } from "../../src/conf/index.ts"; -import { runConfigSchema, type RunConfig } from "../../src/level-blocks/runConfig.ts"; +import { + runConfigSchema, + type RunConfig, +} from "../../src/level-blocks/runConfig.ts"; import { standardRegistry } from "../../src/level-blocks/standard.ts"; // Imported JSON widens literal types (formatVersion → number); read the @@ -25,7 +28,9 @@ function fixture(name: string): string { function parseOk(name: string) { const result = parseConf(fixture(name), { registry }); if (!result.ok) - throw new Error(`${name} unexpectedly failed:\n${JSON.stringify(result.issues, null, 2)}`); + throw new Error( + `${name} unexpectedly failed:\n${JSON.stringify(result.issues, null, 2)}`, + ); return result.config; } @@ -104,9 +109,13 @@ describe("parseConf — grammar features", () => { test("array-of-tables becomes an object array param", () => { const config = parseOk("lexicographic"); - const lateness = config.pipeline.blocks.find((b) => b.instanceId === "lateness")!; + const lateness = config.pipeline.blocks.find( + (b) => b.instanceId === "lateness", + )!; expect(lateness.params).toEqual({ - phases: [{ name: "phase-1", taskUniqueIds: [1, 2, 3], targetDay: 80, weight: 2 }], + phases: [ + { name: "phase-1", taskUniqueIds: [1, 2, 3], targetDay: 80, weight: 2 }, + ], }); }); @@ -122,14 +131,18 @@ describe("parseConf — grammar features", () => { test("a quoted table key is the verbatim instanceId", () => { const config = parseOk("variadic-nested-disabled"); - expect(config.pipeline.blocks.some((b) => b.instanceId === "units.open")).toBe(true); + expect( + config.pipeline.blocks.some((b) => b.instanceId === "units.open"), + ).toBe(true); }); test("reserved flags: enabled=false, candidate provenance, shadow=true", () => { const config = parseOk("variadic-nested-disabled"); const wip = config.pipeline.blocks.find((b) => b.instanceId === "wip")!; expect(wip.enabled).toBe(false); - const open = config.pipeline.blocks.find((b) => b.instanceId === "units.open")!; + const open = config.pipeline.blocks.find( + (b) => b.instanceId === "units.open", + )!; expect(open.provenance).toBe("candidate"); expect(open.shadow).toBe(true); }); @@ -144,7 +157,9 @@ describe("parseConf — grammar features", () => { test("parsing without a registry skips graph validation (raw config)", () => { // No search block — validateGraph would reject, but registry-less parse // returns the raw config for the caller to fill/validate. - const result = parseConf(`[levelset]\nformat = 1\n[blocks.m]\ncode = "levelset.Makespan.v1"`); + const result = parseConf( + `[levelset]\nformat = 1\n[blocks.m]\ncode = "levelset.Makespan.v1"`, + ); expect(result.ok).toBe(true); }); }); @@ -179,7 +194,9 @@ describe("parseConf — error fixtures (one per code)", () => { }); test("E303 unsupported format", () => { - const issue = firstIssue(`[levelset]\nformat = 2\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`); + const issue = firstIssue( + `[levelset]\nformat = 2\n[blocks.s]\ncode = "levelset.SerialSGS.v1"`, + ); expect(issue.code).toBe("LEVELSET_E303"); }); @@ -201,7 +218,10 @@ describe("parseConf — error fixtures (one per code)", () => { tier: "engineer", block: { id: "Colliding", - schema: { input: z.object({ shadow: z.number() }), output: z.unknown() }, + schema: { + input: z.object({ shadow: z.number() }), + output: z.unknown(), + }, apply: (i: unknown) => i, toMiniZinc: () => ({ text: "" }), doc: { nl: "", pseudocode: "" }, @@ -245,7 +265,9 @@ describe("parseConf — error fixtures (one per code)", () => { }); test("E308 zero searches", () => { - const issue = firstIssue(`[levelset]\nformat = 1\n[blocks.m]\ncode = "levelset.Makespan.v1"`); + const issue = firstIssue( + `[levelset]\nformat = 1\n[blocks.m]\ncode = "levelset.Makespan.v1"`, + ); expect(issue.code).toBe("LEVELSET_E308"); expect(issue.message).toMatch(/exactly one search/); }); diff --git a/test/conf/serialize.test.ts b/test/conf/serialize.test.ts index 2ee784d..4ad2567 100644 --- a/test/conf/serialize.test.ts +++ b/test/conf/serialize.test.ts @@ -28,14 +28,19 @@ import { } from "../level-core/fixtures.ts"; const registry = standardRegistry(); -const FIXTURES = ["lexicographic", "variadic-nested-disabled", "minimal"] as const; +const FIXTURES = [ + "lexicographic", + "variadic-nested-disabled", + "minimal", +] as const; function fixtureText(name: string): string { return readFileSync(`${import.meta.dir}/fixtures/${name}.conf`, "utf8"); } function unwrap(result: ParseConfResult): RunConfig { - if (!result.ok) throw new Error(`parse failed: ${JSON.stringify(result.issues, null, 2)}`); + if (!result.ok) + throw new Error(`parse failed: ${JSON.stringify(result.issues, null, 2)}`); return result.config; } @@ -44,7 +49,9 @@ function roundTrips(config: RunConfig): void { const text = serializeConf(config); const reparsed = parseConf(text); expect(reparsed.ok).toBe(true); - expect(runConfigSchema.parse(unwrap(reparsed))).toEqual(runConfigSchema.parse(config)); + expect(runConfigSchema.parse(unwrap(reparsed))).toEqual( + runConfigSchema.parse(config), + ); } describe("serializeConf — byte-exact goldens", () => { @@ -57,7 +64,9 @@ describe("serializeConf — byte-exact goldens", () => { } test("output is deterministic", () => { - const config = unwrap(parseConf(fixtureText("lexicographic"), { registry })); + const config = unwrap( + parseConf(fixtureText("lexicographic"), { registry }), + ); expect(serializeConf(config)).toBe(serializeConf(config)); }); }); @@ -65,17 +74,25 @@ describe("serializeConf — byte-exact goldens", () => { describe("serializeConf — options", () => { test("header emits top-of-file comment lines", () => { const config = unwrap(parseConf(fixtureText("minimal"), { registry })); - const text = serializeConf(config, { header: ["generated by test", "do not edit"] }); - expect(text.startsWith("# generated by test\n# do not edit\n\n[levelset]")).toBe(true); + const text = serializeConf(config, { + header: ["generated by test", "do not edit"], + }); + expect( + text.startsWith("# generated by test\n# do not edit\n\n[levelset]"), + ).toBe(true); // Comments are ignored on the way back in — round trip still holds. expect(unwrap(parseConf(text))).toEqual(config); }); test("annotate comments each block with its rendered assumption", () => { - const config = unwrap(parseConf(fixtureText("variadic-nested-disabled"), { registry })); + const config = unwrap( + parseConf(fixtureText("variadic-nested-disabled"), { registry }), + ); const text = serializeConf(config, { annotate: registry }); // PeakCap's assumptionTemplate, with {resourceUniqueId}/{cap} substituted. - expect(text).toContain("# peak demand on resource 3 capped at 5 units\n[blocks.peak]"); + expect(text).toContain( + "# peak demand on resource 3 capped at 5 units\n[blocks.peak]", + ); expect(unwrap(parseConf(text))).toEqual(config); }); }); @@ -108,7 +125,9 @@ describe("serializeConf — round-trip guarantee", () => { formatVersion: 1, pipeline: { formatVersion: 1, - blocks: [{ instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }], + blocks: [ + { instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }, + ], }, sweep: { axes: [ @@ -128,10 +147,15 @@ describe("serializeConf — round-trip guarantee", () => { formatVersion: 1, pipeline: { formatVersion: 1, - blocks: [{ instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }], + blocks: [ + { instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }, + ], }, objective: { - lexicographic: ["lateness", { weightedSum: { labor: 2, earliness: 0.25 } }], + lexicographic: [ + "lateness", + { weightedSum: { labor: 2, earliness: 0.25 } }, + ], }, }); roundTrips(config); @@ -162,7 +186,11 @@ describe("serializeConf — round-trip guarantee", () => { ...(rand() < 0.2 ? { provenance: "candidate" as const } : {}), ...(rand() < 0.2 ? { shadow: true } : {}), })); - blocks.push({ instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }); + blocks.push({ + instanceId: "search", + code: STANDARD_CODES.serialSGS, + params: {}, + }); const scorerIds = blocks .filter((b) => scorers.some((d) => d.code === b.code)) @@ -236,7 +264,13 @@ describe("serializeConf — schema coverage", () => { }, { id: "zip", - targets: [{ instanceId: "make", paramPath: "$enabled", valueKey: "enabled" }], + targets: [ + { + instanceId: "make", + paramPath: "$enabled", + valueKey: "enabled", + }, + ], values: [{ enabled: true }, { enabled: false }], }, ], @@ -283,13 +317,21 @@ lexicographic = ["makespan"] test("conf → run → serialize(filled) → parse → run yields the same schedule, scores, and provenance", async () => { const project = crewProject(); - const run1 = runFromConfig(project, unwrap(parseConf(confText, { registry })), { registry }); + const run1 = runFromConfig( + project, + unwrap(parseConf(confText, { registry })), + { registry }, + ); const best1 = await run1.best(); expect(best1).not.toBeNull(); // Serialize the *filled* config (provenance artifact) and run it again. const text2 = serializeConf(run1.config); - const run2 = runFromConfig(project, unwrap(parseConf(text2, { registry })), { registry }); + const run2 = runFromConfig( + project, + unwrap(parseConf(text2, { registry })), + { registry }, + ); const best2 = await run2.best(); expect(best2).not.toBeNull(); diff --git a/test/csv.test.ts b/test/csv.test.ts index ace7fec..4b56cd1 100644 --- a/test/csv.test.ts +++ b/test/csv.test.ts @@ -35,8 +35,12 @@ describe("CsvWriter", () => { expect(row["ID"]).toBe(baseline["ID"]); expect(row["Task Name"]).toBe(baseline["Task Name"]); expect(row["WBS"]).toBe(baseline["WBS"]); - expect(formatMinuteDate(row["Start"])).toBe(formatMinuteDate(baseline["Start"])); - expect(formatMinuteDate(row["Finish"])).toBe(formatMinuteDate(baseline["Finish"])); + expect(formatMinuteDate(row["Start"])).toBe( + formatMinuteDate(baseline["Start"]), + ); + expect(formatMinuteDate(row["Finish"])).toBe( + formatMinuteDate(baseline["Finish"]), + ); expect(row["Duration"]).toBe(baseline["Duration"]); expect(row["Critical"]).toBe(baseline["Critical"]); expect(row["Milestone"]).toBe(baseline["Milestone"]); @@ -44,19 +48,24 @@ describe("CsvWriter", () => { }, ); - test.skipIf(!HAS_MPP_FIXTURE)("includes summary tasks when option is set", async () => { - const { MppReader } = await getMppReader(); - const data = await Bun.file(FIXTURE_MPP_PATH).arrayBuffer(); - const project = new MppReader().read(data); + test.skipIf(!HAS_MPP_FIXTURE)( + "includes summary tasks when option is set", + async () => { + const { MppReader } = await getMppReader(); + const data = await Bun.file(FIXTURE_MPP_PATH).arrayBuffer(); + const project = new MppReader().read(data); - const leafCsv = new CsvWriter().write(project); - const allCsv = new CsvWriter().write(project, { includeSummaryTasks: true }); + const leafCsv = new CsvWriter().write(project); + const allCsv = new CsvWriter().write(project, { + includeSummaryTasks: true, + }); - const leafRows = parseCsv(leafCsv); - const allRows = parseCsv(allCsv); + const leafRows = parseCsv(leafCsv); + const allRows = parseCsv(allCsv); - expect(allRows.length).toBeGreaterThan(leafRows.length); - }); + expect(allRows.length).toBeGreaterThan(leafRows.length); + }, + ); test("excludes Resources column when option is set", () => { const project = makeMinimalProject(); diff --git a/test/helpers.ts b/test/helpers.ts index 033205a..f8750db 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -112,6 +112,8 @@ export function parseCsv(text: string): Record[] { const [header, ...body] = rows; if (!header) return []; return body.map((values) => - Object.fromEntries(header.map((column, index) => [column, values[index] ?? ""])), + Object.fromEntries( + header.map((column, index) => [column, values[index] ?? ""]), + ), ); } diff --git a/test/json.test.ts b/test/json.test.ts index 9677d98..abbb301 100644 --- a/test/json.test.ts +++ b/test/json.test.ts @@ -4,7 +4,12 @@ import { JsonReader } from "../src/json/JsonReader.ts"; import { JsonWriter } from "../src/json/JsonWriter.ts"; import { Duration } from "../src/model/Duration.ts"; import { createEmptyProject } from "../src/model/Project.ts"; -import { TimeUnit, RelationType, ResourceType, ConstraintType } from "../src/model/types.ts"; +import { + TimeUnit, + RelationType, + ResourceType, + ConstraintType, +} from "../src/model/types.ts"; // --------------------------------------------------------------------------- // JsonWriter tests @@ -194,7 +199,11 @@ describe("JsonWriter", () => { uniqueId: 1, name: "Standard", weekDays: [ - { dayType: 2, working: true, workingTimes: [{ from: "08:00:00", to: "17:00:00" }] }, + { + dayType: 2, + working: true, + workingTimes: [{ from: "08:00:00", to: "17:00:00" }], + }, ], exceptions: [ { @@ -662,9 +671,19 @@ describe("JsonReader", () => { uniqueId: 1, name: "Standard", weekDays: [ - { dayType: 2, working: true, workingTimes: [{ from: "09:00:00", to: "17:00:00" }] }, + { + dayType: 2, + working: true, + workingTimes: [{ from: "09:00:00", to: "17:00:00" }], + }, + ], + exceptions: [ + { + name: "Holiday", + fromDate: "2024-12-25T00:00:00", + working: false, + }, ], - exceptions: [{ name: "Holiday", fromDate: "2024-12-25T00:00:00", working: false }], }, ], }); diff --git a/test/level-blocks/blockMeta.test.ts b/test/level-blocks/blockMeta.test.ts index e9daf05..18322bb 100644 --- a/test/level-blocks/blockMeta.test.ts +++ b/test/level-blocks/blockMeta.test.ts @@ -9,7 +9,9 @@ describe("generated/block-meta.json", () => { test("matches a fresh dump of the standard registry", () => { // JSON.parse(JSON.stringify(...)) drops `undefined`-valued keys and the // readonly-ness the in-memory dump carries, matching the on-disk shape. - const fresh = JSON.parse(JSON.stringify(dumpBlockMeta(standardRegistry()))) as typeof committed; + const fresh = JSON.parse( + JSON.stringify(dumpBlockMeta(standardRegistry())), + ) as typeof committed; expect(committed).toEqual(fresh); // If this fails, the registry (a block schema/shadow or the composition // grammar) changed: re-run `bun run scripts/generate-block-meta.ts` and @@ -18,12 +20,16 @@ describe("generated/block-meta.json", () => { test("is stamped with the current levelset version and format 1", () => { const fresh = dumpBlockMeta(standardRegistry()); - expect((committed as { levelsetVersion: string }).levelsetVersion).toBe(fresh.levelsetVersion); + expect((committed as { levelsetVersion: string }).levelsetVersion).toBe( + fresh.levelsetVersion, + ); expect((committed as { formatVersion: number }).formatVersion).toBe(1); }); test("has a stable public JSON subpath in source and published exports", () => { - expect(packageJson.exports["./block-meta.json"]).toBe("./generated/block-meta.json"); + expect(packageJson.exports["./block-meta.json"]).toBe( + "./generated/block-meta.json", + ); expect(packageJson.publishConfig.exports["./block-meta.json"]).toBe( "./generated/block-meta.json", ); diff --git a/test/level-blocks/collectors.test.ts b/test/level-blocks/collectors.test.ts index 47ae87f..61337e5 100644 --- a/test/level-blocks/collectors.test.ts +++ b/test/level-blocks/collectors.test.ts @@ -10,7 +10,10 @@ import { type ResolvedGateLimit, } from "../../src/level-blocks/gate.ts"; import { KBestBlock, KBestInputSchema } from "../../src/level-blocks/kBest.ts"; -import { ParetoBlock, ParetoInputSchema } from "../../src/level-blocks/pareto.ts"; +import { + ParetoBlock, + ParetoInputSchema, +} from "../../src/level-blocks/pareto.ts"; import { ScheduleStreamImpl } from "../../src/level-core/scheduleStream.ts"; import type { Schedule, Scorer } from "../../src/level-core/types.ts"; @@ -62,13 +65,19 @@ describe("KBestBlock", () => { }); test("schema requires a positive k and at least one scorer reference", () => { - expect(KBestInputSchema.safeParse({ k: 0, by: ["makespan"] }).success).toBe(false); + expect(KBestInputSchema.safeParse({ k: 0, by: ["makespan"] }).success).toBe( + false, + ); expect(KBestInputSchema.safeParse({ k: 2, by: [] }).success).toBe(false); - expect(KBestInputSchema.safeParse({ k: 2, by: ["makespan"] }).success).toBe(true); + expect(KBestInputSchema.safeParse({ k: 2, by: ["makespan"] }).success).toBe( + true, + ); }); test("toMiniZinc emits a comment, not a constraint", () => { - expect(KBestBlock.toMiniZinc({ k: 3, by: ["makespan"] }).text).toStartWith("%"); + expect(KBestBlock.toMiniZinc({ k: 3, by: ["makespan"] }).text).toStartWith( + "%", + ); }); }); @@ -82,18 +91,25 @@ describe("ParetoBlock", () => { test("schema requires at least one scorer reference", () => { expect(ParetoInputSchema.safeParse({ over: [] }).success).toBe(false); - expect(ParetoInputSchema.safeParse({ over: ["makespan"] }).success).toBe(true); + expect(ParetoInputSchema.safeParse({ over: ["makespan"] }).success).toBe( + true, + ); }); test("toMiniZinc emits a comment, not a constraint", () => { - expect(ParetoBlock.toMiniZinc({ over: ["makespan"] }).text).toStartWith("%"); + expect(ParetoBlock.toMiniZinc({ over: ["makespan"] }).text).toStartWith( + "%", + ); }); }); describe("GateBlock", () => { test("apply returns a Gate collector spec (absent bound stays absent)", () => { expect( - GateBlock.apply({ limits: [{ scorer: "makespan", max: 5 }], fallback: "leastBad" }), + GateBlock.apply({ + limits: [{ scorer: "makespan", max: 5 }], + fallback: "leastBad", + }), ).toEqual({ kind: "Gate", limits: [{ scorer: "makespan", max: 5 }], @@ -103,29 +119,43 @@ describe("GateBlock", () => { test("schema rejects a limit with neither max nor min, accepts one with either", () => { expect( - GateInputSchema.safeParse({ limits: [{ scorer: "makespan" }], fallback: "none" }).success, + GateInputSchema.safeParse({ + limits: [{ scorer: "makespan" }], + fallback: "none", + }).success, ).toBe(false); expect( - GateInputSchema.safeParse({ limits: [{ scorer: "makespan", max: 5 }], fallback: "none" }) - .success, + GateInputSchema.safeParse({ + limits: [{ scorer: "makespan", max: 5 }], + fallback: "none", + }).success, ).toBe(true); expect( - GateInputSchema.safeParse({ limits: [{ scorer: "makespan", min: 1 }], fallback: "none" }) - .success, + GateInputSchema.safeParse({ + limits: [{ scorer: "makespan", min: 1 }], + fallback: "none", + }).success, ).toBe(true); }); test("schema requires at least one limit and a known fallback", () => { - expect(GateInputSchema.safeParse({ limits: [], fallback: "none" }).success).toBe(false); expect( - GateInputSchema.safeParse({ limits: [{ scorer: "makespan", max: 5 }], fallback: "wat" }) - .success, + GateInputSchema.safeParse({ limits: [], fallback: "none" }).success, + ).toBe(false); + expect( + GateInputSchema.safeParse({ + limits: [{ scorer: "makespan", max: 5 }], + fallback: "wat", + }).success, ).toBe(false); }); test("toMiniZinc emits a comment, not a constraint", () => { expect( - GateBlock.toMiniZinc({ limits: [{ scorer: "makespan", max: 5 }], fallback: "none" }).text, + GateBlock.toMiniZinc({ + limits: [{ scorer: "makespan", max: 5 }], + fallback: "none", + }).text, ).toStartWith("%"); }); }); @@ -139,16 +169,22 @@ describe("selectGate", () => { // makespan <= 5 admits a(4) and b(5); c(7) fails. Two-sided/multi-limit: // cost in [20,60] admits only c. const upper: ResolvedGateLimit[] = [{ scorer: byMakespan, max: 5 }]; - expect(selectGate([a, b, c], upper, "none").map((s) => s.makespan)).toEqual([4, 5]); + expect(selectGate([a, b, c], upper, "none").map((s) => s.makespan)).toEqual( + [4, 5], + ); const band: ResolvedGateLimit[] = [{ scorer: byCost, min: 20, max: 60 }]; - expect(selectGate([a, b, c], band, "none").map((s) => s.makespan)).toEqual([7]); + expect(selectGate([a, b, c], band, "none").map((s) => s.makespan)).toEqual([ + 7, + ]); const both: ResolvedGateLimit[] = [ { scorer: byMakespan, max: 6 }, { scorer: byCost, max: 60 }, ]; - expect(selectGate([a, b, c], both, "none").map((s) => s.makespan)).toEqual([5]); + expect(selectGate([a, b, c], both, "none").map((s) => s.makespan)).toEqual([ + 5, + ]); }); test("fallback 'none' yields the empty set when nothing passes", () => { @@ -190,7 +226,10 @@ describe("kBestByLex", () => { const b = sched(5, 10); const c = sched(5, 20); const d = sched(7, 1); - const top = await streamOf([c, d, a, b]).kBestByLex([byMakespan, byCost], 2); + const top = await streamOf([c, d, a, b]).kBestByLex( + [byMakespan, byCost], + 2, + ); expect(top.map((s) => [s.makespan, s.annotations.get("cost")])).toEqual([ [4, 100], [5, 10], @@ -198,18 +237,35 @@ describe("kBestByLex", () => { }); test("returns the whole stream when k exceeds its length, and none when k <= 0", async () => { - const all = await streamOf([sched(2, 1), sched(1, 2)]).kBestByLex([byMakespan], 9); + const all = await streamOf([sched(2, 1), sched(1, 2)]).kBestByLex( + [byMakespan], + 9, + ); expect(all).toHaveLength(2); - expect(await streamOf([sched(1, 1)]).kBestByLex([byMakespan], 0)).toEqual([]); + expect(await streamOf([sched(1, 1)]).kBestByLex([byMakespan], 0)).toEqual( + [], + ); }); test("honors a direction:max tier when breaking ties (higher wins)", async () => { // makespan ties at 5 between the two gain schedules; the max-direction // gain scorer breaks it, so the higher gain (30) ranks above the lower. - const lowGain: Schedule = { ...sched(5, 0), annotations: new Map([["gain", 10]]) }; - const highGain: Schedule = { ...sched(5, 0), annotations: new Map([["gain", 30]]) }; - const clearWin: Schedule = { ...sched(4, 0), annotations: new Map([["gain", 1]]) }; - const top = await streamOf([lowGain, highGain, clearWin]).kBestByLex([byMakespan, byGain], 2); + const lowGain: Schedule = { + ...sched(5, 0), + annotations: new Map([["gain", 10]]), + }; + const highGain: Schedule = { + ...sched(5, 0), + annotations: new Map([["gain", 30]]), + }; + const clearWin: Schedule = { + ...sched(4, 0), + annotations: new Map([["gain", 1]]), + }; + const top = await streamOf([lowGain, highGain, clearWin]).kBestByLex( + [byMakespan, byGain], + 2, + ); expect(top.map((s) => [s.makespan, s.annotations.get("gain")])).toEqual([ [4, 1], [5, 30], @@ -222,7 +278,10 @@ describe("kBestByLex", () => { const first = sched(5, 10); const second = sched(5, 10); const third = sched(5, 10); - const top = await streamOf([first, second, third]).kBestByLex([byMakespan, byCost], 2); + const top = await streamOf([first, second, third]).kBestByLex( + [byMakespan, byCost], + 2, + ); expect(top).toHaveLength(2); expect(top[0]).toBe(first); expect(top[1]).toBe(second); diff --git a/test/level-blocks/compile.test.ts b/test/level-blocks/compile.test.ts index 39957a9..f006e0b 100644 --- a/test/level-blocks/compile.test.ts +++ b/test/level-blocks/compile.test.ts @@ -57,7 +57,10 @@ function makeGraph(): PipelineGraph { } /** Toy target proving the interface: echoes the grouped families + graph. */ -const echoTarget: CompileTarget<{ families: CompiledFamilies; graph: PipelineGraph }> = { +const echoTarget: CompileTarget<{ + families: CompiledFamilies; + graph: PipelineGraph; +}> = { fromFamilies: (families, graph) => ({ families, graph }), }; @@ -65,10 +68,15 @@ describe("compileGraph — family grouping", () => { test("groups outputs by family and routes them to the target", () => { const { families, graph } = compileGraph(registry, makeGraph(), echoTarget); - expect(families.constraints.map((c) => c.instance.instanceId)).toEqual(["c1", "c2"]); + expect(families.constraints.map((c) => c.instance.instanceId)).toEqual([ + "c1", + "c2", + ]); expect(families.scorers.map((s) => s.instance.instanceId)).toEqual(["s1"]); expect(families.search.instance.instanceId).toBe("g1"); - expect(families.collectors.map((c) => c.instance.instanceId)).toEqual(["p1"]); + expect(families.collectors.map((c) => c.instance.instanceId)).toEqual([ + "p1", + ]); expect(graph.blocks).toHaveLength(5); }); @@ -76,11 +84,22 @@ describe("compileGraph — family grouping", () => { const { families } = compileGraph(registry, makeGraph(), echoTarget); expect(families.constraints[0]!.def.code).toBe("ToyCap"); - expect(families.constraints[0]!.output).toEqual({ tag: "cap", resource: "crane", max: 2 }); - expect(families.constraints[1]!.output).toEqual({ tag: "cap", resource: "forklift", max: 4 }); + expect(families.constraints[0]!.output).toEqual({ + tag: "cap", + resource: "crane", + max: 2, + }); + expect(families.constraints[1]!.output).toEqual({ + tag: "cap", + resource: "forklift", + max: 4, + }); expect(families.scorers[0]!.output).toEqual({ tag: "weight", weight: 0.5 }); expect(families.search.output).toEqual({ tag: "greedy", seed: 7 }); - expect(families.collectors[0]!.output).toEqual({ tag: "probe", label: "trace" }); + expect(families.collectors[0]!.output).toEqual({ + tag: "probe", + label: "trace", + }); }); test("grouping is independent of block order in the graph", () => { @@ -88,7 +107,10 @@ describe("compileGraph — family grouping", () => { graph.blocks.reverse(); const { families } = compileGraph(registry, graph, echoTarget); - expect(families.constraints.map((c) => c.instance.instanceId)).toEqual(["c2", "c1"]); + expect(families.constraints.map((c) => c.instance.instanceId)).toEqual([ + "c2", + "c1", + ]); expect(families.search.instance.instanceId).toBe("g1"); expect(families.scorers).toHaveLength(1); expect(families.collectors).toHaveLength(1); @@ -97,7 +119,10 @@ describe("compileGraph — family grouping", () => { test("returns the target's value", () => { const countTarget: CompileTarget = { fromFamilies: (families) => - families.constraints.length + families.scorers.length + families.collectors.length + 1, + families.constraints.length + + families.scorers.length + + families.collectors.length + + 1, }; expect(compileGraph(registry, makeGraph(), countTarget)).toBe(5); }); @@ -107,7 +132,9 @@ describe("compileGraph — validation", () => { test("throws GraphValidationError on an invalid graph", () => { const graph = makeGraph(); graph.blocks[0]!.code = "Mystery"; - expect(() => compileGraph(registry, graph, echoTarget)).toThrow(GraphValidationError); + expect(() => compileGraph(registry, graph, echoTarget)).toThrow( + GraphValidationError, + ); }); test("the thrown error carries the issues", () => { diff --git a/test/level-blocks/composition.test.ts b/test/level-blocks/composition.test.ts index 3550399..d9a4e3b 100644 --- a/test/level-blocks/composition.test.ts +++ b/test/level-blocks/composition.test.ts @@ -10,16 +10,36 @@ import { } from "../../src/level-blocks/composition.ts"; import type { BlockFamily } from "../../src/level-blocks/registry.ts"; -const FAMILIES: readonly BlockFamily[] = ["constraint", "scorer", "search", "collector"]; +const FAMILIES: readonly BlockFamily[] = [ + "constraint", + "scorer", + "search", + "collector", +]; describe("canFollow — the family grammar", () => { // The full truth table, mirroring connections.ts NEXT_ALLOWED. When a // vendor sync deletes its copy, this table is the contract it inherits. const EXPECTED: Record> = { - constraint: { constraint: true, scorer: true, search: true, collector: false }, + constraint: { + constraint: true, + scorer: true, + search: true, + collector: false, + }, scorer: { constraint: false, scorer: true, search: true, collector: false }, - search: { constraint: false, scorer: false, search: false, collector: true }, - collector: { constraint: false, scorer: false, search: false, collector: true }, + search: { + constraint: false, + scorer: false, + search: false, + collector: true, + }, + collector: { + constraint: false, + scorer: false, + search: false, + collector: true, + }, }; for (const above of FAMILIES) { @@ -33,7 +53,9 @@ describe("canFollow — the family grammar", () => { test("FAMILY_FOLLOWS agrees with canFollow", () => { for (const above of FAMILIES) { for (const below of FAMILIES) { - expect(FAMILY_FOLLOWS[above].includes(below)).toBe(canFollow(above, below)); + expect(FAMILY_FOLLOWS[above].includes(below)).toBe( + canFollow(above, below), + ); } } }); @@ -75,8 +97,9 @@ describe("FAMILY_CARDINALITY", () => { }); describe("sortIntoBandOrder", () => { - const familyOf = (item: { family: BlockFamily | undefined }): BlockFamily | undefined => - item.family; + const familyOf = (item: { + family: BlockFamily | undefined; + }): BlockFamily | undefined => item.family; test("sorts a shuffled set into canonical band order", () => { const items = [ @@ -99,7 +122,11 @@ describe("sortIntoBandOrder", () => { { id: "c1", family: "constraint" as const }, { id: "s1", family: "scorer" as const }, ]; - expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual(["c1", "s2", "s1"]); + expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual([ + "c1", + "s2", + "s1", + ]); }); test("unknown families sink to the end", () => { @@ -107,7 +134,10 @@ describe("sortIntoBandOrder", () => { { id: "unknown", family: undefined }, { id: "constraint", family: "constraint" as const }, ]; - expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual(["constraint", "unknown"]); + expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual([ + "constraint", + "unknown", + ]); }); test("every adjacent pair of a sorted single-search set satisfies canFollow", () => { diff --git a/test/level-blocks/concurrentResourceCost.test.ts b/test/level-blocks/concurrentResourceCost.test.ts index c2d2225..8b6edcf 100644 --- a/test/level-blocks/concurrentResourceCost.test.ts +++ b/test/level-blocks/concurrentResourceCost.test.ts @@ -110,7 +110,10 @@ function makeSchedule(opts: { calendars, }; const resolved = resolveCalendar(project); - const makespan = opts.scheduledTasks.reduce((m, t) => Math.max(m, t.finishDay), 0); + const makespan = opts.scheduledTasks.reduce( + (m, t) => Math.max(m, t.finishDay), + 0, + ); return { resolved, tasks: opts.scheduledTasks, @@ -195,7 +198,9 @@ describe("ConcurrentResourceCostBlock — apply", () => { const schedule = makeSchedule({ resourceUniqueId: 100, assignments: [{ taskUniqueId: 1, units: 1 }], - scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 7, modeId: null }], + scheduledTasks: [ + { uniqueId: 1, startDay: 0, finishDay: 7, modeId: null }, + ], }); const scorer = ConcurrentResourceCostBlock.apply({ resourceUniqueId: 100, @@ -255,7 +260,9 @@ describe("ConcurrentResourceCostBlock — apply", () => { const schedule = makeSchedule({ resourceUniqueId: 100, assignments: [{ taskUniqueId: 1, units: 1 }], - scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 7, modeId: null }], + scheduledTasks: [ + { uniqueId: 1, startDay: 0, finishDay: 7, modeId: null }, + ], }); const scorer = ConcurrentResourceCostBlock.apply({ resourceUniqueId: 100, @@ -324,6 +331,8 @@ describe("ConcurrentResourceCostBlock — metadata", () => { test("has stable id and non-empty doc", () => { expect(ConcurrentResourceCostBlock.id).toBe("ConcurrentResourceCost"); expect(ConcurrentResourceCostBlock.doc.nl.length).toBeGreaterThan(0); - expect(ConcurrentResourceCostBlock.doc.pseudocode.length).toBeGreaterThan(0); + expect(ConcurrentResourceCostBlock.doc.pseudocode.length).toBeGreaterThan( + 0, + ); }); }); diff --git a/test/level-blocks/concurrentUnitsLimit.test.ts b/test/level-blocks/concurrentUnitsLimit.test.ts index dda61fb..fa628cd 100644 --- a/test/level-blocks/concurrentUnitsLimit.test.ts +++ b/test/level-blocks/concurrentUnitsLimit.test.ts @@ -16,37 +16,57 @@ describe("ConcurrentUnitsLimitBlock — input schema", () => { }); test("accepts a discipline-scoped cap", () => { - const parsed = ConcurrentUnitsLimitInputSchema.parse({ unitIds, discipline: 200, max: 2 }); + const parsed = ConcurrentUnitsLimitInputSchema.parse({ + unitIds, + discipline: 200, + max: 2, + }); expect(parsed.discipline).toBe(200); }); test("rejects zero max", () => { - expect(() => ConcurrentUnitsLimitInputSchema.parse({ unitIds, max: 0 })).toThrow(); + expect(() => + ConcurrentUnitsLimitInputSchema.parse({ unitIds, max: 0 }), + ).toThrow(); }); test("rejects non-integer max", () => { - expect(() => ConcurrentUnitsLimitInputSchema.parse({ unitIds, max: 1.5 })).toThrow(); + expect(() => + ConcurrentUnitsLimitInputSchema.parse({ unitIds, max: 1.5 }), + ).toThrow(); }); }); describe("ConcurrentUnitsLimitBlock — apply", () => { test("emits a whole-unit ConcurrentUnitsLimit constraint", () => { const c = ConcurrentUnitsLimitBlock.apply({ unitIds, max: 3 }); - if (c.kind !== "ConcurrentUnitsLimit") throw new Error("wrong constraint kind"); + if (c.kind !== "ConcurrentUnitsLimit") + throw new Error("wrong constraint kind"); expect(c.max).toBe(3); expect(c.discipline).toBeUndefined(); expect(c.unitIds).toEqual(unitIds); }); test("carries the discipline through when set", () => { - const c = ConcurrentUnitsLimitBlock.apply({ unitIds, discipline: 200, max: 2 }); - if (c.kind !== "ConcurrentUnitsLimit") throw new Error("wrong constraint kind"); + const c = ConcurrentUnitsLimitBlock.apply({ + unitIds, + discipline: 200, + max: 2, + }); + if (c.kind !== "ConcurrentUnitsLimit") + throw new Error("wrong constraint kind"); expect(c.discipline).toBe(200); }); test("output passes the output schema", () => { - const out = ConcurrentUnitsLimitBlock.apply({ unitIds, discipline: 200, max: 2 }); - expect(() => ConcurrentUnitsLimitBlock.schema.output.parse(out)).not.toThrow(); + const out = ConcurrentUnitsLimitBlock.apply({ + unitIds, + discipline: 200, + max: 2, + }); + expect(() => + ConcurrentUnitsLimitBlock.schema.output.parse(out), + ).not.toThrow(); }); }); diff --git a/test/level-blocks/crewBorrow.test.ts b/test/level-blocks/crewBorrow.test.ts index b4fa856..6bc52dd 100644 --- a/test/level-blocks/crewBorrow.test.ts +++ b/test/level-blocks/crewBorrow.test.ts @@ -42,15 +42,24 @@ describe("CrewBorrow block", () => { }; test("emits the policy and rejects ambiguous inputs", () => { - expect(CrewBorrowBlock.apply(input)).toEqual({ kind: "CrewBorrow", ...input }); + expect(CrewBorrowBlock.apply(input)).toEqual({ + kind: "CrewBorrow", + ...input, + }); expect(CrewBorrowBlock.toMiniZinc(input).text).toBe( "constraint mode[1] in {0,1};\nconstraint mode[2] in {0,1};", ); expect( - CrewBorrowBlock.schema.input.safeParse({ ...input, taskUniqueIds: [1, 1] }).success, + CrewBorrowBlock.schema.input.safeParse({ + ...input, + taskUniqueIds: [1, 1], + }).success, ).toBe(false); expect( - CrewBorrowBlock.schema.input.safeParse({ ...input, borrowResourceUniqueId: 100 }).success, + CrewBorrowBlock.schema.input.safeParse({ + ...input, + borrowResourceUniqueId: 100, + }).success, ).toBe(false); }); @@ -65,7 +74,9 @@ describe("CrewBorrow block", () => { expect(tasks.get(2)).toMatchObject({ startDay: 0, modeId: 1 }); const task2Demand = schedule - .realizedAssignments!.filter((assignment) => assignment.taskUniqueId === 2) + .realizedAssignments!.filter( + (assignment) => assignment.taskUniqueId === 2, + ) .map(({ resourceUniqueId, units }) => ({ resourceUniqueId, units })); expect(task2Demand).toEqual([ { resourceUniqueId: 200, units: 1 }, @@ -76,9 +87,15 @@ describe("CrewBorrow block", () => { test("analysis rejects missing home demand and overlapping mode policies", () => { const resolved = resolveCalendar(borrowProject()); const missingHome = analyzeRun(resolved, [ - CrewBorrowBlock.apply({ ...input, taskUniqueIds: [1], homeResourceUniqueId: 200 }), + CrewBorrowBlock.apply({ + ...input, + taskUniqueIds: [1], + homeResourceUniqueId: 200, + }), ]); - expect(missingHome.issues.map((issue) => issue.code)).toContain("LEVELSET_E110"); + expect(missingHome.issues.map((issue) => issue.code)).toContain( + "LEVELSET_E110", + ); const overlap = analyzeRun(resolved, [ CrewBorrowBlock.apply({ ...input, taskUniqueIds: [1] }), @@ -88,6 +105,8 @@ describe("CrewBorrow block", () => { modes: [{ modeId: 9, durationDays: 1, resourceDemand: [] }], }, ]); - expect(overlap.issues.map((issue) => issue.code)).toContain("LEVELSET_E111"); + expect(overlap.issues.map((issue) => issue.code)).toContain( + "LEVELSET_E111", + ); }); }); diff --git a/test/level-blocks/graph.test.ts b/test/level-blocks/graph.test.ts index 1260dd9..7d085d2 100644 --- a/test/level-blocks/graph.test.ts +++ b/test/level-blocks/graph.test.ts @@ -52,7 +52,9 @@ describe("pipelineGraphSchema", () => { }); test("rejects unknown formatVersion", () => { - expect(() => pipelineGraphSchema.parse({ formatVersion: 2, blocks: [] })).toThrow(); + expect(() => + pipelineGraphSchema.parse({ formatVersion: 2, blocks: [] }), + ).toThrow(); }); }); @@ -101,7 +103,9 @@ describe("validateGraph — rejections", () => { expect(result.ok).toBe(false); if (!result.ok) { expect( - result.issues.some((i) => i.instanceId === "b1" && i.message.includes("Invalid params")), + result.issues.some( + (i) => i.instanceId === "b1" && i.message.includes("Invalid params"), + ), ).toBe(true); } }); @@ -112,7 +116,9 @@ describe("validateGraph — rejections", () => { const result = validateGraph(registry, graph); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.issues.some((i) => i.message.includes("exactly one search"))).toBe(true); + expect( + result.issues.some((i) => i.message.includes("exactly one search")), + ).toBe(true); } }); @@ -128,7 +134,9 @@ describe("validateGraph — rejections", () => { const result = validateGraph(registry, graph); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.issues.some((i) => i.message.includes("found 2"))).toBe(true); + expect(result.issues.some((i) => i.message.includes("found 2"))).toBe( + true, + ); } }); @@ -138,7 +146,11 @@ describe("validateGraph — rejections", () => { const result = validateGraph(registry, graph); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.issues.some((i) => i.message.includes("Duplicate instanceId: b1"))).toBe(true); + expect( + result.issues.some((i) => + i.message.includes("Duplicate instanceId: b1"), + ), + ).toBe(true); } }); diff --git a/test/level-blocks/hiringLagPenalty.test.ts b/test/level-blocks/hiringLagPenalty.test.ts index 2f6d348..dcff9b7 100644 --- a/test/level-blocks/hiringLagPenalty.test.ts +++ b/test/level-blocks/hiringLagPenalty.test.ts @@ -112,7 +112,10 @@ function makeSchedule(opts: { calendars, }; const resolved = resolveCalendar(project); - const makespan = opts.scheduledTasks.reduce((m, t) => Math.max(m, t.finishDay), 0); + const makespan = opts.scheduledTasks.reduce( + (m, t) => Math.max(m, t.finishDay), + 0, + ); return { resolved, tasks: opts.scheduledTasks, @@ -190,7 +193,9 @@ describe("HiringLagPenaltyBlock — apply", () => { const schedule = makeSchedule({ resourceUniqueId: 100, assignments: [{ taskUniqueId: 1, units: 1 }], - scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }], + scheduledTasks: [ + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, + ], }); const scorer = HiringLagPenaltyBlock.apply({ resourceUniqueId: 100, @@ -225,7 +230,9 @@ describe("HiringLagPenaltyBlock — apply", () => { const schedule = makeSchedule({ resourceUniqueId: 100, assignments: [{ taskUniqueId: 1, units: 5 }], - scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }], + scheduledTasks: [ + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, + ], }); const scorer = HiringLagPenaltyBlock.apply({ resourceUniqueId: 100, diff --git a/test/level-blocks/maxConcurrentResource.test.ts b/test/level-blocks/maxConcurrentResource.test.ts index 3333a76..36270e0 100644 --- a/test/level-blocks/maxConcurrentResource.test.ts +++ b/test/level-blocks/maxConcurrentResource.test.ts @@ -7,7 +7,10 @@ import { describe("MaxConcurrentResourceBlock — input schema", () => { test("accepts valid params", () => { - const parsed = MaxConcurrentResourceInputSchema.parse({ resourceUniqueId: 7, max: 3 }); + const parsed = MaxConcurrentResourceInputSchema.parse({ + resourceUniqueId: 7, + max: 3, + }); expect(parsed).toEqual({ resourceUniqueId: 7, max: 3 }); }); @@ -18,7 +21,9 @@ describe("MaxConcurrentResourceBlock — input schema", () => { }); test("rejects zero max", () => { - expect(() => MaxConcurrentResourceInputSchema.parse({ resourceUniqueId: 7, max: 0 })).toThrow(); + expect(() => + MaxConcurrentResourceInputSchema.parse({ resourceUniqueId: 7, max: 0 }), + ).toThrow(); }); test("rejects non-integer max", () => { @@ -30,26 +35,44 @@ describe("MaxConcurrentResourceBlock — input schema", () => { describe("MaxConcurrentResourceBlock — apply", () => { test("emits a MaxConcurrentResource constraint variant", () => { - const constraint = MaxConcurrentResourceBlock.apply({ resourceUniqueId: 42, max: 2 }); - expect(constraint).toEqual({ kind: "MaxConcurrentResource", resourceUniqueId: 42, max: 2 }); + const constraint = MaxConcurrentResourceBlock.apply({ + resourceUniqueId: 42, + max: 2, + }); + expect(constraint).toEqual({ + kind: "MaxConcurrentResource", + resourceUniqueId: 42, + max: 2, + }); }); test("output passes the output schema", () => { - const out = MaxConcurrentResourceBlock.apply({ resourceUniqueId: 1, max: 3 }); - expect(() => MaxConcurrentResourceBlock.schema.output.parse(out)).not.toThrow(); + const out = MaxConcurrentResourceBlock.apply({ + resourceUniqueId: 1, + max: 3, + }); + expect(() => + MaxConcurrentResourceBlock.schema.output.parse(out), + ).not.toThrow(); }); }); describe("MaxConcurrentResourceBlock — toMiniZinc", () => { test("emits a constraint over the resource's task set", () => { - const fragment = MaxConcurrentResourceBlock.toMiniZinc({ resourceUniqueId: 42, max: 2 }); + const fragment = MaxConcurrentResourceBlock.toMiniZinc({ + resourceUniqueId: 42, + max: 2, + }); expect(fragment.text).toBe( "constraint forall(d in DAYS) ( sum(t in tasks_demanding[42]) (bool2int(active[t,d])) <= 2 );", ); }); test("scales with the configured cap", () => { - const fragment = MaxConcurrentResourceBlock.toMiniZinc({ resourceUniqueId: 5, max: 99 }); + const fragment = MaxConcurrentResourceBlock.toMiniZinc({ + resourceUniqueId: 5, + max: 99, + }); expect(fragment.text).toContain("<= 99"); expect(fragment.text).toContain("tasks_demanding[5]"); }); diff --git a/test/level-blocks/meta.test.ts b/test/level-blocks/meta.test.ts index 9919270..2332dc9 100644 --- a/test/level-blocks/meta.test.ts +++ b/test/level-blocks/meta.test.ts @@ -23,7 +23,10 @@ const defsByCode = new Map(standardBlockDefs().map((d) => [d.code, d])); function meta(code: string) { return deriveBlockMeta(defsByCode.get(code)!); } -function field(fields: BlockFieldSpec[], path: string): BlockFieldSpec | undefined { +function field( + fields: BlockFieldSpec[], + path: string, +): BlockFieldSpec | undefined { return fields.find((f) => f.path === path); } @@ -99,7 +102,9 @@ describe("deriveBlockMeta — complex (object-array) params", () => { }); test("PhaseLateness.phases and Gate.limits are complex", () => { - expect(meta(STANDARD_CODES.phaseLateness).complexParams).toContain("phases"); + expect(meta(STANDARD_CODES.phaseLateness).complexParams).toContain( + "phases", + ); expect(meta(STANDARD_CODES.gate).complexParams).toContain("limits"); }); }); @@ -137,7 +142,9 @@ describe("deriveBlockMeta — registry facts", () => { }); test("assumptionTemplate is included when present, omitted otherwise", () => { - expect(meta(STANDARD_CODES.maxConcurrentResource).assumptionTemplate).toContain("concurrent"); + expect( + meta(STANDARD_CODES.maxConcurrentResource).assumptionTemplate, + ).toContain("concurrent"); expect(meta(STANDARD_CODES.makespan).assumptionTemplate).toBeUndefined(); }); }); @@ -198,7 +205,12 @@ describe("dumpBlockMeta", () => { const dump = dumpBlockMeta(standardRegistry()); expect(dump.formatVersion).toBe(1); expect(dump.blocks.length).toBe(standardBlockDefs().length); - expect(dump.composition.order).toEqual(["constraint", "scorer", "search", "collector"]); + expect(dump.composition.order).toEqual([ + "constraint", + "scorer", + "search", + "collector", + ]); expect(dump.composition.cardinality.search).toEqual({ min: 1, max: 1 }); expect(dump.levelsetVersion.length).toBeGreaterThan(0); }); diff --git a/test/level-blocks/newBlocks.test.ts b/test/level-blocks/newBlocks.test.ts index 4bb6fab..3738bd9 100644 --- a/test/level-blocks/newBlocks.test.ts +++ b/test/level-blocks/newBlocks.test.ts @@ -32,7 +32,11 @@ describe("constraint blocks", () => { cap: 2.5, }); expect( - PeakCapBlock.apply({ resourceUniqueId: 100, cap: 2, window: { fromDay: 0, toDay: 10 } }), + PeakCapBlock.apply({ + resourceUniqueId: 100, + cap: 2, + window: { fromDay: 0, toDay: 10 }, + }), ).toEqual({ kind: "PeakCap", resourceUniqueId: 100, @@ -40,8 +44,11 @@ describe("constraint blocks", () => { window: { fromDay: 0, toDay: 10 }, }); expect( - PeakCapBlock.toMiniZinc({ resourceUniqueId: 100, cap: 2, window: { fromDay: 0, toDay: 10 } }) - .text, + PeakCapBlock.toMiniZinc({ + resourceUniqueId: 100, + cap: 2, + window: { fromDay: 0, toDay: 10 }, + }).text, ).toContain("d >= 0"); }); @@ -57,28 +64,45 @@ describe("constraint blocks", () => { earliestStart: 5, }); expect( - DeadlineBlock.schema.input.safeParse({ taskUniqueId: 1, latestFinish: -1 }).success, + DeadlineBlock.schema.input.safeParse({ + taskUniqueId: 1, + latestFinish: -1, + }).success, ).toBe(false); }); test("PhaseRelease expands a shared boundary into attributed Release constraints", () => { - expect(PhaseReleaseBlock.apply({ taskUniqueIds: [1, 2], earliestStart: 5 })).toEqual([ + expect( + PhaseReleaseBlock.apply({ taskUniqueIds: [1, 2], earliestStart: 5 }), + ).toEqual([ { kind: "Release", taskUniqueId: 1, earliestStart: 5 }, { kind: "Release", taskUniqueId: 2, earliestStart: 5 }, ]); - expect(PhaseReleaseBlock.toMiniZinc({ taskUniqueIds: [1, 2], earliestStart: 5 }).text).toBe( - "constraint start_day[1] >= 5;\nconstraint start_day[2] >= 5;", - ); expect( - PhaseReleaseBlock.schema.input.safeParse({ taskUniqueIds: [1, 1], earliestStart: 5 }).success, + PhaseReleaseBlock.toMiniZinc({ taskUniqueIds: [1, 2], earliestStart: 5 }) + .text, + ).toBe("constraint start_day[1] >= 5;\nconstraint start_day[2] >= 5;"); + expect( + PhaseReleaseBlock.schema.input.safeParse({ + taskUniqueIds: [1, 1], + earliestStart: 5, + }).success, ).toBe(false); }); }); describe("ModeSelection block", () => { const modes = [ - { modeId: 1, durationDays: 2, resourceDemand: [{ resourceUniqueId: 100, units: 1 }] }, - { modeId: 2, durationDays: 4, resourceDemand: [{ resourceUniqueId: 200, units: 0.5 }] }, + { + modeId: 1, + durationDays: 2, + resourceDemand: [{ resourceUniqueId: 100, units: 1 }], + }, + { + modeId: 2, + durationDays: 4, + resourceDemand: [{ resourceUniqueId: 200, units: 0.5 }], + }, ]; test("emits the constraint and pins the MiniZinc mode domain", () => { @@ -144,8 +168,16 @@ describe("ModeSelection block", () => { ModeSelectionBlock.apply({ taskUniqueId, modes: [ - { modeId: 0, durationDays: 5, resourceDemand: [{ resourceUniqueId: 100, units: 1 }] }, - { modeId: 1, durationDays: 5, resourceDemand: [{ resourceUniqueId: 200, units: 1 }] }, + { + modeId: 0, + durationDays: 5, + resourceDemand: [{ resourceUniqueId: 100, units: 1 }], + }, + { + modeId: 1, + durationDays: 5, + resourceDemand: [{ resourceUniqueId: 200, units: 1 }], + }, ], }); const schedule = await runOnce(project, [ diff --git a/test/level-blocks/openUnitPenalty.test.ts b/test/level-blocks/openUnitPenalty.test.ts index a9f6454..69195ef 100644 --- a/test/level-blocks/openUnitPenalty.test.ts +++ b/test/level-blocks/openUnitPenalty.test.ts @@ -6,7 +6,11 @@ import { } from "../../src/level-blocks/openUnitPenalty.ts"; import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; import { ResourceType } from "../../src/model/types.ts"; -import type { Schedule, ScheduledTask, WorkUnit } from "../../src/level-core/types.ts"; +import type { + Schedule, + ScheduledTask, + WorkUnit, +} from "../../src/level-core/types.ts"; import type { Calendar } from "../../src/schema/calendar.ts"; import type { ProjectFile } from "../../src/schema/project.ts"; @@ -108,8 +112,16 @@ function makeSchedule(opts: { calendars, }; const resolved = resolveCalendar(project, { workUnits: opts.workUnits }); - const makespan = opts.scheduledTasks.reduce((m, t) => Math.max(m, t.finishDay), 0); - return { resolved, tasks: opts.scheduledTasks, makespan, annotations: new Map() }; + const makespan = opts.scheduledTasks.reduce( + (m, t) => Math.max(m, t.finishDay), + 0, + ); + return { + resolved, + tasks: opts.scheduledTasks, + makespan, + annotations: new Map(), + }; } describe("OpenUnitPenaltyBlock — input schema", () => { @@ -120,11 +132,15 @@ describe("OpenUnitPenaltyBlock — input schema", () => { }); test("rejects negative softMax", () => { - expect(() => OpenUnitPenaltyInputSchema.parse({ unitIds: [], softMax: -1 })).toThrow(); + expect(() => + OpenUnitPenaltyInputSchema.parse({ unitIds: [], softMax: -1 }), + ).toThrow(); }); test("rejects non-positive weight", () => { - expect(() => OpenUnitPenaltyInputSchema.parse({ unitIds: [], weight: 0 })).toThrow(); + expect(() => + OpenUnitPenaltyInputSchema.parse({ unitIds: [], weight: 0 }), + ).toThrow(); }); }); @@ -147,7 +163,11 @@ describe("OpenUnitPenaltyBlock — apply (whole-unit)", () => { { id: 20, taskUniqueIds: [2] }, ], }); - const scorer = OpenUnitPenaltyBlock.apply({ unitIds: [10, 20], softMax: 1, weight: 1 }); + const scorer = OpenUnitPenaltyBlock.apply({ + unitIds: [10, 20], + softMax: 1, + weight: 1, + }); expect(scorer.score(schedule)).toBe(5); }); @@ -167,7 +187,11 @@ describe("OpenUnitPenaltyBlock — apply (whole-unit)", () => { { id: 20, taskUniqueIds: [2] }, ], }); - const scorer = OpenUnitPenaltyBlock.apply({ unitIds: [10, 20], softMax: 2, weight: 1 }); + const scorer = OpenUnitPenaltyBlock.apply({ + unitIds: [10, 20], + softMax: 2, + weight: 1, + }); expect(scorer.score(schedule)).toBe(0); }); @@ -188,7 +212,11 @@ describe("OpenUnitPenaltyBlock — apply (whole-unit)", () => { ], workUnits: [{ id: 10, taskUniqueIds: [1, 2] }], }); - const scorer = OpenUnitPenaltyBlock.apply({ unitIds: [10], softMax: 0, weight: 1 }); + const scorer = OpenUnitPenaltyBlock.apply({ + unitIds: [10], + softMax: 0, + weight: 1, + }); expect(scorer.score(schedule)).toBe(4); }); @@ -196,10 +224,16 @@ describe("OpenUnitPenaltyBlock — apply (whole-unit)", () => { const schedule = makeSchedule({ resourceUniqueIds: [100], assignments: [{ taskUniqueId: 1, resourceUniqueId: 100 }], - scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }], + scheduledTasks: [ + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, + ], workUnits: [{ id: 10, taskUniqueIds: [1] }], }); - const scorer = OpenUnitPenaltyBlock.apply({ unitIds: [10], softMax: 0, weight: 10 }); + const scorer = OpenUnitPenaltyBlock.apply({ + unitIds: [10], + softMax: 0, + weight: 10, + }); // 5 working days open × 10 = 50. expect(scorer.score(schedule)).toBe(50); }); @@ -210,11 +244,21 @@ describe("OpenUnitPenaltyBlock — apply (whole-unit)", () => { const schedule = makeSchedule({ resourceUniqueIds: [100], assignments: [{ taskUniqueId: 1, resourceUniqueId: 100 }], - scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }], + scheduledTasks: [ + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, + ], workUnits: [{ id: 10, taskUniqueIds: [1] }], }); - const once = OpenUnitPenaltyBlock.apply({ unitIds: [10], softMax: 0, weight: 1 }); - const twice = OpenUnitPenaltyBlock.apply({ unitIds: [10, 10], softMax: 0, weight: 1 }); + const once = OpenUnitPenaltyBlock.apply({ + unitIds: [10], + softMax: 0, + weight: 1, + }); + const twice = OpenUnitPenaltyBlock.apply({ + unitIds: [10, 10], + softMax: 0, + weight: 1, + }); expect(twice.score(schedule)).toBe(once.score(schedule)); expect(twice.score(schedule)).toBe(5); }); @@ -249,7 +293,11 @@ describe("OpenUnitPenaltyBlock — apply (discipline-scoped)", () => { describe("OpenUnitPenaltyBlock — toMiniZinc", () => { test("emits a per-day soft penalty over the open-span matrix", () => { - const fragment = OpenUnitPenaltyBlock.toMiniZinc({ unitIds: [10, 20], softMax: 1, weight: 2 }); + const fragment = OpenUnitPenaltyBlock.toMiniZinc({ + unitIds: [10, 20], + softMax: 1, + weight: 2, + }); expect(fragment.text).toContain("open_unit_penalty"); expect(fragment.text).toContain("unit_open_span[u,d]"); expect(fragment.text).toContain("sum(u in {10,20})"); @@ -260,9 +308,10 @@ describe("OpenUnitPenaltyBlock — toMiniZinc", () => { describe("OpenUnitPenaltyBlock — metadata", () => { test("has stable id, min direction, and non-empty doc", () => { expect(OpenUnitPenaltyBlock.id).toBe("OpenUnitPenalty"); - expect(OpenUnitPenaltyBlock.apply({ unitIds: [], softMax: 0, weight: 1 }).direction).toBe( - "min", - ); + expect( + OpenUnitPenaltyBlock.apply({ unitIds: [], softMax: 0, weight: 1 }) + .direction, + ).toBe("min"); expect(OpenUnitPenaltyBlock.doc.nl.length).toBeGreaterThan(0); expect(OpenUnitPenaltyBlock.doc.pseudocode.length).toBeGreaterThan(0); }); diff --git a/test/level-blocks/phaseScorers.test.ts b/test/level-blocks/phaseScorers.test.ts index fa56e27..f756cd0 100644 --- a/test/level-blocks/phaseScorers.test.ts +++ b/test/level-blocks/phaseScorers.test.ts @@ -17,12 +17,20 @@ import { PhaseLatenessInputSchema, } from "../../src/level-blocks/phaseLateness.ts"; import { PhaseWindowSchema } from "../../src/level-blocks/phaseScoring.ts"; -import { fillRunConfig, runFromConfig } from "../../src/level-blocks/runConfig.ts"; +import { + fillRunConfig, + runFromConfig, +} from "../../src/level-blocks/runConfig.ts"; import { STANDARD_CODES } from "../../src/level-blocks/standard.ts"; import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; import type { Schedule, ScheduledTask } from "../../src/level-core/types.ts"; -import { MON_JAN_5, SAT_JAN_10, makeProject, makeTask } from "../level-core/fixtures.ts"; +import { + MON_JAN_5, + SAT_JAN_10, + makeProject, + makeTask, +} from "../level-core/fixtures.ts"; /** Wrap chosen scheduled day indices in a Schedule. The ProjectFile's task * dates only seed the calendar/horizon; scoring reads the ScheduledTask day @@ -40,7 +48,10 @@ function scheduleWith(scheduledTasks: ScheduledTask[]): Schedule { describe("PhaseWindow schema", () => { test("defaults weight to 1 and keeps name optional", () => { - const parsed = PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: 5 }); + const parsed = PhaseWindowSchema.parse({ + taskUniqueIds: [1], + targetDay: 5, + }); expect(parsed.weight).toBe(1); expect(parsed.name).toBeUndefined(); }); @@ -49,7 +60,9 @@ describe("PhaseWindow schema", () => { expect(() => PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: 5, weight: 0 }), ).toThrow(); - expect(() => PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: -1 })).toThrow(); + expect(() => + PhaseWindowSchema.parse({ taskUniqueIds: [1], targetDay: -1 }), + ).toThrow(); }); test("empty phases arrays parse (schema-permissive, like OpenUnitPenalty)", () => { @@ -74,7 +87,9 @@ describe("PhaseLateness — apply", () => { test("weight scales the penalty", () => { // finish = 5, target 2 → [2,5) = {2,3,4} = 3, × weight 2 = 6. - const schedule = scheduleWith([{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }]); + const schedule = scheduleWith([ + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, + ]); const scorer = PhaseLatenessBlock.apply({ phases: [{ taskUniqueIds: [1], targetDay: 2, weight: 2 }], }); @@ -82,7 +97,9 @@ describe("PhaseLateness — apply", () => { }); test("a phase finishing on or before target costs nothing", () => { - const schedule = scheduleWith([{ uniqueId: 1, startDay: 0, finishDay: 2, modeId: null }]); + const schedule = scheduleWith([ + { uniqueId: 1, startDay: 0, finishDay: 2, modeId: null }, + ]); // finish 2 == target 2 → 0; finish before target → also 0. expect( PhaseLatenessBlock.apply({ @@ -97,7 +114,9 @@ describe("PhaseLateness — apply", () => { }); test("a phase with no scheduled tasks contributes nothing", () => { - const schedule = scheduleWith([{ uniqueId: 1, startDay: 0, finishDay: 8, modeId: null }]); + const schedule = scheduleWith([ + { uniqueId: 1, startDay: 0, finishDay: 8, modeId: null }, + ]); // Phase references task 99, absent from the schedule → skipped. const scorer = PhaseLatenessBlock.apply({ phases: [{ taskUniqueIds: [99], targetDay: 0, weight: 5 }], @@ -174,7 +193,14 @@ describe("PhaseLateness — apply", () => { { uniqueId: 12, startDay: 4, finishDay: 9, modeId: null }, ]); const scorer = PhaseLatenessBlock.apply({ - phases: [{ name: "assembly", taskUniqueIds: [10, 11, 12], targetDay: 4, weight: 3 }], + phases: [ + { + name: "assembly", + taskUniqueIds: [10, 11, 12], + targetDay: 4, + weight: 3, + }, + ], }); expect(scorer.score(schedule)).toBe(9); }); @@ -195,7 +221,9 @@ describe("PhaseEarliness — apply", () => { }); test("a phase starting on or after target costs nothing", () => { - const schedule = scheduleWith([{ uniqueId: 1, startDay: 3, finishDay: 8, modeId: null }]); + const schedule = scheduleWith([ + { uniqueId: 1, startDay: 3, finishDay: 8, modeId: null }, + ]); expect( PhaseEarlinessBlock.apply({ phases: [{ taskUniqueIds: [1], targetDay: 3, weight: 1 }], @@ -209,7 +237,9 @@ describe("PhaseEarliness — apply", () => { }); test("weight scales and reads declares task ids", () => { - const schedule = scheduleWith([{ uniqueId: 7, startDay: 0, finishDay: 6, modeId: null }]); + const schedule = scheduleWith([ + { uniqueId: 7, startDay: 0, finishDay: 6, modeId: null }, + ]); // start 0, target 3 → [0,3) = 3, × weight 4 = 12. const scorer = PhaseEarlinessBlock.apply({ phases: [{ taskUniqueIds: [7], targetDay: 3, weight: 4 }], @@ -247,7 +277,9 @@ describe("Phase scorers — RunConfig wiring", () => { // One unconstrained task Mon Jan5 → Sat Jan10 schedules at day 0 and runs // 5 working days → finishDay 5. A phase targeting day 2 is late by the // working days in [2,5) = {2,3,4} = 3. - const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + const project = makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]); const config = fillRunConfig({ formatVersion: 1, pipeline: { @@ -256,7 +288,11 @@ describe("Phase scorers — RunConfig wiring", () => { { instanceId: "phase-late", code: STANDARD_CODES.phaseLateness, - params: { phases: [{ name: "P1", taskUniqueIds: [1], targetDay: 2, weight: 1 }] }, + params: { + phases: [ + { name: "P1", taskUniqueIds: [1], targetDay: 2, weight: 1 }, + ], + }, }, { instanceId: "makespan", code: STANDARD_CODES.makespan, params: {} }, { instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }, diff --git a/test/level-blocks/pipelineFixtures.ts b/test/level-blocks/pipelineFixtures.ts index 01fd300..c61e19e 100644 --- a/test/level-blocks/pipelineFixtures.ts +++ b/test/level-blocks/pipelineFixtures.ts @@ -5,7 +5,11 @@ import { z, type ZodType } from "zod"; import type { Block } from "../../src/level-blocks/types.ts"; -import type { BlockDef, BlockFamily, BlockTier } from "../../src/level-blocks/registry.ts"; +import type { + BlockDef, + BlockFamily, + BlockTier, +} from "../../src/level-blocks/registry.ts"; function toyBlock( id: string, @@ -22,8 +26,15 @@ function toyBlock( }; } -const capInput = z.object({ resource: z.string(), max: z.number().int().positive() }); -const capOutput = z.object({ tag: z.literal("cap"), resource: z.string(), max: z.number() }); +const capInput = z.object({ + resource: z.string(), + max: z.number().int().positive(), +}); +const capOutput = z.object({ + tag: z.literal("cap"), + resource: z.string(), + max: z.number(), +}); const weightInput = z.object({ weight: z.number().nonnegative() }); const weightOutput = z.object({ tag: z.literal("weight"), weight: z.number() }); @@ -34,26 +45,46 @@ const greedyOutput = z.object({ tag: z.literal("greedy"), seed: z.number() }); const probeInput = z.object({ label: z.string() }); const probeOutput = z.object({ tag: z.literal("probe"), label: z.string() }); -export const ToyCapBlock = toyBlock("ToyCap", capInput, capOutput, ({ resource, max }) => ({ - tag: "cap" as const, - resource, - max, -})); +export const ToyCapBlock = toyBlock( + "ToyCap", + capInput, + capOutput, + ({ resource, max }) => ({ + tag: "cap" as const, + resource, + max, + }), +); -export const ToyWeightBlock = toyBlock("ToyWeight", weightInput, weightOutput, ({ weight }) => ({ - tag: "weight" as const, - weight, -})); +export const ToyWeightBlock = toyBlock( + "ToyWeight", + weightInput, + weightOutput, + ({ weight }) => ({ + tag: "weight" as const, + weight, + }), +); -export const ToyGreedyBlock = toyBlock("ToyGreedy", greedyInput, greedyOutput, ({ seed }) => ({ - tag: "greedy" as const, - seed, -})); +export const ToyGreedyBlock = toyBlock( + "ToyGreedy", + greedyInput, + greedyOutput, + ({ seed }) => ({ + tag: "greedy" as const, + seed, + }), +); -export const ToyProbeBlock = toyBlock("ToyProbe", probeInput, probeOutput, ({ label }) => ({ - tag: "probe" as const, - label, -})); +export const ToyProbeBlock = toyBlock( + "ToyProbe", + probeInput, + probeOutput, + ({ label }) => ({ + tag: "probe" as const, + label, + }), +); export function makeDef( code: string, @@ -67,9 +98,23 @@ export function makeDef( export function makeDefs(): BlockDef[] { return [ - makeDef("ToyCap", "constraint", ToyCapBlock as never, { resource: "crane", max: 2 }, "sales"), + makeDef( + "ToyCap", + "constraint", + ToyCapBlock as never, + { resource: "crane", max: 2 }, + "sales", + ), makeDef("ToyWeight", "scorer", ToyWeightBlock as never, { weight: 1 }), - makeDef("ToyGreedy", "search", ToyGreedyBlock as never, { seed: 0 }, "engineer"), - makeDef("ToyProbe", "collector", ToyProbeBlock as never, { label: "probe" }), + makeDef( + "ToyGreedy", + "search", + ToyGreedyBlock as never, + { seed: 0 }, + "engineer", + ), + makeDef("ToyProbe", "collector", ToyProbeBlock as never, { + label: "probe", + }), ]; } diff --git a/test/level-blocks/provenance.test.ts b/test/level-blocks/provenance.test.ts index 4699c72..ab995de 100644 --- a/test/level-blocks/provenance.test.ts +++ b/test/level-blocks/provenance.test.ts @@ -86,7 +86,9 @@ describe("run provenance", () => { const reread = new JsonReader().read(new JsonWriter().write(annotated)); expect(reread.levelset).toEqual(annotated.levelset); - expect((reread.levelset as Record)["futureField"]).toEqual({ nested: true }); + expect((reread.levelset as Record)["futureField"]).toEqual( + { nested: true }, + ); }); test("unknown nested sweep provenance keys survive the round trip", async () => { @@ -107,7 +109,9 @@ describe("run provenance", () => { }; const reread = new JsonReader().read(new JsonWriter().write(annotated)); - expect((reread.levelset!.sweep as Record)["futureDiagnostic"]).toEqual({ + expect( + (reread.levelset!.sweep as Record)["futureDiagnostic"], + ).toEqual({ retained: true, }); }); diff --git a/test/level-blocks/registry.test.ts b/test/level-blocks/registry.test.ts index 1be0d50..e459588 100644 --- a/test/level-blocks/registry.test.ts +++ b/test/level-blocks/registry.test.ts @@ -19,7 +19,10 @@ describe("createRegistry — register/get/list", () => { test("register adds a def after construction", () => { const registry = createRegistry(); registry.register( - makeDef("ToyCap", "constraint", ToyCapBlock as never, { resource: "crane", max: 2 }), + makeDef("ToyCap", "constraint", ToyCapBlock as never, { + resource: "crane", + max: 2, + }), ); expect(registry.get("ToyCap")).toBeDefined(); }); @@ -43,8 +46,12 @@ describe("createRegistry — register/get/list", () => { test("list filters by family and tier together", () => { const registry = createRegistry(makeDefs()); - expect(registry.list({ family: "constraint", tier: "engineer" })).toHaveLength(0); - expect(registry.list({ family: "search", tier: "engineer" })).toHaveLength(1); + expect( + registry.list({ family: "constraint", tier: "engineer" }), + ).toHaveLength(0); + expect(registry.list({ family: "search", tier: "engineer" })).toHaveLength( + 1, + ); }); }); @@ -53,7 +60,10 @@ describe("createRegistry — registration validation", () => { const registry = createRegistry(makeDefs()); expect(() => registry.register( - makeDef("ToyCap", "constraint", ToyCapBlock as never, { resource: "x", max: 1 }), + makeDef("ToyCap", "constraint", ToyCapBlock as never, { + resource: "x", + max: 1, + }), ), ).toThrow(/duplicate/i); }); @@ -61,7 +71,10 @@ describe("createRegistry — registration validation", () => { test("throws when defaultShadow fails the block input schema", () => { expect(() => createRegistry([ - makeDef("BadShadow", "constraint", ToyCapBlock as never, { resource: "crane", max: 0 }), + makeDef("BadShadow", "constraint", ToyCapBlock as never, { + resource: "crane", + max: 0, + }), ]), ).toThrow(/defaultShadow/); }); @@ -69,7 +82,9 @@ describe("createRegistry — registration validation", () => { test("a failed register leaves the registry unchanged", () => { const registry = createRegistry(); expect(() => - registry.register(makeDef("Bad", "constraint", ToyCapBlock as never, { max: 2 })), + registry.register( + makeDef("Bad", "constraint", ToyCapBlock as never, { max: 2 }), + ), ).toThrow(); expect(registry.get("Bad")).toBeUndefined(); expect(registry.list()).toHaveLength(0); @@ -81,7 +96,9 @@ describe("createRegistry — registration validation", () => { resource: "crane", max: 2, }); - def.panelBindings = [{ paramPath: "max", label: "Max crews", control: "number" }]; + def.panelBindings = [ + { paramPath: "max", label: "Max crews", control: "number" }, + ]; def.projectAnchoredPaths = ["resource"]; expect(() => registry.register(def)).not.toThrow(); }); @@ -92,7 +109,9 @@ describe("createRegistry — registration validation", () => { resource: "crane", max: 2, }); - def.panelBindings = [{ paramPath: "maximum", label: "Max crews", control: "number" }]; + def.panelBindings = [ + { paramPath: "maximum", label: "Max crews", control: "number" }, + ]; expect(() => registry.register(def)).toThrow(/"maximum" does not resolve/); }); @@ -103,6 +122,8 @@ describe("createRegistry — registration validation", () => { max: 2, }); def.projectAnchoredPaths = ["window.start"]; - expect(() => registry.register(def)).toThrow(/"window.start" does not resolve/); + expect(() => registry.register(def)).toThrow( + /"window.start" does not resolve/, + ); }); }); diff --git a/test/level-blocks/runConfig.test.ts b/test/level-blocks/runConfig.test.ts index 7627586..e199b94 100644 --- a/test/level-blocks/runConfig.test.ts +++ b/test/level-blocks/runConfig.test.ts @@ -6,8 +6,14 @@ import { z } from "zod"; import { GraphValidationError } from "../../src/level-blocks/compile.ts"; import type { BlockDef } from "../../src/level-blocks/registry.ts"; -import { fillRunConfig, runFromConfig } from "../../src/level-blocks/runConfig.ts"; -import { STANDARD_CODES, standardRegistry } from "../../src/level-blocks/standard.ts"; +import { + fillRunConfig, + runFromConfig, +} from "../../src/level-blocks/runConfig.ts"; +import { + STANDARD_CODES, + standardRegistry, +} from "../../src/level-blocks/standard.ts"; import { RunAnalysisError } from "../../src/level-core/errors.ts"; import { LEVELSET_VERSION } from "../../src/version.ts"; @@ -66,7 +72,9 @@ describe("fillRunConfig", () => { test("expands block-schema defaults and stamps the levelset version", () => { const filled = fillRunConfig(baseConfig()); expect(filled.levelset).toBe(LEVELSET_VERSION); - const wipCost = filled.pipeline.blocks.find((b) => b.instanceId === "wip-cost")!; + const wipCost = filled.pipeline.blocks.find( + (b) => b.instanceId === "wip-cost", + )!; // threshold has a schema default of 0 the input config never mentioned. expect(wipCost.params).toEqual({ resourceUniqueId: 100, @@ -83,9 +91,9 @@ describe("fillRunConfig", () => { }); test("rejects weights keyed by non-scorer instances", () => { - expect(() => fillRunConfig({ ...baseConfig(), weights: { "crew-cap": 1 } })).toThrow( - GraphValidationError, - ); + expect(() => + fillRunConfig({ ...baseConfig(), weights: { "crew-cap": 1 } }), + ).toThrow(GraphValidationError); }); }); @@ -116,13 +124,18 @@ describe("runFromConfig", () => { ...baseConfig(), weights: { "wip-cost": null, makespan: 1 }, }); - expect(run.scorers.find((s) => s.instanceId === "wip-cost")!.weight).toBeNull(); + expect( + run.scorers.find((s) => s.instanceId === "wip-cost")!.weight, + ).toBeNull(); expect(run.objective.name).toBe("objective"); }); test("a disabled constraint block compiles to a disabled constraint", () => { const config = baseConfig(); - config.pipeline.blocks[0] = { ...config.pipeline.blocks[0]!, enabled: false }; + config.pipeline.blocks[0] = { + ...config.pipeline.blocks[0]!, + enabled: false, + }; const run = runFromConfig(crewProject(), config); expect(run.constraints[0]!.enabled).toBe(false); }); @@ -150,10 +163,9 @@ describe("runFromConfig", () => { params: { taskUniqueIds: [1, 2], earliestStart: 3 }, }); const run = runFromConfig(crewProject(), config); - expect(run.constraints.slice(0, 2).map((constraint) => constraint.name)).toEqual([ - "phase-release[0]", - "phase-release[1]", - ]); + expect( + run.constraints.slice(0, 2).map((constraint) => constraint.name), + ).toEqual(["phase-release[0]", "phase-release[1]"]); const best = await run.best(); expect(best!.tasks.every((task) => task.startDay >= 3)).toBe(true); }); @@ -183,7 +195,10 @@ describe("runFromConfig", () => { ).toThrow(GraphValidationError); const config = baseConfig(); - config.pipeline.blocks[1] = { ...config.pipeline.blocks[1]!, enabled: false }; + config.pipeline.blocks[1] = { + ...config.pipeline.blocks[1]!, + enabled: false, + }; expect(() => runFromConfig(crewProject(), { ...config, @@ -209,7 +224,10 @@ describe("runFromConfig", () => { ).toThrow(GraphValidationError); const disabled = baseConfig(); - disabled.pipeline.blocks[1] = { ...disabled.pipeline.blocks[1]!, enabled: false }; + disabled.pipeline.blocks[1] = { + ...disabled.pipeline.blocks[1]!, + enabled: false, + }; expect(() => runFromConfig(crewProject(), { ...disabled, @@ -249,7 +267,10 @@ describe("runFromConfig", () => { ); const run = runFromConfig(crewProject(), config); const results = await run.collectors(); - expect(results.map((r) => r.instanceId).sort()).toEqual(["frontier", "top3"]); + expect(results.map((r) => r.instanceId).sort()).toEqual([ + "frontier", + "top3", + ]); const kbest = results.find((r) => r.instanceId === "top3")!; const pareto = results.find((r) => r.instanceId === "frontier")!; @@ -301,7 +322,9 @@ describe("runFromConfig", () => { // "crew-cap" is a constraint instance, not a scorer. params: { k: 2, by: ["crew-cap"] }, }); - expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); + expect(() => runFromConfig(crewProject(), config)).toThrow( + GraphValidationError, + ); }); test("a collector referencing a missing instance fails validation", () => { @@ -311,7 +334,9 @@ describe("runFromConfig", () => { code: STANDARD_CODES.pareto, params: { over: ["nope"] }, }); - expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); + expect(() => runFromConfig(crewProject(), config)).toThrow( + GraphValidationError, + ); }); // A custom collector whose input carries scorer refs under a param name @@ -333,7 +358,11 @@ describe("runFromConfig", () => { by: z.array(z.string()), }), }, - apply: ({ scorers }: { scorers: string[] }) => ({ kind: "KBest", k: 1, by: scorers }), + apply: ({ scorers }: { scorers: string[] }) => ({ + kind: "KBest", + k: 1, + by: scorers, + }), toMiniZinc: () => ({ text: "% pick" }), doc: { nl: "pick", pseudocode: "pick" }, }, @@ -349,7 +378,9 @@ describe("runFromConfig", () => { code: "myapp.PickBy.v1", params: { scorers: ["ghost"] }, }); - expect(() => fillRunConfig(dangling, registry)).toThrow(GraphValidationError); + expect(() => fillRunConfig(dangling, registry)).toThrow( + GraphValidationError, + ); const valid = baseConfig(); valid.pipeline.blocks.push({ @@ -363,18 +394,24 @@ describe("runFromConfig", () => { test("a collector referencing a disabled scorer fails validation", () => { const config = baseConfig(); // Disable the wip-cost scorer, then reference it from a collector. - config.pipeline.blocks[1] = { ...config.pipeline.blocks[1]!, enabled: false }; + config.pipeline.blocks[1] = { + ...config.pipeline.blocks[1]!, + enabled: false, + }; config.pipeline.blocks.push({ instanceId: "top3", code: STANDARD_CODES.kBest, params: { k: 2, by: ["wip-cost"] }, }); - expect(() => runFromConfig(crewProject(), config)).toThrow(/disabled scorer/); + expect(() => runFromConfig(crewProject(), config)).toThrow( + /disabled scorer/, + ); }); test("a Gate collector filters the stream and honors its fallback", async () => { // serialSGS emits one schedule; learn its makespan to set gate bounds. - const m = (await runFromConfig(crewProject(), baseConfig()).best())!.makespan; + const m = (await runFromConfig(crewProject(), baseConfig()).best())! + .makespan; const withGate = (max: number, fallback: "leastBad" | "none") => { const config = baseConfig(); @@ -405,7 +442,9 @@ describe("runFromConfig", () => { code: STANDARD_CODES.gate, params: { limits: [{ scorer: "crew-cap", max: 5 }], fallback: "none" }, }); - expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); + expect(() => runFromConfig(crewProject(), config)).toThrow( + GraphValidationError, + ); }); test("analysis errors throw RunAnalysisError with coded issues", () => { @@ -423,7 +462,11 @@ describe("runFromConfig", () => { } expect(caught).toBeInstanceOf(RunAnalysisError); const issues = (caught as RunAnalysisError).issues; - expect(issues.some((i) => i.code === "LEVELSET_E101" && i.source === "ghost-cap")).toBe(true); + expect( + issues.some( + (i) => i.code === "LEVELSET_E101" && i.source === "ghost-cap", + ), + ).toBe(true); }); test("unknown block codes fail graph validation", () => { @@ -433,7 +476,9 @@ describe("runFromConfig", () => { code: "levelset.DoesNotExist.v1", params: {}, }; - expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); + expect(() => runFromConfig(crewProject(), config)).toThrow( + GraphValidationError, + ); }); test("resolve options flow through (workUnits become resolvable refs)", () => { diff --git a/test/level-blocks/standard.test.ts b/test/level-blocks/standard.test.ts index e2d7f6f..ef477fd 100644 --- a/test/level-blocks/standard.test.ts +++ b/test/level-blocks/standard.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"; -import { STANDARD_CODES, standardRegistry } from "../../src/level-blocks/standard.ts"; +import { + STANDARD_CODES, + standardRegistry, +} from "../../src/level-blocks/standard.ts"; import type { BlockDef } from "../../src/level-blocks/registry.ts"; describe("standardRegistry", () => { @@ -17,8 +20,12 @@ describe("standardRegistry", () => { test("covers all four families with exactly one search", () => { const registry = standardRegistry(); - expect(registry.list({ family: "constraint" }).length).toBeGreaterThanOrEqual(5); - expect(registry.list({ family: "scorer" }).length).toBeGreaterThanOrEqual(5); + expect( + registry.list({ family: "constraint" }).length, + ).toBeGreaterThanOrEqual(5); + expect(registry.list({ family: "scorer" }).length).toBeGreaterThanOrEqual( + 5, + ); expect(registry.list({ family: "search" }).map((d) => d.code)).toEqual([ STANDARD_CODES.serialSGS, ]); @@ -29,7 +36,9 @@ describe("standardRegistry", () => { // accepts them so shadow blocks are runnable, not just parseable. for (const def of standardRegistry().list()) { const parsed = def.block.schema.input.parse(def.defaultShadow); - expect(() => (def.block as { apply(input: unknown): unknown }).apply(parsed)).not.toThrow(); + expect(() => + (def.block as { apply(input: unknown): unknown }).apply(parsed), + ).not.toThrow(); } }); @@ -41,8 +50,10 @@ describe("standardRegistry", () => { block: { id: "Probe", schema: { - input: standardRegistry().get(STANDARD_CODES.makespan)!.block.schema.input, - output: standardRegistry().get(STANDARD_CODES.makespan)!.block.schema.output, + input: standardRegistry().get(STANDARD_CODES.makespan)!.block.schema + .input, + output: standardRegistry().get(STANDARD_CODES.makespan)!.block.schema + .output, }, apply: () => ({ tag: "probe" }), toMiniZinc: () => ({ text: "% probe" }), diff --git a/test/level-blocks/sweep.test.ts b/test/level-blocks/sweep.test.ts index b41a914..2d30e07 100644 --- a/test/level-blocks/sweep.test.ts +++ b/test/level-blocks/sweep.test.ts @@ -1,9 +1,16 @@ import { describe, expect, test } from "bun:test"; import { GraphValidationError } from "../../src/level-blocks/compile.ts"; -import { fillRunConfig, runFromConfig } from "../../src/level-blocks/runConfig.ts"; +import { + fillRunConfig, + runFromConfig, +} from "../../src/level-blocks/runConfig.ts"; import { STANDARD_CODES } from "../../src/level-blocks/standard.ts"; -import { expandSweep, expandSweepCells, SweepError } from "../../src/level-blocks/sweep.ts"; +import { + expandSweep, + expandSweepCells, + SweepError, +} from "../../src/level-blocks/sweep.ts"; import { MON_JAN_5, @@ -139,7 +146,9 @@ describe("GridSweep expansion and validation", () => { axes: [ { id: "ragged", - targets: [{ instanceId: "cap-a", paramPath: "max", valueKey: "A" }], + targets: [ + { instanceId: "cap-a", paramPath: "max", valueKey: "A" }, + ], values: [{ B: 1 }], }, ], @@ -279,7 +288,9 @@ describe("GridSweep execution", () => { // methods return void — but they return a promise at runtime, so dropping // this await would let the test finish before the assertion resolves. // oxlint-disable-next-line typescript/await-thenable - await expect(runFromConfig(project(), infeasible).best()).rejects.toBeInstanceOf(SweepError); + await expect( + runFromConfig(project(), infeasible).best(), + ).rejects.toBeInstanceOf(SweepError); }); test("skips and records an infeasible cell while retaining feasible candidates", async () => { diff --git a/test/level-blocks/unimodalDeviation.test.ts b/test/level-blocks/unimodalDeviation.test.ts index 1ce8468..4472b63 100644 --- a/test/level-blocks/unimodalDeviation.test.ts +++ b/test/level-blocks/unimodalDeviation.test.ts @@ -110,7 +110,10 @@ function makeSchedule(opts: { calendars, }; const resolved = resolveCalendar(project); - const makespan = opts.scheduledTasks.reduce((m, t) => Math.max(m, t.finishDay), 0); + const makespan = opts.scheduledTasks.reduce( + (m, t) => Math.max(m, t.finishDay), + 0, + ); return { resolved, tasks: opts.scheduledTasks, @@ -136,7 +139,10 @@ describe("UnimodalDeviationBlock — input schema", () => { test("rejects negative jitterTolerance", () => { expect(() => - UnimodalDeviationInputSchema.parse({ resourceUniqueId: 7, jitterTolerance: -1 }), + UnimodalDeviationInputSchema.parse({ + resourceUniqueId: 7, + jitterTolerance: -1, + }), ).toThrow(); }); }); diff --git a/test/level-core/analyzeRun.test.ts b/test/level-core/analyzeRun.test.ts index 82971e9..4f6be16 100644 --- a/test/level-core/analyzeRun.test.ts +++ b/test/level-core/analyzeRun.test.ts @@ -5,7 +5,11 @@ import { describe, expect, test } from "bun:test"; import { analyzeRun } from "../../src/level-core/analyzeRun.ts"; import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; import { serialSGS } from "../../src/level-core/search/serialSGS.ts"; -import type { Constraint, ResolvedProject, Scorer } from "../../src/level-core/types.ts"; +import type { + Constraint, + ResolvedProject, + Scorer, +} from "../../src/level-core/types.ts"; import { Duration } from "../../src/model/Duration.ts"; import { RelationType, TimeUnit } from "../../src/model/types.ts"; @@ -50,21 +54,34 @@ describe("analyzeRun — reference checks", () => { test("unknown resource / task / unit references are coded errors with attribution", () => { const analysis = analyzeRun(resolvedFixture(), [ - { kind: "MaxConcurrentResource", resourceUniqueId: 999, max: 1, name: "ghost-cap" }, + { + kind: "MaxConcurrentResource", + resourceUniqueId: 999, + max: 1, + name: "ghost-cap", + }, { kind: "Deadline", taskUniqueId: 999, latestFinish: 30 }, { kind: "ConcurrentUnitsLimit", unitIds: [999], max: 1 }, ]); expect(analysis.ok).toBe(false); expect(codes(analysis.issues)).toEqual( - expect.arrayContaining(["LEVELSET_E101", "LEVELSET_E102", "LEVELSET_E103"]) as string[], + expect.arrayContaining([ + "LEVELSET_E101", + "LEVELSET_E102", + "LEVELSET_E103", + ]) as string[], ); - const resourceIssue = analysis.issues.find((i) => i.code === "LEVELSET_E101")!; + const resourceIssue = analysis.issues.find( + (i) => i.code === "LEVELSET_E101", + )!; expect(resourceIssue.source).toBe("ghost-cap"); expect(resourceIssue.message).toContain("999"); }); test("unknown calendar reference is LEVELSET_E109", () => { - const analysis = analyzeRun(resolvedFixture(), [{ kind: "Calendars", calendarUniqueId: 42 }]); + const analysis = analyzeRun(resolvedFixture(), [ + { kind: "Calendars", calendarUniqueId: 42 }, + ]); expect(codes(analysis.issues)).toContain("LEVELSET_E109"); }); @@ -89,7 +106,9 @@ describe("analyzeRun — cross-constraint feasibility", () => { ]); expect(analysis.ok).toBe(false); expect(codes(analysis.issues)).toContain("LEVELSET_E104"); - expect(analysis.issues.find((i) => i.code === "LEVELSET_E104")!.source).toBe("gate"); + expect( + analysis.issues.find((i) => i.code === "LEVELSET_E104")!.source, + ).toBe("gate"); }); test("deadline tighter than task duration is LEVELSET_E105", () => { @@ -105,8 +124,19 @@ describe("analyzeRun — cross-constraint feasibility", () => { // Each of these would be an error if enabled; disabled they are exactly // the what-if ablation case and must not invalidate the run. const analysis = analyzeRun(resolvedFixture(), [ - { kind: "Deadline", taskUniqueId: 1, latestFinish: 0, enabled: false, name: "ablated" }, - { kind: "MaxConcurrentResource", resourceUniqueId: 999, max: 1, enabled: false }, + { + kind: "Deadline", + taskUniqueId: 1, + latestFinish: 0, + enabled: false, + name: "ablated", + }, + { + kind: "MaxConcurrentResource", + resourceUniqueId: 999, + max: 1, + enabled: false, + }, { kind: "Precedence", enabled: false, @@ -135,7 +165,12 @@ describe("analyzeRun — cross-constraint feasibility", () => { // carries (serialSGS's milestone branch), so a same-day release + // deadline pair is feasible and must not be reported as E105. const gate = { - ...makeTask({ uniqueId: 3, start: MON_JAN_5, finish: MON_JAN_5, milestone: true }), + ...makeTask({ + uniqueId: 3, + start: MON_JAN_5, + finish: MON_JAN_5, + milestone: true, + }), duration: Duration.from(5, TimeUnit.Days), }; const resolved = resolveCalendar(makeProject([gate]), {}); @@ -171,7 +206,9 @@ describe("analyzeRun — cross-constraint feasibility", () => { lagDays: 0, }, ]; - const analysis = analyzeRun(resolvedFixture(), [{ kind: "Precedence", edges }]); + const analysis = analyzeRun(resolvedFixture(), [ + { kind: "Precedence", edges }, + ]); expect(codes(analysis.issues)).toContain("LEVELSET_E107"); }); }); @@ -179,15 +216,26 @@ describe("analyzeRun — cross-constraint feasibility", () => { describe("analyzeRun — warnings", () => { test("kinds the search records but does not enforce warn LEVELSET_W201", () => { const constraints: Constraint[] = [ - { kind: "UnitPrecedence", unitId: 10, afterUnitIds: [20], name: "floors" }, + { + kind: "UnitPrecedence", + unitId: 10, + afterUnitIds: [20], + name: "floors", + }, ]; - const withSearch = analyzeRun(resolvedFixture(), constraints, { search: serialSGS }); + const withSearch = analyzeRun(resolvedFixture(), constraints, { + search: serialSGS, + }); expect(withSearch.ok).toBe(true); // warning, not error expect(codes(withSearch.issues)).toContain("LEVELSET_W201"); // Disabled constraints don't warn — they're not going to run. - const disabled = analyzeRun(resolvedFixture(), [{ ...constraints[0]!, enabled: false }], { - search: serialSGS, - }); + const disabled = analyzeRun( + resolvedFixture(), + [{ ...constraints[0]!, enabled: false }], + { + search: serialSGS, + }, + ); expect(codes(disabled.issues)).not.toContain("LEVELSET_W201"); }); @@ -200,7 +248,12 @@ describe("analyzeRun — warnings", () => { test("duplicate explicit names warn LEVELSET_W203", () => { const analysis = analyzeRun(resolvedFixture(), [ - { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1, name: "cap" }, + { + kind: "MaxConcurrentResource", + resourceUniqueId: 100, + max: 1, + name: "cap", + }, { kind: "PeakCap", resourceUniqueId: 100, cap: 2, name: "cap" }, ]); expect(codes(analysis.issues)).toContain("LEVELSET_W203"); diff --git a/test/level-core/constraintMeta.test.ts b/test/level-core/constraintMeta.test.ts index 0435aa2..5dc9730 100644 --- a/test/level-core/constraintMeta.test.ts +++ b/test/level-core/constraintMeta.test.ts @@ -29,7 +29,11 @@ function twoParallelTasksOnOneCrew() { describe("constraintLabel", () => { test("falls back to kind when unnamed", () => { - const c: Constraint = { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1 }; + const c: Constraint = { + kind: "MaxConcurrentResource", + resourceUniqueId: 100, + max: 1, + }; expect(constraintLabel(c)).toBe("MaxConcurrentResource"); }); @@ -65,7 +69,12 @@ describe("serialSGS — enabled: false", () => { test("the same cap enabled serializes the tasks", async () => { const schedule = await runOnce(twoParallelTasksOnOneCrew(), [ - { kind: "MaxConcurrentResource", resourceUniqueId: 100, max: 1, name: "crew-cap" }, + { + kind: "MaxConcurrentResource", + resourceUniqueId: 100, + max: 1, + name: "crew-cap", + }, ]); const starts = schedule.tasks.map((t) => t.startDay).sort((a, b) => a - b); expect(starts[0]).toBe(0); @@ -78,7 +87,12 @@ describe("serialSGS — named-instance attribution", () => { test("deadline violation attributes to the named constraint instance", async () => { const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); const failure = await runExpectingFailure(makeProject([t1]), [ - { kind: "Deadline", taskUniqueId: 1, latestFinish: 2, name: "phase1-gate" }, + { + kind: "Deadline", + taskUniqueId: 1, + latestFinish: 2, + name: "phase1-gate", + }, ]); expect(failure.explanations).toHaveLength(1); const explanation = failure.explanations[0]!; diff --git a/test/level-core/fixtures.ts b/test/level-core/fixtures.ts index db91fd6..63cd57a 100644 --- a/test/level-core/fixtures.ts +++ b/test/level-core/fixtures.ts @@ -8,7 +8,12 @@ import { resolveCalendar } from "../../src/level-core/resolveCalendar.ts"; import { ScheduleStreamImpl } from "../../src/level-core/scheduleStream.ts"; import { serialSGS } from "../../src/level-core/search/serialSGS.ts"; import { ResourceType } from "../../src/model/types.ts"; -import type { Constraint, Failure, Schedule, WorkUnit } from "../../src/level-core/types.ts"; +import type { + Constraint, + Failure, + Schedule, + WorkUnit, +} from "../../src/level-core/types.ts"; import type { Calendar } from "../../src/schema/calendar.ts"; import type { ProjectFile } from "../../src/schema/project.ts"; import type { Relation } from "../../src/schema/relation.ts"; @@ -104,7 +109,10 @@ export function makeProject( }; } -export function makeResource(uniqueId: number, name = `Resource ${String(uniqueId)}`) { +export function makeResource( + uniqueId: number, + name = `Resource ${String(uniqueId)}`, +) { return { id: uniqueId, uniqueId, @@ -119,7 +127,11 @@ export function makeResource(uniqueId: number, name = `Resource ${String(uniqueI }; } -export function makeAssignment(taskUniqueId: number, resourceUniqueId: number, units = 1) { +export function makeAssignment( + taskUniqueId: number, + resourceUniqueId: number, + units = 1, +) { return { taskUniqueId, resourceUniqueId, diff --git a/test/level-core/materialize.test.ts b/test/level-core/materialize.test.ts index b6faa4a..4a0b74d 100644 --- a/test/level-core/materialize.test.ts +++ b/test/level-core/materialize.test.ts @@ -10,7 +10,10 @@ import type { Calendar, CalendarException } from "../../src/schema/calendar.ts"; import type { ProjectFile } from "../../src/schema/project.ts"; import type { Task } from "../../src/schema/task.ts"; -function monFriCalendar(uniqueId = 1, exceptions: CalendarException[] = []): Calendar { +function monFriCalendar( + uniqueId = 1, + exceptions: CalendarException[] = [], +): Calendar { return { uniqueId, name: "Standard", @@ -72,7 +75,10 @@ function makeTask(args: { }; } -function makeProject(tasks: Task[], calendars: Calendar[] = [monFriCalendar()]): ProjectFile { +function makeProject( + tasks: Task[], + calendars: Calendar[] = [monFriCalendar()], +): ProjectFile { return { properties: { title: "Test", @@ -113,12 +119,16 @@ describe("materialize — round-trip", () => { const sched2 = currentSchedule(resolved2); expect(sched2.tasks[0]!.startDay).toBe(sched1.tasks[0]!.startDay); expect(sched2.tasks[0]!.finishDay).toBe(sched1.tasks[0]!.finishDay); - expect(resolved2.tasks[0]!.durationDays).toBe(resolved1.tasks[0]!.durationDays); + expect(resolved2.tasks[0]!.durationDays).toBe( + resolved1.tasks[0]!.durationDays, + ); }); test("MSPDI Fri 17:00 finish round-trips through midnight normalization", () => { const friAt17 = new Date(2026, 0, 9, 17, 0, 0); - const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: friAt17 })]); + const project = makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: friAt17 }), + ]); const out = materialize(currentSchedule(resolveCalendar(project))); expect(out.tasks[0]!.finish).toEqual(new Date(2026, 0, 10)); expect(currentSchedule(resolveCalendar(out)).tasks[0]!.finishDay).toBe(5); @@ -144,7 +154,9 @@ describe("materialize — field preservation", () => { test("source-level resources/assignments/calendars/properties survive", () => { const project: ProjectFile = { - ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), + ...makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]), resources: [ { id: 1, @@ -244,7 +256,9 @@ describe("materialize — duration update across units", () => { }); test("null source duration emits Days-unit by default", () => { - const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + const project = makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]); const out = materialize(currentSchedule(resolveCalendar(project))); expect(out.tasks[0]!.duration?.unit).toBe(TimeUnit.Days); expect(out.tasks[0]!.duration?.value).toBe(5); @@ -263,7 +277,12 @@ describe("materialize — duration update across units", () => { ], [ monFriCalendar(1, [ - { name: "Holiday", fromDate: wedJan7, toDate: wedJan7, working: false }, + { + name: "Holiday", + fromDate: wedJan7, + toDate: wedJan7, + working: false, + }, ]), ], ); @@ -274,13 +293,20 @@ describe("materialize — duration update across units", () => { describe("materialize — schedule shifts", () => { test("shifting startDay by 7 cal days updates finish dates correctly", () => { - const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + const project = makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]); const resolved = resolveCalendar(project); const seed = currentSchedule(resolved).tasks[0]!; const shifted: Schedule = { resolved, tasks: [ - { uniqueId: 1, startDay: seed.startDay + 7, finishDay: seed.finishDay + 7, modeId: null }, + { + uniqueId: 1, + startDay: seed.startDay + 7, + finishDay: seed.finishDay + 7, + modeId: null, + }, ], makespan: seed.finishDay + 7, annotations: new Map(), @@ -296,7 +322,9 @@ describe("materialize — ModeSelection reattribution", () => { // Task 1 statically assigned to resource 100, but scheduled in a mode that // ran it on resource 200 (e.g. a borrowed crew). materialize must drop the // static assignment and emit the realized one so the file agrees with load. - const base = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + const base = makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]); const project = { ...base, assignments: [ @@ -318,7 +346,9 @@ describe("materialize — ModeSelection reattribution", () => { tasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: 2 }], makespan: 5, annotations: new Map(), - realizedAssignments: [{ taskUniqueId: 1, resourceUniqueId: 200, units: 1 }], + realizedAssignments: [ + { taskUniqueId: 1, resourceUniqueId: 200, units: 1 }, + ], }; const out = materialize(sched); expect(out.assignments).toHaveLength(1); diff --git a/test/level-core/profiles.test.ts b/test/level-core/profiles.test.ts index d64b180..e76170e 100644 --- a/test/level-core/profiles.test.ts +++ b/test/level-core/profiles.test.ts @@ -3,7 +3,10 @@ import { describe, expect, test } from "bun:test"; -import { openUnitCount, scheduleProfiles } from "../../src/level-core/profiles.ts"; +import { + openUnitCount, + scheduleProfiles, +} from "../../src/level-core/profiles.ts"; import { buildResourceLoad } from "../../src/level-core/weeklyProfile.ts"; import type { Schedule } from "../../src/level-core/types.ts"; @@ -51,7 +54,9 @@ describe("scheduleProfiles", () => { const shared = scheduleProfiles(schedule).resourceLoad; const direct = buildResourceLoad(schedule); const byId = (a: number, b: number) => a - b; - expect([...shared.keys()].sort(byId)).toEqual([...direct.keys()].sort(byId)); + expect([...shared.keys()].sort(byId)).toEqual( + [...direct.keys()].sort(byId), + ); for (const [resourceId, arr] of direct) { expect([...shared.get(resourceId)!]).toEqual([...arr]); } diff --git a/test/level-core/resolveCalendar.test.ts b/test/level-core/resolveCalendar.test.ts index 255e483..338b7a9 100644 --- a/test/level-core/resolveCalendar.test.ts +++ b/test/level-core/resolveCalendar.test.ts @@ -9,7 +9,10 @@ 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 { +function monFriCalendar( + uniqueId = 1, + exceptions: CalendarException[] = [], +): Calendar { return { uniqueId, name: "Standard", @@ -71,7 +74,10 @@ function makeTask(args: { }; } -function makeProject(tasks: Task[], calendars: Calendar[] = [monFriCalendar()]): ProjectFile { +function makeProject( + tasks: Task[], + calendars: Calendar[] = [monFriCalendar()], +): ProjectFile { return { properties: { title: "Test", @@ -94,7 +100,9 @@ function makeProject(tasks: Task[], calendars: Calendar[] = [monFriCalendar()]): function defaultCal(resolved: ReturnType) { const id = resolved.defaultCalendarUniqueId; - return id !== null ? resolved.calendars.get(id)! : resolved.calendars.values().next().value!; + return id !== null + ? resolved.calendars.get(id)! + : resolved.calendars.values().next().value!; } const MON_JAN_5 = new Date(2026, 0, 5); @@ -103,24 +111,38 @@ 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 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.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 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 }), + makeTask({ + uniqueId: 1, + start: MON_JAN_5, + finish: MON_JAN_5, + milestone: true, + }), ]); const resolved = resolveCalendar(project); expect(resolved.tasks[0]!.durationDays).toBe(0); @@ -131,7 +153,9 @@ describe("resolveCalendar — Mon–Fri calendar", () => { 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 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); @@ -139,7 +163,9 @@ describe("resolveCalendar — Mon–Fri calendar", () => { }); 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 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); @@ -151,7 +177,12 @@ 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 }, + { + name: "Plant maintenance", + fromDate: wedJan7, + toDate: wedJan7, + working: false, + }, ]); const project = makeProject( [makeTask({ uniqueId: 1, start: MON_JAN_5, finish: TUE_JAN_13 })], @@ -219,7 +250,9 @@ describe("resolveCalendar — precedence edges", () => { ], }); // 480 min/day, 2400 min/week → 5 working days/week. - expect(resolveCalendar(makeProject([t1, t2])).precedences[0]!.lagDays).toBe(5); + expect(resolveCalendar(makeProject([t1, t2])).precedences[0]!.lagDays).toBe( + 5, + ); }); test("week-unit lag honors a 6-day workweek (minutesPerWeek = 2880)", () => { @@ -259,7 +292,9 @@ describe("resolveCalendar — precedence edges", () => { 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 })]), + ...makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]), assignments: [ { taskUniqueId: 1, @@ -281,7 +316,9 @@ describe("resolveCalendar — assignments, resources, source preservation", () = 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 })]), + ...makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]), resources: [ { id: 10, @@ -303,7 +340,9 @@ describe("resolveCalendar — assignments, resources, source preservation", () = }); test("`source` field is the input project (identity)", () => { - const project = makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]); + const project = makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]); expect(resolveCalendar(project).source).toBe(project); }); }); @@ -312,7 +351,9 @@ 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 })]), + ...makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]), properties: { title: null, author: null, @@ -333,7 +374,9 @@ describe("resolveCalendar — epoch", () => { test("statusDate is used when opts.epoch is absent", () => { const project: ProjectFile = { - ...makeProject([makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 })]), + ...makeProject([ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + ]), properties: { title: null, author: null, @@ -399,14 +442,20 @@ describe("resolveCalendar — multi-calendar map", () => { 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/); + 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/, - ); + const broken = makeTask({ + uniqueId: 1, + start: MON_JAN_5, + finish: SAT_JAN_10, + }); + expect(() => + resolveCalendar(makeProject([{ ...broken, uniqueId: null }])), + ).toThrow(/missing uniqueId/); }); }); diff --git a/test/level-core/scheduleStream.test.ts b/test/level-core/scheduleStream.test.ts index 433fa4e..64ee32f 100644 --- a/test/level-core/scheduleStream.test.ts +++ b/test/level-core/scheduleStream.test.ts @@ -23,7 +23,9 @@ const fakeResolved = { } as unknown as ResolvedProject; function fakeSchedule(makespan: number, peak = 0): Schedule { - const tasks: ScheduledTask[] = [{ uniqueId: 1, startDay: 0, finishDay: makespan, modeId: null }]; + const tasks: ScheduledTask[] = [ + { uniqueId: 1, startDay: 0, finishDay: makespan, modeId: null }, + ]; // Stash peak in modeId of a fake second task purely for scoring tests. tasks.push({ uniqueId: 99, startDay: 0, finishDay: peak, modeId: null }); return { resolved: fakeResolved, tasks, makespan, annotations: new Map() }; @@ -54,7 +56,11 @@ function streamOf(...schedules: Schedule[]) { describe("ScheduleStream — lazy transforms", () => { test("filter keeps only matching schedules", async () => { - const out = await streamOf(fakeSchedule(3), fakeSchedule(5), fakeSchedule(7)) + const out = await streamOf( + fakeSchedule(3), + fakeSchedule(5), + fakeSchedule(7), + ) .filter((s) => s.tasks[0]!.finishDay > 4) .collect(); expect(out).toHaveLength(2); @@ -105,7 +111,10 @@ describe("ScheduleStream — lazy transforms", () => { test("branch composes — each input fans out into the sub-stream", async () => { const out = await streamOf(fakeSchedule(3), fakeSchedule(5)) .branch((s) => - streamOf(s, { ...s, tasks: [{ ...s.tasks[0]!, finishDay: s.tasks[0]!.finishDay + 1 }] }), + streamOf(s, { + ...s, + tasks: [{ ...s.tasks[0]!, finishDay: s.tasks[0]!.finishDay + 1 }], + }), ) .collect(); expect(out).toHaveLength(4); @@ -126,9 +135,11 @@ describe("ScheduleStream — lazy transforms", () => { ]); expect(events).toEqual([]); - expect((await concatenated.take(2).collect()).map((schedule) => schedule.makespan)).toEqual([ - 1, 2, - ]); + expect( + (await concatenated.take(2).collect()).map( + (schedule) => schedule.makespan, + ), + ).toEqual([1, 2]); expect(events).toEqual(["create-a"]); }); }); @@ -151,7 +162,11 @@ describe("ScheduleStream — materializing operations", () => { }); test("collect with limit stops early", async () => { - const out = await streamOf(fakeSchedule(1), fakeSchedule(2), fakeSchedule(3)).collect(2); + const out = await streamOf( + fakeSchedule(1), + fakeSchedule(2), + fakeSchedule(3), + ).collect(2); expect(out).toHaveLength(2); }); @@ -161,7 +176,10 @@ describe("ScheduleStream — materializing operations", () => { const b = fakeSchedule(5, 3); const c = fakeSchedule(6, 3); const d = fakeSchedule(3, 5); - const frontier = await streamOf(a, b, c, d).paretoFrontier([makespanScorer, peakScorer]); + const frontier = await streamOf(a, b, c, d).paretoFrontier([ + makespanScorer, + peakScorer, + ]); expect(frontier).toHaveLength(2); expect(frontier).toContain(a); expect(frontier).toContain(d); @@ -172,7 +190,9 @@ describe("ScheduleStream — materializing operations", () => { const a = fakeSchedule(5, 3); const b = fakeSchedule(5, 2); const c = fakeSchedule(6, 0); - expect(await streamOf(a, b, c).bestByLex([makespanScorer, peakScorer])).toBe(b); + expect( + await streamOf(a, b, c).bestByLex([makespanScorer, peakScorer]), + ).toBe(b); }); test("bestByLex respects per-tier direction and keeps the first on full ties", async () => { @@ -183,7 +203,9 @@ describe("ScheduleStream — materializing operations", () => { const dupA = fakeSchedule(5, 2); const dupB = fakeSchedule(5, 2); - expect(await streamOf(dupA, dupB).bestByLex([makespanScorer, peakScorer])).toBe(dupA); + expect( + await streamOf(dupA, dupB).bestByLex([makespanScorer, peakScorer]), + ).toBe(dupA); }); test("bestByLex on an empty stream returns null", async () => { diff --git a/test/level-core/serialSGS.test.ts b/test/level-core/serialSGS.test.ts index 7e76c7d..76e7da2 100644 --- a/test/level-core/serialSGS.test.ts +++ b/test/level-core/serialSGS.test.ts @@ -405,7 +405,9 @@ describe("serialSGS — resource caps", () => { remainingWork: null, })), }); - const schedule = await runOnce(project, [{ kind: "PeakCap", resourceUniqueId: 100, cap: 2 }]); + const schedule = await runOnce(project, [ + { kind: "PeakCap", resourceUniqueId: 100, cap: 2 }, + ]); expect(schedule.tasks.find((t) => t.uniqueId === 1)!.startDay).toBe(0); expect(schedule.tasks.find((t) => t.uniqueId === 2)!.startDay).toBe(0); expect(schedule.tasks.find((t) => t.uniqueId === 3)!.startDay).toBe(0); @@ -480,7 +482,12 @@ describe("serialSGS — unit caps (ConcurrentUnitsLimit)", () => { work: null, resourcePool: null, }; - const elecResource = { ...crewResource, id: 2, uniqueId: 200, name: "Electrical" }; + const elecResource = { + ...crewResource, + id: 2, + uniqueId: 200, + name: "Electrical", + }; function assignment(taskUniqueId: number, resourceUniqueId: number) { return { @@ -548,17 +555,45 @@ describe("serialSGS — unit caps (ConcurrentUnitsLimit)", () => { // ConcurrentUnitsLimit{discipline:200,max:1} may not have both units' // electrical work active together → electrical serializes; general work, // uncounted, stays parallel. - const elecA = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); - const genA = makeTask({ uniqueId: 2, start: MON_JAN_5, finish: SAT_JAN_10 }); - const elecB = makeTask({ uniqueId: 3, start: MON_JAN_5, finish: SAT_JAN_10 }); - const genB = makeTask({ uniqueId: 4, start: MON_JAN_5, finish: SAT_JAN_10 }); + const elecA = makeTask({ + uniqueId: 1, + start: MON_JAN_5, + finish: SAT_JAN_10, + }); + const genA = makeTask({ + uniqueId: 2, + start: MON_JAN_5, + finish: SAT_JAN_10, + }); + const elecB = makeTask({ + uniqueId: 3, + start: MON_JAN_5, + finish: SAT_JAN_10, + }); + const genB = makeTask({ + uniqueId: 4, + start: MON_JAN_5, + finish: SAT_JAN_10, + }); const project = makeProject([elecA, genA, elecB, genB], { resources: [crewResource, elecResource], - assignments: [assignment(1, 200), assignment(2, 100), assignment(3, 200), assignment(4, 100)], + assignments: [ + assignment(1, 200), + assignment(2, 100), + assignment(3, 200), + assignment(4, 100), + ], }); const schedule = await runOnce( project, - [{ kind: "ConcurrentUnitsLimit", discipline: 200, unitIds: [10, 20], max: 1 }], + [ + { + kind: "ConcurrentUnitsLimit", + discipline: 200, + unitIds: [10, 20], + max: 1, + }, + ], [ { id: 10, taskUniqueIds: [1, 2] }, { id: 20, taskUniqueIds: [3, 4] }, @@ -589,7 +624,11 @@ describe("serialSGS — ModeSelection (multi-mode RCPSP)", () => { }; const borrowRes = { ...homeRes, id: 2, uniqueId: 200, name: "Borrow" }; - function assignment(taskUniqueId: number, resourceUniqueId: number, units = 1) { + function assignment( + taskUniqueId: number, + resourceUniqueId: number, + units = 1, + ) { return { taskUniqueId, resourceUniqueId, @@ -607,8 +646,16 @@ describe("serialSGS — ModeSelection (multi-mode RCPSP)", () => { kind: "ModeSelection", taskUniqueId, modes: [ - { modeId: 0, durationDays: 5, resourceDemand: [{ resourceUniqueId: 100, units: 1 }] }, - { modeId: 1, durationDays: 5, resourceDemand: [{ resourceUniqueId: 200, units: 1 }] }, + { + modeId: 0, + durationDays: 5, + resourceDemand: [{ resourceUniqueId: 100, units: 1 }], + }, + { + modeId: 1, + durationDays: 5, + resourceDemand: [{ resourceUniqueId: 200, units: 1 }], + }, ], }); @@ -714,7 +761,9 @@ describe("serialSGS — unimplemented constraints", () => { | undefined; expect(unsupported).toBeDefined(); expect(unsupported).toHaveLength(1); - expect(unsupported![0]!.message).toMatch(/UnimodalProfile.*not implemented/); + expect(unsupported![0]!.message).toMatch( + /UnimodalProfile.*not implemented/, + ); }); test("UnitPrecedence is recorded as unsupported", async () => { diff --git a/test/level-core/weeklyProfile.test.ts b/test/level-core/weeklyProfile.test.ts index d7c2c69..b617c79 100644 --- a/test/level-core/weeklyProfile.test.ts +++ b/test/level-core/weeklyProfile.test.ts @@ -34,7 +34,11 @@ function monFriCalendar(uniqueId = 1): Calendar { function makeSchedule(opts: { scheduledTasks: ScheduledTask[]; - assignments: { taskUniqueId: number; resourceUniqueId: number; units: number }[]; + assignments: { + taskUniqueId: number; + resourceUniqueId: number; + units: number; + }[]; resourceUniqueIds: readonly number[]; epoch?: Date; }): Schedule { @@ -113,7 +117,10 @@ function makeSchedule(opts: { calendars, }; const resolved = resolveCalendar(project); - const makespan = opts.scheduledTasks.reduce((m, t) => Math.max(m, t.finishDay), 0); + const makespan = opts.scheduledTasks.reduce( + (m, t) => Math.max(m, t.finishDay), + 0, + ); return { resolved, tasks: opts.scheduledTasks, @@ -147,7 +154,9 @@ describe("weeklyProfile — buildResourceLoad", () => { const schedule = makeSchedule({ resourceUniqueIds: [100], assignments: [{ taskUniqueId: 1, resourceUniqueId: 100, units: 3 }], - scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 7, modeId: null }], + scheduledTasks: [ + { uniqueId: 1, startDay: 0, finishDay: 7, modeId: null }, + ], }); const load = buildResourceLoad(schedule); const arr = load.get(100)!; @@ -160,7 +169,9 @@ describe("weeklyProfile — buildResourceLoad", () => { const schedule = makeSchedule({ resourceUniqueIds: [100, 200], assignments: [{ taskUniqueId: 1, resourceUniqueId: 200, units: 1 }], - scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }], + scheduledTasks: [ + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, + ], }); const load = buildResourceLoad(schedule); expect(load.has(200)).toBe(true); @@ -207,7 +218,9 @@ describe("weeklyProfile — peakLoad / combinedPeakLoad", () => { const schedule = makeSchedule({ resourceUniqueIds: [100], assignments: [{ taskUniqueId: 1, resourceUniqueId: 100, units: 1 }], - scheduledTasks: [{ uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }], + scheduledTasks: [ + { uniqueId: 1, startDay: 0, finishDay: 5, modeId: null }, + ], }); const load = buildResourceLoad(schedule); expect(peakLoad(load, 999)).toBe(0); diff --git a/test/level-core/weightedScorer.test.ts b/test/level-core/weightedScorer.test.ts index dc68cd8..5f3ebc8 100644 --- a/test/level-core/weightedScorer.test.ts +++ b/test/level-core/weightedScorer.test.ts @@ -2,12 +2,19 @@ import { describe, expect, test } from "bun:test"; -import { scoreReport, weightedScorer } from "../../src/level-core/weightedScorer.ts"; +import { + scoreReport, + weightedScorer, +} from "../../src/level-core/weightedScorer.ts"; import type { Schedule, Scorer } from "../../src/level-core/types.ts"; const fakeSchedule = { makespan: 10 } as Schedule; -function constScorer(name: string, value: number, direction: "min" | "max" = "min"): Scorer { +function constScorer( + name: string, + value: number, + direction: "min" | "max" = "min", +): Scorer { return { name, direction, score: () => value }; } @@ -41,7 +48,10 @@ describe("weightedScorer", () => { describe("scoreReport", () => { test("reports every scorer's raw value regardless of weight", () => { - const report = scoreReport(fakeSchedule, [constScorer("a", 5), constScorer("b", 7, "max")]); + const report = scoreReport(fakeSchedule, [ + constScorer("a", 5), + constScorer("b", 7, "max"), + ]); expect(report).toEqual({ a: 5, b: 7 }); }); }); diff --git a/test/library.test.ts b/test/library.test.ts index 972b13a..72e1279 100644 --- a/test/library.test.ts +++ b/test/library.test.ts @@ -34,7 +34,9 @@ describe("fixture mapping", () => { "extracts project data from the mpp binary matching the json baseline", async () => { const project = await readMppFixture(FIXTURE_MPP_PATH); - const expected = JSON.parse(await Bun.file(FIXTURE_JSON_PATH).text()) as SerializedProject; + const expected = JSON.parse( + await Bun.file(FIXTURE_JSON_PATH).text(), + ) as SerializedProject; expect(serializeProject(project)).toEqual(expected); }, @@ -44,7 +46,9 @@ describe("fixture mapping", () => { "matches the exported CSV for leaf task schedule rows from the mpp binary", async () => { const project = await readMppFixture(FIXTURE_MPP_PATH); - const leafTasks = project.tasks.filter((task) => task.summary === false && task.id !== null); + const leafTasks = project.tasks.filter( + (task) => task.summary === false && task.id !== null, + ); const csvRows = parseCsv(await Bun.file(FIXTURE_CSV_PATH).text()); expect(csvRows).toHaveLength(leafTasks.length); @@ -57,8 +61,12 @@ describe("fixture mapping", () => { expect(task!.wbs ?? "").toBe(row["WBS"] ?? ""); expect(formatMinuteDate(task!.start) ?? "").toBe(row["Start"] ?? ""); expect(formatMinuteDate(task!.finish) ?? "").toBe(row["Finish"] ?? ""); - expect(task!.duration?.toSimpleString() ?? "").toBe(row["Duration"] ?? ""); - expect(formatDecimal(task!.percentComplete)).toBe(row["% Complete"] ?? ""); + expect(task!.duration?.toSimpleString() ?? "").toBe( + row["Duration"] ?? "", + ); + expect(formatDecimal(task!.percentComplete)).toBe( + row["% Complete"] ?? "", + ); expect(task!.critical ? "Yes" : "No").toBe(row["Critical"] ?? ""); expect(task!.milestone ? "Yes" : "No").toBe(row["Milestone"] ?? ""); } @@ -67,22 +75,25 @@ describe("fixture mapping", () => { }); describe("mspdi", () => { - test.skipIf(!HAS_MPP_FIXTURE)("round-trips the fixture project through MSPDI XML", async () => { - const project = await readMppFixture(FIXTURE_MPP_PATH); - const xml = new MspdiWriter().write(project, { saveVersion: 14 }); - const roundTripped = new MspdiReader().read(xml); + test.skipIf(!HAS_MPP_FIXTURE)( + "round-trips the fixture project through MSPDI XML", + async () => { + const project = await readMppFixture(FIXTURE_MPP_PATH); + const xml = new MspdiWriter().write(project, { saveVersion: 14 }); + const roundTripped = new MspdiReader().read(xml); - expect(xml).toContain(""); - expect(roundTripped.tasks).toHaveLength(project.tasks.length); - expect(roundTripped.resources).toHaveLength(project.resources.length); - expect(roundTripped.assignments).toHaveLength(project.assignments.length); - expect(roundTripped.calendars).toHaveLength(project.calendars.length); - expect(roundTripped.tasks[10]?.name).toBe(project.tasks[10]?.name); - expect(formatMinuteDate(roundTripped.tasks[10]?.start ?? null)).toBe( - formatMinuteDate(project.tasks[10]?.start ?? null), - ); - }); + expect(xml).toContain(""); + expect(roundTripped.tasks).toHaveLength(project.tasks.length); + expect(roundTripped.resources).toHaveLength(project.resources.length); + expect(roundTripped.assignments).toHaveLength(project.assignments.length); + expect(roundTripped.calendars).toHaveLength(project.calendars.length); + expect(roundTripped.tasks[10]?.name).toBe(project.tasks[10]?.name); + expect(formatMinuteDate(roundTripped.tasks[10]?.start ?? null)).toBe( + formatMinuteDate(project.tasks[10]?.start ?? null), + ); + }, + ); test("parses and writes working-time durations, lag, and units using MSPDI conventions", () => { const xml = ` @@ -146,7 +157,9 @@ describe("mspdi", () => { expect(project.properties.defaultCalendarUniqueId).toBe(1); expect(project.tasks[0]?.duration?.toSimpleString()).toBe("1.0d"); expect(project.tasks[0]?.work?.toSimpleString()).toBe("1.0d"); - expect(project.tasks[1]?.predecessors[0]?.lag?.toSimpleString()).toBe("1.0d"); + expect(project.tasks[1]?.predecessors[0]?.lag?.toSimpleString()).toBe( + "1.0d", + ); expect(project.resources[0]?.maxUnits).toBe(150); expect(project.assignments[0]?.units).toBe(50); expect(project.assignments[0]?.work?.toSimpleString()).toBe("4.0h"); @@ -217,7 +230,11 @@ function formatMinuteDate(value: Date | null): string | null { } function formatDecimal(value: number | null): string { - return value === null ? "" : Number.isInteger(value) ? value.toFixed(1) : String(value); + return value === null + ? "" + : Number.isInteger(value) + ? value.toFixed(1) + : String(value); } function resolveFixturePath(relativePath: string): string { @@ -242,12 +259,16 @@ function serializeProject(project: ProjectFile) { start: formatMinuteDate(task.start), finish: formatMinuteDate(task.finish), duration: serializeDuration(task.duration), - percent_complete: task.percentComplete === null ? null : formatDecimal(task.percentComplete), + percent_complete: + task.percentComplete === null + ? null + : formatDecimal(task.percentComplete), summary: task.summary, milestone: task.milestone, critical: task.critical, notes: task.notes, - priority: task.priority === null ? null : `[Priority value=${task.priority}]`, + priority: + task.priority === null ? null : `[Priority value=${task.priority}]`, actual_start: formatMinuteDate(task.actualStart), actual_finish: formatMinuteDate(task.actualFinish), baseline_start: formatMinuteDate(task.baselineStart), @@ -268,7 +289,8 @@ function serializeProject(project: ProjectFile) { type: resource.type, email: resource.email, group: resource.group, - max_units: resource.maxUnits === null ? null : formatDecimal(resource.maxUnits), + max_units: + resource.maxUnits === null ? null : formatDecimal(resource.maxUnits), cost: resource.cost === null ? null : formatDecimal(resource.cost), })), assignments: project.assignments.map((assignment) => ({ @@ -287,7 +309,10 @@ function serializeProject(project: ProjectFile) { } function serializeDuration( - value: { value: number; unit: TimeUnit; toSimpleString(): string } | null | undefined, + value: + | { value: number; unit: TimeUnit; toSimpleString(): string } + | null + | undefined, ): string | null { if (!value) { return null; diff --git a/test/mspdi.test.ts b/test/mspdi.test.ts index 144cd38..77260db 100644 --- a/test/mspdi.test.ts +++ b/test/mspdi.test.ts @@ -20,7 +20,11 @@ import { createEmptyProject } from "../src/model/Project.ts"; // --------------------------------------------------------------------------- describe("XsdDuration", () => { - const defaultContext = { minutesPerDay: 480, minutesPerWeek: 2400, daysPerMonth: 20 }; + const defaultContext = { + minutesPerDay: 480, + minutesPerWeek: 2400, + daysPerMonth: 20, + }; describe("parseXsdDuration", () => { test("returns null for null/undefined/empty", () => { @@ -189,7 +193,10 @@ describe("XsdDuration", () => { describe("formatLinkLag", () => { test("returns nulls for null/undefined", () => { expect(formatLinkLag(null)).toEqual({ linkLag: null, lagFormat: null }); - expect(formatLinkLag(undefined)).toEqual({ linkLag: null, lagFormat: null }); + expect(formatLinkLag(undefined)).toEqual({ + linkLag: null, + lagFormat: null, + }); }); test("formats day lag", () => { @@ -313,7 +320,9 @@ describe("MspdiReader", () => { expect(project.tasks[1]?.predecessors).toHaveLength(1); expect(project.tasks[1]?.predecessors[0]?.predecessorUniqueId).toBe(1); expect(project.tasks[1]?.predecessors[0]?.successorUniqueId).toBe(2); - expect(project.tasks[1]?.predecessors[0]?.type).toBe(RelationType.FinishToStart); + expect(project.tasks[1]?.predecessors[0]?.type).toBe( + RelationType.FinishToStart, + ); }); test("reads resources with type parsing", () => { @@ -396,8 +405,12 @@ describe("MspdiReader", () => { expect(project.calendars[0]?.weekDays[0]?.dayType).toBe(2); expect(project.calendars[0]?.weekDays[0]?.working).toBe(true); expect(project.calendars[0]?.weekDays[0]?.workingTimes).toHaveLength(2); - expect(project.calendars[0]?.weekDays[0]?.workingTimes[0]?.from).toBe("08:00:00"); - expect(project.calendars[0]?.weekDays[0]?.workingTimes[0]?.to).toBe("12:00:00"); + expect(project.calendars[0]?.weekDays[0]?.workingTimes[0]?.from).toBe( + "08:00:00", + ); + expect(project.calendars[0]?.weekDays[0]?.workingTimes[0]?.to).toBe( + "12:00:00", + ); expect(project.calendars[0]?.weekDays[1]?.working).toBe(false); expect(project.calendars[0]?.exceptions).toHaveLength(1); expect(project.calendars[0]?.exceptions[0]?.name).toBe("Holiday"); @@ -461,7 +474,9 @@ describe("MspdiWriter", () => { const xml = writer.write(project); expect(xml).toContain(''); - expect(xml).toContain(''); + expect(xml).toContain( + '', + ); expect(xml).toContain("14"); expect(xml).toContain("Test"); expect(xml).toContain(""); @@ -570,7 +585,9 @@ describe("MSPDI round-trip", () => { expect(project.properties.defaultCalendarUniqueId).toBe(1); expect(project.tasks[0]?.duration?.toSimpleString()).toBe("1.0d"); expect(project.tasks[0]?.work?.toSimpleString()).toBe("1.0d"); - expect(project.tasks[1]?.predecessors[0]?.lag?.toSimpleString()).toBe("1.0d"); + expect(project.tasks[1]?.predecessors[0]?.lag?.toSimpleString()).toBe( + "1.0d", + ); expect(project.resources[0]?.maxUnits).toBe(150); expect(project.assignments[0]?.units).toBe(50); expect(project.assignments[0]?.work?.toSimpleString()).toBe("4.0h"); @@ -707,7 +724,9 @@ describe("MSPDI round-trip", () => { expect(roundTripped.tasks[1]?.duration?.toSimpleString()).toBe("1.0w"); expect(roundTripped.tasks[2]?.name).toBe("Build Phase"); expect(roundTripped.tasks[2]?.predecessors).toHaveLength(1); - expect(roundTripped.tasks[2]?.predecessors[0]?.type).toBe(RelationType.FinishToStart); + expect(roundTripped.tasks[2]?.predecessors[0]?.type).toBe( + RelationType.FinishToStart, + ); expect(roundTripped.resources).toHaveLength(1); expect(roundTripped.resources[0]?.name).toBe("Developer"); @@ -743,10 +762,18 @@ describe("MSPDI round-trip", () => { `; const project = new MspdiReader().read(xml); - expect(project.tasks[1]?.predecessors[0]?.type).toBe(RelationType.FinishToStart); - expect(project.tasks[2]?.predecessors[0]?.type).toBe(RelationType.StartToStart); - expect(project.tasks[3]?.predecessors[0]?.type).toBe(RelationType.FinishToFinish); - expect(project.tasks[4]?.predecessors[0]?.type).toBe(RelationType.StartToFinish); + expect(project.tasks[1]?.predecessors[0]?.type).toBe( + RelationType.FinishToStart, + ); + expect(project.tasks[2]?.predecessors[0]?.type).toBe( + RelationType.StartToStart, + ); + expect(project.tasks[3]?.predecessors[0]?.type).toBe( + RelationType.FinishToFinish, + ); + expect(project.tasks[4]?.predecessors[0]?.type).toBe( + RelationType.StartToFinish, + ); const written = new MspdiWriter().write(project); expect(written).toContain("0"); @@ -793,24 +820,27 @@ const HAS_MPP_FIXTURE = existsSync( ); describe("MSPDI with MPP fixture", () => { - test.skipIf(!HAS_MPP_FIXTURE)("round-trips the fixture project through MSPDI XML", async () => { - const { MppReader } = await import("../src/mpp/MppReader.ts"); - const fixturePath = new URL("./sample-schedule.mpp", import.meta.url); - const { fileURLToPath } = await import("node:url"); - const data = await Bun.file(fileURLToPath(fixturePath)).arrayBuffer(); - const project = new MppReader().read(data); - - const xml = new MspdiWriter().write(project); - const roundTripped = new MspdiReader().read(xml); - - expect(roundTripped.tasks.length).toBe(project.tasks.length); - expect(roundTripped.resources.length).toBe(project.resources.length); - expect(roundTripped.assignments.length).toBe(project.assignments.length); - - for (const [i, task] of roundTripped.tasks.entries()) { - const original = project.tasks[i]!; - expect(task.name ?? null).toBe(original.name?.trimEnd() ?? null); - expect(task.id).toBe(original.id); - } - }); + test.skipIf(!HAS_MPP_FIXTURE)( + "round-trips the fixture project through MSPDI XML", + async () => { + const { MppReader } = await import("../src/mpp/MppReader.ts"); + const fixturePath = new URL("./sample-schedule.mpp", import.meta.url); + const { fileURLToPath } = await import("node:url"); + const data = await Bun.file(fileURLToPath(fixturePath)).arrayBuffer(); + const project = new MppReader().read(data); + + const xml = new MspdiWriter().write(project); + const roundTripped = new MspdiReader().read(xml); + + expect(roundTripped.tasks.length).toBe(project.tasks.length); + expect(roundTripped.resources.length).toBe(project.resources.length); + expect(roundTripped.assignments.length).toBe(project.assignments.length); + + for (const [i, task] of roundTripped.tasks.entries()) { + const original = project.tasks[i]!; + expect(task.name ?? null).toBe(original.name?.trimEnd() ?? null); + expect(task.id).toBe(original.id); + } + }, + ); }); diff --git a/test/schema.test.ts b/test/schema.test.ts index 40b3313..3be3316 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { Duration } from "../src/model/Duration.ts"; -import { TimeUnit, RelationType, ResourceType, ConstraintType } from "../src/model/types.ts"; +import { + TimeUnit, + RelationType, + ResourceType, + ConstraintType, +} from "../src/model/types.ts"; import { ProjectFileSchema, TaskSchema, @@ -50,7 +55,9 @@ describe("enum schemas", () => { }); test("ConstraintTypeSchema accepts valid values", () => { - expect(ConstraintTypeSchema.parse("ASAP")).toBe(ConstraintType.AsSoonAsPossible); + expect(ConstraintTypeSchema.parse("ASAP")).toBe( + ConstraintType.AsSoonAsPossible, + ); expect(ConstraintTypeSchema.parse("MFO")).toBe(ConstraintType.MustFinishOn); }); @@ -93,7 +100,9 @@ describe("DurationSchema", () => { }); test("rejects invalid units", () => { - expect(() => DurationSchema.parse({ duration: 8, units: "invalid" })).toThrow(); + expect(() => + DurationSchema.parse({ duration: 8, units: "invalid" }), + ).toThrow(); }); test("rejects missing fields", () => { @@ -182,7 +191,9 @@ describe("TaskSchema", () => { levelingDelay: null, deadline: null, splits: null, - predecessors: [{ predecessorUniqueId: 0, successorUniqueId: 1, type: "FS", lag: null }], + predecessors: [ + { predecessorUniqueId: 0, successorUniqueId: 1, type: "FS", lag: null }, + ], }); expect(result.name).toBe("Task A"); expect(result.start).toBeInstanceOf(Date); diff --git a/test/version.test.ts b/test/version.test.ts index bce323c..5cf2a3a 100644 --- a/test/version.test.ts +++ b/test/version.test.ts @@ -45,7 +45,9 @@ describe("error handling: invalid data", () => { const buf = CFB.write(wb, { type: "array" }) as number[]; const container = parseMppBuffer(new Uint8Array(buf)); - expect(() => detectMppVariant(container)).toThrow("no TBknd table streams found"); + expect(() => detectMppVariant(container)).toThrow( + "no TBknd table streams found", + ); }); }); @@ -57,7 +59,13 @@ describe("error handling: old MPP versions", () => { const dummyData = new Uint8Array(64); // Root path "Root Entry/ 109" implies version 9 (109 - 100 = 9) - const tables = ["TBkndTask", "TBkndRsc", "TBkndAssn", "TBkndCal", "TBkndCons"]; + const tables = [ + "TBkndTask", + "TBkndRsc", + "TBkndAssn", + "TBkndCal", + "TBkndCons", + ]; for (const table of tables) { streams.set(`Root Entry/ 109/${table}/FixedData`, dummyData); } @@ -72,7 +80,13 @@ describe("error handling: old MPP versions", () => { const streams = new Map(); const dummyData = new Uint8Array(64); - const tables = ["TBkndTask", "TBkndRsc", "TBkndAssn", "TBkndCal", "TBkndCons"]; + const tables = [ + "TBkndTask", + "TBkndRsc", + "TBkndAssn", + "TBkndCal", + "TBkndCons", + ]; for (const table of tables) { streams.set(`Root Entry/ 112/${table}/FixedData`, dummyData); } @@ -95,21 +109,27 @@ describe("error handling: old MPP versions", () => { }); describe("sanity: valid MPP14 file", () => { - test.skipIf(!HAS_MPP_FIXTURE)("a valid MPP14 file still loads successfully", async () => { - const data = await readFixture(FIXTURE_MPP_PATH); - const reader = new MppReader(); - const project = reader.read(data); - - expect(project.tasks.length).toBeGreaterThan(0); - expect(project.resources.length).toBeGreaterThan(0); - }); - - test.skipIf(!HAS_MPP_FIXTURE)("a valid MPP14 file inspects successfully", async () => { - const data = await readFixture(FIXTURE_MPP_PATH); - const reader = new MppReader(); - const inspection = reader.inspect(data); - - expect(inspection.version).toBe(14); - expect(inspection.family).toBe("modern"); - }); + test.skipIf(!HAS_MPP_FIXTURE)( + "a valid MPP14 file still loads successfully", + async () => { + const data = await readFixture(FIXTURE_MPP_PATH); + const reader = new MppReader(); + const project = reader.read(data); + + expect(project.tasks.length).toBeGreaterThan(0); + expect(project.resources.length).toBeGreaterThan(0); + }, + ); + + test.skipIf(!HAS_MPP_FIXTURE)( + "a valid MPP14 file inspects successfully", + async () => { + const data = await readFixture(FIXTURE_MPP_PATH); + const reader = new MppReader(); + const inspection = reader.inspect(data); + + expect(inspection.version).toBe(14); + expect(inspection.family).toBe("modern"); + }, + ); }); diff --git a/test/xlsx.test.ts b/test/xlsx.test.ts index 87b60c1..c389146 100644 --- a/test/xlsx.test.ts +++ b/test/xlsx.test.ts @@ -14,43 +14,46 @@ async function getMppReader() { } describe("XlsxWriter", () => { - test.skipIf(!HAS_MPP_FIXTURE)("produces a valid XLSX buffer from the MPP fixture", async () => { - const { MppReader } = await getMppReader(); - const data = await Bun.file(FIXTURE_MPP_PATH).arrayBuffer(); - const project = new MppReader().read(data); - - const writer = new XlsxWriter(); - const buffer = await writer.write(project); - - expect(buffer).toBeInstanceOf(Uint8Array); - expect(buffer.byteLength).toBeGreaterThan(0); - - // Parse the buffer back and verify structure - const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(buffer); - - const sheet = workbook.getWorksheet("Schedule"); - expect(sheet).toBeDefined(); - - // Header row + task rows (tasks with non-null id) - const tasksWithId = project.tasks.filter((t) => t.id !== null); - expect(sheet!.rowCount).toBe(tasksWithId.length + 1); // +1 for header - - // Verify header - const headerRow = sheet!.getRow(1); - expect(headerRow.getCell(1).value).toBe("ID"); - expect(headerRow.getCell(2).value).toBe("Task Name"); - expect(headerRow.getCell(3).value).toBe("WBS"); - expect(headerRow.getCell(4).value).toBe("Start"); - expect(headerRow.getCell(5).value).toBe("Finish"); - expect(headerRow.getCell(6).value).toBe("Duration"); - - // Verify first data row matches first task - const firstTask = tasksWithId[0]!; - const firstRow = sheet!.getRow(2); - expect(firstRow.getCell(1).value).toBe(firstTask.id); - expect(firstRow.getCell(2).value).toBe(firstTask.name); - }); + test.skipIf(!HAS_MPP_FIXTURE)( + "produces a valid XLSX buffer from the MPP fixture", + async () => { + const { MppReader } = await getMppReader(); + const data = await Bun.file(FIXTURE_MPP_PATH).arrayBuffer(); + const project = new MppReader().read(data); + + const writer = new XlsxWriter(); + const buffer = await writer.write(project); + + expect(buffer).toBeInstanceOf(Uint8Array); + expect(buffer.byteLength).toBeGreaterThan(0); + + // Parse the buffer back and verify structure + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer); + + const sheet = workbook.getWorksheet("Schedule"); + expect(sheet).toBeDefined(); + + // Header row + task rows (tasks with non-null id) + const tasksWithId = project.tasks.filter((t) => t.id !== null); + expect(sheet!.rowCount).toBe(tasksWithId.length + 1); // +1 for header + + // Verify header + const headerRow = sheet!.getRow(1); + expect(headerRow.getCell(1).value).toBe("ID"); + expect(headerRow.getCell(2).value).toBe("Task Name"); + expect(headerRow.getCell(3).value).toBe("WBS"); + expect(headerRow.getCell(4).value).toBe("Start"); + expect(headerRow.getCell(5).value).toBe("Finish"); + expect(headerRow.getCell(6).value).toBe("Duration"); + + // Verify first data row matches first task + const firstTask = tasksWithId[0]!; + const firstRow = sheet!.getRow(2); + expect(firstRow.getCell(1).value).toBe(firstTask.id); + expect(firstRow.getCell(2).value).toBe(firstTask.name); + }, + ); test("applies bold formatting to summary tasks", async () => { const project = makeMinimalProject(); @@ -80,7 +83,9 @@ describe("XlsxWriter", () => { test("uses custom sheet name from options", async () => { const project = makeMinimalProject(); - const buffer = await new XlsxWriter().write(project, { sheetName: "Tasks" }); + const buffer = await new XlsxWriter().write(project, { + sheetName: "Tasks", + }); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(buffer); @@ -156,7 +161,9 @@ describe("XlsxWriter", () => { const drawingFile = zip.file("xl/drawings/drawing1.xml"); expect(drawingFile).not.toBeNull(); - const sheetRels = await zip.file("xl/worksheets/_rels/sheet3.xml.rels")!.async("string"); + const sheetRels = await zip + .file("xl/worksheets/_rels/sheet3.xml.rels")! + .async("string"); expect(sheetRels).toContain("drawings/drawing1.xml"); const ct = await zip.file("[Content_Types].xml")!.async("string"); @@ -166,7 +173,9 @@ describe("XlsxWriter", () => { test("writeWithGantt with empty phases returns a plain xlsx", async () => { const project = makeMinimalProject(); - const buffer = await new XlsxWriter().writeWithGantt(project, { phases: [] }); + const buffer = await new XlsxWriter().writeWithGantt(project, { + phases: [], + }); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(buffer.buffer.slice(0) as ArrayBuffer); diff --git a/test/xlsx/formulas.test.ts b/test/xlsx/formulas.test.ts index cf8fe24..01fac99 100644 --- a/test/xlsx/formulas.test.ts +++ b/test/xlsx/formulas.test.ts @@ -77,17 +77,30 @@ describe("XlsxWriter.writeSheets — formulas, names, notes, styles", () => { const dir = await mkdtemp(join(tmpdir(), "levelset-recalc-")); try { const inPath = join(dir, "pricing.xlsx"); - await writeFile(inPath, new Uint8Array(await workbook.xlsx.writeBuffer())); + await writeFile( + inPath, + new Uint8Array(await workbook.xlsx.writeBuffer()), + ); const proc = Bun.spawn( - [SOFFICE!, "--headless", "--convert-to", "xlsx", "--outdir", join(dir, "out"), inPath], + [ + SOFFICE!, + "--headless", + "--convert-to", + "xlsx", + "--outdir", + join(dir, "out"), + inPath, + ], { stdout: "pipe", stderr: "pipe" }, ); const exitCode = await proc.exited; expect(exitCode).toBe(0); const converted = await loadWorkbook( - new Uint8Array(await Bun.file(join(dir, "out", "pricing.xlsx")).arrayBuffer()), + new Uint8Array( + await Bun.file(join(dir, "out", "pricing.xlsx")).arrayBuffer(), + ), ); // Total row shifted from 6 to 7 by the insertion. const total = converted.getWorksheet("Pricing")!.getCell("B7"); @@ -174,7 +187,9 @@ describe("XlsxWriter.writeWithGanttAndSheets", () => { phases: [ { label: "Phase 1", - items: [{ name: "Task A", start: "2026-01-05", finish: "2026-01-09" }], + items: [ + { name: "Task A", start: "2026-01-05", finish: "2026-01-09" }, + ], }, ], }, @@ -186,6 +201,8 @@ describe("XlsxWriter.writeWithGanttAndSheets", () => { expect(workbook.getWorksheet("Gantt")).toBeDefined(); const pricing = workbook.getWorksheet("Pricing")!; expect(pricing.getCell("B6").formula).toBe("SUM(Line_Amounts)"); - expect(workbook.definedNames.getRanges("Line_Amounts").ranges).toEqual(["Pricing!$B$2:$B$5"]); + expect(workbook.definedNames.getRanges("Line_Amounts").ranges).toEqual([ + "Pricing!$B$2:$B$5", + ]); }); });