diff --git a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx index be0518fc0..39966510b 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx @@ -5,20 +5,27 @@ * die mitzaehlt, wie oft sie auf- und abgebaut wird; die Beziehungen kommen * ueber dieselbe RPC-Strecke wie im Betrieb. */ -import { act, useEffect } from 'react'; +import { act, useEffect, useLayoutEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import GalaxyPanel from './GalaxyPanel'; import type { GraphData, GraphEdge, GraphNode } from './types'; import type { ScenePath } from './PathLayer'; +import { GALAXY_LEGEND_KEY } from './galaxy-legend'; const scene = vi.hoisted(() => ({ mounts: 0, unmounts: 0, renders: 0, roots: undefined as ReadonlySet | undefined, - highlighted: null as Set | null, path: undefined as ScenePath | undefined })); + highlighted: null as Set | null, path: undefined as ScenePath | undefined, + /** Every committed picture: what the scene draws and what the edge type filter lists beside it. */ + frames: [] as { nodes: number; types: string[]; traceRows: number }[] })); vi.mock('./GraphScene', async importOriginal => ({ ...await importOriginal(), GraphScene: ({ data, rootIds, highlightedIds, path }: { data: GraphData; rootIds?: ReadonlySet; highlightedIds: Set | null; path?: ScenePath }) => { scene.renders += 1; scene.roots = rootIds; scene.highlighted = highlightedIds; scene.path = path; useEffect(() => { scene.mounts += 1; return () => { scene.unmounts += 1; }; }, []); + useLayoutEffect(() => { + scene.frames.push({ nodes: data.nodes.length, types: [...new Set(data.edges.map(edge => edge.type))].sort(), + traceRows: document.querySelectorAll('.atlas-trace-edge-row').length }); + }); return {data.nodes.length}; }, })); @@ -61,7 +68,7 @@ function graphFetch() { let host: HTMLDivElement, root: Root; beforeEach(() => { (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; - scene.mounts = 0; scene.unmounts = 0; scene.renders = 0; + scene.mounts = 0; scene.unmounts = 0; scene.renders = 0; scene.frames = []; host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; }); @@ -245,3 +252,23 @@ it('keeps the picked name when a removed layer takes the path target out of the expect(panel()?.textContent).toContain('No path to n4 over the loaded relationships'); expect(panel()?.textContent).not.toContain('#4'); }); + +it('shows the whole graph with its hidden edge kinds until the first scoped picture, and lists only current kinds', async () => { + await act(async () => root.render( (key === GALAXY_LEGEND_KEY ? 'open' : null), setItem: vi.fn() } as unknown as Storage} />)); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => host.querySelector('[data-testid="atlas-galaxy-legend-swatch"][data-type="CALLS"]')!.click()); + expect(seam().hiddenKinds).toEqual(['CALLS']); + expect(seam().drawnEdges).toBe(1); + + scene.frames = []; + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + const placeholder = scene.frames.filter(frame => frame.nodes === 5); + expect(placeholder.length).toBeGreaterThan(0); + // The whole graph that stays on screen keeps the reader's choice ... + expect(placeholder.every(frame => frame.types.join() === 'IMPORTS')).toBe(true); + // ... and the trace filter beside it never offers the whole graph's kinds as the scope's. + expect(placeholder.every(frame => frame.traceRows === 0)).toBe(true); + expect(scene.frames.at(-1)).toMatchObject({ nodes: 4, types: ['CALLS'], traceRows: 1 }); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index dc98d4584..67276925c 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -1139,19 +1139,27 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const hiddenHere = kinds.filter((kind) => hiddenKinds.has(kind.type)).length; const kindNote = edgeKindNote(kinds.length, hiddenHere); - // Die Szene bekommt in der Galaxie das stehende Bild (siehe `sceneData`). + /* + * Die Szene bekommt in der Galaxie das stehende Bild (siehe `sceneData`). + * Steht dort noch der ganze Graph, gelten auch seine ausgeblendeten + * Kantenarten weiter; erst ein Scope-Bild filtert ueber `traceTypes`. + * `shown` ist nur das AKTUELLE Bild: Kantenfilter, Zaehler und Auskunft an + * den Chat rechnen nie mit dem Platzhalter. + */ const scenePicture = mode === 'hierarchy' ? picture : sceneData; - const shown = useMemo( + const sceneShown = useMemo( () => { if (!scenePicture) return undefined; - const filtered = props.workspaceExpanded && scope.scope ? scenePicture : withoutEdgeKinds(scenePicture, hiddenKinds); + const traced = props.workspaceExpanded && scope.scope && (mode === 'hierarchy' || sceneScoped); + const filtered = traced ? scenePicture : withoutEdgeKinds(scenePicture, hiddenKinds); const requiredNames = new Set(data?.nodes.filter(node => scope.result?.roots.has(node.id)).map(node => node.qualified_name)); return props.workspaceExpanded ? limitGraphRender(filtered, nodeBudget, edgeBudget, mode === 'hierarchy' ? new Set(filtered.nodes.filter(node => requiredNames.has(node.qualified_name)).map(node => node.id)) : scope.result?.roots) : filtered; }, - [scenePicture, hiddenKinds, props.workspaceExpanded, nodeBudget, edgeBudget, scope.result?.roots, scope.scope, mode, data], + [scenePicture, hiddenKinds, props.workspaceExpanded, nodeBudget, edgeBudget, scope.result?.roots, scope.scope, mode, data, sceneScoped], ); + const shown = mode === 'hierarchy' || sceneData === data ? sceneShown : undefined; const traceKinds = useMemo(() => edgeKinds(shown), [shown]); const agentEvidence = useMemo(() => { @@ -2068,7 +2076,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { pulsedQn: pulsedNode?.qualified_name ?? pulsedNode?.name ?? '', edgeKinds: kinds.map((kind) => ({ ...kind, hidden: hiddenKinds.has(kind.type) })), hiddenKinds: [...hiddenKinds].sort(), - drawnEdges: shown?.edges.length ?? 0, + drawnEdges: sceneShown?.edges.length ?? 0, edgeNote, hierarchy: projection === undefined @@ -2522,14 +2530,14 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { )} - {shown !== undefined && everVisible.current && ( + {sceneShown !== undefined && everVisible.current && ( 1 ? null : highlighted)} path={trailIds && trailView ? { steps: trailView.steps, active: trailActive, labels: trailView.labels } : undefined} @@ -2568,7 +2576,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { */ showLabels={ mode === 'hierarchy' - ? shown.nodes.length <= HIERARCHY_LABEL_BUDGET + ? sceneShown.nodes.length <= HIERARCHY_LABEL_BUDGET : trailIds !== undefined || (highlighted !== null && highlighted.size > 0) } /*