feat: implement Story 2.1 — canvas workspace with @xyflow/react and unified graph model
Replace the placeholder diagram editor with a professional Studio layout featuring an interactive @xyflow/react canvas, unified graph data model with bidirectional converters, Zustand state management, and oklch design tokens. Includes 25 unit tests for converters and store, with all code review fixes applied. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
124
apps/web/src/modules/diagram/stores/useGraphStore.test.ts
Normal file
124
apps/web/src/modules/diagram/stores/useGraphStore.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useGraphStore } from "./useGraphStore";
|
||||
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
|
||||
const makeNode = (id: string, label = "Node"): Node => ({
|
||||
id,
|
||||
type: "default",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label },
|
||||
});
|
||||
|
||||
const makeEdge = (id: string, source: string, target: string): Edge => ({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
});
|
||||
|
||||
describe("useGraphStore", () => {
|
||||
beforeEach(() => {
|
||||
useGraphStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
nodeCount: 0,
|
||||
zoomLevel: 100,
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializeFromGraphData", () => {
|
||||
it("should set nodes, edges, and nodeCount", () => {
|
||||
const nodes = [makeNode("n1"), makeNode("n2")];
|
||||
const edges = [makeEdge("e1", "n1", "n2")];
|
||||
|
||||
useGraphStore.getState().initializeFromGraphData(nodes, edges);
|
||||
|
||||
expect(useGraphStore.getState().nodes).toHaveLength(2);
|
||||
expect(useGraphStore.getState().edges).toHaveLength(1);
|
||||
expect(useGraphStore.getState().nodeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setNodes", () => {
|
||||
it("should replace nodes and update nodeCount", () => {
|
||||
useGraphStore.getState().setNodes([makeNode("a"), makeNode("b"), makeNode("c")]);
|
||||
expect(useGraphStore.getState().nodes).toHaveLength(3);
|
||||
expect(useGraphStore.getState().nodeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle empty array", () => {
|
||||
useGraphStore.getState().setNodes([makeNode("x")]);
|
||||
useGraphStore.getState().setNodes([]);
|
||||
expect(useGraphStore.getState().nodes).toEqual([]);
|
||||
expect(useGraphStore.getState().nodeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setEdges", () => {
|
||||
it("should replace edges", () => {
|
||||
const edges = [makeEdge("e1", "a", "b"), makeEdge("e2", "b", "c")];
|
||||
useGraphStore.getState().setEdges(edges);
|
||||
expect(useGraphStore.getState().edges).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onNodesChange", () => {
|
||||
it("should apply node position changes", () => {
|
||||
useGraphStore.getState().setNodes([makeNode("n1")]);
|
||||
|
||||
useGraphStore.getState().onNodesChange([
|
||||
{
|
||||
type: "position",
|
||||
id: "n1",
|
||||
position: { x: 100, y: 200 },
|
||||
},
|
||||
]);
|
||||
|
||||
const updatedNode = useGraphStore.getState().nodes[0];
|
||||
expect(updatedNode!.position).toEqual({ x: 100, y: 200 });
|
||||
});
|
||||
|
||||
it("should update nodeCount when nodes are removed", () => {
|
||||
useGraphStore.getState().setNodes([makeNode("n1"), makeNode("n2")]);
|
||||
expect(useGraphStore.getState().nodeCount).toBe(2);
|
||||
|
||||
useGraphStore.getState().onNodesChange([
|
||||
{ type: "remove", id: "n1" },
|
||||
]);
|
||||
|
||||
expect(useGraphStore.getState().nodeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onEdgesChange", () => {
|
||||
it("should apply edge removal", () => {
|
||||
useGraphStore.getState().setEdges([makeEdge("e1", "a", "b")]);
|
||||
|
||||
useGraphStore.getState().onEdgesChange([
|
||||
{ type: "remove", id: "e1" },
|
||||
]);
|
||||
|
||||
expect(useGraphStore.getState().edges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onViewportChange", () => {
|
||||
it("should update viewport and zoomLevel", () => {
|
||||
useGraphStore.getState().onViewportChange({ x: 50, y: 50, zoom: 1.5 });
|
||||
|
||||
expect(useGraphStore.getState().viewport).toEqual({ x: 50, y: 50, zoom: 1.5 });
|
||||
expect(useGraphStore.getState().zoomLevel).toBe(150);
|
||||
});
|
||||
|
||||
it("should round zoomLevel to nearest integer", () => {
|
||||
useGraphStore.getState().onViewportChange({ x: 0, y: 0, zoom: 0.333 });
|
||||
expect(useGraphStore.getState().zoomLevel).toBe(33);
|
||||
});
|
||||
|
||||
it("should handle zoom at 100%", () => {
|
||||
useGraphStore.getState().onViewportChange({ x: 0, y: 0, zoom: 1.0 });
|
||||
expect(useGraphStore.getState().zoomLevel).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user