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 {
|
} else {
|
||||||
const nodesFlatById = new Map(state.nodesFlatById);
|
const nodesFlatById = new Map(state.nodesFlatById);
|
||||||
nodesFlatById.set(childNode.key, childNode);
|
nodesFlatById.set(childNode.key, childNode);
|
||||||
|
const newRootNodes = [...state.rootNodes, childNode];
|
||||||
const newState = {
|
const newState = {
|
||||||
nodesFlatById: nodesFlatById,
|
nodesFlatById: nodesFlatById,
|
||||||
parentIdByChildId: state.parentIdByChildId,
|
parentIdByChildId: state.parentIdByChildId,
|
||||||
rootNodes: [...state.rootNodes, childNode],
|
rootNodes: newRootNodes,
|
||||||
tree: createTree([...state.rootNodes], nodesFlatById)
|
tree: createTree(newRootNodes, nodesFlatById)
|
||||||
}
|
}
|
||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user