From 8bf3ab296d953d46cecd0a6cb386ed27fecf7f64 Mon Sep 17 00:00:00 2001 From: tymurbaniak Date: Fri, 6 Mar 2026 14:54:20 +0100 Subject: [PATCH] feature: loading completed with teste --- e2e/load.spec.ts | 233 +++++++++++++++++++++++++++++++++++++++ src/stores/TreeStore.tsx | 5 +- 2 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 e2e/load.spec.ts diff --git a/e2e/load.spec.ts b/e2e/load.spec.ts new file mode 100644 index 0000000..24f5ccc --- /dev/null +++ b/e2e/load.spec.ts @@ -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 }); + }); +}); diff --git a/src/stores/TreeStore.tsx b/src/stores/TreeStore.tsx index ff36b9b..bd913e8 100644 --- a/src/stores/TreeStore.tsx +++ b/src/stores/TreeStore.tsx @@ -43,11 +43,12 @@ export const useGraphLayersTreeStore = create()((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; }