import { describe, expect, it, vi } from "vitest"; import type { ToolcraftSourceAssetCoordinator } from "../../../source-assets/source-asset-coordinator"; import { createToolcraftSourceAssetPresentation } from "../../../source-assets/source-asset-presentation"; import { ToolcraftSourceAssetCoordinatorContext } from "../../app-shell/toolcraft-source-asset-context"; import { ControlsPanel, defineToolcraft, fireEvent, render, renderControlsPanelWithSchema, screen, ToolcraftRoot, } from "../testing/controls-panel-test-utils"; describe("ControlsPanel media upload multi-image grid", () => { it("submits one complete multi-image selection to the coordinator", () => { const schema = defineToolcraft({ canvas: { enabled: true, upload: true }, panels: { controls: { sections: [ { controls: { source: { label: "Images", multiple: true, target: "input.sources", type: "fileDrop", }, }, title: "Input", }, ], title: "Generation Controls", }, }, }); const importBatch = vi.fn( async () => ({ assetIds: [] as const, kind: "committed" as const, }), ); const idleOperation = Object.freeze({ phase: "idle" as const, target: "input.sources", }); const coordinator: ToolcraftSourceAssetCoordinator = { cancelTarget: vi.fn(), clearPresentationFeedback: vi.fn(), dispose: vi.fn(async () => undefined), getOperation: () => idleOperation, getPresentation: (control, mediaAssets, operation) => createToolcraftSourceAssetPresentation( "image", control, mediaAssets, operation ?? idleOperation, ), hydrateBinaryMedia: vi.fn(async () => undefined), hydrateModels: vi.fn(async () => undefined), importBatch, repairModel: vi.fn(async () => ({ kind: "cancelled" as const })), reportPresentationFeedback: vi.fn(), subscribe: () => () => undefined, }; const { container } = render( , ); const input = container.querySelector('input[type="file"]'); const files = [ new File(["one"], "one.png", { type: "image/png" }), new File(["two"], "two.png", { type: "image/png" }), ]; fireEvent.change(input as HTMLInputElement, { target: { files }, }); expect(importBatch).toHaveBeenCalledTimes(1); expect(importBatch.mock.calls[0]?.[0]).toMatchObject({ files, origin: "panel", target: "input.sources", }); expect(importBatch.mock.calls[0]?.[1]).toMatchObject({ target: "input.sources", }); }); it("renders multi-image file uploads as an add-last thumbnail grid", () => { const schema = defineToolcraft({ canvas: { enabled: true, upload: true }, panels: { controls: { sections: [ { controls: { source: { accept: "PNG, SVG", label: "Images", multiple: true, target: "input.sources", type: "fileDrop", }, }, layout: "standalone", title: "Input", }, ], title: "Generation Controls", }, }, }); const { container } = renderControlsPanelWithSchema(schema, undefined, { layers: [ { id: "layer-1", kind: "layer", name: "one", visible: true }, { id: "layer-2", kind: "layer", name: "two", visible: true }, ], mediaAssets: [ { dataUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' fill='%23777'/%3E%3C/svg%3E", fileName: "one.svg", id: "media-1", layerId: "layer-1", mimeType: "image/svg+xml", position: { x: 0, y: 0 }, size: { height: 96, unit: "px", width: 96 }, }, { dataUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' fill='%23999'/%3E%3C/svg%3E", fileName: "two.svg", id: "media-2", layerId: "layer-2", mimeType: "image/svg+xml", position: { x: 0, y: 0 }, size: { height: 96, unit: "px", width: 96 }, }, ], selectedLayerId: "layer-2", }); expect(screen.getByRole("button", { name: "Add image files" })).toBeTruthy(); const previewGrid = container.querySelector('[data-slot="file-upload-preview-grid"]'); expect(previewGrid?.className).toContain("grid-cols-4"); expect(previewGrid?.lastElementChild?.getAttribute("data-slot")).toBe( "file-upload-add-preview", ); expect(screen.getByRole("img", { name: "one.svg" })).toBeTruthy(); expect(screen.getByRole("img", { name: "two.svg" })).toBeTruthy(); expect(screen.getByTestId("media-ids").textContent).toBe("media-1,media-2"); expect(screen.queryByRole("button", { name: "90° Left" })).toBeNull(); const firstPreview = screen .getByRole("img", { name: "one.svg" }) .closest('[data-slot="file-upload-preview-item"]'); const secondPreview = screen .getByRole("img", { name: "two.svg" }) .closest('[data-slot="file-upload-preview-item"]'); if (!firstPreview || !secondPreview) { throw new Error("Expected multi-image previews to render"); } expect(firstPreview.getAttribute("role")).toBeNull(); expect(screen.getByRole("button", { name: "Select one.svg" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Reorder one.svg" })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Select one.svg" })); expect(firstPreview.getAttribute("data-selected")).toBe("true"); expect(screen.getByRole("button", { name: "90° Right" })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "90° Right" })); expect(screen.getByTestId("media-transforms").textContent).toBe( '[{"rotationDeg":90},null]', ); fireEvent.click(screen.getByRole("button", { name: "Remove two.svg" })); expect(screen.getByTestId("media-count").textContent).toBe("1"); expect(screen.queryByRole("img", { name: "two.svg" })).toBeNull(); }); it("uses Layers selection for image transforms without rendering a duplicate media collection", () => { const schema = defineToolcraft({ canvas: { enabled: true, upload: true }, panels: { controls: { sections: [ { controls: { source: { assetKind: "image", label: "Images", multiple: true, target: "input.sources", type: "fileDrop", }, }, title: "Input", }, ], title: "Generation Controls", }, layers: true, }, }); const { container } = renderControlsPanelWithSchema(schema, undefined, { layers: [ { id: "layer-1", kind: "layer", name: "one", visible: true }, { id: "layer-2", kind: "layer", name: "two", visible: true }, ], mediaAssets: [ { dataUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' fill='%23777'/%3E%3C/svg%3E", fileName: "one.svg", id: "media-1", layerId: "layer-1", mimeType: "image/svg+xml", position: { x: 0, y: 0 }, size: { height: 96, unit: "px", width: 96 }, sourceTarget: "input.sources", }, { dataUrl: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' fill='%23999'/%3E%3C/svg%3E", fileName: "two.svg", id: "media-2", layerId: "layer-2", mimeType: "image/svg+xml", position: { x: 0, y: 0 }, size: { height: 96, unit: "px", width: 96 }, sourceTarget: "input.sources", }, ], selectedLayerId: "layer-2", }); expect(container.querySelector('input[type="file"]')).toBeTruthy(); expect( container.querySelector('[data-slot="file-upload-preview-grid"]'), ).toBeNull(); expect( container.querySelector('[data-slot="file-upload-file-list"]'), ).toBeNull(); expect(screen.queryByRole("img", { name: "one.svg" })).toBeNull(); expect(screen.queryByRole("img", { name: "two.svg" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "90° Right" })); fireEvent.click(screen.getByRole("button", { name: "Flip horizontal" })); fireEvent.click(screen.getByRole("button", { name: "Flip vertical" })); expect(screen.getByTestId("media-transforms").textContent).toBe( '[null,{"rotationDeg":90,"flipHorizontal":true,"flipVertical":true}]', ); }); });