import { describe, expect, it } from "vitest"; import { normalizeControlsPanelLayout } from "../schema/controls-panel-normalization"; 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 { resolveToolcraftModuleContributions } from "./testing/module-resolution-test-support"; import { getSettingsContribution, getTestModuleContributions, permutations, } from "./testing/module-contribution-test-fixtures"; describe("Toolcraft module contribution materialization", () => { it("orders image settings before video settings for every declaration order", () => { const definitions = [imageExportModule(), videoExportModule()]; for (const declarationOrder of permutations(definitions)) { const resolution = resolveToolcraftModuleContributions( getTestModuleContributions(declarationOrder), ); expect(resolution.controlSections.map(({ id }) => id)).toEqual([ "runtime.image-export", "runtime.video-export", ]); } }); it("materializes no settings for SVG and permits a missing placement peer", () => { expect( resolveToolcraftModuleContributions(svgExportModule().contributions) .controlSections, ).toEqual([]); expect( resolveToolcraftModuleContributions( imageExportModule().contributions, ).controlSections.map(({ id }) => id), ).toEqual(["runtime.image-export"]); }); it("registers runtime section identities that survive panel normalization", () => { const resolution = resolveToolcraftModuleContributions( getTestModuleContributions([ videoExportModule(), svgExportModule(), imageExportModule(), ]), ); const normalized = normalizeControlsPanelLayout({ sections: [ ...resolution.controlSections, resolution.panelActionsSection!, ], title: "Controls", }); expect(normalized.sections.map(({ id }) => id)).toEqual([ "runtime.image-export", "runtime.video-export", "runtime.export", ]); expect(normalized.sections.map(({ title }) => title)).toEqual([ "Image Export", "Video Export", "Export", ]); expect(normalized.sections[0]?.controls.imageFormat).toMatchObject({ applicability: { mode: "always" as const }, defaultValue: "png", options: [ { label: "PNG", value: "png" }, { label: "JPG", value: "jpg" }, ], target: "export.image.format", type: "select", }); expect(normalized.sections[1]?.controls.videoResolution).toMatchObject({ applicability: { mode: "always" as const }, defaultValue: "current", target: "export.video.resolution", type: "select", }); expect( resolution.panelActionsSection?.controls.outputActions, ).toMatchObject({ applicability: { mode: "always" as const }, target: "actions.output", type: "panelActions", }); expect(normalized.sections[2]?.controls.footer).toMatchObject({ applicability: { mode: "always" as const }, target: "actions.output", type: "panelActions", }); expect(normalized.sections[2]?.controls.footer?.actions).toEqual( resolution.panelActionsSection?.controls.outputActions?.actions, ); }); it("registers a distinct settings shell while reusing its normalized immutable payload", () => { const definition = imageExportModule(); const authoredSettings = getSettingsContribution( definition, "image-export.settings", ); const materialized = resolveToolcraftModuleContributions( definition.contributions, ).controlSections[0]!; const normalized = normalizeControlsPanelLayout({ sections: [materialized], title: "Controls", }); expect(materialized).not.toBe(authoredSettings.section); expect(materialized.id).toBe(authoredSettings.runtimeSectionId); expect(materialized.controls).toBe(authoredSettings.section.controls); expect(materialized.layoutGroups).toBe( authoredSettings.section.layoutGroups, ); expect(Object.isFrozen(materialized)).toBe(true); expect(normalized.sections[0]?.id).toBe("runtime.image-export"); }); it("rejects a present placement cycle with the complete normalized path", () => { const imageSettings = getSettingsContribution( imageExportModule(), "image-export.settings", ); const videoSettings = getSettingsContribution( videoExportModule(), "video-export.settings", ); const cyclicVideo = { ...videoSettings, placement: { after: [], before: ["image-export.settings"], slot: "artifact-settings", }, } as const; expect(() => resolveToolcraftModuleContributions([cyclicVideo, imageSettings]), ).toThrowError( "Toolcraft artifact-settings placement cycle: image-export.settings -> video-export.settings -> image-export.settings.", ); }); it("materializes canonical primary and outline artifact actions", () => { const imageOnly = resolveToolcraftModuleContributions( imageExportModule().contributions, ).panelActionsSection!; expect(imageOnly).toMatchObject({ actionGroup: "secondary", id: "runtime.export", title: "Export", }); expect(imageOnly.controls.outputActions?.actions).toEqual([ { icon: "upload-simple", label: "Export PNG", role: "export-image", value: "export.png", }, ]); const allActions = resolveToolcraftModuleContributions( getTestModuleContributions([ videoExportModule(), imageExportModule(), svgExportModule(), ]), ).panelActionsSection!; expect(allActions.controls.outputActions?.actions).toEqual([ { icon: "upload-simple", label: "Export PNG", role: "export-image", value: "export.png", variant: "outline", }, { icon: "upload-simple", label: "Export SVG", role: "export-svg", value: "export.svg", variant: "outline", }, { icon: "upload-simple", label: "Export Video", role: "export-video", value: "export.video", }, ]); const svgOnly = resolveToolcraftModuleContributions( svgExportModule().contributions, ).panelActionsSection!; expect(svgOnly.controls.outputActions?.actions).toEqual([ { icon: "upload-simple", label: "Export SVG", role: "export-svg", value: "export.svg", }, ]); }); it("materializes one authored timeline config and persistence requirement", () => { const definition = timelineModule({ mode: "keyframes" }); const authoredTimeline = definition.contributions.find( (contribution) => contribution.kind === "panel-surface" && contribution.surface === "timeline", )!.configuration; const resolution = resolveToolcraftModuleContributions( definition.contributions, ); expect(resolution.timeline).toEqual({ enabled: true, mode: "keyframes" }); expect(resolution.timeline).toBe(authoredTimeline); expect(resolution.persistenceRequirements).toEqual(["timeline"]); }); it("returns recursively frozen materialized output", () => { const resolution = resolveToolcraftModuleContributions( getTestModuleContributions([ videoExportModule(), timelineModule({ mode: "playback" }), svgExportModule(), imageExportModule(), ]), ); expect(Object.isFrozen(resolution)).toBe(true); expect(Object.isFrozen(resolution.controlSections)).toBe(true); expect(resolution.controlSections.every(Object.isFrozen)).toBe(true); expect( resolution.controlSections.every((section) => Object.isFrozen(section.controls), ), ).toBe(true); expect( resolution.controlSections.every((section) => Object.values(section.controls).every( (control) => Object.isFrozen(control) && Object.isFrozen(control.applicability) && Object.isFrozen(control.options) && (control.options ?? []).every(Object.isFrozen), ), ), ).toBe(true); expect( resolution.controlSections.every( (section) => Object.isFrozen(section.layoutGroups) && (section.layoutGroups ?? []).every( (layoutGroup) => Object.isFrozen(layoutGroup) && Object.isFrozen(layoutGroup.controls), ), ), ).toBe(true); expect(Object.isFrozen(resolution.panelActionsSection)).toBe(true); expect(Object.isFrozen(resolution.panelActionsSection!.controls)).toBe( true, ); expect( Object.isFrozen(resolution.panelActionsSection!.controls.outputActions), ).toBe(true); expect( Object.isFrozen( resolution.panelActionsSection!.controls.outputActions?.actions, ), ).toBe(true); expect( resolution.panelActionsSection!.controls.outputActions?.actions?.every( Object.isFrozen, ), ).toBe(true); expect(Object.isFrozen(resolution.timeline)).toBe(true); expect(Object.isFrozen(resolution.persistenceRequirements)).toBe(true); }); });