From 21a385ba77e4442d44b2c967372000c76e4a640a Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Wed, 19 Aug 2026 12:12:56 -0600 Subject: [PATCH] style: oxfmt sweep at printWidth 80 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns .oxfmtrc.json with the downstream workspace's, which formats this library as packages/levelset when it is vendored there. printWidth was 100, carried over from the Prettier config oxfmt replaced; measured at that width, a `bun run format` in that workspace reflowed 104 of 143 files inside the vendored copy, so the two settings could not both stay. 105 files reflowed, no behavioral change. Kept as its own commit so the reflow never mixes with a real edit — the same reason the earlier printWidth-100 sweep was separated. bun run typecheck clean, bun run lint exits 0, bun run format:check exits 0, bun test 584 pass / 12 skip / 1 fail (pre-existing), generated/block-meta.json unchanged after regeneration. --- .oxfmtrc.json | 9 +- .oxlintrc.json | 10 +- README.md | 39 ++++- src/conf/index.ts | 6 +- src/conf/parse.ts | 50 ++++-- src/conf/serialize.ts | 51 ++++-- src/csv/CsvWriter.ts | 20 ++- src/dateTime.ts | 4 +- src/exceljs.d.ts | 5 +- src/fixture.ts | 15 +- src/index.ts | 23 ++- src/json/JsonReader.ts | 4 +- src/level-blocks/compile.ts | 14 +- src/level-blocks/composition.ts | 11 +- src/level-blocks/concurrentResourceCost.ts | 97 ++++++----- src/level-blocks/concurrentUnitsLimit.ts | 75 +++++---- src/level-blocks/crewBorrow.ts | 26 ++- src/level-blocks/gate.ts | 17 +- src/level-blocks/graph.ts | 11 +- src/level-blocks/hiringLagPenalty.ts | 15 +- src/level-blocks/makespan.ts | 3 +- src/level-blocks/maxConcurrentResource.ts | 60 +++---- src/level-blocks/meta.ts | 42 +++-- src/level-blocks/modeSelection.ts | 21 ++- src/level-blocks/openUnitPenalty.ts | 9 +- src/level-blocks/pareto.ts | 3 +- src/level-blocks/peakCap.ts | 3 +- src/level-blocks/phaseEarliness.ts | 6 +- src/level-blocks/phaseLateness.ts | 6 +- src/level-blocks/phaseRelease.ts | 22 ++- src/level-blocks/phaseScoring.ts | 13 +- src/level-blocks/registry.ts | 8 +- src/level-blocks/runConfig.ts | 157 +++++++++++++---- src/level-blocks/serialSgsSearch.ts | 3 +- src/level-blocks/standard.ts | 49 ++++-- src/level-blocks/sweep.ts | 13 +- src/level-blocks/unimodalDeviation.ts | 7 +- src/level-core/analyzeRun.ts | 25 ++- src/level-core/calendarDays.ts | 10 +- src/level-core/materialize.ts | 30 +++- src/level-core/profiles.ts | 15 +- src/level-core/resolveCalendar.ts | 76 +++++++-- src/level-core/scheduleStream.ts | 23 ++- src/level-core/search/serialSGS.ts | 158 +++++++++++++----- src/level-core/types.ts | 29 +++- src/level-core/weeklyProfile.ts | 23 ++- src/level-core/weightedScorer.ts | 8 +- src/model/Duration.ts | 4 +- src/model/types.ts | 9 +- src/mpp/FixedData.ts | 32 +++- src/mpp/FixedMeta.ts | 8 +- src/mpp/Mpp14Reader.ts | 121 ++++++++++---- src/mpp/MppReader.ts | 15 +- src/mpp/MppUtility.ts | 61 +++++-- src/mpp/MppVariant.ts | 15 +- src/mpp/Props.ts | 6 +- src/mspdi/MspdiReader.ts | 47 ++++-- src/mspdi/MspdiWriter.ts | 76 +++++++-- src/mspdi/XsdDuration.ts | 43 ++++- src/schema/date.ts | 26 +-- src/schema/index.ts | 19 ++- src/schema/types.ts | 7 +- src/xlsx/XlsxWriter.ts | 51 +++++- test/conf/parse.test.ts | 42 +++-- test/conf/serialize.test.ts | 72 ++++++-- test/csv.test.ts | 33 ++-- test/helpers.ts | 4 +- test/json.test.ts | 27 ++- test/level-blocks/blockMeta.test.ts | 12 +- test/level-blocks/collectors.test.ts | 113 ++++++++++--- test/level-blocks/compile.test.ts | 45 ++++- test/level-blocks/composition.test.ts | 48 +++++- .../concurrentResourceCost.test.ts | 17 +- .../level-blocks/concurrentUnitsLimit.test.ts | 36 +++- test/level-blocks/crewBorrow.test.ts | 33 +++- test/level-blocks/graph.test.ts | 22 ++- test/level-blocks/hiringLagPenalty.test.ts | 13 +- .../maxConcurrentResource.test.ts | 39 ++++- test/level-blocks/meta.test.ts | 20 ++- test/level-blocks/newBlocks.test.ts | 58 +++++-- test/level-blocks/openUnitPenalty.test.ts | 83 +++++++-- test/level-blocks/phaseScorers.test.ts | 60 +++++-- test/level-blocks/pipelineFixtures.ts | 91 +++++++--- test/level-blocks/provenance.test.ts | 8 +- test/level-blocks/registry.test.ts | 39 ++++- test/level-blocks/runConfig.test.ts | 95 ++++++++--- test/level-blocks/standard.test.ts | 23 ++- test/level-blocks/sweep.test.ts | 19 ++- test/level-blocks/unimodalDeviation.test.ts | 10 +- test/level-core/analyzeRun.test.ts | 85 ++++++++-- test/level-core/constraintMeta.test.ts | 20 ++- test/level-core/fixtures.ts | 18 +- test/level-core/materialize.test.ts | 52 ++++-- test/level-core/profiles.test.ts | 9 +- test/level-core/resolveCalendar.test.ts | 91 +++++++--- test/level-core/scheduleStream.test.ts | 42 +++-- test/level-core/serialSGS.test.ts | 73 ++++++-- test/level-core/weeklyProfile.test.ts | 23 ++- test/level-core/weightedScorer.test.ts | 16 +- test/library.test.ts | 75 ++++++--- test/mspdi.test.ts | 94 +++++++---- test/schema.test.ts | 19 ++- test/version.test.ts | 60 ++++--- test/xlsx.test.ts | 89 +++++----- test/xlsx/formulas.test.ts | 27 ++- 105 files changed, 2784 insertions(+), 935 deletions(-) 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", + ]); }); }); -- 2.54.0