89 lines
6.0 KiB
TypeScript
89 lines
6.0 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
|
|
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
|
|
import { selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility";
|
|
import { buildCanvasSpatialIndex, canvasNodeBounds } from "@/lib/canvas/canvas-spatial-index";
|
|
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
|
import { markCanvasTaskRecoveryUnconfirmed, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
|
|
|
|
const image = (id: string, x = 0): CanvasNodeData => ({ id, type: CanvasNodeType.Image, title: id, position: { x, y: 0 }, width: 20, height: 20, metadata: { taskId: `task-${id}`, status: "loading" } });
|
|
|
|
describe("canvas synchronous editor state", () => {
|
|
test("commands see previous commands without waiting for React, and execute once", () => {
|
|
const ref = { current: [] as string[] };
|
|
const snapshots: string[][] = [];
|
|
const write = createCanvasStateWriter(ref, (value) => snapshots.push(value));
|
|
let calls = 0;
|
|
write((current) => { calls++; return [...current, "first"]; });
|
|
write((current) => { calls++; return [...current, "second"]; });
|
|
expect(calls).toBe(2);
|
|
expect(ref.current).toEqual(["first", "second"]);
|
|
expect(snapshots).toEqual([["first"], ["first", "second"]]);
|
|
});
|
|
|
|
test("explicit ref writes must still publish to React", () => {
|
|
const ref = { current: [] as string[] };
|
|
const snapshots: string[][] = [];
|
|
const write = createCanvasStateWriter(ref, (value) => snapshots.push(value));
|
|
ref.current = ["restored"];
|
|
write(ref.current);
|
|
expect(snapshots).toEqual([["restored"]]);
|
|
});
|
|
});
|
|
|
|
describe("canvas generation node commits", () => {
|
|
test("task lookup errors preserve unknown state and cannot undo success or a new binding", () => {
|
|
const pending = image("a");
|
|
expect(markCanvasTaskRecoveryUnconfirmed(pending, "task-a", "network unavailable").metadata).toMatchObject({ status: "loading", taskId: "task-a", errorDetails: "network unavailable" });
|
|
expect(markCanvasTaskRecoveryUnconfirmed(pending, "old-task", "missing")).toBe(pending);
|
|
const success: CanvasNodeData = { ...pending, metadata: { ...pending.metadata, status: "success", storageKey: "resource:a" } };
|
|
expect(markCanvasTaskRecoveryUnconfirmed(success, "task-a", "missing")).toBe(success);
|
|
expect(resetGenerationTaskMetadata({ ...success.metadata, taskStatus: "failed" }, "loading")).toMatchObject({ storageKey: "resource:a", status: "loading" });
|
|
expect(resetGenerationTaskMetadata(success.metadata).taskId).toBeUndefined();
|
|
});
|
|
test("five concurrent results preserve two successes, three failures, and unrelated edits", async () => {
|
|
const before = Array.from({ length: 5 }, (_, i) => image(String(i)));
|
|
const ref = { current: [...before, image("new")] };
|
|
const write = createCanvasStateWriter(ref, () => {});
|
|
await Promise.all(before.map(async (node, i) => {
|
|
await Promise.resolve();
|
|
const result: CanvasNodeData = { ...node, metadata: { ...node.metadata, status: i < 2 ? "success" : "error", storageKey: i < 2 ? `resource:${i}` : undefined } };
|
|
write((current) => commitCanvasGenerationResult(current, node, result, `task-${i}`));
|
|
}));
|
|
expect(ref.current.filter((node) => node.metadata?.status === "success")).toHaveLength(2);
|
|
expect(ref.current.filter((node) => node.metadata?.status === "error")).toHaveLength(3);
|
|
expect(ref.current.at(-1)?.id).toBe("new");
|
|
});
|
|
|
|
test("keeps movement, manual resize, rename and other node deletion during materialization", () => {
|
|
const before = image("a");
|
|
const live = { ...before, title: "renamed", position: { x: 300, y: 400 }, width: 600 };
|
|
const result: CanvasNodeData = { ...before, width: 100, title: "generated", metadata: { ...before.metadata, status: "success", storageKey: "resource:a" } };
|
|
const committed = commitCanvasGenerationResult([live], before, result, "task-a");
|
|
expect(committed).toHaveLength(1);
|
|
expect(committed[0]).toMatchObject({ title: "renamed", position: live.position, width: 600, metadata: { storageKey: "resource:a" } });
|
|
});
|
|
|
|
test("does not resurrect deleted nodes or overwrite a reset/new task", () => {
|
|
const before = image("a");
|
|
expect(() => commitCanvasGenerationResult([], before, before, "task-a")).toThrow();
|
|
expect(() => commitCanvasGenerationResult([{ ...before, metadata: { taskId: "new" } }], before, before, "task-a")).toThrow();
|
|
expect(() => commitCanvasGenerationResult([{ ...before, metadata: {} }], before, before, "task-a")).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("canvas viewport selection", () => {
|
|
test("hidden and overscan nodes cannot consume the viewport budget", () => {
|
|
const nodes = [...Array.from({ length: 400 }, (_, i) => image(`hidden-${i}`)), ...Array.from({ length: 400 }, (_, i) => image(`buffer-${i}`, -100)), ...Array.from({ length: 1000 }, (_, i) => image(`visible-${i}`)), image("forced", 2000)];
|
|
const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, value: node.id, bounds: canvasNodeBounds(node) })));
|
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
const view = { left: 0, top: 0, right: 100, bottom: 100 };
|
|
const retain = { ...view, left: -200, right: 200 };
|
|
const selected = selectCanvasVisibleNodes({ index, nodeById, view, enter: retain, retain, hiddenIds: new Set(nodes.filter((node) => node.id.startsWith("hidden")).map((node) => node.id)), retainedIds: new Set(), forcedIds: new Set(["forced"]), budget: 280 });
|
|
expect(selected).toHaveLength(1001);
|
|
expect(selected.filter((node) => node.id.startsWith("visible"))).toHaveLength(1000);
|
|
expect(selected.at(-1)?.id).toBe("forced");
|
|
expect(nodes).toHaveLength(1801);
|
|
});
|
|
});
|