diff --git a/e2e/save.spec.ts b/e2e/save.spec.ts new file mode 100644 index 0000000..499b158 --- /dev/null +++ b/e2e/save.spec.ts @@ -0,0 +1,195 @@ +import { test, expect, type Page, type Download } from '@playwright/test'; +import * as fs from 'fs'; + +// ── types mirroring saveGraph.ts ────────────────────────────────────────────── +interface SavedEdge { id: string; from: string; to: string; } +interface SavedNode { id: string; label?: string; subgraph?: SavedGraph; } +interface SavedGraph { id: string; nodes: SavedNode[]; edges: SavedEdge[]; } + +// ── helpers ─────────────────────────────────────────────────────────────────── +async function waitForGraph(page: Page) { + await page.waitForSelector('svg g.node', { timeout: 15000 }); +} + +function nodeByLabel(page: Page, label: string) { + return page.locator('g.node').filter({ hasText: label }); +} + +async function openSubgraph(page: Page, nodeLabel: string) { + await nodeByLabel(page, nodeLabel).click({ button: 'right' }); + await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); + await page.waitForTimeout(300); + await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click(); + await waitForGraph(page); +} + +function saveButton(page: Page) { + return page.getByTitle('Save as JSON'); +} + +async function triggerSave(page: Page): Promise { + const [download] = await Promise.all([ + page.waitForEvent('download'), + saveButton(page).click(), + ]); + return download; +} + +async function getSavedJson(page: Page): Promise { + const download = await triggerSave(page); + const filePath = await download.path(); + expect(filePath).not.toBeNull(); + return JSON.parse(fs.readFileSync(filePath!, 'utf-8')) as SavedGraph; +} + +// ── tests ───────────────────────────────────────────────────────────────────── +test.describe('Graph saving', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await waitForGraph(page); + }); + + // ── button presence ───────────────────────────────────────────────────────── + test('save button is visible in the header', async ({ page }) => { + await expect(saveButton(page)).toBeVisible(); + }); + + // ── download mechanics ────────────────────────────────────────────────────── + test('clicking save downloads a file named concept-sketch.json', async ({ page }) => { + const download = await triggerSave(page); + expect(download.suggestedFilename()).toBe('concept-sketch.json'); + }); + + test('downloaded file is valid JSON', async ({ page }) => { + const download = await triggerSave(page); + const filePath = await download.path(); + expect(filePath).not.toBeNull(); + expect(() => JSON.parse(fs.readFileSync(filePath!, 'utf-8'))).not.toThrow(); + }); + + // ── root graph structure ──────────────────────────────────────────────────── + test('saved JSON has id, nodes and edges at the root', async ({ page }) => { + const data = await getSavedJson(page); + expect(data.id).toBe('main'); + expect(Array.isArray(data.nodes)).toBe(true); + expect(Array.isArray(data.edges)).toBe(true); + }); + + test('default graph saves Start and End nodes with one connecting edge', async ({ page }) => { + const data = await getSavedJson(page); + expect(data.nodes).toHaveLength(2); + const labels = data.nodes.map(n => n.label); + expect(labels).toContain('Start'); + expect(labels).toContain('End'); + expect(data.edges).toHaveLength(1); + }); + + test('edge from/to ids reference nodes that exist in the graph', async ({ page }) => { + const data = await getSavedJson(page); + const nodeIds = new Set(data.nodes.map(n => n.id)); + for (const edge of data.edges) { + expect(nodeIds.has(edge.from)).toBe(true); + expect(nodeIds.has(edge.to)).toBe(true); + } + }); + + // ── edits reflected in the save ───────────────────────────────────────────── + test('newly added node appears in the saved JSON', async ({ page }) => { + await nodeByLabel(page, 'Start').click(); + await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); + + const data = await getSavedJson(page); + expect(data.nodes).toHaveLength(3); + expect(data.edges).toHaveLength(2); + }); + + test('renamed node label appears correctly in the saved JSON', async ({ page }) => { + await nodeByLabel(page, 'Start').click({ button: 'right' }); + await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); + await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' }).click(); + + const modal = page.locator('.ant-modal'); + await expect(modal).toBeVisible({ timeout: 3000 }); + await modal.locator('.ant-input').clear(); + await modal.locator('.ant-input').fill('Concept'); + await modal.locator('.ant-btn-primary').click(); + await expect(modal).not.toBeVisible(); + await expect(nodeByLabel(page, 'Concept')).toBeVisible({ timeout: 5000 }); + + const data = await getSavedJson(page); + const labels = data.nodes.map(n => n.label); + expect(labels).toContain('Concept'); + expect(labels).not.toContain('Start'); + }); + + test('removed node is absent from the saved JSON', async ({ page }) => { + await nodeByLabel(page, 'End').click({ button: 'right' }); + await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); + await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click(); + await expect(page.locator('g.node')).toHaveCount(1, { timeout: 5000 }); + + const data = await getSavedJson(page); + expect(data.nodes).toHaveLength(1); + expect(data.nodes.map(n => n.label)).not.toContain('End'); + }); + + // ── subgraph serialisation ────────────────────────────────────────────────── + test('entering a subgraph and returning adds it nested inside the parent node', async ({ page }) => { + await openSubgraph(page, 'Start'); + await page.locator('.ant-breadcrumb-link').first().click(); + await waitForGraph(page); + + const data = await getSavedJson(page); + const startNode = data.nodes.find(n => n.label === 'Start')!; + expect(startNode.subgraph).toBeDefined(); + expect(startNode.subgraph!.nodes).toHaveLength(2); + expect(startNode.subgraph!.edges).toHaveLength(1); + }); + + test('subgraph id matches the id of the node that contains it', async ({ page }) => { + await openSubgraph(page, 'Start'); + await page.locator('.ant-breadcrumb-link').first().click(); + await waitForGraph(page); + + const data = await getSavedJson(page); + const startNode = data.nodes.find(n => n.label === 'Start')!; + expect(startNode.subgraph!.id).toBe(startNode.id); + }); + + test('node without a subgraph has no subgraph key in the JSON', async ({ page }) => { + await openSubgraph(page, 'Start'); + await page.locator('.ant-breadcrumb-link').first().click(); + await waitForGraph(page); + + const data = await getSavedJson(page); + const endNode = data.nodes.find(n => n.label === 'End')!; + expect(endNode.subgraph).toBeUndefined(); + }); + + test('nodes added inside a subgraph are serialised in the nested subgraph', async ({ page }) => { + await openSubgraph(page, 'Start'); + // Add one extra node inside the subgraph + await nodeByLabel(page, 'End').click(); + await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); + + await page.locator('.ant-breadcrumb-link').first().click(); + await waitForGraph(page); + + const data = await getSavedJson(page); + const startNode = data.nodes.find(n => n.label === 'Start')!; + expect(startNode.subgraph!.nodes).toHaveLength(3); + expect(startNode.subgraph!.edges).toHaveLength(2); + }); + + test('saving from inside a subgraph includes the full hierarchy from root', async ({ page }) => { + await openSubgraph(page, 'Start'); + // Still inside the subgraph when saving + const data = await getSavedJson(page); + // Root must still be present + expect(data.id).toBe('main'); + expect(data.nodes.map(n => n.label)).toContain('Start'); + // And the subgraph must be nested + const startNode = data.nodes.find(n => n.label === 'Start')!; + expect(startNode.subgraph).toBeDefined(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 9ba79f2..5bec202 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,10 @@ import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb'; import { useKeysdownStore } from './stores/ArrayStore'; import { MenuFoldOutlined, - MenuUnfoldOutlined + MenuUnfoldOutlined, + SaveOutlined, } from '@ant-design/icons'; +import { saveConceptSketch } from './utils/saveGraph'; import { useGraphLayersTreeStore } from './stores/TreeStore'; const { Header, Content, Sider } = Layout; @@ -67,6 +69,18 @@ const App: React.FC = () => { color: colorBgContainer, }} /> +