Files
ttstd-infinite-canvas/web/test/canvas-editor-consistency.test.ts

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