Right-clicking on empty graph area (outside any node or edge) now shows a context menu with a single 'Create Node' option. Clicking it adds a new node with a random label and no edges, allowing the user to wire it up manually afterwards. - NodeContext gains isEmptyArea flag to distinguish empty-area from node right-clicks - Container div contextmenu handler covers the white space below the SVG; SVG-level handler covers empty space within the rendered graph - node contextmenu handler calls stopPropagation so the SVG handler never fires for node clicks; SVG handler also guards via target.closest check - NodeContextMenu renders emptyAreaItems (Create Node only) vs nodeItems (Rename / Subgraph / Remove) based on the flag - randomWordList extracted as a named export so NodeContextMenu can reuse the same word bank - Three new Playwright e2e tests cover: empty-area menu shows only Create Node, created node is orphaned (no new edges), node right-click does not show Create Node
178 lines
7.7 KiB
TypeScript
178 lines
7.7 KiB
TypeScript
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 });
|
|
});
|
|
});
|