import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ToolcraftSourceAssetCoordinator } from "../../source-assets/source-asset-coordinator"; import { createToolcraftSourceAssetCoordinator } from "../../source-assets/source-asset-coordinator"; import { createLease, createRepository, createStateHarness, deferred, type Deferred, } from "../../source-assets/source-asset-coordinator-test-support"; import type { ToolcraftSourceAssetFeedback, ToolcraftSourceAssetImportOutcome, } from "../../source-assets/source-asset-types"; import { ToolcraftRoot } from "../app-shell/toolcraft-root"; import { ToolcraftSourceAssetCoordinatorContext } from "../app-shell/toolcraft-source-asset-context"; import { CanvasShell } from "./canvas-shell"; import { createSchema } from "./canvas-shell-test-utils"; class ControlledFile extends File { static instances: ControlledFile[] = []; private rejectRead: ((reason: Error) => void) | undefined; constructor(name: string, type: string) { super(["pending"], name, { type }); ControlledFile.instances.push(this); } override arrayBuffer(): Promise { return new Promise((_, reject) => { this.rejectRead = reject; }); } fail(): void { if (!this.rejectRead) { throw new Error(`Expected "${this.name}" to have a pending read.`); } this.rejectRead(new Error(`Could not decode image file "${this.name}".`)); } } function requireControlledFile(): ControlledFile { const file = ControlledFile.instances[0]; if (!file) { throw new Error("Expected a controlled file instance."); } return file; } const ownedCoordinators: ToolcraftSourceAssetCoordinator[] = []; afterEach(async () => { cleanup(); await Promise.allSettled(ownedCoordinators.splice(0).map((coordinator) => coordinator.dispose())); ControlledFile.instances = []; vi.unstubAllGlobals(); }); function renderWithCoordinator(coordinator: ToolcraftSourceAssetCoordinator) { return render(
, ); } function dropFile(file: File): void { fireEvent.drop(screen.getByRole("application", { name: "Canvas viewport" }), { clientX: 0, clientY: 0, dataTransfer: { files: [file] }, }); } function createRealCoordinator( repository = createRepository([createLease()]), ): ToolcraftSourceAssetCoordinator { const harness = createStateHarness(false); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, repository, }); ownedCoordinators.push(coordinator); return coordinator; } function feedback(message: string): ToolcraftSourceAssetFeedback { return { category: "resource-unavailable", code: "test-failure", message, }; } function createOutcomeCoordinator( outcomes: readonly Deferred[], ): ToolcraftSourceAssetCoordinator { let index = 0; const idleOperation = Object.freeze({ phase: "idle" as const, target: "toolcraft.canvas.image", }); return { cancelTarget: vi.fn(), clearPresentationFeedback: vi.fn(), dispose: vi.fn(async () => undefined), getOperation: () => idleOperation, getPresentation: () => null, hydrateBinaryMedia: vi.fn(async () => undefined), hydrateModels: vi.fn(async () => undefined), importBatch: vi.fn(() => { const outcome = outcomes[index++]; if (!outcome) throw new Error("Missing test outcome"); return outcome.promise; }), repairModel: vi.fn(async () => ({ kind: "cancelled" as const })), reportPresentationFeedback: vi.fn(), subscribe: () => () => undefined, }; } describe("CanvasShell source asset feedback", () => { it("announces direct-canvas decoding status and read failure", async () => { renderWithCoordinator(createRealCoordinator()); dropFile(new ControlledFile("broken.png", "image/png")); await waitFor(() => { expect(screen.getByRole("status").textContent).toContain("Decoding files"); expect(ControlledFile.instances).toHaveLength(1); }); requireControlledFile().fail(); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( 'Could not decode image file "broken.png".', ); }); expect(screen.getByRole("alert").getAttribute("data-canvas-upload-feedback-code")).toBe( "import-failed", ); expect(screen.queryByRole("status")).toBeNull(); }); it("announces a direct-canvas repository failure", async () => { const repository = createRepository([], { beginLease: vi.fn(async () => { throw new Error("Source repository is unavailable."); }), }); renderWithCoordinator(createRealCoordinator(repository)); dropFile(new File(["image"], "source.png", { type: "image/png" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain("Source repository is unavailable."); }); }); it("ignores an older awaited import outcome after a newer drop settles", async () => { const first = deferred(); const second = deferred(); renderWithCoordinator(createOutcomeCoordinator([first, second])); dropFile(new File(["first"], "first.png", { type: "image/png" })); dropFile(new File(["second"], "second.png", { type: "image/png" })); second.resolve({ feedback: feedback("Newest drop failed."), kind: "rejected", }); await waitFor(() => { expect(screen.getByRole("alert").textContent).toBe("Newest drop failed."); }); expect(screen.getByRole("alert").getAttribute("data-canvas-upload-feedback-code")).toBe( "test-failure", ); first.resolve({ feedback: feedback("Stale drop failed."), kind: "rejected", }); await Promise.resolve(); await Promise.resolve(); expect(screen.getByRole("alert").textContent).toBe("Newest drop failed."); }); it("hides an older direct operation after a newer non-direct drop", async () => { renderWithCoordinator(createRealCoordinator()); dropFile(new ControlledFile("pending.png", "image/png")); await waitFor(() => expect(screen.getByRole("status")).toBeTruthy()); dropFile(new File(["notes"], "notes.txt", { type: "text/plain" })); await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); requireControlledFile().fail(); await Promise.resolve(); await Promise.resolve(); expect(screen.queryByRole("alert")).toBeNull(); }); });