import { describe, expect, test } from "bun:test"; import type { Layer } from "@core/layer"; import { createInitialAppState } from "@editor/initial-state"; import { documentAddArtboardCommand, documentAddAssetCommand, documentAddGroupLayerCommand, documentAddImageLayerCommand, documentAddLayerMaskCommand, documentAddRasterLayerCommand, documentApplyLayerMaskOperationCommand, documentGroupLayersCommand, documentDuplicateLayerCommand, documentMoveLayerCommand, documentRemoveArtboardCommand, documentRemoveLayerCommand, documentRemoveLayerMaskCommand, documentRenameArtboardCommand, documentRenameLayerCommand, documentSetArtboardBoundsCommand, documentSetArtboardLockedCommand, documentSetArtboardVisibleCommand, documentSetLayerClippingMaskCommand, documentSetLayerLockedCommand, documentSetLayerOpacityCommand, documentSetLayerVisibleCommand, documentUpdateAssetSourceCommand, documentUngroupLayerCommand, } from "./document"; describe("document commands", () => { test("adds transparent artboard", () => { const next = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, { id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } }, ); expect(next.document.artboards).toEqual([ { id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 }, backgroundColor: "transparent", visible: true, locked: false, layers: [], }, ]); }); test("adds assets", () => { const next = documentAddAssetCommand.execute( { state: createInitialAppState("Test") }, { asset: { id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 100, h: 50 } } }, ); expect(next.document.assets).toEqual([ { id: "asset-1", name: "Image", mimeType: "image/png", source: "blob:test", intrinsicSize: { w: 100, h: 50 } }, ]); }); test("updates asset sources", () => { const state = documentAddAssetCommand.execute( { state: createInitialAppState("Test") }, { asset: { id: "asset-1", name: "Image", mimeType: "image/png", source: "old", intrinsicSize: { w: 100, h: 50 } } }, ); const next = documentUpdateAssetSourceCommand.execute({ state }, { assetId: "asset-1", source: "new" }); expect(next.document.assets[0]?.source).toBe("new"); }); test("adds image layers to artboards", () => { const state = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, { id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } }, ); const next = documentAddImageLayerCommand.execute( { state }, { artboardId: "a1", layer: { id: "l1", type: "image", name: "Image", visible: true, locked: false, opacity: 1, assetId: "asset-1", transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 }, }, }, ); expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["l1"]); }); test("adds raster layers to artboards", () => { const state = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, { id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } }, ); const next = documentAddRasterLayerCommand.execute( { state }, { artboardId: "a1", layer: { id: "r1", type: "raster", name: "Raster", visible: true, locked: false, opacity: 1, assetId: "asset-1", transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 }, }, }, ); expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["r1"]); }); test("adds group layers", () => { const state = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, { id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } }, ); const next = documentAddGroupLayerCommand.execute({ state }, { artboardId: "a1", group: group("g1", "Group") }); expect(next.document.artboards[0]?.layers).toEqual([group("g1", "Group")]); expect(next.editor.selection.layerIds).toEqual(["g1"]); }); test("moves layers", () => { const state = documentWithLayers([group("a", "A"), group("b", "B"), group("c", "C")]); const next = documentMoveLayerCommand.execute({ state }, { layerId: "a", toArtboardId: "a1", toIndex: 2 }); expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["b", "c", "a"]); }); test("groups and ungroups top-level layers", () => { const state = documentWithLayers([group("a", "A"), group("b", "B"), group("c", "C")]); const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") }); expect(grouped.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["g", "c"]); expect((grouped.document.artboards[0]?.layers[0] as { children: { id: string }[] }).children.map((layer) => layer.id)).toEqual(["a", "b"]); expect(grouped.editor.selection.layerIds).toEqual(["g"]); const ungrouped = documentUngroupLayerCommand.execute({ state: grouped }, { groupId: "g" }); expect(ungrouped.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["a", "b", "c"]); expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]); }); test("groups nested sibling layers in place", () => { const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("a", "A"), group("b", "B"), group("c", "C")] }]); const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") }); const parent = grouped.document.artboards[0]?.layers[0]; const nestedGroup = parent?.type === "group" ? parent.children[0] : undefined; expect(parent?.type).toBe("group"); expect(parent?.type === "group" ? parent.children.map((layer) => layer.id) : []).toEqual(["g", "c"]); expect(nestedGroup?.type === "group" ? nestedGroup.children.map((layer) => layer.id) : []).toEqual(["a", "b"]); expect(grouped.editor.selection).toEqual({ artboardId: "a1", layerIds: ["g"] }); }); test("does not group layers from different parents", () => { const state = documentWithLayers([group("a", "A"), { ...group("parent", "Parent"), children: [group("b", "B")] }]); const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") }); expect(grouped).toBe(state); }); test("does not move groups into their own descendants", () => { const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("child", "Child")] }]); const moved = documentMoveLayerCommand.execute({ state }, { layerId: "parent", toArtboardId: "a1", toParentGroupId: "child", toIndex: 0 }); expect(moved).toBe(state); }); test("removes layers", () => { const state = documentWithLayers([group("a", "A"), group("b", "B")]); const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } }; const next = documentRemoveLayerCommand.execute({ state: selectedState }, { layerId: "a" }); expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["b"]); expect(next.editor.selection.layerIds).toEqual([]); }); test("removes artboards and clears artboard selection", () => { const state = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, { id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } }, ); const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: [] } } }; const next = documentRemoveArtboardCommand.execute({ state: selectedState }, { id: "a1" }); expect(next.document.artboards).toEqual([]); expect(next.editor.selection).toEqual({ layerIds: [] }); }); test("renames artboards and layers", () => { const state = documentWithLayers([group("a", "A")]); const renamedArtboard = documentRenameArtboardCommand.execute({ state }, { id: "a1", name: "New Artboard" }); const renamedLayer = documentRenameLayerCommand.execute({ state: renamedArtboard }, { layerId: "a", name: "New Layer" }); expect(renamedLayer.document.artboards[0]?.name).toBe("New Artboard"); expect(renamedLayer.document.artboards[0]?.layers[0]?.name).toBe("New Layer"); }); test("sets and clears layer clipping masks", () => { const state = documentWithLayers([group("mask", "Mask"), group("target", "Target")]); const masked = documentSetLayerClippingMaskCommand.execute({ state }, { layerId: "target", maskLayerId: "mask" }); expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" }); const cleared = documentSetLayerClippingMaskCommand.execute({ state: masked }, { layerId: "target" }); expect(cleared.document.artboards[0]?.layers[1]?.clippingMask).toBeUndefined(); }); test("moves masked layers directly after their mask", () => { const state = documentWithLayers([group("target", "Target"), group("other", "Other"), group("mask", "Mask")]); const masked = documentSetLayerClippingMaskCommand.execute({ state }, { layerId: "target", maskLayerId: "mask" }); expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["other", "mask", "target"]); expect(masked.document.artboards[0]?.layers[2]?.clippingMask).toEqual({ maskLayerId: "mask" }); }); test("adds editable raster masks directly before the target layer", () => { const state = documentWithLayers([raster("target", "Target")]); const masked = documentAddLayerMaskCommand.execute( { state }, { layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset"), }, ); expect(masked.document.assets).toContainEqual(maskAsset()); expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]); expect(masked.document.artboards[0]?.layers[1]?.layerMask).toEqual({ kind: "raster", maskLayerId: "mask", enabled: true, inverted: false, }); expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" }); expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" }); expect(masked.editor.tools.activeTool).toBe("brush"); }); test("removes attached layer masks and exits mask editing", () => { const state = documentAddLayerMaskCommand.execute( { state: documentWithLayers([raster("target", "Target")]) }, { layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") }, ); const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" }); expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]); expect(unmasked.document.artboards[0]?.layers[0]?.layerMask).toBeUndefined(); expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined(); expect(unmasked.editor.maskEdit).toBeUndefined(); }); test("applies operations only to attached layer mask assets", () => { const state = documentAddLayerMaskCommand.execute( { state: documentWithLayers([raster("target", "Target"), raster("loose", "Loose", "loose-asset")]) }, { layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") }, ); const withLooseAsset = documentAddAssetCommand.execute( { state }, { asset: { id: "loose-asset", name: "Loose", mimeType: "image/png", source: "loose-source", intrinsicSize: { w: 100, h: 100 } } }, ); const updated = documentApplyLayerMaskOperationCommand.execute( { state: withLooseAsset }, { maskLayerId: "mask", source: "updated-mask", mimeType: "image/png", operation: { type: "invert" } }, ); const ignored = documentApplyLayerMaskOperationCommand.execute( { state: updated }, { maskLayerId: "loose", source: "wrong", operation: { type: "invert" } }, ); expect(updated.document.assets.find((asset) => asset.id === "mask-asset")?.source).toBe("updated-mask"); expect(updated.document.assets.find((asset) => asset.id === "mask-asset")?.mimeType).toBe("image/png"); expect(ignored.document.assets.find((asset) => asset.id === "loose-asset")?.source).toBe("loose-source"); }); test("cleans mask references when deleting targets or mask layers", () => { const state = documentAddLayerMaskCommand.execute( { state: documentWithLayers([raster("target", "Target")]) }, { layerId: "target", asset: maskAsset(), maskLayer: raster("mask", "Target Mask", "mask-asset") }, ); const removedTarget = documentRemoveLayerCommand.execute({ state }, { layerId: "target" }); const removedMask = documentRemoveLayerCommand.execute({ state }, { layerId: "mask" }); expect(removedTarget.document.artboards[0]?.layers).toEqual([]); expect(removedMask.document.artboards[0]?.layers[0]?.id).toBe("target"); expect(removedMask.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined(); expect(removedMask.editor.maskEdit).toBeUndefined(); }); test("sets artboard visibility and lock state", () => { const state = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, { id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } }, ); const hidden = documentSetArtboardVisibleCommand.execute({ state }, { id: "a1", visible: false }); const locked = documentSetArtboardLockedCommand.execute({ state: hidden }, { id: "a1", locked: true }); expect(locked.document.artboards[0]?.visible).toBe(false); expect(locked.document.artboards[0]?.locked).toBe(true); }); test("sets layer visibility and lock state", () => { const state = documentWithLayers([group("a", "A")]); const hidden = documentSetLayerVisibleCommand.execute({ state }, { layerId: "a", visible: false }); const locked = documentSetLayerLockedCommand.execute({ state: hidden }, { layerId: "a", locked: true }); expect(locked.document.artboards[0]?.layers[0]?.visible).toBe(false); expect(locked.document.artboards[0]?.layers[0]?.locked).toBe(true); }); test("sets normalized opacity and refuses locked layer edits", () => { const state = documentWithLayers([raster("a", "A")]); const translucent = documentSetLayerOpacityCommand.execute({ state }, { layerId: "a", opacity: 0.35 }); const clamped = documentSetLayerOpacityCommand.execute({ state: translucent }, { layerId: "a", opacity: 2 }); const lockedState = documentSetLayerLockedCommand.execute({ state: clamped }, { layerId: "a", locked: true }); const ignored = documentSetLayerOpacityCommand.execute({ state: lockedState }, { layerId: "a", opacity: 0 }); expect(translucent.document.artboards[0]?.layers[0]?.opacity).toBe(0.35); expect(clamped.document.artboards[0]?.layers[0]?.opacity).toBe(1); expect(ignored).toBe(lockedState); }); test("duplicates a nested group with remapped child mask references", () => { const maskedTarget = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } }; const parent = { ...group("parent", "Parent"), children: [raster("mask", "Mask"), maskedTarget] }; const state = documentWithLayers([parent]); const next = documentDuplicateLayerCommand.execute( { state }, { layerId: "parent", idByLayerId: { parent: "parent-copy", mask: "mask-copy", target: "target-copy" } }, ); const duplicate = next.document.artboards[0]?.layers[1]; expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["parent", "parent-copy"]); expect(duplicate?.type === "group" ? duplicate.children.map((layer) => layer.id) : []).toEqual(["mask-copy", "target-copy"]); expect(duplicate?.type === "group" ? duplicate.children[1]?.layerMask?.maskLayerId : undefined).toBe("mask-copy"); expect(next.editor.selection.layerIds).toEqual(["parent-copy"]); }); test("duplicates an attached top-level mask beside its target", () => { const target = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } }; const state = documentWithLayers([raster("mask", "Mask"), target]); const next = documentDuplicateLayerCommand.execute( { state }, { layerId: "target", idByLayerId: { mask: "mask-copy", target: "target-copy" } }, ); expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target", "mask-copy", "target-copy"]); expect(next.document.artboards[0]?.layers[3]?.layerMask?.maskLayerId).toBe("mask-copy"); }); test("sets artboard bounds", () => { const state = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, { id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } }, ); const next = documentSetArtboardBoundsCommand.execute({ state }, { id: "a1", bounds: { x: 10, y: 20, w: 640, h: 480 } }); expect(next.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 640, h: 480 }); }); }); function documentWithLayers(layers: Layer[]) { return { ...createInitialAppState("Test"), document: { ...createInitialAppState("Test").document, artboards: [{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 }, backgroundColor: "transparent", visible: true, locked: false, layers }], }, editor: { ...createInitialAppState("Test").editor, selection: { artboardId: "a1", layerIds: [] }, }, }; } function group(id: string, name: string) { return { id, type: "group" as const, name, visible: true, locked: false, opacity: 1, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, children: [], }; } function raster(id: string, name: string, assetId = `${id}-asset`) { return { id, type: "raster" as const, name, visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, }; } function maskAsset() { return { id: "mask-asset", name: "Target Mask", mimeType: "image/svg+xml", source: "mask", intrinsicSize: { w: 100, h: 100 } }; }