import { test, expect } from '@playwright/test'; // Helper: wait for the SVG graph to fully render (viz.js is async) async function waitForGraph(page: import('@playwright/test').Page) { await page.waitForSelector('svg g.node', { timeout: 15000 }); } // Helper: find a graph node by its label text function nodeByLabel(page: import('@playwright/test').Page, label: string) { return page.locator('g.node').filter({ hasText: label }); } // Helper: the graph container div (empty space below the viz.js SVG) function graphContainer(page: import('@playwright/test').Page) { return page.locator('.bg-white.rounded.shadow'); } test.describe('ConceptSketch', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await waitForGraph(page); }); test('renders the initial graph with Start and End nodes', async ({ page }) => { await expect(page.locator('svg g.graph')).toBeVisible(); await expect(nodeByLabel(page, 'Start')).toBeVisible(); await expect(nodeByLabel(page, 'End')).toBeVisible(); // There is exactly one edge connecting them await expect(page.locator('g.edge')).toHaveCount(1); }); test('toggles the sidebar open and closed', async ({ page }) => { const sider = page.locator('.ant-layout-sider'); const toggleBtn = page.locator('header button').first(); // Initially collapsed (collapsedWidth=0 → ant-layout-sider-collapsed) await expect(sider).toHaveClass(/ant-layout-sider-collapsed/); // Expand await toggleBtn.click(); await expect(sider).not.toHaveClass(/ant-layout-sider-collapsed/); // Collapse again await toggleBtn.click(); await expect(sider).toHaveClass(/ant-layout-sider-collapsed/); }); test('creates a child node when clicking a node', async ({ page }) => { const initialNodeCount = await page.locator('g.node').count(); const initialEdgeCount = await page.locator('g.edge').count(); await nodeByLabel(page, 'Start').click(); // One more node and one more edge appear after re-render await expect(page.locator('g.node')).toHaveCount(initialNodeCount + 1, { timeout: 5000 }); await expect(page.locator('g.edge')).toHaveCount(initialEdgeCount + 1, { timeout: 5000 }); }); test('removes a node via the context menu', async ({ page }) => { const initialNodeCount = await page.locator('g.node').count(); 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(initialNodeCount - 1, { timeout: 5000 }); await expect(nodeByLabel(page, 'End')).not.toBeAttached(); }); test('renames a node via the context menu', 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 }); const input = modal.locator('.ant-input'); await input.clear(); await input.fill('Concept'); await modal.locator('.ant-btn-primary').click(); await expect(modal).not.toBeVisible(); await expect(nodeByLabel(page, 'Concept')).toBeVisible({ timeout: 5000 }); await expect(nodeByLabel(page, 'Start')).not.toBeAttached(); }); test('navigates into a subgraph via the context menu', 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: 'Subgraph' }).click(); // Breadcrumb shows "Start" as the new path segment await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 }); // A fresh subgraph is rendered with its own default Start/End nodes await waitForGraph(page); await expect(nodeByLabel(page, 'Start')).toBeVisible(); await expect(nodeByLabel(page, 'End')).toBeVisible(); }); test('navigating back via breadcrumb restores the parent graph', async ({ page }) => { // Go into Start's subgraph 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: 'Subgraph' }).click(); await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 }); await waitForGraph(page); // Click the first breadcrumb item (Main) to go back await page.locator('.ant-breadcrumb-link').first().click(); // Breadcrumb should only show the root segment again await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(1, { timeout: 3000 }); await waitForGraph(page); await expect(nodeByLabel(page, 'Start')).toBeVisible(); }); test('right-clicking empty graph area shows only Create Node option', async ({ page }) => { await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } }); const dropdown = page.locator('.ant-dropdown:visible'); await expect(dropdown).toBeVisible({ timeout: 3000 }); await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' })).toBeVisible(); await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' })).not.toBeAttached(); await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' })).not.toBeAttached(); await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' })).not.toBeAttached(); }); test('creates an orphaned node via Create Node in the context menu', async ({ page }) => { const initialNodeCount = await page.locator('g.node').count(); const initialEdgeCount = await page.locator('g.edge').count(); await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } }); await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' }).click(); await expect(page.locator('g.node')).toHaveCount(initialNodeCount + 1, { timeout: 5000 }); // No new edge — node is orphaned await expect(page.locator('g.edge')).toHaveCount(initialEdgeCount, { timeout: 5000 }); }); test('right-clicking a node does not show Create Node option', async ({ page }) => { await nodeByLabel(page, 'Start').click({ button: 'right' }); const dropdown = page.locator('.ant-dropdown:visible'); await expect(dropdown).toBeVisible({ timeout: 3000 }); await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' })).not.toBeAttached(); await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' })).toBeVisible(); }); test('links nodes using Ctrl+click selection', async ({ page }) => { // Create a third node to link to await nodeByLabel(page, 'Start').click(); await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); const edgeCountBefore = await page.locator('g.edge').count(); // Ctrl+click End to select it as a parent await page.keyboard.down('Control'); await nodeByLabel(page, 'End').click(); await page.keyboard.up('Control'); // Click Start (without Ctrl) — links selected End as a parent of Start await nodeByLabel(page, 'Start').click(); // One new edge End→Start should have been created await expect(page.locator('g.edge')).toHaveCount(edgeCountBefore + 1, { timeout: 5000 }); }); });