feature: loading completed with teste
This commit is contained in:
233
e2e/load.spec.ts
Normal file
233
e2e/load.spec.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// ── fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SIMPLE_GRAPH = {
|
||||
id: 'main',
|
||||
nodes: [
|
||||
{ id: 'node-a', label: 'Alpha' },
|
||||
{ id: 'node-b', label: 'Beta' },
|
||||
],
|
||||
edges: [{ id: 'edge-1', from: 'node-a', to: 'node-b' }],
|
||||
};
|
||||
|
||||
const GRAPH_WITH_SUBGRAPH = {
|
||||
id: 'main',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-a',
|
||||
label: 'Alpha',
|
||||
subgraph: {
|
||||
id: 'node-a',
|
||||
nodes: [
|
||||
{ id: 'sub-1', label: 'SubAlpha' },
|
||||
{ id: 'sub-2', label: 'SubBeta' },
|
||||
],
|
||||
edges: [{ id: 'sub-edge-1', from: 'sub-1', to: 'sub-2' }],
|
||||
},
|
||||
},
|
||||
{ id: 'node-b', label: 'Beta' },
|
||||
],
|
||||
edges: [{ id: 'edge-1', from: 'node-a', to: 'node-b' }],
|
||||
};
|
||||
|
||||
function writeTempJson(data: object): string {
|
||||
const name = `concept-sketch-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
||||
const filePath = path.join(os.tmpdir(), name);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// ── 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 loadButton(page: Page) {
|
||||
return page.getByTitle('Load JSON');
|
||||
}
|
||||
|
||||
// Triggers load and waits until a specific node label appears in the graph —
|
||||
// that is the reliable signal that the full async load pipeline has finished.
|
||||
async function loadFile(page: Page, filePath: string, waitForLabel: string) {
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
loadButton(page).click(),
|
||||
]);
|
||||
await fileChooser.setFiles(filePath);
|
||||
await expect(nodeByLabel(page, waitForLabel)).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Graph loading', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForGraph(page);
|
||||
});
|
||||
|
||||
// ── button presence ──────────────────────────────────────────────────────────
|
||||
test('load button is visible in the header', async ({ page }) => {
|
||||
await expect(loadButton(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking the load button opens a file chooser', async ({ page }) => {
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
loadButton(page).click(),
|
||||
]);
|
||||
expect(fileChooser).toBeDefined();
|
||||
});
|
||||
|
||||
// ── basic load ───────────────────────────────────────────────────────────────
|
||||
test('loaded nodes and edges replace the default graph', async ({ page }) => {
|
||||
const filePath = writeTempJson(SIMPLE_GRAPH);
|
||||
await loadFile(page, filePath, 'Alpha');
|
||||
|
||||
await expect(nodeByLabel(page, 'Alpha')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'Beta')).toBeVisible();
|
||||
await expect(page.locator('g.node')).toHaveCount(2);
|
||||
await expect(page.locator('g.edge')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('loading removes nodes that existed before the load', async ({ page }) => {
|
||||
await nodeByLabel(page, 'Start').click();
|
||||
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
|
||||
|
||||
const filePath = writeTempJson(SIMPLE_GRAPH);
|
||||
await loadFile(page, filePath, 'Alpha');
|
||||
|
||||
await expect(page.locator('g.node')).toHaveCount(2, { timeout: 5000 });
|
||||
await expect(nodeByLabel(page, 'Start')).not.toBeAttached();
|
||||
await expect(nodeByLabel(page, 'End')).not.toBeAttached();
|
||||
});
|
||||
|
||||
test('a second load completely replaces the previously loaded graph', async ({ page }) => {
|
||||
const firstFile = writeTempJson(SIMPLE_GRAPH);
|
||||
await loadFile(page, firstFile, 'Alpha');
|
||||
|
||||
const secondFile = writeTempJson({
|
||||
id: 'main',
|
||||
nodes: [
|
||||
{ id: 'n1', label: 'Gamma' },
|
||||
{ id: 'n2', label: 'Delta' },
|
||||
],
|
||||
edges: [{ id: 'e1', from: 'n1', to: 'n2' }],
|
||||
});
|
||||
await loadFile(page, secondFile, 'Gamma');
|
||||
|
||||
await expect(nodeByLabel(page, 'Gamma')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'Delta')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'Alpha')).not.toBeAttached();
|
||||
await expect(page.locator('g.node')).toHaveCount(2);
|
||||
});
|
||||
|
||||
// ── navigation reset ──────────────────────────────────────────────────────────
|
||||
test('loading resets the breadcrumb to "Main" only', async ({ page }) => {
|
||||
await openSubgraph(page, 'Start');
|
||||
await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(2);
|
||||
|
||||
const filePath = writeTempJson(SIMPLE_GRAPH);
|
||||
await loadFile(page, filePath, 'Alpha');
|
||||
|
||||
await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(1, { timeout: 3000 });
|
||||
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
|
||||
await expect(page.locator('.ant-breadcrumb')).not.toContainText('Start');
|
||||
});
|
||||
|
||||
test('loading while inside a subgraph shows the loaded main graph', async ({ page }) => {
|
||||
await openSubgraph(page, 'Start');
|
||||
|
||||
const filePath = writeTempJson(SIMPLE_GRAPH);
|
||||
await loadFile(page, filePath, 'Alpha');
|
||||
|
||||
await expect(nodeByLabel(page, 'Alpha')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'Beta')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'Start')).not.toBeAttached();
|
||||
await expect(nodeByLabel(page, 'End')).not.toBeAttached();
|
||||
});
|
||||
|
||||
// ── subgraph restoration ──────────────────────────────────────────────────────
|
||||
test('subgraph from loaded JSON is accessible via the context menu', async ({ page }) => {
|
||||
const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH);
|
||||
await loadFile(page, filePath, 'Alpha');
|
||||
|
||||
await openSubgraph(page, 'Alpha');
|
||||
|
||||
await expect(nodeByLabel(page, 'SubAlpha')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'SubBeta')).toBeVisible();
|
||||
await expect(page.locator('g.node')).toHaveCount(2);
|
||||
await expect(page.locator('g.edge')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('breadcrumb shows the loaded node label when navigated into its subgraph', async ({ page }) => {
|
||||
const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH);
|
||||
await loadFile(page, filePath, 'Alpha');
|
||||
await openSubgraph(page, 'Alpha');
|
||||
|
||||
await expect(page.locator('.ant-breadcrumb-link')).toHaveCount(2);
|
||||
await expect(page.locator('.ant-breadcrumb')).toContainText('Alpha');
|
||||
});
|
||||
|
||||
test('navigating back from a loaded subgraph restores the loaded main graph', async ({ page }) => {
|
||||
const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH);
|
||||
await loadFile(page, filePath, 'Alpha');
|
||||
await openSubgraph(page, 'Alpha');
|
||||
|
||||
await page.locator('.ant-breadcrumb-link').first().click();
|
||||
await waitForGraph(page);
|
||||
|
||||
await expect(nodeByLabel(page, 'Alpha')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'Beta')).toBeVisible();
|
||||
await expect(page.locator('g.node')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('node without a subgraph in the loaded JSON does not expose a pre-existing subgraph', async ({ page }) => {
|
||||
// First load graph with subgraph, then load a graph without
|
||||
const withSubgraph = writeTempJson(GRAPH_WITH_SUBGRAPH);
|
||||
await loadFile(page, withSubgraph, 'Alpha');
|
||||
await openSubgraph(page, 'Alpha');
|
||||
await expect(nodeByLabel(page, 'SubAlpha')).toBeVisible();
|
||||
|
||||
// Go back and load a plain graph
|
||||
await page.locator('.ant-breadcrumb-link').first().click();
|
||||
await waitForGraph(page);
|
||||
const plain = writeTempJson(SIMPLE_GRAPH);
|
||||
await loadFile(page, plain, 'Alpha');
|
||||
|
||||
// Alpha no longer has a subgraph — entering it should create a fresh default graph
|
||||
await openSubgraph(page, 'Alpha');
|
||||
await expect(nodeByLabel(page, 'Start')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'End')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'SubAlpha')).not.toBeAttached();
|
||||
});
|
||||
|
||||
// ── sidebar tree ──────────────────────────────────────────────────────────────
|
||||
test('sidebar tree reflects subgraph structure from loaded JSON', async ({ page }) => {
|
||||
const filePath = writeTempJson(GRAPH_WITH_SUBGRAPH);
|
||||
await loadFile(page, filePath, 'Alpha');
|
||||
|
||||
// Expand sidebar
|
||||
await page.locator('header button').first().click();
|
||||
await expect(page.locator('.ant-layout-sider')).not.toHaveClass(/ant-layout-sider-collapsed/, { timeout: 3000 });
|
||||
|
||||
// Alpha has a subgraph so it appears in the tree
|
||||
await expect(page.locator('.ant-tree-title').filter({ hasText: 'Alpha' })).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
});
|
||||
@@ -43,11 +43,12 @@ export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({
|
||||
} else {
|
||||
const nodesFlatById = new Map(state.nodesFlatById);
|
||||
nodesFlatById.set(childNode.key, childNode);
|
||||
const newRootNodes = [...state.rootNodes, childNode];
|
||||
const newState = {
|
||||
nodesFlatById: nodesFlatById,
|
||||
parentIdByChildId: state.parentIdByChildId,
|
||||
rootNodes: [...state.rootNodes, childNode],
|
||||
tree: createTree([...state.rootNodes], nodesFlatById)
|
||||
rootNodes: newRootNodes,
|
||||
tree: createTree(newRootNodes, nodesFlatById)
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user