@principal-ai/principal-view-react 0.16.63 → 0.16.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,11 +32,12 @@ import {
32
32
  applyNodeChanges,
33
33
  } from '@xyflow/react';
34
34
  import { useTheme } from '@principal-ade/industry-theme';
35
- import { Map as MapIcon, X } from 'lucide-react';
35
+ import { ChevronDown, ChevronUp, X } from 'lucide-react';
36
36
  import { IndustryMarkdownSlide } from 'themed-markdown';
37
37
  import {
38
38
  buildSubsystemGraph,
39
39
  MECHANISM_COLOR,
40
+ MECHANISM_DESCRIPTIONS,
40
41
  subsystemGraphLayoutKey,
41
42
  type SubsystemComponentEdge,
42
43
  type SubsystemComponent,
@@ -50,7 +51,6 @@ import { GraphLayoutCover } from './GraphLayoutCover';
50
51
  import { ComponentDeclaration } from './ComponentDeclaration';
51
52
  import type { ComponentVerificationState } from './ComponentDeclaration';
52
53
  import { FileDrawer } from './FileDrawer';
53
- import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
54
54
  import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
55
55
 
56
56
  /** Cap screen-space edge labels to this fraction of the edge's on-screen length. */
@@ -91,8 +91,6 @@ export interface SubsystemComponentGraphProps {
91
91
  maxNodeWidth?: number;
92
92
  /** Show edge labels (mechanism names) on the graph. @default true */
93
93
  showEdgeLabels?: boolean;
94
- /** Show the mechanism-legend button overlay on the canvas. @default true */
95
- showLegend?: boolean;
96
94
  /** Subsystem title displayed in the sidebar. */
97
95
  title?: string;
98
96
  /**
@@ -123,8 +121,8 @@ export interface SubsystemComponentGraphProps {
123
121
  renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
124
122
  /**
125
123
  * Host-injected multi-snippet viewer for a focused throughline. When set,
126
- * clicking a flow step (or the whole flow) opens the bottom drawer with
127
- * this content and updates it as the focused step changes.
124
+ * clicking a flow step opens the bottom drawer with this content and
125
+ * updates it as the focused step changes.
128
126
  */
129
127
  renderThroughlineViewer?: (ctx: ThroughlineViewerContext) => ReactNode;
130
128
  /**
@@ -185,7 +183,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
185
183
  measured: { w: number; h: number } | null;
186
184
  }
187
185
 
188
- function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showLegend, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
186
+ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
189
187
  const { theme } = useTheme();
190
188
  const { fitView } = useReactFlow();
191
189
  const viewport = useViewport();
@@ -197,8 +195,6 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
197
195
  const [selected, setSelected] = useState<SubsystemComponent | null>(null);
198
196
  /** Bottom drawer: single file or throughline multi-snippet mode. */
199
197
  const [drawerTarget, setDrawerTarget] = useState<DrawerTarget | null>(null);
200
- // Edge-legend modal visibility (opened from the canvas's top-left button).
201
- const [legendOpen, setLegendOpen] = useState(false);
202
198
  // Component the pointer is over (null on leave) → transient tree highlight.
203
199
  const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
204
200
  // Throughline focus — selected flow (or step) is full strength; other
@@ -213,6 +209,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
213
209
  // Throughline flows the user has expanded (via the title row). Closed by
214
210
  // default so a graph with several flows doesn't dump every step list at once.
215
211
  const [expandedThroughlines, setExpandedThroughlines] = useState<Set<string>>(new Set());
212
+ // Sidebar description visibility. Hidden by default so the files/flows
213
+ // panel gets the vertical room; the title-row toggle reveals it.
214
+ const [descriptionVisible, setDescriptionVisible] = useState(false);
215
+ const [descToggleHover, setDescToggleHover] = useState(false);
216
+ // `true` only when a description exists AND the user opened it.
217
+ const showDesc = !!description && descriptionVisible;
216
218
  // Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
217
219
  // closure over the effect deps) can toggle without a stale value.
218
220
  const selectedRef = useRef<SubsystemComponent | null>(null);
@@ -688,7 +690,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
688
690
 
689
691
  // Focus an entire flow: hide everything but the flow's nodes and edges, and
690
692
  // frame the flow on the canvas. Selection state is cleared — the graph now
691
- // reads as the narrative. Opens the throughline drawer when a viewer is set.
693
+ // reads as the narrative. No drawer: the code view only opens on a step
694
+ // click. A stale throughline drawer (from a previously focused flow's step)
695
+ // closes; an explicitly opened file drawer stays.
692
696
  const focusThroughlineEdges = useCallback(
693
697
  (tl: SubsystemThroughline) => {
694
698
  setSelected(null);
@@ -696,18 +700,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
696
700
  setFocusedStepIndex(null);
697
701
  setFocusedThroughlineId(tl.id);
698
702
  fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
699
- if (renderThroughlineViewer) {
700
- setDrawerTarget({ kind: 'throughline', throughlineId: tl.id, stepIndex: null });
701
- } else if (tl.steps[0]) {
702
- // Fallback: single-file snippet of the first step.
703
- setDrawerTarget({
704
- kind: 'file',
705
- file: tl.steps[0].file,
706
- startLine: tl.steps[0].line,
707
- });
708
- }
703
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
709
704
  },
710
- [fitFocusBounds, renderThroughlineViewer],
705
+ [fitFocusBounds],
711
706
  );
712
707
 
713
708
  const clearThroughlineFocus = useCallback(() => {
@@ -744,6 +739,50 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
744
739
  [fitFocusBounds, renderThroughlineViewer],
745
740
  );
746
741
 
742
+ // Arrow keys step through the focused throughline once a step is active
743
+ // (sidebar click or drawer open). Ignores typing targets and chords.
744
+ useEffect(() => {
745
+ if (focusedThroughlineId == null || focusedStepIndex == null || !throughlines) {
746
+ return;
747
+ }
748
+ const tl = throughlines.find((t) => t.id === focusedThroughlineId);
749
+ if (!tl || tl.steps.length === 0) return;
750
+
751
+ const onKeyDown = (e: KeyboardEvent) => {
752
+ const el = e.target as HTMLElement | null;
753
+ if (
754
+ el &&
755
+ (el.tagName === 'INPUT' ||
756
+ el.tagName === 'TEXTAREA' ||
757
+ el.tagName === 'SELECT' ||
758
+ el.isContentEditable)
759
+ ) {
760
+ return;
761
+ }
762
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
763
+
764
+ let next: number | null = null;
765
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
766
+ next = Math.min(tl.steps.length - 1, focusedStepIndex + 1);
767
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
768
+ next = Math.max(0, focusedStepIndex - 1);
769
+ } else {
770
+ return;
771
+ }
772
+ if (next === focusedStepIndex) return;
773
+ e.preventDefault();
774
+ focusThroughlineStep(tl, next);
775
+ };
776
+
777
+ window.addEventListener('keydown', onKeyDown);
778
+ return () => window.removeEventListener('keydown', onKeyDown);
779
+ }, [
780
+ focusedThroughlineId,
781
+ focusedStepIndex,
782
+ throughlines,
783
+ focusThroughlineStep,
784
+ ]);
785
+
747
786
  const toggleThroughlineCollapsed = useCallback((tlId: string) => {
748
787
  setExpandedThroughlines((prev) => {
749
788
  const next = new Set(prev);
@@ -813,10 +852,6 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
813
852
  });
814
853
  }, [dispEdges, selectedFlowStepNos]);
815
854
 
816
- const usedMechanisms = useMemo(() => {
817
- return new Set(edgeLabels.map((l) => l.mechanism));
818
- }, [edgeLabels]);
819
-
820
855
  // Unique source files across components → sidebar file trees.
821
856
  const treeFilePaths = treeFiles;
822
857
 
@@ -860,7 +895,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
860
895
 
861
896
  return (
862
897
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
863
- {/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
898
+ {/* Sidebar: scrollable title/description on top, files or flows pinned below.
899
+ The description hides by default so files/flows get the room; the
900
+ title-row toggle reveals it, and the lower panel yields back to 50%. */}
864
901
  {!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
865
902
  <div
866
903
  style={{
@@ -875,7 +912,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
875
912
  >
876
913
  <div
877
914
  style={{
878
- flex: 1,
915
+ flex: showDesc ? 1 : '0 0 auto',
879
916
  minHeight: 0,
880
917
  overflowY: 'auto',
881
918
  padding: '16px',
@@ -885,20 +922,54 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
885
922
  }}
886
923
  >
887
924
  {sidebarExtra}
888
- {title && (
889
- <h2
890
- style={{
891
- margin: 0,
892
- fontSize: theme.fontSizes[2],
893
- fontWeight: 600,
894
- color: theme.colors.text,
895
- fontFamily: theme.fonts.heading,
896
- }}
897
- >
898
- {title}
899
- </h2>
925
+ {(title || description) && (
926
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
927
+ {title && (
928
+ <h2
929
+ style={{
930
+ margin: 0,
931
+ flex: 1,
932
+ minWidth: 0,
933
+ fontSize: theme.fontSizes[2],
934
+ fontWeight: 600,
935
+ color: theme.colors.text,
936
+ fontFamily: theme.fonts.monospace,
937
+ }}
938
+ >
939
+ {title}
940
+ </h2>
941
+ )}
942
+ {description && (
943
+ <button
944
+ type="button"
945
+ aria-expanded={descriptionVisible}
946
+ aria-label={descriptionVisible ? 'Hide description' : 'Show description'}
947
+ title={descriptionVisible ? 'Hide description' : 'Show description'}
948
+ onMouseEnter={() => setDescToggleHover(true)}
949
+ onMouseLeave={() => setDescToggleHover(false)}
950
+ onClick={() => setDescriptionVisible((v) => !v)}
951
+ style={{
952
+ display: 'inline-flex',
953
+ alignItems: 'center',
954
+ justifyContent: 'center',
955
+ flexShrink: 0,
956
+ width: 22,
957
+ height: 22,
958
+ padding: 0,
959
+ border: 'none',
960
+ borderRadius: 4,
961
+ background: descToggleHover ? theme.colors.border : 'transparent',
962
+ color: descToggleHover ? theme.colors.text : (theme.colors.textMuted ?? theme.colors.textSecondary),
963
+ cursor: 'pointer',
964
+ transition: 'background 120ms ease, color 120ms ease',
965
+ }}
966
+ >
967
+ {descriptionVisible ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
968
+ </button>
969
+ )}
970
+ </div>
900
971
  )}
901
- {description && (
972
+ {description && descriptionVisible && (
902
973
  <div style={{ fontSize: theme.fontSizes[0], lineHeight: 1.5 }}>
903
974
  <IndustryMarkdownSlide
904
975
  content={description}
@@ -918,7 +989,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
918
989
  {(treeFilePaths.length > 0 || hasThroughlines) && (
919
990
  <div
920
991
  style={{
921
- height: '50%',
992
+ ...(showDesc ? { height: '50%' as const } : { flex: 1, minHeight: 0 }),
922
993
  minHeight: 160,
923
994
  flexShrink: 0,
924
995
  borderTop: `1px solid ${theme.colors.border}`,
@@ -939,7 +1010,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
939
1010
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
940
1011
  }}
941
1012
  >
942
- {(['files', 'flows'] as const).map((view) => (
1013
+ {(['flows', 'files'] as const).map((view) => (
943
1014
  <button
944
1015
  key={view}
945
1016
  type="button"
@@ -1142,37 +1213,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1142
1213
  <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
1143
1214
  <Controls showZoom showFitView showInteractive />
1144
1215
  </ReactFlow>
1145
- {/* Legend button — top-left overlay on the canvas; opens the modal. */}
1146
- {showLegend !== false && usedMechanisms.size > 0 && (
1147
- <button
1148
- type="button"
1149
- onClick={() => setLegendOpen(true)}
1150
- style={{
1151
- position: 'absolute',
1152
- top: 10,
1153
- left: 10,
1154
- zIndex: 7,
1155
- display: 'inline-flex',
1156
- alignItems: 'center',
1157
- gap: 6,
1158
- padding: '4px 10px',
1159
- fontSize: theme.fontSizes[0],
1160
- fontFamily: theme.fonts.body,
1161
- color: theme.colors.text,
1162
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
1163
- border: `1px solid ${theme.colors.border}`,
1164
- borderRadius: 6,
1165
- cursor: 'pointer',
1166
- boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
1167
- }}
1168
- >
1169
- <MapIcon size={13} />
1170
- Legend
1171
- </button>
1172
- )}
1173
1216
  {/* Graph title — non-interactive chip centered at the top of the canvas
1174
- (clear of the top-left legend button and top-right declaration
1175
- card). Lets graph-only embeds name the subsystem they show. */}
1217
+ (clear of the top-right declaration card). Lets graph-only embeds
1218
+ name the subsystem they show. */}
1176
1219
  {graphTitle && (
1177
1220
  <div
1178
1221
  style={{
@@ -1188,7 +1231,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1188
1231
  padding: '6px 18px',
1189
1232
  fontSize: theme.fontSizes[3],
1190
1233
  fontWeight: 600,
1191
- fontFamily: theme.fonts.heading,
1234
+ fontFamily: theme.fonts.monospace,
1192
1235
  color: theme.colors.text,
1193
1236
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
1194
1237
  border: `1px solid ${theme.colors.border}`,
@@ -1201,8 +1244,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1201
1244
  </div>
1202
1245
  )}
1203
1246
  {/* Selected-component declaration — floating card over the canvas
1204
- (top-right, clear of the top-left legend button). The graph never
1205
- moves for it. */}
1247
+ (top-right). The graph never moves for it. */}
1206
1248
  {selected && (
1207
1249
  <div
1208
1250
  style={{
@@ -1229,11 +1271,6 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1229
1271
  </div>
1230
1272
  )}
1231
1273
  </div>
1232
- <EdgeLegendModal
1233
- open={legendOpen}
1234
- mechanisms={usedMechanisms}
1235
- onClose={() => setLegendOpen(false)}
1236
- />
1237
1274
  <FileDrawer title={drawerTitle} onClose={() => setDrawerTarget(null)}>
1238
1275
  {drawerTarget?.kind === 'throughline' &&
1239
1276
  focusedThroughline &&
@@ -1370,6 +1407,15 @@ function ThroughlineFlow({
1370
1407
  const [headerHover, setHeaderHover] = useState(false);
1371
1408
  const [closeHover, setCloseHover] = useState(false);
1372
1409
  const [hoveredStep, setHoveredStep] = useState<number | null>(null);
1410
+ const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
1411
+
1412
+ // Keep DOM focus on the active step so the browser focus ring (and
1413
+ // subsequent arrow keys) follow arrow navigation, not the originally
1414
+ // clicked button.
1415
+ useEffect(() => {
1416
+ if (active?.stepIndex == null) return;
1417
+ stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
1418
+ }, [active?.stepIndex]);
1373
1419
 
1374
1420
  return (
1375
1421
  <div style={{ margin: '4px 0', borderRadius: 8 }}>
@@ -1467,6 +1513,9 @@ function ThroughlineFlow({
1467
1513
  return (
1468
1514
  <button
1469
1515
  key={`${step.edgeId}-${i}`}
1516
+ ref={(el) => {
1517
+ stepButtonRefs.current[i] = el;
1518
+ }}
1470
1519
  type="button"
1471
1520
  onMouseEnter={() => setHoveredStep(i)}
1472
1521
  onMouseLeave={() => setHoveredStep(null)}
@@ -1480,6 +1529,7 @@ function ThroughlineFlow({
1480
1529
  textAlign: 'left',
1481
1530
  borderRadius: 6,
1482
1531
  border: 'none',
1532
+ outline: 'none',
1483
1533
  background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
1484
1534
  cursor: 'pointer',
1485
1535
  transition: 'background 120ms ease',
@@ -86,12 +86,12 @@ describe('subsystem graph model', () => {
86
86
  });
87
87
 
88
88
  test('deriveNameFromSymbol is consistent per kind', () => {
89
- // brace-bodied constructs wear {}; module uses the symbol as-is.
90
- expect(deriveNameFromSymbol('SessionReader', 'class')).toBe('SessionReader {}');
89
+ // brace-bodied constructs wear {}; classes render bare; module uses the symbol as-is.
90
+ expect(deriveNameFromSymbol('SessionReader', 'class')).toBe('SessionReader');
91
91
  expect(deriveNameFromSymbol('SessionRecord', 'type_alias')).toBe('SessionRecord {}');
92
92
  expect(deriveNameFromSymbol('transcript', 'module')).toBe('transcript');
93
- // falls back to existing name when no symbol (still brace-decorated).
94
- expect(deriveNameFromSymbol(undefined, 'class', 'SessionReader')).toBe('SessionReader {}');
93
+ // falls back to existing name when no symbol (class stays bare).
94
+ expect(deriveNameFromSymbol(undefined, 'class', 'SessionReader')).toBe('SessionReader');
95
95
  expect(deriveNameFromSymbol('', 'external', 'trail-viewer-host')).toBe('trail-viewer-host');
96
96
  });
97
97
 
@@ -104,7 +104,7 @@ describe('subsystem graph model', () => {
104
104
  // data-shaped constructs stay bare
105
105
  expect(deriveNameFromSymbol('ROOT', 'store')).toBe('ROOT');
106
106
  // brace bodies don't double up
107
- expect(deriveNameFromSymbol('Foo {}', 'class')).toBe('Foo {}');
107
+ expect(deriveNameFromSymbol('Foo {}', 'interface')).toBe('Foo {}');
108
108
  });
109
109
 
110
110
  test('deriveNameFromSymbol uses JSX decoration for component stereotype', () => {
@@ -269,8 +269,10 @@ export function deriveNameFromSymbol(
269
269
 
270
270
  // Decorations = what the drill-down shows. Framework stereotypes can override
271
271
  // the language decoration (a React component wears `<>` instead of `()`).
272
- // Executable constructs wear `()`; brace-bodied constructs wear ` {}`.
273
- // Everything else (store, module, external) renders bare.
272
+ // Executable constructs wear `()`; brace-bodied constructs (interface,
273
+ // type_alias, enum) wear ` {}`. Classes render bare — the construct badge
274
+ // already says "class".
275
+ // Everything else (class, store, module, external) renders bare.
274
276
  if (stereotype === 'component' && !name.startsWith('<')) {
275
277
  return `<${name}>`;
276
278
  }
@@ -278,8 +280,7 @@ export function deriveNameFromSymbol(
278
280
  name = `${name}()`;
279
281
  }
280
282
  if (
281
- (construct === 'class' ||
282
- construct === 'interface' ||
283
+ (construct === 'interface' ||
283
284
  construct === 'type_alias' ||
284
285
  construct === 'enum') &&
285
286
  !name.endsWith('{}')
@@ -447,6 +448,30 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
447
448
  'registers-into': 'dashed',
448
449
  };
449
450
 
451
+ /** Mechanism → [description, verifiable-with-graphify]. Drives the "not
452
+ * directly verifiable" styling of edge labels. */
453
+ export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
454
+ ['imports', 'import statement (code-level dependency)', true],
455
+ ['imports_from', 'imported by (reverse dependency)', true],
456
+ ['re_exports', 're-exports symbols from', true],
457
+ ['defines', 'defines / declares symbol', true],
458
+ ['calls', 'function/method call (call graph edge)', true],
459
+ ['extends', 'class inheritance', true],
460
+ ['inherits', 'class inheritance', true],
461
+ ['implements', 'implements interface / protocol', true],
462
+ ['mixes_in', 'applies mixin', true],
463
+ ['uses', 'general dependency (import, call, or reference)', false],
464
+ ['method', 'structural: has method / member', true],
465
+ ['references', 'type / symbol reference (not a call)', true],
466
+ ['contains', 'structural: contains / encapsulates', true],
467
+ ['feeds', 'data flow: output feeds into input', false],
468
+ ['produces', 'data flow: produces / outputs', false],
469
+ ['writes', 'state access: mutates retained state', true],
470
+ ['reads', 'state access: reads retained state', true],
471
+ ['watches', 'observes retained state without owning it', false],
472
+ ['registers-into', 'registration pattern', false],
473
+ ];
474
+
450
475
  /** Package color palette (derived deterministically from the package name). */
451
476
  export function packageColor(name: string): string {
452
477
  const palette = [
@@ -1,118 +0,0 @@
1
- import React from 'react';
2
- import { render, screen } from '@testing-library/react';
3
- import { GraphRenderer } from './GraphRenderer';
4
- import type { GraphConfiguration, NodeState, EdgeState } from '@principal-ai/principal-view-core';
5
-
6
- describe('GraphRenderer', () => {
7
- const testConfig: GraphConfiguration = {
8
- metadata: {
9
- name: 'Test Graph',
10
- version: '1.0.0',
11
- },
12
- nodeTypes: {
13
- user: {
14
- shape: 'circle',
15
- color: '#4CAF50',
16
- dataSchema: {
17
- userId: { type: 'string', required: true },
18
- },
19
- },
20
- },
21
- edgeTypes: {
22
- connection: {
23
- style: 'solid',
24
- directed: true,
25
- },
26
- },
27
- allowedConnections: [{ from: 'user', to: 'user', via: 'connection' }],
28
- };
29
-
30
- const testNodes: NodeState[] = [
31
- {
32
- id: 'user-1',
33
- type: 'user',
34
- data: { userId: 'alice' },
35
- createdAt: Date.now(),
36
- updatedAt: Date.now(),
37
- },
38
- {
39
- id: 'user-2',
40
- type: 'user',
41
- data: { userId: 'bob' },
42
- createdAt: Date.now(),
43
- updatedAt: Date.now(),
44
- },
45
- ];
46
-
47
- const testEdges: EdgeState[] = [
48
- {
49
- id: 'conn-1',
50
- type: 'connection',
51
- from: 'user-1',
52
- to: 'user-2',
53
- createdAt: Date.now(),
54
- updatedAt: Date.now(),
55
- },
56
- ];
57
-
58
- it('should render without crashing', () => {
59
- render(<GraphRenderer configuration={testConfig} nodes={testNodes} edges={testEdges} />);
60
-
61
- expect(screen.getByText(/Graph Renderer/i)).toBeDefined();
62
- });
63
-
64
- it('should display configuration name', () => {
65
- render(<GraphRenderer configuration={testConfig} nodes={testNodes} edges={testEdges} />);
66
-
67
- expect(screen.getByText(/Test Graph/i)).toBeDefined();
68
- });
69
-
70
- it('should display node count', () => {
71
- render(<GraphRenderer configuration={testConfig} nodes={testNodes} edges={testEdges} />);
72
-
73
- expect(screen.getByText(/Nodes: 2/i)).toBeDefined();
74
- });
75
-
76
- it('should display edge count', () => {
77
- render(<GraphRenderer configuration={testConfig} nodes={testNodes} edges={testEdges} />);
78
-
79
- expect(screen.getByText(/Edges: 1/i)).toBeDefined();
80
- });
81
-
82
- it('should render with empty nodes and edges', () => {
83
- render(<GraphRenderer configuration={testConfig} nodes={[]} edges={[]} />);
84
-
85
- expect(screen.getByText(/Nodes: 0/i)).toBeDefined();
86
- expect(screen.getByText(/Edges: 0/i)).toBeDefined();
87
- });
88
-
89
- it('should apply custom className', () => {
90
- const { container } = render(
91
- <GraphRenderer
92
- configuration={testConfig}
93
- nodes={testNodes}
94
- edges={testEdges}
95
- className="custom-class"
96
- />
97
- );
98
-
99
- const element = container.querySelector('.custom-class');
100
- expect(element).toBeDefined();
101
- });
102
-
103
- it('should apply custom width and height', () => {
104
- const { container } = render(
105
- <GraphRenderer
106
- configuration={testConfig}
107
- nodes={testNodes}
108
- edges={testEdges}
109
- width="500px"
110
- height="400px"
111
- />
112
- );
113
-
114
- const element = container.querySelector('div');
115
- expect(element?.style.width).toBe('500px');
116
- expect(element?.style.height).toBe('400px');
117
- });
118
- });