From 4cb164241ab19116c1c118879d83b44e0327ab46 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 28 Mar 2026 12:26:48 +0100 Subject: [PATCH] test: add standalone mode save/load and demo data validation tests --- .../utils/__tests__/standaloneStorage.test.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 frontend/src/utils/__tests__/standaloneStorage.test.ts diff --git a/frontend/src/utils/__tests__/standaloneStorage.test.ts b/frontend/src/utils/__tests__/standaloneStorage.test.ts new file mode 100644 index 0000000..f1ad38b --- /dev/null +++ b/frontend/src/utils/__tests__/standaloneStorage.test.ts @@ -0,0 +1,196 @@ +/** + * Standalone mode save/load tests. + * + * In standalone mode (VITE_STANDALONE=true) the canvas is persisted directly + * to localStorage as JSON — no backend involved. The full RF node object is + * serialized as-is, which means width/height survive the round-trip without + * going through serializeNode / deserializeApiNode. + * + * These tests verify that critical node properties (especially width/height + * added for resizable nodes) are not lost through the localStorage cycle, and + * that the demo data is structurally valid. + */ + +import { describe, it, expect, beforeEach } from 'vitest' +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' +import { useCanvasStore } from '@/stores/canvasStore' +import { demoNodes, demoEdges } from '@/utils/demoData' + +const STORAGE_KEY = 'homelable_canvas' + +// Simulates what App.tsx does on Ctrl+S in standalone mode +function standaloneSerialize(nodes: Node[], edges: Edge[], theme_id = 'default') { + return JSON.stringify({ nodes, edges, theme_id }) +} + +// Simulates what App.tsx does on load in standalone mode +function standaloneDeserialize(raw: string) { + return JSON.parse(raw) as { nodes: Node[]; edges: Edge[]; theme_id: string } +} + +function makeNode(id: string, overrides: Partial> = {}): Node { + return { + id, + type: 'server', + position: { x: 0, y: 0 }, + data: { label: id, type: 'server', status: 'unknown', services: [] }, + ...overrides, + } +} + +describe('Standalone localStorage save/load cycle', () => { + beforeEach(() => { + localStorage.clear() + useCanvasStore.setState({ nodes: [], edges: [], hasUnsavedChanges: false }) + }) + + // ── width / height round-trip ───────────────────────────────────────────── + + it('preserves width and height for resized nodes', () => { + const nodes = [makeNode('n1', { width: 320, height: 180 })] + const raw = standaloneSerialize(nodes, []) + localStorage.setItem(STORAGE_KEY, raw) + + const { nodes: loaded } = standaloneDeserialize(localStorage.getItem(STORAGE_KEY)!) + useCanvasStore.getState().loadCanvas(loaded, []) + + const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(stored?.width).toBe(320) + expect(stored?.height).toBe(180) + }) + + it('preserves undefined width/height for default-sized nodes', () => { + const nodes = [makeNode('n1')] + const raw = standaloneSerialize(nodes, []) + const { nodes: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas(loaded, []) + + const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(stored?.width).toBeUndefined() + expect(stored?.height).toBeUndefined() + }) + + it('preserves mixed: some nodes resized, others not', () => { + const nodes = [ + makeNode('n1', { width: 280, height: 120 }), + makeNode('n2'), + ] + const raw = standaloneSerialize(nodes, []) + const { nodes: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas(loaded, []) + + const { nodes: stored } = useCanvasStore.getState() + expect(stored.find((n) => n.id === 'n1')?.width).toBe(280) + expect(stored.find((n) => n.id === 'n2')?.width).toBeUndefined() + }) + + // ── other node properties ───────────────────────────────────────────────── + + it('preserves position through the round-trip', () => { + const nodes = [makeNode('n1', { position: { x: 123, y: 456 } })] + const raw = standaloneSerialize(nodes, []) + const { nodes: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas(loaded, []) + + expect(useCanvasStore.getState().nodes[0].position).toEqual({ x: 123, y: 456 }) + }) + + it('preserves node data fields through the round-trip', () => { + const nodes = [makeNode('n1', { + data: { + label: 'My Router', type: 'router', status: 'online', services: [], + ip: '192.168.1.1', hostname: 'gw.local', + }, + })] + const raw = standaloneSerialize(nodes, []) + const { nodes: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas(loaded, []) + + const stored = useCanvasStore.getState().nodes[0] + expect(stored.data.label).toBe('My Router') + expect(stored.data.ip).toBe('192.168.1.1') + expect(stored.data.hostname).toBe('gw.local') + expect(stored.data.status).toBe('online') + }) + + it('preserves theme_id through the round-trip', () => { + const raw = standaloneSerialize([], [], 'cyberpunk') + const { theme_id } = standaloneDeserialize(raw) + expect(theme_id).toBe('cyberpunk') + }) + + it('preserves edge data through the round-trip', () => { + const edges: Edge[] = [{ + id: 'e1', source: 'n1', target: 'n2', type: 'vlan', + data: { type: 'vlan', vlan_id: 20, label: 'VLAN 20' }, + }] + const raw = standaloneSerialize([], edges) + const { edges: loaded } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas([], loaded) + + const stored = useCanvasStore.getState().edges[0] + expect(stored.data?.vlan_id).toBe(20) + expect(stored.data?.label).toBe('VLAN 20') + }) + + // ── loadCanvas marks clean ──────────────────────────────────────────────── + + it('loadCanvas sets hasUnsavedChanges to false', () => { + useCanvasStore.setState({ hasUnsavedChanges: true }) + const { nodes: loaded } = standaloneDeserialize(standaloneSerialize([makeNode('n1')], [])) + useCanvasStore.getState().loadCanvas(loaded, []) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) +}) + +// ── Demo data validation ────────────────────────────────────────────────────── + +describe('Demo data (standalone fallback)', () => { + it('all demo nodes have required fields', () => { + for (const n of demoNodes) { + expect(n.id, `${n.id} missing id`).toBeTruthy() + expect(n.type, `${n.id} missing type`).toBeTruthy() + expect(n.position, `${n.id} missing position`).toBeDefined() + expect(n.data.label, `${n.id} missing label`).toBeTruthy() + expect(n.data.type, `${n.id} missing data.type`).toBeTruthy() + expect(n.data.status, `${n.id} missing status`).toBeTruthy() + expect(Array.isArray(n.data.services), `${n.id} services must be array`).toBe(true) + } + }) + + it('demo nodes have no explicit width/height — render at natural size', () => { + for (const n of demoNodes) { + expect(n.width, `${n.id} should not have explicit width`).toBeUndefined() + expect(n.height, `${n.id} should not have explicit height`).toBeUndefined() + } + }) + + it('all demo edges reference valid node ids', () => { + const nodeIds = new Set(demoNodes.map((n) => n.id)) + for (const e of demoEdges) { + expect(nodeIds.has(e.source), `edge ${e.id} source '${e.source}' not in nodes`).toBe(true) + expect(nodeIds.has(e.target), `edge ${e.id} target '${e.target}' not in nodes`).toBe(true) + } + }) + + it('demo data loads into store without errors', () => { + useCanvasStore.getState().loadCanvas(demoNodes, demoEdges) + const { nodes, edges } = useCanvasStore.getState() + expect(nodes).toHaveLength(demoNodes.length) + expect(edges).toHaveLength(demoEdges.length) + }) + + it('demo data round-trips through standalone JSON serialization', () => { + const raw = standaloneSerialize(demoNodes, demoEdges) + const { nodes: loaded, edges: loadedEdges } = standaloneDeserialize(raw) + useCanvasStore.getState().loadCanvas(loaded, loadedEdges) + + const { nodes, edges } = useCanvasStore.getState() + expect(nodes).toHaveLength(demoNodes.length) + expect(edges).toHaveLength(demoEdges.length) + // Positions intact + const router = nodes.find((n) => n.id === 'router-1')! + expect(router.position).toEqual({ x: 300, y: 140 }) + }) +})