Files
Artificial-Sweetener-Simple…/web/tests/orderedMediaPreviewActions.test.ts
T

412 lines
13 KiB
TypeScript

// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { afterEach, describe, expect, it, vi } from "vitest";
import { NativeNodePreview } from "../src/nativeNodePreview";
import { OrderedMediaPreviewActions } from "../src/orderedMediaPreviewActions";
import type { ComfyApi, ComfyApp, ComfyImageResult } from "../src/types";
const adapters: OrderedMediaPreviewActions[] = [];
afterEach(() => {
for (const adapter of adapters.splice(0)) adapter.dispose();
document.body.replaceChildren();
});
describe("OrderedMediaPreviewActions", () => {
it("keeps Nodes 2.0 controls within the native preview stacking context", async () => {
actionFixture(["one.png", "two.png"]);
const root = document.createElement("section");
root.dataset.nodeId = "7";
const previewSurface = document.createElement("div");
previewSurface.setAttribute("role", "region");
previewSurface.setAttribute(
"aria-label",
"Image preview - Use arrow keys to navigate between images"
);
mockRect(previewSurface, 40, 60, 200, 100);
const firstImage = previewImage("one.png", 50);
const secondImage = previewImage("two.png", 130);
mockRect(firstImage, 50, 70, 80, 80);
mockRect(secondImage, 130, 70, 80, 80);
previewSurface.append(firstImage, secondImage);
root.append(previewSurface);
document.body.append(root);
await vi.waitFor(() => {
expect(
previewSurface.querySelectorAll(".ss-native-preview-affordance")
).toHaveLength(2);
});
const first = previewSurface.querySelector<HTMLElement>(
".ss-native-preview-affordance"
);
expect(first?.style.position).toBe("absolute");
expect(first?.style.left).toBe("10px");
expect(first?.style.top).toBe("10px");
expect(first?.style.zIndex).toBe("1");
});
it("discovers Nodes 2.0 thumbnails by output reference", async () => {
const fixture = actionFixture(["one.png", "two.png"]);
const root = document.createElement("section");
root.dataset.nodeId = "7";
const unrelated = document.createElement("img");
unrelated.src = "/icon.png";
root.append(unrelated, previewImage("one.png", 0), previewImage("two.png", 100));
document.body.append(root);
await vi.waitFor(() => {
expect(document.querySelectorAll("[data-ss-media-move-earlier]")).toHaveLength(2);
});
document
.querySelectorAll<HTMLButtonElement>("[data-ss-media-move-earlier]")[1]
?.click();
document
.querySelectorAll<HTMLButtonElement>("[data-ss-media-move-later]")[0]
?.click();
document
.querySelectorAll<HTMLButtonElement>("[data-ss-media-remove]")[1]
?.click();
expect(fixture.moveEarlier).toHaveBeenCalledWith(1);
expect(fixture.moveLater).toHaveBeenCalledWith(0);
expect(fixture.remove).toHaveBeenCalledWith(1);
});
it("does not claim Nodes 2.0 DOM owned by another open workflow", async () => {
const inactiveRoot = {};
const fixture = actionFixture(["one.png", "two.png"], {
graph: { _rootGraph: inactiveRoot }
});
fixture.app.rootGraph = {};
const root = document.createElement("section");
root.dataset.nodeId = "7";
root.append(previewImage("one.png", 0), previewImage("two.png", 100));
document.body.append(root);
document.dispatchEvent(new MouseEvent("click"));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(0);
});
it("positions controls from Nodes 1.0 imageRects", async () => {
const canvas = document.createElement("canvas");
mockRect(canvas, 10, 20, 500, 500);
const fixture = actionFixture(["one.png", "two.png"], {
pos: [100, 200],
imageIndex: null,
imageRects: [
[0, 30, 80, 80],
[80, 30, 80, 80]
]
});
fixture.app.canvas = {
canvas,
convertEventToCanvasOffset: (event) => [
event.clientX - 10,
event.clientY - 20
],
convertOffsetToCanvas: (position) => [...position]
};
document.body.append(canvas);
await vi.waitFor(() => {
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2);
});
const first = document.querySelector<HTMLElement>(
".ss-native-preview-affordance"
);
expect(first?.style.left).toBe("110px");
expect(first?.style.top).toBe("250px");
expect(first?.style.width).toBe("80px");
});
it("anchors embedded Nodes 1.0 controls to SugarCubes' clipped face projection", async () => {
const canvas = document.createElement("canvas");
const container = document.createElement("div");
mockRect(canvas, 0, 0, 800, 600);
mockRect(container, 200, 300, 320, 240);
const projectRect = vi.fn(
([left, top, width, height]: readonly [number, number, number, number]) => ({
left: 200 + left,
top: 300 + top,
width,
height
})
);
const projectionSymbol = Symbol.for("sugarcubes.cube-face-projection.v1");
const fixture = actionFixture(
["one.png", "two.png"],
{
pos: [0, 0],
imageIndex: null,
imageRects: [
[10, 30, 80, 80],
[90, 30, 80, 80]
],
[projectionSymbol]: { container, projectRect }
}
);
const nativeProjection = vi.fn(() => {
throw new Error("Root graph projection must not run for an embedded node.");
});
fixture.app.canvas = {
canvas,
convertEventToCanvasOffset: (event) => [event.clientX, event.clientY],
convertOffsetToCanvas: nativeProjection
};
document.body.append(canvas, container);
await vi.waitFor(() => {
expect(container.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2);
});
const first = container.querySelector<HTMLElement>(
".ss-native-preview-affordance"
);
expect(projectRect).toHaveBeenCalledWith([10, 30, 80, 80]);
expect(nativeProjection).not.toHaveBeenCalled();
expect(first?.style.position).toBe("absolute");
expect(first?.style.left).toBe("10px");
expect(first?.style.top).toBe("30px");
expect(first?.style.width).toBe("80px");
});
it("positions selected-item controls in Nodes 1.0 detail view", async () => {
const canvas = document.createElement("canvas");
mockRect(canvas, 10, 20, 500, 500);
const fixture = actionFixture(["one.png", "two.png"], {
pos: [100, 200],
size: [160, 260],
imageIndex: 1,
widgets: [
{
y: 30,
computedHeight: 200,
options: { canvasOnly: true }
}
]
});
fixture.app.canvas = {
canvas,
convertEventToCanvasOffset: (event) => [
event.clientX - 10,
event.clientY - 20
],
convertOffsetToCanvas: (position) => [...position]
};
document.body.append(canvas);
await vi.waitFor(() => {
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1);
});
expect(
document.querySelector('[aria-label="Move image 2 earlier"]')
).not.toBeNull();
expect(
document.querySelector<HTMLButtonElement>(
'[aria-label="Move image 2 later"]'
)?.disabled
).toBe(true);
expect(
document.querySelector<HTMLElement>(".ss-native-preview-affordance")
?.style.width
).toBe("160px");
});
it("reuses the Nodes 1.0 grid footprint after detail clears imageRects", async () => {
const canvas = document.createElement("canvas");
mockRect(canvas, 10, 20, 500, 500);
const node = {
id: 7,
pos: [100, 200] as const,
imageIndex: null as number | null,
imageRects: [
[0, 30, 80, 80],
[80, 30, 80, 80]
] as Array<readonly [number, number, number, number]>
};
const fixture = actionFixture(["one.png", "two.png"], node);
fixture.app.canvas = {
canvas,
convertEventToCanvasOffset: (event) => [
event.clientX - 10,
event.clientY - 20
],
convertOffsetToCanvas: (position) => [...position]
};
document.body.append(canvas);
await vi.waitFor(() => {
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(2);
});
fixture.node.imageIndex = 1;
delete (fixture.node as { imageRects?: unknown }).imageRects;
document.dispatchEvent(new MouseEvent("click"));
await vi.waitFor(() => {
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1);
});
expect(
document.querySelector('[aria-label="Move image 2 earlier"]')
).not.toBeNull();
expect(
document.querySelector<HTMLElement>(".ss-native-preview-affordance")
?.style.width
).toBe("160px");
});
it("keeps selected-item controls while native detail view is open", async () => {
const node = { id: 7 };
const fixture = actionFixture(["one.png", "two.png"], node);
const { root, select } = nativeDetailPreview();
document.body.append(root);
select(0);
await vi.waitFor(() => {
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(1);
});
document
.querySelector<HTMLButtonElement>('[aria-label="Move image 1 later"]')
?.click();
await vi.waitFor(() => {
expect(
document.querySelector('[aria-label="Remove image 2"]')
).not.toBeNull();
});
expect(fixture.moveLater).toHaveBeenCalledWith(0);
expect(
root.querySelector('[aria-label="View image 2 of 2"]')?.getAttribute(
"aria-current"
)
).toBe("true");
select(-1);
fixture.preview.publish({
images: references("two.png", "one.png"),
animated: [false, false]
});
await vi.waitFor(() => {
expect(
root.querySelector('[aria-label="View image 2 of 2"]')?.getAttribute(
"aria-current"
)
).toBe("true");
});
});
it("hides actions when Comfy output cannot map to every file", async () => {
const fixture = actionFixture(["one.png", "two.png"]);
fixture.app.nodeOutputs = { "7": { images: references("one.png") } };
document.dispatchEvent(new MouseEvent("click"));
await vi.waitFor(() => {
expect(document.querySelectorAll(".ss-native-preview-affordance")).toHaveLength(0);
});
});
});
function actionFixture(
files: string[],
nodeOverrides: Record<string, unknown> = {}
) {
const app: ComfyApp = {
nodeOutputs: {
"7": { images: references(...files), animated: files.map(() => false) }
},
ui: { settings: { addSetting: vi.fn() } },
registerExtension: vi.fn()
};
const api = new EventTarget() as ComfyApi;
const node = { id: 7, ...nodeOverrides } as {
id: number;
imageIndex?: number | null;
imageRects?: Array<readonly [number, number, number, number]>;
widgets?: Array<{
y?: number;
computedHeight?: number;
options?: { canvasOnly?: boolean };
}>;
};
const preview = new NativeNodePreview(app, api, node);
const moveEarlier = vi.fn();
const moveLater = vi.fn();
const remove = vi.fn();
const adapter = new OrderedMediaPreviewActions({
app,
node,
preview,
itemLabel: "image",
getFiles: () => [...files],
moveEarlier,
moveLater,
remove
});
adapters.push(adapter);
return { adapter, app, moveEarlier, moveLater, node, preview, remove };
}
function previewImage(filename: string, left: number): HTMLImageElement {
const image = document.createElement("img");
image.src = `/api/view?filename=${filename}&subfolder=&type=input&preview=webp`;
mockRect(image, left, 0, 80, 80);
return image;
}
function nativeDetailPreview(): {
root: HTMLElement;
select: (index: number) => void;
} {
const root = document.createElement("section");
root.dataset.nodeId = "7";
const region = document.createElement("div");
region.setAttribute("role", "region");
region.setAttribute(
"aria-label",
"Image preview - Use arrow keys to navigate between images"
);
mockRect(region, 20, 30, 160, 200);
const navigation = document.createElement("div");
const select = (index: number): void => {
for (const [buttonIndex, button] of buttons.entries()) {
if (buttonIndex === index) button.setAttribute("aria-current", "true");
else button.removeAttribute("aria-current");
}
};
const buttons = [0, 1].map((index) => {
const button = document.createElement("button");
button.setAttribute(
"aria-label",
`View image ${String(index + 1)} of 2`
);
button.addEventListener("click", () => {
select(index);
});
return button;
});
navigation.append(...buttons);
root.append(region, navigation);
return { root, select };
}
function mockRect(
element: Element,
left: number,
top: number,
width: number,
height: number
): void {
element.getBoundingClientRect = () => new DOMRect(left, top, width, height);
}
function references(...filenames: string[]): ComfyImageResult[] {
return filenames.map((filename) => ({
filename,
subfolder: "",
type: "input"
}));
}