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();
});
});