diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 127afc6dc..b0e7ebf6f 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -121,7 +121,7 @@ import AtlasChrome, { CLOSE_COMMAND_SEARCH_EVENT, OPEN_COMMAND_SEARCH_EVENT } fr import { AtlasTreeIndexDetails } from './app/AtlasTree'; import ArchitecturePanel from './architecture/ArchitecturePanel'; import AdrWorkspace from './adr/AdrWorkspace'; -import SelectionContextPanel from './why/SelectionContext'; +import SelectionContextPanel, { type SelectionScope } from './why/SelectionContext'; import FileImpactSummary from './impact/FileImpactSummary'; import SourceEvidenceDrawer, { fileQualifiedName, type SourceEvidenceTarget } from './architecture/SourceEvidenceDrawer'; import { setUiLogProject } from './app/ui-log-install'; @@ -4515,6 +4515,20 @@ export default function App(): JSX.Element { ; + /* + * Galaxy reads its selection from the loaded scope (hand test K13). The + * repository snapshot is capped at 20,000 nodes by file path; a symbol in + * the scope but behind that cap is still a valid selection here. + */ + const galaxySelectionPanel = (scope: SelectionScope | undefined) => { + const raw = galaxySelectionProject === project ? galaxySelection : undefined; + const inScope = raw && scope?.graph.nodes.some(node => raw.qualified_name + ? node.qualified_name === raw.qualified_name && node.file_path === raw.file_path : node.id === raw.id); + const selected = inScope ? raw : activeGalaxySelection; + if (!selected) return selectionUnavailable ?

{selectionUnavailableText}

: undefined; + return ; + }; const followInspectorTarget = (target: SymbolRef): void => { setPinnedCode(undefined); followTarget(target); @@ -4558,7 +4572,7 @@ export default function App(): JSX.Element { project={project} onClearSelection={clearGraphSelection} onSelectionEvidence={workspace === 'galaxy' ? onSelectionEvidence : undefined} - selectionPanel={workspace === 'galaxy' ? mapSelectionPanel : undefined} + selectionPanel={workspace === 'galaxy' ? galaxySelectionPanel : undefined} visible={workspace === 'galaxy' || (workspace === 'explore' && galaxyOn)} workspaceExpanded={workspace === 'galaxy'} focusQualifiedName={workspace === 'galaxy' ? activeGalaxySelection?.qualified_name : markedGraphRange ? readerFocusSymbol?.qualifiedName : undefined} diff --git a/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx b/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx index e3cc942d0..6812221c1 100644 --- a/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx +++ b/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx @@ -179,3 +179,16 @@ it('focuses close, lets Escape close the modeless drawer, then restores its orig await ui.unmount(); expect(document.activeElement).toBe(opener); opener.remove(); }); + +it('says "end of file" when the last page reaches the end of the Module, so a disabled Next is not a mystery (hand test K13)', async () => { + // django/contrib/postgres/aggregates/general.py: 65 lines plus the empty EOF line, read from line 42. + const last = { start_line: 42, end_line: 66, source: Array.from({ length: 25 }, (_, i) => `line ${42 + i}`).join('\n'), + source_truncated: false, source_clipped: false, next_start_line: undefined, original_end_line: undefined }; + const ui = await mount(vi.fn(async () => page(42, last))); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + const pager = ui.container.querySelector('nav[aria-label="Source pages"]')!; + expect(pager.textContent).toContain('Lines 42 to 66 · end of file'); + const next = [...pager.querySelectorAll('button')].find(button => button.textContent === 'Next lines')!; + expect(next.disabled).toBe(true); + expect(next.title).toBe('Line 66 is the end of this file.'); +}); diff --git a/graph-ui/src/architecture/SourceEvidenceDrawer.tsx b/graph-ui/src/architecture/SourceEvidenceDrawer.tsx index bd9466077..dcf74682e 100644 --- a/graph-ui/src/architecture/SourceEvidenceDrawer.tsx +++ b/graph-ui/src/architecture/SourceEvidenceDrawer.tsx @@ -7,6 +7,9 @@ import './source-evidence.css'; export interface SourceEvidenceTarget { filePath: string; line?: number; name?: string; kind?: 'callsite' | 'declaration' | 'file' } const PAGE_LINES = 160; +/* Hand test K13: a disabled "Next lines" at the last page read like a fault. The page says where the file ends. */ +const END_OF_FILE = ' · end of file'; +const endOfFileTitle = (line: number) => `Line ${line} is the end of this file.`; const validLine = (value: number | undefined): value is number => value !== undefined && Number.isSafeInteger(value) && value > 0; export function fileQualifiedName(graph: GraphData | undefined, path: string): string | undefined { @@ -100,6 +103,7 @@ export default function SourceEvidenceDrawer({ project, target, graph, client, o const more = source?.source_truncated === true || source?.source_clipped === true; const next = source?.next_start_line; const canNext = Boolean(more && validLine(next) && validLine(lastLine) && next === lastLine + 1 && rangeMatches); + const atEnd = Boolean(source && !more && rangeMatches && validLine(lastLine)); const locationVisible = requestedLine !== undefined && validLine(firstLine) && validLine(lastLine) && requestedLine >= firstLine && requestedLine <= lastLine && rangeMatches; useEffect(() => { @@ -145,8 +149,8 @@ export default function SourceEvidenceDrawer({ project, target, graph, client, o {lines.length === 0 &&

No source text was returned.

} {more && !canNext &&

The source is truncated, but no valid next page was supplied. Refresh or open Explore to inspect the available source.

} + {rangeMatches ? `Lines ${firstLine} to ${lastLine}` : 'Unverified line range'}{source.original_end_line ? ` · reported end ${source.original_end_line}` : ''}{atEnd ? END_OF_FILE : ''} + } ; } diff --git a/graph-ui/src/galaxy/GalaxyPanel.selection.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.selection.test.tsx new file mode 100644 index 000000000..a6c3c9c14 --- /dev/null +++ b/graph-ui/src/galaxy/GalaxyPanel.selection.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +/* + * Handtest K13: "Selection details" bekommt in Galaxy den geladenen + * Ausschnitt, nicht nur den gekappten Repository-Schnappschuss. + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import GalaxyPanel from './GalaxyPanel'; +import type { SelectionScope } from '../why/SelectionContext'; +import { scopeFetch, scopeNode } from './test-scope-fetch'; + +vi.mock('./GraphScene', async importOriginal => ({ + ...await importOriginal(), + GraphScene: () => , +})); + +let host: HTMLDivElement, root: Root; +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; }); + +const settle = (check: () => void) => vi.waitFor(async () => { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + check(); +}); + +it('K13: hands the loaded scope to the selection panel, with its completeness and trace', async () => { + const nodes = [1, 2, 3, 4].map(id => scopeNode(id)); + const edges = [{ id: 1, source: 2, target: 1, type: 'CALLS' }, { id: 2, source: 2, target: 1, type: 'TESTS' }, + { id: 3, source: 3, target: 1, type: 'DEFINES' }, { id: 4, source: 1, target: 4, type: 'INHERITS' }]; + const seen: (SelectionScope | undefined)[] = []; + const panel = (scope: SelectionScope | undefined) => { seen.push(scope); return

selection evidence

; }; + await act(async () => root.render()); + await settle(() => expect(globalThis.__atlasGalaxy?.nodes).toBe(4)); + expect(seen.at(-1)).toBeUndefined(); + await act(async () => { globalThis.__atlasGalaxy!.clickNode('sample.n1'); }); + await settle(() => expect(seen.at(-1)?.complete).toBe(true)); + const scope = seen.at(-1)!; + expect(scope.direction).toBe('both'); + expect(scope.depth).toBe(1); + expect(scope.graph.edges.map(edge => edge.type).sort()).toEqual(['CALLS', 'DEFINES', 'INHERITS', 'TESTS']); + expect(host.querySelector('.atlas-galaxy-selection-details')?.textContent).toContain('selection evidence'); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index eafee75af..226d8d87e 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -190,6 +190,7 @@ import { readerFocusFrame, readerGraphFocus, type SourceFocusRange } from './rea import { projectReaderHierarchy } from './reader-hierarchy'; import Hint from '../ui/tooltip/Hint'; import type { GraphData, GraphNode } from './types'; +import type { SelectionScope } from '../why/SelectionContext'; import { AgentLayer, agentAngles, @@ -741,7 +742,12 @@ export interface GalaxyPanelProps { fullscreenToggle?: number; /** Der Speicher fuer die Lage des Instruments. Ersetzbar fuer Tests. */ agentStore?: Storage | undefined; - selectionPanel?: import('react').ReactNode; + /** + * "Selection details". As a function it receives the loaded scope (hand + * test K13): the relationships of the selection then come from what this + * panel loaded, not from the capped repository snapshot. + */ + selectionPanel?: ReactNode | ((scope: SelectionScope | undefined) => ReactNode); } /** Wie lange die Zeitangaben im Instrument stehen, bis sie neu gerechnet werden. */ @@ -1987,6 +1993,13 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { } : null; + /* Der geladene Ausschnitt fuer "Selection details" (K13). */ + const selectionScope = useMemo(() => props.workspaceExpanded && scope.scope && scope.result ? { + graph: scope.result.data, complete: scope.complete, direction: scope.direction, depth: scope.result.depth, + ...(traceTypes ? { edgeTypes: traceTypes } : {}), ...(scope.result.partial ? { partial: scope.result.partial } : {}), + } : undefined, [props.workspaceExpanded, scope.scope, scope.result, scope.complete, scope.direction, traceTypes]); + const selectionContent = typeof props.selectionPanel === 'function' ? props.selectionPanel(selectionScope) : props.selectionPanel; + /* Was die Leiste sonst noch braucht (K3): Quelle der Wurzel, Gruppen und was ausserhalb der Limits liegt. */ const openRoot = props.workspaceExpanded && scopedRoot?.file_path ? () => props.onOpenNode(layoutNodeForSelection(layout, scopedRoot) ?? scopedRoot) : undefined; @@ -2885,9 +2898,9 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { )} - {props.selectionPanel &&
+ {selectionContent &&
Selection details -
{props.selectionPanel}
+
{selectionContent}
} {note.length > 0 && !scope.loading && (

diff --git a/graph-ui/src/why/SelectionContext.test.tsx b/graph-ui/src/why/SelectionContext.test.tsx new file mode 100644 index 000000000..4a9ba8cea --- /dev/null +++ b/graph-ui/src/why/SelectionContext.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +/* + * Handtest K13: in Galaxy nennt "Selection details" die Beziehungen aus dem + * geladenen Ausschnitt und sagt, wenn er unvollstaendig ist. Bis dahin las das + * Panel den gekappten Repository-Schnappschuss und zeigte bei JSONBAgg nur + * "Outgoing relationships · 2". + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import SelectionContextPanel from './SelectionContext'; +import type { GraphData, GraphNode } from '../galaxy/types'; + +let host: HTMLDivElement, root: Root; +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); }); + +const node = (id: number, extra: Partial = {}): GraphNode => ({ id, name: `s${id}`, qualified_name: `p.s${id}`, file_path: `src/${id}.py`, + label: 'Function', x: 0, y: 0, z: 0, size: 1, color: '#fff', ...extra }); +const selected = node(1, { name: 'JSONBAgg', qualified_name: 'p.JSONBAgg', file_path: 'general.py', label: 'Class' }); +const tests = Array.from({ length: 11 }, (_, at) => node(10 + at)); +const scopeGraph: GraphData = { total_nodes: 15, nodes: [selected, ...tests, node(30, { label: 'Module', file_path: 'general.py' }), node(40), node(41)], edges: [ + ...tests.flatMap(test => [{ source: test.id, target: 1, type: 'CALLS', line: 5 }, { source: test.id, target: 1, type: 'TESTS' }]), + { source: 30, target: 1, type: 'DEFINES' }, { source: 1, target: 40, type: 'INHERITS' }, { source: 1, target: 41, type: 'INHERITS' }] }; +// The capped repository snapshot knows the class and its bases, but not the tests behind the 20,000-node cap. +const snapshot: GraphData = { total_nodes: 3, nodes: [selected, node(40), node(41)], edges: scopeGraph.edges.filter(edge => edge.type === 'INHERITS') }; +const summaries = () => [...host.querySelectorAll('summary')].map(entry => entry.textContent); + +it('K13: lists incoming and outgoing relationships of the loaded scope by type', async () => { + await act(async () => root.render()); + expect(summaries()).toContain('Incoming relationships · 23 (CALLS 11 · TESTS 11 · DEFINES 1)'); + expect(summaries()).toContain('Outgoing relationships · 2 (INHERITS 2)'); + expect(host.textContent).toContain('From the loaded Galaxy scope'); +}); + +it('K13: says when the scope is partial, still loading or traced in one direction or for some types only', async () => { + const render = (scope: Parameters[0]['scope']) => act(async () => root.render( + )); + await render({ graph: scopeGraph, complete: false, direction: 'both' }); + expect(host.textContent).toContain('The scope is still loading'); + await render({ graph: scopeGraph, complete: true, direction: 'both', partial: { layer: 3, nodes: 9139, edges: 43010, limit: 'nodes' } }); + expect(host.textContent).toContain('Layer 3 of this scope stopped at the render limit'); + await render({ graph: scopeGraph, complete: true, direction: 'outbound' }); + expect(host.textContent).toContain('Incoming relationships are not traced in this scope'); + await render({ graph: scopeGraph, complete: true, direction: 'both', edgeTypes: ['CALLS'] }); + expect(host.textContent).toContain('Only these relationship types are traced: CALLS'); +}); + +it('K13: without a scope that contains the selection, it keeps the repository snapshot and says so', async () => { + await act(async () => root.render()); + expect(summaries()).toContain('Outgoing relationships · 2 (INHERITS 2)'); + expect(host.textContent).toContain('From the repository map snapshot'); +}); diff --git a/graph-ui/src/why/SelectionContext.tsx b/graph-ui/src/why/SelectionContext.tsx index 9b3b02888..0463767b6 100644 --- a/graph-ui/src/why/SelectionContext.tsx +++ b/graph-ui/src/why/SelectionContext.tsx @@ -2,14 +2,36 @@ import { useMemo } from 'react'; import type { GraphData, GraphNode } from '../galaxy/types'; import type { AgentsState } from '../agents/agent-store'; import { selectionContext } from './selection-context'; +import { selectionScopeText } from './selection-strings'; import { RelationshipEvidence } from '../architecture/RepositoryMap'; +import type { MapEvidence } from '../architecture/repository-map'; +import type { ScopePartial, TraceDirection } from '../galaxy/graph-scope'; import './selection-context.css'; -export default function SelectionContextPanel({ graph, selected, path, agents, onNavigate, onImpact }: { +/** The scope that Galaxy has loaded around the selection (hand test K13). */ +export interface SelectionScope { + graph: GraphData; + complete: boolean; + direction: TraceDirection; + edgeTypes?: readonly string[]; + partial?: ScopePartial; + depth?: number; +} + +export default function SelectionContextPanel({ graph, selected, path, agents, onNavigate, onImpact, scope }: { graph?: GraphData; selected?: GraphNode; path: string; agents?: AgentsState; onNavigate: (path: string, line?: number, name?: string) => void; onImpact?: () => void; + /** + * In Galaxy: the loaded scope. Its relationships replace the repository + * snapshot, which is capped (20,000 nodes by file path) and missed, for + * JSONBAgg, all 23 incoming relationships from the tests behind the cap. + */ + scope?: SelectionScope; }) { - const context = useMemo(() => selectionContext(graph, selected, path, agents), [graph, selected, path, agents]); + const inScope = Boolean(scope && selected && scope.graph.nodes.some(node => selected.qualified_name + ? node.qualified_name === selected.qualified_name && node.file_path === path : node.id === selected.id)); + const source = inScope ? scope!.graph : graph; + const context = useMemo(() => selectionContext(source, selected, path, agents, { allRelations: inScope }), [source, selected, path, agents, inScope]); const callers = context.incoming.filter(edge => edge.type === 'CALLS'); const callerFiles = new Set(callers.map(edge => edge.source.file_path).filter(Boolean)); return

@@ -17,7 +39,12 @@ export default function SelectionContextPanel({ graph, selected, path, agents, o {!path ?

Select a file or symbol to inspect its indexed connections.

: <>

{selected?.name ?? path}

Graph evidence

- {!graph ?

The repository graph is not available. Relevance cannot be established yet.

: <> + {!source ?

The repository graph is not available. Relevance cannot be established yet.

: <> + {scope &&

{inScope ? selectionScopeText.fromScope(scope.depth) : selectionScopeText.fromSnapshot}

} + {inScope && !scope!.complete &&

{selectionScopeText.loading}

} + {inScope && scope!.partial &&

{selectionScopeText.partial(scope!.partial.layer, scope!.partial.limit)}

} + {inScope && scope!.direction !== 'both' &&

{selectionScopeText.notTraced(scope!.direction)}

} + {inScope && scope!.edgeTypes &&

{selectionScopeText.typesOnly(scope!.edgeTypes)}

} {context.selected?.status === 'entry' &&

The index classifies this declaration as an entry candidate. Open its source and follow outgoing calls to verify its role.

} {callers.length > 0 &&

{callers.length} indexed caller edges from {callerFiles.size} files reach this selection. Check these callers when changing its contract.

} {context.entryPath.length > 0 &&
Reachable from {context.entryPath[0].source.name} · inspect {context.entryPath.length} calls @@ -27,8 +54,11 @@ export default function SelectionContextPanel({ graph, selected, path, agents, o {!context.incoming.length && !context.outgoing.length &&

{/\.(md|rst|txt)$/i.test(path) ? 'This documentation file has no code dependencies in the loaded graph. Read its source for project context.' : 'No supported dependency edge for this selection is present in the loaded graph. Inspect the source and index coverage before assessing its impact.'}

} - {context.incoming.length > 0 &&
Incoming relationships · {context.incoming.length}
} - {context.outgoing.length > 0 &&
Outgoing relationships · {context.outgoing.length}
} + {/* RelationshipEvidence prints the type as text, so a scope type outside the map relations reads the same. */} + {context.incoming.length > 0 &&
{selectionScopeText.incoming(context.incoming.length, context.incomingByType)} +
} + {context.outgoing.length > 0 &&
{selectionScopeText.outgoing(context.outgoing.length, context.outgoingByType)} +
} {context.pathSearchLimited && !context.entryPath.length &&

No entry path found within 4 calls / 500 visited symbols. Longer paths were not evaluated.

} } {agents &&
Observed agent activity · {context.activity.length} retained events for this file diff --git a/graph-ui/src/why/selection-context.test.ts b/graph-ui/src/why/selection-context.test.ts index 887c80ec5..16c33460b 100644 --- a/graph-ui/src/why/selection-context.test.ts +++ b/graph-ui/src/why/selection-context.test.ts @@ -40,3 +40,28 @@ describe('selection relevance', () => { expect(selectionContext(graph, undefined, 'not-indexed.ts').incoming).toEqual([]); }); }); + +describe('hand test K13: relationships from the loaded Galaxy scope', () => { + // JSONBAgg in django-demo: 11 tests CALL and TEST it, its module DEFINES it, and it INHERITS twice. + const root = { ...node(1), name: 'JSONBAgg', qualified_name: 'p.JSONBAgg', file_path: 'general.py' }; + const tests = Array.from({ length: 11 }, (_, at) => ({ ...node(10 + at), file_path: 'tests/test_aggregates.py' })); + const module = { ...node(30), label: 'Module', file_path: 'general.py' }; + const bases = [node(40), node(41)]; + const scope: GraphData = { total_nodes: 15, nodes: [root, ...tests, module, ...bases], edges: [ + ...tests.flatMap(test => [{ source: test.id, target: 1, type: 'CALLS', line: 5 }, { source: test.id, target: 1, type: 'TESTS' }]), + { source: 30, target: 1, type: 'DEFINES' }, { source: 1, target: 40, type: 'INHERITS' }, { source: 1, target: 41, type: 'INHERITS' }] }; + + it('keeps every relationship type of the scope, not only the repository-map relations', () => { + const result = selectionContext(scope, root, 'general.py', undefined, { allRelations: true }); + expect(result.incoming).toHaveLength(23); + expect(result.outgoing).toHaveLength(2); + expect(result.incomingByType).toEqual([['CALLS', 11], ['TESTS', 11], ['DEFINES', 1]]); + expect(result.outgoingByType).toEqual([['INHERITS', 2]]); + }); + + it('still limits the repository map snapshot to its map relations', () => { + const result = selectionContext(scope, root, 'general.py'); + expect(result.incoming).toHaveLength(11); + expect(result.incomingByType).toEqual([['CALLS', 11]]); + }); +}); diff --git a/graph-ui/src/why/selection-context.ts b/graph-ui/src/why/selection-context.ts index 609ab7d45..fbd08caaa 100644 --- a/graph-ui/src/why/selection-context.ts +++ b/graph-ui/src/why/selection-context.ts @@ -3,10 +3,16 @@ import type { AgentEvent } from '../agents/agent-event'; import type { AgentsState } from '../agents/agent-store'; import { MAP_RELATIONS, type MapEvidence, type MapRelation } from '../architecture/repository-map'; +/** A relationship of the selection. In a Galaxy scope every indexed type counts (K13), not only the map relations. */ +export type SelectionEvidence = Omit & { type: MapRelation | string }; + export interface SelectionContext { selected?: GraphNode; - incoming: MapEvidence[]; - outgoing: MapEvidence[]; + incoming: SelectionEvidence[]; + outgoing: SelectionEvidence[]; + /** The same relationships counted by type, most frequent first. */ + incomingByType: [string, number][]; + outgoingByType: [string, number][]; entryPath: MapEvidence[]; pathSearchLimited: boolean; activity: { agent: string; event: AgentEvent }[]; @@ -14,8 +20,8 @@ export interface SelectionContext { /** All sentences are derived from edges or observed events. No intent inference. */ export function selectionContext(graph: GraphData | undefined, selected: GraphNode | undefined, - path: string, agents?: AgentsState): SelectionContext { - const result: SelectionContext = { selected, incoming: [], outgoing: [], entryPath: [], pathSearchLimited: false, activity: [] }; + path: string, agents?: AgentsState, options: { allRelations?: boolean } = {}): SelectionContext { + const result: SelectionContext = { selected, incoming: [], outgoing: [], incomingByType: [], outgoingByType: [], entryPath: [], pathSearchLimited: false, activity: [] }; if (!path) return result; for (const actor of agents?.actors ?? []) { if (actor.you) continue; @@ -39,20 +45,22 @@ export function selectionContext(graph: GraphData | undefined, selected: GraphNo const incoming = new Map(); const seen = new Set(); for (const edge of graph.edges) { - if (!MAP_RELATIONS.includes(edge.type as MapRelation)) continue; + if (!options.allRelations && !MAP_RELATIONS.includes(edge.type as MapRelation)) continue; const source = nodes.get(edge.source); const target = nodes.get(edge.target); if (!source || !target) continue; const key = `${source.id}:${edge.type}:${target.id}`; if (seen.has(key)) continue; seen.add(key); - const row = { source, target, type: edge.type as MapRelation, id: edge.id, line: edge.line }; + const row = { source, target, type: edge.type, id: edge.id, line: edge.line }; if (selected ? target.id === selected.id : target.file_path === path && source.file_path !== path) result.incoming.push(row); if (selected ? source.id === selected.id : source.file_path === path && target.file_path !== path) result.outgoing.push(row); if (edge.type === 'CALLS') { const rows = incoming.get(target.id) ?? []; - rows.push(row); incoming.set(target.id, rows); + rows.push({ ...row, type: 'CALLS' }); incoming.set(target.id, rows); } } + result.incomingByType = countByType(result.incoming); + result.outgoingByType = countByType(result.outgoing); // A shortest static caller path within a stated bound. No import edge may // appear in a call path, and no topological ordering is called execution. if (selected && selected.status !== 'entry') { @@ -73,3 +81,9 @@ export function selectionContext(graph: GraphData | undefined, selected: GraphNo } return result; } + +function countByType(edges: readonly SelectionEvidence[]): [string, number][] { + const counts = new Map(); + for (const edge of edges) counts.set(edge.type, (counts.get(edge.type) ?? 0) + 1); + return [...counts].sort(([a, left], [b, right]) => right - left || a.localeCompare(b)); +} diff --git a/graph-ui/src/why/selection-strings.ts b/graph-ui/src/why/selection-strings.ts new file mode 100644 index 000000000..7e4b784d0 --- /dev/null +++ b/graph-ui/src/why/selection-strings.ts @@ -0,0 +1,21 @@ +/** + * The words of the selection context where it reads a Galaxy scope (hand test + * K13). Every sentence names where the relationships come from and what the + * scope leaves out; nothing here claims completeness the scope does not have. + */ +const byType = (types: readonly [string, number][]) => types.map(([type, count]) => `${type} ${count.toLocaleString()}`).join(' · '); + +export const selectionScopeText = { + incoming: (count: number, types: readonly [string, number][]) => + `Incoming relationships · ${count.toLocaleString()}${types.length ? ` (${byType(types)})` : ''}`, + outgoing: (count: number, types: readonly [string, number][]) => + `Outgoing relationships · ${count.toLocaleString()}${types.length ? ` (${byType(types)})` : ''}`, + fromScope: (depth?: number) => `From the loaded Galaxy scope${depth ? ` (${depth === 1 ? '1 layer' : `${depth} layers`})` : ''}, every indexed relationship type.`, + fromSnapshot: 'From the repository map snapshot. It is capped and can miss relationships that the Galaxy scope shows; select the symbol in Galaxy to load them.', + loading: 'The scope is still loading: relationships may be missing until it is complete.', + partial: (layer: number, limit: 'nodes' | 'edges') => `Layer ${layer} of this scope stopped at the render limit (${limit}). ` + + (layer > 1 ? 'The direct relationships of the root are complete; deeper ones may be missing.' : 'The direct relationships of the root are incomplete.'), + notTraced: (direction: 'inbound' | 'outbound') => + `${direction === 'outbound' ? 'Incoming' : 'Outgoing'} relationships are not traced in this scope (${direction === 'outbound' ? 'outgoing' : 'incoming'} only).`, + typesOnly: (types: readonly string[]) => `Only these relationship types are traced: ${types.length ? types.join(', ') : 'none'}.`, +}; diff --git a/graph-ui/tools/handtest-fixes-galaxy.mjs b/graph-ui/tools/handtest-fixes-galaxy.mjs index 89d6cd164..3c814e44c 100644 --- a/graph-ui/tools/handtest-fixes-galaxy.mjs +++ b/graph-ui/tools/handtest-fixes-galaxy.mjs @@ -424,8 +424,65 @@ async function checkK3(page) { } /* ------------------------------------------------------------------ */ +/* K13: Selection details aus dem geladenen Ausschnitt */ -const CHECKS = { K9: checkK9, K2: checkK2, K8: checkK8, K3: checkK3 }; +const typeList = (counts) => Object.entries(counts).sort(([a, x], [b, y]) => y - x || a.localeCompare(b)).map(([type, n]) => `${type} ${n}`).join(' · '); + +async function checkK13(page) { + await section(page, 'K13', 'Selection details lists the relationships of the loaded scope; "Next lines" at the end of a file says so'); + const truth = await relationCounts(ROOT); + const total = (counts) => Object.values(counts).reduce((sum, n) => sum + n, 0); + await open(page); + await select(page, ROOT); + await settled(page); + const details = page.locator('.atlas-galaxy-selection-details').first(); + await details.locator('> summary').click(); + await wait(700); + const summaries = await details.locator('summary').allInnerTexts(); + const source = await details.locator('.selection-context-source').innerText().catch(() => ''); + await shot(page, 'K13', 'details-root', `Selection details for ${ROOT}: ${summaries.filter((text) => /relationships/.test(text)).join(' | ')}`); + await details.screenshot({ path: join(OUT, 'K13', 'details-root-panel.png') }).catch(() => {}); + const wantIn = `Incoming relationships · ${total(truth.incoming)} (${typeList(truth.incoming)})`; + const wantOut = `Outgoing relationships · ${total(truth.outgoing)} (${typeList(truth.outgoing)})`; + await check('K13', `Selection details of ${ROOT} match the index: incoming and outgoing by type, from the loaded scope`, + summaries.includes(wantIn) && summaries.includes(wantOut) && /loaded Galaxy scope/.test(source), { truth, wantIn, wantOut, summaries, source }); + + // Eine Auswahl hinter dem Deckel des Schnappschusses (tests/ liegt hinter 20.000 Knoten). + const child = await qualifiedName('test_jsonb_agg_jsonfield_order_by'); + const childTruth = await relationCounts('test_jsonb_agg_jsonfield_order_by'); + await page.evaluate((qn) => globalThis.__atlasGalaxy?.clickNode(qn), child); + await settled(page); + await wait(800); + const childText = await details.innerText().catch(() => ''); + const childSummaries = await details.locator('summary').allInnerTexts(); + await shot(page, 'K13', 'details-test-behind-cap', `Selection details for a test behind the snapshot cap: ${childSummaries.filter((text) => /relationships/.test(text)).join(' | ')}`); + const childOut = `Outgoing relationships · ${total(childTruth.outgoing)} (${typeList(childTruth.outgoing)})`; + await check('K13', 'A selected test outside the capped snapshot still shows its relationships from the scope', childSummaries.includes(childOut) + && !/absent from this bounded index snapshot/.test(childText), { childTruth, childOut, childSummaries }); + + // "Read source evidence": general.py endet in Zeile 65 (dazu die leere Zeile 66). + await page.getByRole('button', { name: 'Back', exact: true }).click().catch(() => {}); + await settled(page); + await wait(800); + await details.getByRole('button', { name: 'Read source evidence' }).click(); + await page.waitForSelector('nav[aria-label="Source pages"]', { timeout: 20000 }).catch(() => {}); + await wait(500); + const pager = page.locator('nav[aria-label="Source pages"]').first(); + const pagerText = await pager.innerText().catch(() => ''); + const next = pager.getByRole('button', { name: 'Next lines' }); + const nextState = { disabled: await next.isDisabled().catch(() => null), title: await next.getAttribute('title').catch(() => null) }; + const lines = await page.locator('.source-evidence-lines code > span').count(); + await page.locator('.source-evidence-lines').evaluate((el) => { el.scrollTop = el.scrollHeight; }).catch(() => {}); + await wait(300); + await shot(page, 'K13', 'source-evidence-end', `Source evidence for general.py scrolled to the end: "${pagerText.replace(/\s+/g, ' ')}"`); + await check('K13', '"Next lines" is disabled at the real end of general.py and says so', /end of file/.test(pagerText) && nextState.disabled === true + && /end of this file/.test(nextState.title ?? ''), { pagerText, nextState, renderedLines: lines }); + await page.keyboard.press('Escape'); +} + +/* ------------------------------------------------------------------ */ + +const CHECKS = { K9: checkK9, K2: checkK2, K8: checkK8, K3: checkK3, K13: checkK13 }; await mkdir(OUT, { recursive: true }); const context = await chromium.launchPersistentContext(PROFILE, {