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:
Alejandro Gutiérrez
2026-02-24 02:07:59 +00:00
parent 098f4968be
commit 5033109656
17 changed files with 1922 additions and 96 deletions

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