Files
ttstd-infinite-canvas/canvas-agent/test/canvas-context.test.ts

127 lines
6.2 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { buildCanvasContext, findCanvasNodes, getCanvasConnection, getCanvasGenerationTasks, getCanvasNode, getCanvasResources, validateCanvasOps } from "../src/canvas-context.js";
import type { CanvasSnapshot } from "../src/types.js";
const state: CanvasSnapshot = {
projectId: "canvas-1",
title: "分镜画布",
viewport: { x: 0, y: 0, k: 1 },
selectedNodeIds: ["ref-1"],
nodes: [
{ id: "prompt-1", type: "text", title: "提示词", position: { x: 0, y: 0 }, width: 340, height: 240, metadata: { content: "夜雨中的城市", status: "success" } },
{ id: "ref-1", type: "image", title: "角色参考", position: { x: 420, y: 0 }, width: 320, height: 320, metadata: { status: "success", storageKey: "resource:abc", mimeType: "image/png", assetId: "asset-1", assetCategory: "character", naturalWidth: 1024, naturalHeight: 1024 } },
{ id: "loading-1", type: "video", title: "待生成视频", position: { x: 840, y: 0 }, width: 480, height: 270, metadata: { status: "loading", taskId: "task-1", taskStatus: "running", taskProgress: 42, taskStage: "rendering", taskProvider: "backend" } },
],
connections: [{ id: "c-1", fromNodeId: "prompt-1", toNodeId: "ref-1" }],
};
test("builds semantic canvas context without media URLs", () => {
const context = buildCanvasContext(state);
assert.equal(context.canvas.nodeCount, 3);
assert.equal(context.selection[0]?.id, "ref-1");
assert.equal(context.resources[0]?.resourceId, "abc");
assert.equal(context.resources[0]?.isReady, true);
assert.equal(context.resources[1]?.isReady, false);
assert.equal((context.nodes[1] as { resource?: { ready?: boolean } }).resource?.ready, true);
assert.equal((context.nodes[2] as { generation?: { taskId?: string; progress?: number } }).generation?.taskId, "task-1");
assert.equal((context.nodes[2] as { generation?: { taskId?: string; progress?: number; stage?: string } }).generation?.progress, 42);
assert.equal((context.nodes[2] as { generation?: { taskId?: string; progress?: number; stage?: string } }).generation?.stage, "rendering");
assert.equal("url" in context.nodes[1], false);
assert.match(context.stateHash, /^[a-f0-9]{16}$/);
});
test("finds real nodes and resources by semantic query", () => {
assert.equal(findCanvasNodes(state, { query: "角色", resourceOnly: true }).nodes[0]?.id, "ref-1");
assert.equal(getCanvasResources(state, { status: "loading" }).resources[0]?.nodeId, "loading-1");
});
test("reads one node and its related connections without media URLs", () => {
const result = getCanvasNode(state, { id: "ref-1" });
assert.equal(result.found, true);
assert.equal(result.node?.id, "ref-1");
assert.equal(result.resource?.isReady, true);
assert.equal(result.connections[0]?.id, "c-1");
assert.equal("url" in (result.node || {}), false);
assert.equal(getCanvasNode(state, { id: "missing" }).found, false);
});
test("reads one connection with endpoint summaries", () => {
const result = getCanvasConnection(state, { id: "c-1" });
assert.equal(result.found, true);
assert.equal(result.connection?.fromTitle, "提示词");
assert.equal(result.toNode?.id, "ref-1");
assert.equal(getCanvasConnection(state, { id: "missing" }).found, false);
});
test("projects observable generation task state from canvas nodes", () => {
const result = getCanvasGenerationTasks(state, { status: "running" });
assert.equal(result.total, 1);
assert.deepEqual(result.tasks[0], {
taskId: "task-1",
nodeId: "loading-1",
nodeTitle: "待生成视频",
mode: undefined,
nodeStatus: "loading",
status: "running",
progress: 42,
stage: "rendering",
provider: "backend",
errorCode: undefined,
officialStatus: undefined,
resourceReady: false,
});
});
test("rejects stale or unsafe operations before dispatch", () => {
const result = validateCanvasOps(state, [
{ type: "update_node", id: "missing" },
{ type: "connect_nodes", fromNodeId: "prompt-1", toNodeId: "prompt-1" },
]);
assert.equal(result.ok, false);
assert.equal(result.issues.length, 2);
});
test("validates a batch transaction against its evolving node and connection state", () => {
const result = validateCanvasOps(state, [
{ type: "add_node", id: "new-1", nodeType: "image", position: { x: 10, y: 20 }, width: 320, height: 240 },
{ type: "connect_nodes", fromNodeId: "new-1", toNodeId: "ref-1", id: "new-connection" },
{ type: "delete_node", id: "new-1" },
{ type: "update_node", id: "new-1", patch: { title: "should fail" } },
]);
assert.equal(result.ok, false);
assert.match(result.issues.at(-1)?.message || "", /不在当前画布状态中/);
});
test("rejects duplicate, missing, and unsafe connection or viewport operations", () => {
const result = validateCanvasOps(state, [
{ type: "connect_nodes", fromNodeId: "prompt-1", toNodeId: "ref-1" },
{ type: "delete_connections", ids: ["missing-connection"] },
{ type: "set_viewport", viewport: { x: 0, y: 0, k: 99 } },
{ type: "add_node", nodeType: "image", width: -1, height: 0, position: { x: Number.NaN, y: 0 } },
]);
assert.equal(result.ok, false);
assert.ok(result.issues.some((issue) => issue.message.includes("相同端点")));
assert.ok(result.issues.some((issue) => issue.message.includes("不存在")));
assert.ok(result.issues.some((issue) => issue.message.includes("0.05 到 8")));
assert.ok(result.issues.some((issue) => issue.message.includes("正数")));
});
test("evolving connection state allows delete then recreate in one atomic batch", () => {
const result = validateCanvasOps(state, [
{ type: "delete_connections", id: "c-1" },
{ type: "connect_nodes", id: "c-2", fromNodeId: "prompt-1", toNodeId: "ref-1" },
]);
assert.equal(result.ok, true);
});
test("rejects direct media status forgery as a write error", () => {
const result = validateCanvasOps(state, [
{ type: "update_node", id: "ref-1", patch: { metadata: { status: "success" } } },
]);
assert.equal(result.ok, false);
assert.equal(result.issues[0]?.severity, "error");
});