import { describe, expect, it } from "vitest"; import { imageExportModule } from "./built-ins/image-export/image-export-module"; import { svgExportModule } from "./built-ins/svg-export/svg-export-module"; import { timelineModule } from "./built-ins/timeline/timeline-module"; import { videoExportModule } from "./built-ins/video-export/video-export-module"; import type { ToolcraftProductModuleRecord } from "./contract/module-definition"; import { resolveToolcraftModuleDependencies } from "./testing/module-resolution-test-support"; import { createInvalidTestToolcraftProductModuleRecord, createTestToolcraftProductModuleDefinition, unsafeCastTestOnlyToolcraftProductModuleRecord, } from "./testing/create-test-module-definition"; const emptyTestModuleFields = { contributions: [], defaultProviders: [], portRequirements: [], } as const; function permutations(values: readonly T[]): readonly (readonly T[])[] { if (values.length <= 1) { return [[...values]]; } return values.flatMap((value, index) => permutations([...values.slice(0, index), ...values.slice(index + 1)]).map( (tail) => [value, ...tail], ), ); } describe("resolveToolcraftModuleDependencies", () => { it.each([ { capabilityIds: ["artifact.image-export"], definition: imageExportModule(), moduleId: "image-export", provides: ["artifact.image-export"], requires: [], }, { capabilityIds: ["artifact.svg-export"], definition: svgExportModule(), moduleId: "svg-export", provides: ["artifact.svg-export"], requires: [], }, { capabilityIds: ["timeline.playback"], definition: timelineModule({ mode: "playback" }), moduleId: "timeline", provides: ["timeline.playback"], requires: [], }, { capabilityIds: ["timeline.keyframes", "timeline.playback"], definition: timelineModule({ mode: "keyframes" }), moduleId: "timeline", provides: ["timeline.keyframes", "timeline.playback"], requires: [], }, ] as const)( "resolves explicit $moduleId without injecting a module for $capabilityIds", ({ capabilityIds, definition, moduleId, provides, requires }) => { const resolution = resolveToolcraftModuleDependencies([definition]); expect(resolution.definitions).toEqual([definition]); expect(resolution.definitions[0]).toBe(definition); expect(resolution.modules).toEqual([ { id: moduleId, origin: "explicit", provides, requestedBy: [], requires, }, ]); expect(resolution.capabilities).toEqual( capabilityIds.map((capabilityId) => ({ capabilityId, moduleId })), ); }, ); it("injects the canonical playback timeline for video and records its requester", () => { const resolution = resolveToolcraftModuleDependencies([ videoExportModule(), ]); expect(resolution.definitions).toEqual([ timelineModule({ mode: "playback" }), videoExportModule(), ]); expect(resolution.modules).toEqual([ { id: "timeline", origin: "default-provider", provides: ["timeline.playback"], requestedBy: ["video-export"], requires: [], }, { id: "video-export", origin: "explicit", provides: ["artifact.video-export"], requestedBy: [], requires: ["timeline.playback"], }, ]); expect(resolution.capabilities).toEqual([ { capabilityId: "artifact.video-export", moduleId: "video-export", }, { capabilityId: "timeline.playback", moduleId: "timeline" }, ]); expect(resolution.definitions[0]).toBe( timelineModule({ mode: "playback" }), ); }); it("accumulates sorted unique requesters for one injected provider", () => { const imageRequiringPlayback = createTestToolcraftProductModuleDefinition({ ...emptyTestModuleFields, id: "image-export", provides: ["artifact.image-export"], requires: ["timeline.playback"], }); const resolution = resolveToolcraftModuleDependencies([ videoExportModule(), imageRequiringPlayback, ]); expect(resolution.modules.find(({ id }) => id === "timeline")).toEqual({ id: "timeline", origin: "default-provider", provides: ["timeline.playback"], requestedBy: ["image-export", "video-export"], requires: [], }); }); it.each(["playback", "keyframes"] as const)( "uses an explicit %s timeline for video without injecting another timeline", (mode) => { const explicitTimeline = timelineModule({ mode }); const resolution = resolveToolcraftModuleDependencies([ videoExportModule(), explicitTimeline, ]); expect(resolution.definitions).toEqual([ explicitTimeline, videoExportModule(), ]); expect(resolution.modules).toHaveLength(2); expect(resolution.modules[0]).toMatchObject({ id: "timeline", origin: "explicit", requestedBy: [], }); }, ); it.each([ { definitions: [imageExportModule(), svgExportModule()] }, { definitions: [imageExportModule(), videoExportModule()] }, { definitions: [svgExportModule(), videoExportModule()] }, { definitions: [ imageExportModule(), svgExportModule(), videoExportModule(), ], }, { definitions: [ imageExportModule(), svgExportModule(), timelineModule({ mode: "playback" }), videoExportModule(), ], }, { definitions: [ imageExportModule(), svgExportModule(), timelineModule({ mode: "keyframes" }), videoExportModule(), ], }, ])("returns byte-equal JSON for every input permutation", ({ definitions }) => { const expected = JSON.stringify( resolveToolcraftModuleDependencies(definitions), ); for (const permutation of permutations(definitions)) { expect( JSON.stringify(resolveToolcraftModuleDependencies(permutation)), ).toBe(expected); } }); it.each([ { definitions: [imageExportModule(), imageExportModule()], id: "image-export", label: "identical image declarations", }, { definitions: [svgExportModule(), svgExportModule()], id: "svg-export", label: "identical SVG declarations", }, { definitions: [videoExportModule(), videoExportModule()], id: "video-export", label: "identical video declarations", }, { definitions: [ timelineModule({ mode: "playback" }), timelineModule({ mode: "playback" }), ], id: "timeline", label: "identical playback timelines", }, { definitions: [ timelineModule({ mode: "playback" }), timelineModule({ mode: "keyframes" }), ], id: "timeline", label: "playback and keyframes timelines", }, ])("rejects repeated module id $id for $label", ({ definitions, id }) => { expect(() => resolveToolcraftModuleDependencies(definitions)).toThrowError( `Toolcraft product module id "${id}" is declared more than once.`, ); }); it("rejects an unsupported module contract version before resolution", () => { const unsupported = unsafeCastTestOnlyToolcraftProductModuleRecord( createInvalidTestToolcraftProductModuleRecord({ contractVersion: 3, id: "image-export", provides: ["artifact.image-export"], requires: [], }), ); expect(() => resolveToolcraftModuleDependencies([unsupported]), ).toThrowError( 'Toolcraft product module "image-export" uses contract version 3; expected current version 2.', ); }); it("rejects duplicate ids before checking their contract versions", () => { const unsupported = unsafeCastTestOnlyToolcraftProductModuleRecord( createInvalidTestToolcraftProductModuleRecord({ contractVersion: 3, id: "image-export", provides: ["artifact.image-export"], requires: [], }), ); expect(() => resolveToolcraftModuleDependencies([unsupported, unsupported]), ).toThrowError( 'Toolcraft product module id "image-export" is declared more than once.', ); }); it("rejects a missing capability with its complete requirement path", () => { const missingKeyframes = createTestToolcraftProductModuleDefinition({ ...emptyTestModuleFields, id: "image-export", provides: ["artifact.image-export"], requires: ["timeline.keyframes"], }); expect(() => resolveToolcraftModuleDependencies([missingKeyframes]), ).toThrowError( 'Toolcraft capability "timeline.keyframes" is missing along requirement path image-export -> timeline.keyframes; module "image-export" declares no default provider for it.', ); }); it("rejects a default provider whose module id is already occupied", () => { const occupiedTimeline = createTestToolcraftProductModuleDefinition({ ...emptyTestModuleFields, id: "timeline", provides: [], requires: [], }); expect(() => resolveToolcraftModuleDependencies([ videoExportModule(), occupiedTimeline, ]), ).toThrowError( 'Toolcraft default provider "timeline.playback-default" cannot insert module "timeline" because that id is already occupied by an explicit module along requirement path video-export -> timeline.playback.', ); }); it("rejects multiply-provided capabilities with every provider id", () => { const imagePlaybackProvider = createTestToolcraftProductModuleDefinition({ ...emptyTestModuleFields, id: "image-export", provides: ["artifact.image-export", "timeline.playback"], requires: [], }); expect(() => resolveToolcraftModuleDependencies([ timelineModule({ mode: "playback" }), imagePlaybackProvider, ]), ).toThrowError( 'Toolcraft capability "timeline.playback" has multiple providers: image-export, timeline.', ); }); it("rejects a two-node dependency cycle with the complete normalized path", () => { const image = createTestToolcraftProductModuleDefinition({ ...emptyTestModuleFields, id: "image-export", provides: ["artifact.image-export"], requires: ["artifact.svg-export"], }); const svg = createTestToolcraftProductModuleDefinition({ ...emptyTestModuleFields, id: "svg-export", provides: ["artifact.svg-export"], requires: ["artifact.image-export"], }); expect(() => resolveToolcraftModuleDependencies([svg, image]), ).toThrowError( "Toolcraft module dependency cycle: image-export -> svg-export -> image-export.", ); }); it("rejects a three-node dependency cycle with the complete normalized path", () => { const image = createTestToolcraftProductModuleDefinition({ ...emptyTestModuleFields, id: "image-export", provides: ["artifact.image-export"], requires: ["artifact.svg-export"], }); const svg = createTestToolcraftProductModuleDefinition({ ...emptyTestModuleFields, id: "svg-export", provides: ["artifact.svg-export"], requires: ["artifact.video-export"], }); const video = createTestToolcraftProductModuleDefinition({ ...emptyTestModuleFields, id: "video-export", provides: ["artifact.video-export"], requires: ["artifact.image-export"], }); expect(() => resolveToolcraftModuleDependencies([video, svg, image]), ).toThrowError( "Toolcraft module dependency cycle: image-export -> svg-export -> video-export -> image-export.", ); }); it("returns a recursively frozen, id-sorted resolution", () => { const resolution = resolveToolcraftModuleDependencies([ videoExportModule(), imageExportModule(), ]); expect(Object.isFrozen(resolution)).toBe(true); expect(Object.isFrozen(resolution.definitions)).toBe(true); expect(resolution.definitions.every(Object.isFrozen)).toBe(true); expect(Object.isFrozen(resolution.modules)).toBe(true); expect(resolution.modules.every(Object.isFrozen)).toBe(true); expect(resolution.modules.every((module) => Object.isFrozen(module.provides))) .toBe(true); expect(resolution.modules.every((module) => Object.isFrozen(module.requires))) .toBe(true); expect( resolution.modules.every((module) => Object.isFrozen(module.requestedBy)), ).toBe(true); expect(Object.isFrozen(resolution.capabilities)).toBe(true); expect(resolution.capabilities.every(Object.isFrozen)).toBe(true); expect(resolution.modules.map(({ id }) => id)).toEqual([ "image-export", "timeline", "video-export", ]); }); it("rejects mutable structural inputs instead of granting opaque identity", () => { const source = imageExportModule(); const structuralInput = { contractVersion: 2, contributions: [...source.contributions], defaultProviders: [...source.defaultProviders], id: source.id, portRequirements: [...source.portRequirements], provides: [...source.provides], requires: [...source.requires], } satisfies ToolcraftProductModuleRecord; const inputSnapshot = JSON.stringify(structuralInput); expect(() => resolveToolcraftModuleDependencies([ unsafeCastTestOnlyToolcraftProductModuleRecord(structuralInput), ]), ).toThrowError( 'Toolcraft product module "image-export" is not an opaque definition created by a Toolcraft module factory; pass the factory result without copying or mutation.', ); expect(JSON.stringify(structuralInput)).toBe(inputSnapshot); }); });