From 7d1301138054401baabbe4a3be117e6a23953cba Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 10:04:30 -0700 Subject: [PATCH 1/2] Seed guest-visible hierarchical "graphs of graphs" demo (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Altium-style hierarchy demo, visible to the guest account, that showcases high-performance / dynamic LOD via drill-in (the hierarchy is the LOD strategy — never render all ~2900 nodes at once). - services/hierarchyDemo.ts + scripts/create-hierarchy-demo.ts (npm run create-hierarchy-demo): idempotent, NON-destructive. Builds a "System Overview" graph of 16 sheet-symbol WorkItems, each with subgraphId + DRILLS_INTO a sub-graph; inter-sheet wires kept endpoint-local to the overview. 16 sub-graphs (one ~1000-node "Compute Core" perf showcase), totaling 2911 work items / 4073 edges. All createdBy:'system' isShared:true (guest-visible, read-only). Edge nodes carry both EDGE_SOURCE+EDGE_TARGET. - GraphContext.tsx: fresh-load default graph now picked by identity (Welcome, then System Overview) instead of array position — shared/system demo graphs no longer hijack the default view. (The seed surfaced this latent fragility.) Verified: 17 system/shared graphs, overview sheets resolve subgraph counts, big sub-graph = 1000 nodes, idempotent re-run skips; THE GATE 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server/package.json | 3 +- .../src/scripts/create-hierarchy-demo.ts | 30 +++ packages/server/src/services/hierarchyDemo.ts | 242 ++++++++++++++++++ packages/web/src/contexts/GraphContext.tsx | 11 +- 4 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/scripts/create-hierarchy-demo.ts create mode 100644 packages/server/src/services/hierarchyDemo.ts diff --git a/packages/server/package.json b/packages/server/package.json index 27ebdce3..67600201 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -15,7 +15,8 @@ "clean": "rm -rf dist coverage", "db:seed": "tsx src/scripts/seed.ts", "create-admin": "tsx src/scripts/create-admin.ts", - "create-welcome-graphs": "tsx src/scripts/create-welcome-graphs.ts" + "create-welcome-graphs": "tsx src/scripts/create-welcome-graphs.ts", + "create-hierarchy-demo": "tsx src/scripts/create-hierarchy-demo.ts" }, "dependencies": { "@apollo/server": "^4.9.0", diff --git a/packages/server/src/scripts/create-hierarchy-demo.ts b/packages/server/src/scripts/create-hierarchy-demo.ts new file mode 100644 index 00000000..4b76e971 --- /dev/null +++ b/packages/server/src/scripts/create-hierarchy-demo.ts @@ -0,0 +1,30 @@ +import { driver } from '../db.js'; +import { createHierarchyDemo, hierarchyDemoExists } from '../services/hierarchyDemo.js'; + +async function ensureHierarchyDemo() { + console.log('🏗️ Ensuring hierarchical "graphs of graphs" demo exists...\n'); + try { + if (await hierarchyDemoExists(driver)) { + console.log('⏭️ Hierarchy demo already exists - skipping creation\n'); + } else { + const r = await createHierarchyDemo(driver); + console.log('\n========================================'); + console.log(' Hierarchy Demo Created'); + console.log('========================================'); + console.log(`• Graphs: ${r.graphs} (1 overview + sub-graphs)`); + console.log(`• Work items: ${r.nodes}`); + console.log(`• Edges: ${r.edges}`); + console.log('• Shared: Yes (guest + all users, read-only)'); + console.log('• Open "System Overview" and click a node to drill in'); + console.log('========================================\n'); + } + } catch (error: any) { + console.error('❌ Failed to create hierarchy demo:', error); + await driver.close(); + process.exit(1); + } + await driver.close(); + process.exit(0); +} + +ensureHierarchyDemo(); diff --git a/packages/server/src/services/hierarchyDemo.ts b/packages/server/src/services/hierarchyDemo.ts new file mode 100644 index 00000000..53f58c54 --- /dev/null +++ b/packages/server/src/services/hierarchyDemo.ts @@ -0,0 +1,242 @@ +import { Driver } from 'neo4j-driver'; + +/** + * Guest-visible demo of an Altium-style HIERARCHY of graphs ("graphs of + * graphs"). A top OVERVIEW graph holds sheet-symbol nodes; each drills into a + * sub-graph (via the WorkItem.subgraph / DRILLS_INTO relationship). The + * hierarchy is the level-of-detail strategy — any single view renders one graph + * (a few dozen sheets at the overview, up to ~1000 in the perf showcase + * sub-graph), never all ~2600 nodes at once. + * + * Everything is createdBy:'system' + isShared:true so the GUEST account sees it. + * Idempotent and NON-destructive: it only creates if the overview graph is + * absent (unlike scripts/seed.ts which wipes the DB). Edges are canonical Edge + * nodes with both EDGE_SOURCE and EDGE_TARGET (no orphan edges). + */ + +export const OVERVIEW_GRAPH_ID = 'overview-graph-shared'; + +const NODE_TYPES = ['TASK', 'FEATURE', 'BUG', 'MILESTONE', 'OUTCOME', 'IDEA']; +const STATUSES = ['NOT_STARTED', 'PROPOSED', 'PLANNED', 'IN_PROGRESS', 'BLOCKED', 'COMPLETED']; +const EDGE_TYPES = ['DEPENDS_ON', 'BLOCKS', 'ENABLES', 'RELATES_TO']; + +// Subsystems = sub-graphs. One large "Compute Core" (~1000 nodes) is the +// high-performance / LOD showcase; the rest are varied mid-size graphs. +interface Subsystem { key: string; name: string; size: number; } +const SUBSYSTEMS: Subsystem[] = [ + { key: 'compute', name: 'Compute Core', size: 1000 }, + { key: 'power', name: 'Power Management', size: 90 }, + { key: 'clocking', name: 'Clocking & PLL', size: 110 }, + { key: 'memctl', name: 'Memory Controller', size: 150 }, + { key: 'ddrphy', name: 'DDR PHY', size: 130 }, + { key: 'pcie', name: 'PCIe Root Complex', size: 160 }, + { key: 'usb', name: 'USB Subsystem', size: 120 }, + { key: 'ethernet', name: 'Ethernet MAC', size: 140 }, + { key: 'display', name: 'Display Pipeline', size: 170 }, + { key: 'audio', name: 'Audio Codec', size: 90 }, + { key: 'security', name: 'Security Enclave', size: 100 }, + { key: 'thermal', name: 'Thermal & Sensors', size: 110 }, + { key: 'ioexp', name: 'I/O Expander', size: 95 }, + { key: 'firmware', name: 'Firmware & Boot', size: 130 }, + { key: 'telemetry', name: 'Telemetry & Logging', size: 120 }, + { key: 'fabric', name: 'Interconnect Fabric', size: 180 }, +]; + +interface NodeRow { id: string; type: string; title: string; description: string; status: string; priority: number; x: number; y: number; } +interface EdgeRow { id: string; s: string; t: string; type: string; weight: number; } + +/** Grid positions centered on the origin so nodes load pinned in a real layout. */ +function gridPositions(n: number, spacing: number): Array<{ x: number; y: number }> { + const cols = Math.ceil(Math.sqrt(n)); + const half = (cols * spacing) / 2; + return Array.from({ length: n }, (_, i) => ({ + x: (i % cols) * spacing - half, + y: Math.floor(i / cols) * spacing - half, + })); +} + +/** A connected sub-graph: backbone chain + deterministic forward links (~1.4x). */ +function buildSubgraph(graphId: string, size: number): { nodes: NodeRow[]; edges: EdgeRow[] } { + const pos = gridPositions(size, 140); + const nodes: NodeRow[] = Array.from({ length: size }, (_, i) => { + const type = NODE_TYPES[(i * 7) % NODE_TYPES.length]; + return { + id: `${graphId}-n${i}`, + type, + title: `${type} ${i}`, + description: '', + status: STATUSES[i % STATUSES.length], + priority: ((i * 37) % 100) / 100, + x: pos[i].x, + y: pos[i].y, + }; + }); + + const edges: EdgeRow[] = []; + const link = (a: string, b: string, t: string) => + edges.push({ id: `${graphId}-e${edges.length}`, s: a, t: b, type: t, weight: 0.5 + (edges.length % 5) / 10 }); + for (let i = 0; i + 1 < size; i++) link(nodes[i].id, nodes[i + 1].id, 'DEPENDS_ON'); + let extra = Math.round(size * 1.4) - edges.length; + for (let i = 0; i < size && extra > 0; i++) { + const jump = 2 + ((i * 5) % Math.max(2, Math.floor(size / 4))); + const j = i + jump; + if (j < size) { link(nodes[i].id, nodes[j].id, EDGE_TYPES[i % EDGE_TYPES.length]); extra--; } + } + return { nodes, edges }; +} + +function chunk(arr: T[], n: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); + return out; +} + +export async function hierarchyDemoExists(driver: Driver): Promise { + const session = driver.session(); + try { + const r = await session.run(`MATCH (g:Graph {id: $id}) RETURN count(g) > 0 AS exists`, { id: OVERVIEW_GRAPH_ID }); + return r.records[0]?.get('exists') ?? false; + } finally { + await session.close(); + } +} + +async function createGraphNode(session: any, params: { + id: string; name: string; description: string; type: string; depth: number; path: string[]; parentGraphId: string | null; nodeCount: number; edgeCount: number; tags: string[]; +}) { + await session.run( + `CREATE (g:Graph { + id: $id, name: $name, description: $description, type: $type, status: 'ACTIVE', + teamId: null, createdBy: 'system', tags: $tags, defaultRole: 'VIEWER', + parentGraphId: $parentGraphId, depth: $depth, path: $path, isShared: true, + nodeCount: $nodeCount, edgeCount: $edgeCount, contributorCount: 0, + lastActivity: datetime(), settings: '{}', + permissions: '{"public":"read","authenticated":"read"}', + shareSettings: '{"public":true,"readOnly":true}', + createdAt: datetime(), updatedAt: datetime() + })`, + params + ); +} + +async function insertNodesAndEdges(session: any, graphId: string, nodes: NodeRow[], edges: EdgeRow[]) { + for (const batch of chunk(nodes, 500)) { + await session.run( + `MATCH (g:Graph {id: $graphId}) + UNWIND $nodes AS n + CREATE (w:WorkItem { + id: n.id, type: n.type, title: n.title, description: n.description, status: n.status, + positionX: toFloat(n.x), positionY: toFloat(n.y), positionZ: 0.0, + radius: 1.0, theta: 0.0, phi: 0.0, priority: toFloat(n.priority), priorityComp: 0.0, + tags: [], metadata: '{}', createdAt: datetime(), updatedAt: datetime() + }) + CREATE (w)-[:BELONGS_TO]->(g)`, + { graphId, nodes: batch } + ); + } + for (const batch of chunk(edges, 700)) { + await session.run( + `UNWIND $edges AS ed + MATCH (s:WorkItem {id: ed.s}), (t:WorkItem {id: ed.t}) + CREATE (e:Edge { id: ed.id, type: ed.type, weight: toFloat(ed.weight), metadata: '{}', createdAt: datetime() }) + CREATE (e)-[:EDGE_SOURCE]->(s) + CREATE (e)-[:EDGE_TARGET]->(t)`, + { edges: batch } + ); + } +} + +export async function createHierarchyDemo(driver: Driver): Promise<{ graphs: number; nodes: number; edges: number }> { + const session = driver.session(); + let totalNodes = 0; + let totalEdges = 0; + try { + console.log('🏗️ Building hierarchical "graphs of graphs" demo...'); + + // 1) Build + populate each sub-graph. + for (const sub of SUBSYSTEMS) { + const subId = `subgraph-${sub.key}-shared`; + const { nodes, edges } = buildSubgraph(subId, sub.size); + await createGraphNode(session, { + id: subId, + name: sub.name, + description: `${sub.name} — a sub-sheet of the System Overview (${sub.size} work items).`, + type: 'SUBGRAPH', + depth: 1, + path: [OVERVIEW_GRAPH_ID], + parentGraphId: OVERVIEW_GRAPH_ID, + nodeCount: nodes.length, + edgeCount: edges.length, + tags: ['demo', 'subgraph', sub.key], + }); + await insertNodesAndEdges(session, subId, nodes, edges); + totalNodes += nodes.length; + totalEdges += edges.length; + console.log(` • ${sub.name}: ${nodes.length} nodes / ${edges.length} edges`); + } + + // 2) Overview graph: one sheet symbol per subsystem. + const sheetPos = gridPositions(SUBSYSTEMS.length, 320); + const sheets = SUBSYSTEMS.map((sub, i) => ({ + id: `${OVERVIEW_GRAPH_ID}-sheet-${sub.key}`, + subgraphId: `subgraph-${sub.key}-shared`, + title: sub.name, + description: `Drill in to open the ${sub.name} sub-graph (${sub.size} work items).`, + x: sheetPos[i].x, + y: sheetPos[i].y, + })); + // Inter-sheet "wires": backbone chain + a few cross links (both endpoints in overview). + const sheetEdges: EdgeRow[] = []; + const wire = (a: string, b: string, t: string) => + sheetEdges.push({ id: `${OVERVIEW_GRAPH_ID}-w${sheetEdges.length}`, s: a, t: b, type: t, weight: 0.8 }); + for (let i = 0; i + 1 < sheets.length; i++) wire(sheets[i].id, sheets[i + 1].id, 'DEPENDS_ON'); + for (let i = 0; i + 3 < sheets.length; i += 3) wire(sheets[i].id, sheets[i + 3].id, 'RELATES_TO'); + + await createGraphNode(session, { + id: OVERVIEW_GRAPH_ID, + name: 'System Overview', + description: 'Top-level overview — each node is a sub-sheet. Click a node to drill into its sub-graph (Altium-style hierarchy).', + type: 'PROJECT', + depth: 0, + path: [], + parentGraphId: null, + nodeCount: sheets.length, + edgeCount: sheetEdges.length, + tags: ['demo', 'overview', 'hierarchy'], + }); + + // Sheet WorkItems with subgraphId + DRILLS_INTO to their sub-graph. + await session.run( + `MATCH (g:Graph {id: $overviewId}) + UNWIND $sheets AS sh + MATCH (sub:Graph {id: sh.subgraphId}) + CREATE (w:WorkItem { + id: sh.id, type: 'OUTCOME', title: sh.title, description: sh.description, status: 'IN_PROGRESS', + positionX: toFloat(sh.x), positionY: toFloat(sh.y), positionZ: 0.0, + radius: 1.0, theta: 0.0, phi: 0.0, priority: 0.8, priorityComp: 0.0, + tags: ['sheet'], metadata: '{}', subgraphId: sh.subgraphId, + createdAt: datetime(), updatedAt: datetime() + }) + CREATE (w)-[:BELONGS_TO]->(g) + CREATE (w)-[:DRILLS_INTO]->(sub) + CREATE (g)-[:PARENT_OF]->(sub)`, + { overviewId: OVERVIEW_GRAPH_ID, sheets } + ); + // Inter-sheet wires. + await session.run( + `UNWIND $edges AS ed + MATCH (s:WorkItem {id: ed.s}), (t:WorkItem {id: ed.t}) + CREATE (e:Edge { id: ed.id, type: ed.type, weight: toFloat(ed.weight), metadata: '{}', createdAt: datetime() }) + CREATE (e)-[:EDGE_SOURCE]->(s) + CREATE (e)-[:EDGE_TARGET]->(t)`, + { edges: sheetEdges } + ); + totalNodes += sheets.length; + totalEdges += sheetEdges.length; + + console.log(`✅ Hierarchy demo: ${SUBSYSTEMS.length + 1} graphs, ${totalNodes} work items, ${totalEdges} edges`); + return { graphs: SUBSYSTEMS.length + 1, nodes: totalNodes, edges: totalEdges }; + } finally { + await session.close(); + } +} diff --git a/packages/web/src/contexts/GraphContext.tsx b/packages/web/src/contexts/GraphContext.tsx index 13c0e350..715181fa 100644 --- a/packages/web/src/contexts/GraphContext.tsx +++ b/packages/web/src/contexts/GraphContext.tsx @@ -154,10 +154,17 @@ export function GraphProvider({ children }: GraphProviderProps) { graphToSelect = parsedGraphs.find((g: any) => g.id === storedGraphId); } - // Auto-select graph: either previously selected or first available + // Auto-select graph: previously selected, else a sensible default. + // Pick by identity (Welcome tutorial first, then the System Overview), + // never by array position — merge order isn't stable and shared/system + // demo graphs must not hijack the fresh-load graph. if (parsedGraphs.length > 0) { if (!currentGraph || !parsedGraphs.find((g: any) => g.id === currentGraph.id)) { - const selectedGraph = graphToSelect || parsedGraphs[0]; + const preferredDefault = + parsedGraphs.find((g: any) => g.name === 'Welcome') || + parsedGraphs.find((g: any) => g.id === 'overview-graph-shared') || + parsedGraphs[0]; + const selectedGraph = graphToSelect || preferredDefault; setCurrentGraph(selectedGraph); // Save to localStorage for persistence localStorage.setItem('currentGraphId', selectedGraph.id); From 578b114c3eb77efee73b5e1b695f46ab962e77aa Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 10:10:25 -0700 Subject: [PATCH 2/2] Drill-in/ascend navigation for hierarchical graphs (PR3) Makes the "graphs of graphs" hierarchy navigable like Altium schematics. - GraphContext: descendInto(subgraphId) / ascendTo(graphId) / getBreadcrumb() (breadcrumb derived from the graph's path ancestors + itself). Added to the context type. - InteractiveGraphVisualization: a plain click on a sheet-symbol node (one with subgraphId) descends into its sub-graph, via a ref so the D3-bound handler isn't re-created. Grow/connect, drag, and edit/relationship icons are unaffected (handled earlier / stopPropagation). - Workspace: a breadcrumb bar (Up button + clickable ancestors) shown when inside a sub-graph. Verified by tests/diagnostics/hierarchy-navigation.spec.ts: System Overview (16 sheets) -> click descends into the 1000-node Compute Core sub-graph -> breadcrumb -> Up returns to the overview. Web typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 12 ++- packages/web/src/contexts/GraphContext.tsx | 21 ++++- packages/web/src/pages/Workspace.tsx | 41 ++++++++- packages/web/src/types/graph.ts | 4 + .../diagnostics/hierarchy-navigation.spec.ts | 91 +++++++++++++++++++ 5 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/diagnostics/hierarchy-navigation.spec.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 93bd79b6..23836d12 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -90,7 +90,11 @@ interface InteractiveGraphVisualizationProps { export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGraphVisualizationProps = {}) { const svgRef = useRef(null); const containerRef = useRef(null); - const { currentGraph, availableGraphs } = useGraph(); + const { currentGraph, availableGraphs, descendInto } = useGraph(); + // descendInto from context isn't memoized; hold the latest in a ref so the + // D3-bound node click handler can call it without re-binding every render. + const descendIntoRef = useRef(descendInto); + descendIntoRef.current = descendInto; const { currentUser } = useAuth(); const { showSuccess, showError } = useNotifications(); const navigate = useNavigate(); @@ -1036,6 +1040,12 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap setIsConnecting(false); setConnectionSource(null); + } else if (node.subgraphId) { + // Altium-style sheet symbol: a plain click descends into its sub-graph. + // (Grow/connect is handled above; drag is suppressed by mousedownNodeRef; + // edit/relationship icons stopPropagation, so this only fires on a plain + // click of a sheet node.) Called via ref to avoid re-binding the handler. + descendIntoRef.current(node.subgraphId); } else { // Handle node selection with 2-item ring buffer setSelectedNodes(prev => { diff --git a/packages/web/src/contexts/GraphContext.tsx b/packages/web/src/contexts/GraphContext.tsx index 715181fa..38541803 100644 --- a/packages/web/src/contexts/GraphContext.tsx +++ b/packages/web/src/contexts/GraphContext.tsx @@ -667,10 +667,26 @@ export function GraphProvider({ children }: GraphProviderProps) { const getGraphPath = (graphId: string): Graph[] => { const graph = availableGraphs.find(g => g.id === graphId); if (!graph?.path) return []; - + return graph.path.map(pathId => availableGraphs.find(g => g.id === pathId)).filter(Boolean) as Graph[]; }; + // Altium-style hierarchy navigation: descend into a node's sub-graph, ascend + // back up via the breadcrumb. Both reduce to selectGraph; the breadcrumb is + // derived from the target graph's `path` (ancestors) + itself. + const descendInto = async (subgraphId: string): Promise => { + await selectGraph(subgraphId); + }; + + const ascendTo = async (graphId: string): Promise => { + await selectGraph(graphId); + }; + + const getBreadcrumb = (): Graph[] => { + if (!currentGraph) return []; + return [...getGraphPath(currentGraph.id), currentGraph]; + }; + const getGraphDepth = (graphId: string): number => { const graph = availableGraphs.find(g => g.id === graphId); return graph?.depth || 0; @@ -725,6 +741,9 @@ export function GraphProvider({ children }: GraphProviderProps) { moveGraph, getGraphPath, getGraphChildren, + descendInto, + ascendTo, + getBreadcrumb, shareGraph, updatePermissions, joinSharedGraph, diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 31007e2c..0ff19cdd 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus } from 'lucide-react'; +import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight } from 'lucide-react'; import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; import { SafeGraphVisualization } from '../components/SafeGraphVisualization'; @@ -26,7 +26,8 @@ export function Workspace() { const [graphToEdit, setGraphToEdit] = useState(null); const [viewMode, setViewMode] = useState<'graph' | 'dashboard' | 'table' | 'cards' | 'kanban' | 'gantt' | 'calendar' | 'activity'>('graph'); const [showMiniMap, setShowMiniMap] = useState(true); - const { currentGraph, availableGraphs } = useGraph(); + const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph(); + const breadcrumb = getBreadcrumb(); const { currentTeam, currentUser } = useAuth(); const { health, loading: healthLoading, error: healthError } = useHealthStatus(); @@ -249,6 +250,42 @@ export function Workspace() { + {/* Hierarchy breadcrumb — shown when we've descended into a sub-graph */} + {breadcrumb.length > 1 && ( +
+ + | + {breadcrumb.map((g, i) => { + const isLast = i === breadcrumb.length - 1; + return ( + + {i > 0 && } + {isLast ? ( + {g.name} + ) : ( + + )} + + ); + })} +
+ )} + {/* Main Content */}
{!currentGraph ? ( diff --git a/packages/web/src/types/graph.ts b/packages/web/src/types/graph.ts index 0198e276..f4f3c82f 100644 --- a/packages/web/src/types/graph.ts +++ b/packages/web/src/types/graph.ts @@ -160,6 +160,10 @@ export interface GraphContextType { moveGraph: (graphId: string, newParentId?: string) => Promise; getGraphPath: (graphId: string) => Graph[]; getGraphChildren: (graphId: string) => Graph[]; + // Altium-style drill-in navigation + descendInto: (subgraphId: string) => Promise; + ascendTo: (graphId: string) => Promise; + getBreadcrumb: () => Graph[]; // Sharing and permissions shareGraph: (graphId: string, settings: Partial) => Promise; diff --git a/tests/diagnostics/hierarchy-navigation.spec.ts b/tests/diagnostics/hierarchy-navigation.spec.ts new file mode 100644 index 00000000..3fdfd8ad --- /dev/null +++ b/tests/diagnostics/hierarchy-navigation.spec.ts @@ -0,0 +1,91 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Altium-style "graphs of graphs" navigation: from the System Overview, a + * sheet-symbol node drills into its sub-graph; a breadcrumb ascends back. Needs + * the hierarchy demo seeded (`npm run create-hierarchy-demo`). Report-only + * screenshots + hard assertions on descend/ascend. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/hierarchy'); +const OVERVIEW_ID = 'overview-graph-shared'; + +async function openOverview(page: Page) { + await page.evaluate((gid) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', 'HIGH'); + }, OVERVIEW_ID); + await page.reload(); + await page.waitForTimeout(6000); +} + +async function readState(page: Page) { + return page.evaluate(() => { + const nodes = [...document.querySelectorAll('.graph-container svg .node')]; + const sheets = nodes.filter((n) => (n as any).__data__?.subgraphId); + return { + currentGraphId: localStorage.getItem('currentGraphId'), + nodeCount: nodes.length, + sheetCount: sheets.length, + firstSheetSubgraphId: (sheets[0] as any)?.__data__?.subgraphId ?? null, + }; + }); +} + +test.describe('hierarchy navigation @geometry', () => { + test.describe.configure({ timeout: 150_000 }); + + test('descend into a sheet symbol, ascend via breadcrumb', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openOverview(page); + + // Overview should render sheet-symbol nodes (each with a subgraphId). + const overview = await readState(page); + await page.screenshot({ path: path.join(OUT, '1-overview.png') }); + // eslint-disable-next-line no-console + console.log('[hierarchy] overview ' + JSON.stringify(overview)); + expect(overview.currentGraphId, 'on the overview graph').toBe(OVERVIEW_ID); + expect(overview.sheetCount, 'overview has sheet-symbol nodes').toBeGreaterThan(0); + + // Descend: click a sheet node's card. + const targetSubgraphId = overview.firstSheetSubgraphId as string; + await page.evaluate(() => { + const sheet = [...document.querySelectorAll('.graph-container svg .node')].find( + (n) => (n as any).__data__?.subgraphId + ) as SVGGElement | undefined; + const bg = (sheet?.querySelector('.node-bg') ?? sheet) as Element | undefined; + (bg as any)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await page.waitForTimeout(5000); + + const descended = await readState(page); + await page.screenshot({ path: path.join(OUT, '2-descended.png') }); + // eslint-disable-next-line no-console + console.log('[hierarchy] descended ' + JSON.stringify(descended)); + expect(descended.currentGraphId, 'descended into the sheet sub-graph').toBe(targetSubgraphId); + expect(descended.nodeCount, 'sub-graph rendered its own nodes').toBeGreaterThan(0); + + // Breadcrumb shows two crumbs (overview / sub-graph). + const crumbs = await page.locator('[data-testid="graph-breadcrumb"]').count(); + expect(crumbs, 'breadcrumb is shown after descending').toBe(1); + + // Ascend via the "Up" button. + await page.locator('[data-testid="graph-breadcrumb"] button', { hasText: 'Up' }).click(); + await page.waitForTimeout(4000); + const ascended = await readState(page); + await page.screenshot({ path: path.join(OUT, '3-ascended.png') }); + // eslint-disable-next-line no-console + console.log('[hierarchy] ascended ' + JSON.stringify(ascended)); + expect(ascended.currentGraphId, 'back on the overview after Up').toBe(OVERVIEW_ID); + + fs.writeFileSync( + path.join(OUT, 'report.json'), + JSON.stringify({ overview, descended, ascended }, null, 2) + ); + }); +});