import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../../schema/define-toolcraft"; import type { ToolcraftSourceAssetCoordinator } from "../../source-assets/source-asset-coordinator"; import { ToolcraftRoot } from "../app-shell/toolcraft-root"; import { ToolcraftSourceAssetCoordinatorContext } from "../app-shell/toolcraft-source-asset-context"; import { useToolcraft } from "../app-shell/use-toolcraft"; import { CanvasShell } from "./canvas-shell"; import { CanvasStateProbe, createSchema, renderCanvasShell, } from "./canvas-shell-test-utils"; afterEach(() => { cleanup(); }); function CanvasHistoryProbe() { const { state } = useToolcraft(); return {state.history.undo.length}; } describe("CanvasShell upload routing", () => { it("submits one complete direct-canvas image batch to the shared coordinator", () => { const importBatch = vi.fn( async () => ({ assetIds: [] as const, kind: "committed" as const, }), ); const idleOperation = Object.freeze({ phase: "idle" as const, target: "toolcraft.canvas.image", }); const coordinator: ToolcraftSourceAssetCoordinator = { cancelTarget: vi.fn(), clearPresentationFeedback: vi.fn(), dispose: vi.fn(async () => undefined), getOperation: () => idleOperation, getPresentation: vi.fn(() => null), hydrateBinaryMedia: vi.fn(async () => undefined), hydrateModels: vi.fn(async () => undefined), importBatch, repairModel: vi.fn(async () => ({ kind: "cancelled" as const })), reportPresentationFeedback: vi.fn(), subscribe: () => () => undefined, }; render(
, ); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); const files = [ new File(["one"], "one.png", { type: "image/png" }), new File(["two"], "two.png", { type: "image/png" }), ]; fireEvent.drop(canvas, { clientX: 120, clientY: 80, dataTransfer: { files }, }); expect(importBatch).toHaveBeenCalledTimes(1); const [batch, control] = importBatch.mock.calls[0] ?? []; expect(batch).toMatchObject({ files, origin: "canvas" }); expect(batch?.files).toHaveLength(2); expect(batch?.files.map((file) => file.name)).toEqual([ "one.png", "two.png", ]); expect(batch).not.toHaveProperty("target"); expect(control).toBeUndefined(); }); it("replaces prior direct-canvas media with one complete atomic batch", async () => { renderCanvasShell({ afterCanvas: ( <> ), schema: createSchema(), }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); const files = [ new File(["one"], "material-one.png", { type: "image/png" }), new File(["two"], "material-two.png", { type: "image/png" }), ]; fireEvent.drop(canvas, { clientX: 0, clientY: 0, dataTransfer: { files }, }); await waitFor(() => { expect(screen.getByTestId("media-count").textContent).toBe("2"); }); expect(screen.getByTestId("history-count").textContent).toBe("1"); expect(screen.getByTestId("media-size").textContent).toBe("300,200"); expect(screen.getByTestId("media-targets").textContent).toBe("none,none"); expect( screen.getByRole("button", { name: "Select material-one.png" }), ).toBeTruthy(); expect( screen.getByRole("button", { name: "Select material-two.png" }), ).toBeTruthy(); const replacementFiles = [ new File(["three"], "replacement-one.png", { type: "image/png" }), new File(["four"], "replacement-two.png", { type: "image/png" }), ]; fireEvent.drop(canvas, { clientX: 0, clientY: 0, dataTransfer: { files: replacementFiles }, }); await waitFor(() => { expect( screen.getByRole("button", { name: "Select replacement-two.png" }), ).toBeTruthy(); }); expect(screen.getByTestId("media-count").textContent).toBe("2"); expect(screen.getByTestId("history-count").textContent).toBe("2"); expect(screen.getByTestId("media-targets").textContent).toBe("none,none"); expect( screen.getByRole("button", { name: "Select replacement-one.png" }), ).toBeTruthy(); expect( screen.queryByRole("button", { name: "Select material-one.png" }), ).toBeNull(); expect( screen.queryByRole("button", { name: "Select material-two.png" }), ).toBeNull(); }); it("appends complete direct-canvas batches when layers are enabled", async () => { const schema = defineToolcraft({ canvas: { enabled: true, size: { width: 300, height: 200, unit: "px" }, upload: true, }, panels: { layers: true }, }); renderCanvasShell({ afterCanvas: ( <> ), schema, }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); const firstBatch = [ new File(["one"], "layer-one.png", { type: "image/png" }), new File(["two"], "layer-two.png", { type: "image/png" }), ]; const secondBatch = [ new File(["three"], "layer-three.png", { type: "image/png" }), new File(["four"], "layer-four.png", { type: "image/png" }), ]; fireEvent.drop(canvas, { clientX: 0, clientY: 0, dataTransfer: { files: firstBatch }, }); await waitFor(() => { expect(screen.getByTestId("media-count").textContent).toBe("2"); }); fireEvent.drop(canvas, { clientX: 0, clientY: 0, dataTransfer: { files: secondBatch }, }); await waitFor(() => { expect(screen.getByTestId("media-count").textContent).toBe("4"); }); expect(screen.getByTestId("history-count").textContent).toBe("2"); for (const file of [...firstBatch, ...secondBatch]) { expect( screen.getByRole("button", { name: `Select ${file.name}` }), ).toBeTruthy(); } }); it("announces ambiguous canvas upload targets", async () => { const schema = defineToolcraft({ canvas: { enabled: true, size: { width: 300, height: 200, unit: "px" }, upload: true, }, panels: { controls: { sections: [ { controls: { primary: { label: "Primary image", target: "source.primary", type: "fileDrop", }, secondary: { label: "Secondary image", target: "source.secondary", type: "fileDrop", }, }, title: "Sources", }, ], title: "Controls", }, }, }); renderCanvasShell({ schema }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); fireEvent.drop(canvas, { clientX: 0, clientY: 0, dataTransfer: { files: [new File(["image"], "ambiguous.png", { type: "image/png" })], }, }); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( "more than one equally specific upload target", ); }); }); it("routes a mixed canvas batch to one generic file fallback target", async () => { const schema = defineToolcraft({ canvas: { enabled: true, size: { width: 300, height: 200, unit: "px" }, upload: true, }, panels: { controls: { sections: [ { controls: { files: { assetKind: "file", label: "Files", multiple: true, target: "source.files", type: "fileDrop", }, image: { label: "Image", target: "source.image", type: "fileDrop", }, }, title: "Sources", }, ], title: "Controls", }, }, }); renderCanvasShell({ afterCanvas: , schema, }); const canvas = screen.getByRole("application", { name: "Canvas viewport" }); const imageFile = new File(["image"], "material.png", { type: "image/png" }); const textFile = new File(["notes"], "notes.txt", { type: "text/plain" }); fireEvent.drop(canvas, { clientX: 0, clientY: 0, dataTransfer: { files: [imageFile, textFile], }, }); await waitFor(() => { expect(screen.getByTestId("media-count").textContent).toBe("2"); }); expect(screen.getByTestId("media-targets").textContent?.split(",")).toEqual([ "source.files", "source.files", ]); expect(screen.getByTestId("media-kinds").textContent?.split(",")).toEqual([ "file", "file", ]); expect(screen.queryByRole("button", { name: "Select material.png" })).toBeNull(); expect(screen.queryByRole("button", { name: "Select notes.txt" })).toBeNull(); }); });