Forge
csharpdeeb25a2
1using CascadeIDE.Cockpit.Graph;
2using CascadeIDE.Cockpit.Graph.Layout;
3using CascadeIDE.Models;
4using CascadeIDE.Services;
5using CascadeIDE.Features.WorkspaceNavigation.Application;
6using CascadeIDE.Services.SkiaInstruments;
7using CascadeIDE.ViewModels;
8using Xunit;
9
10namespace CascadeIDE.Tests;
11
12public sealed class CodeNavigationMapCompositorTests
13{
14 [Fact]
15 public void Compose_ControlFlow_ExpandsPreferredHeightForLongFlow()
16 {
17 var compositor = new CodeNavigationMapCompositor();
18 var doc = new GraphDocument
19 {
20 AnchorPath = @"D:\w\A.cs",
21 Nodes =
22 [
23 new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" },
24 new GraphNode { Id = "n1", Path = @"D:\w\A.cs", Kind = "call_step", Label = "S1" },
25 new GraphNode { Id = "n2", Path = @"D:\w\A.cs", Kind = "call_step", Label = "S2" },
26 new GraphNode { Id = "n3", Path = @"D:\w\A.cs", Kind = "call_step", Label = "S3" },
27 new GraphNode { Id = "n4", Path = @"D:\w\A.cs", Kind = "call_step", Label = "S4" },
28 new GraphNode { Id = "n5", Path = @"D:\w\A.cs", Kind = "call_step", Label = "S5" }
29 ],
30 Edges =
31 [
32 new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" },
33 new GraphEdge { FromId = "n1", ToId = "n2", Kind = "Call" },
34 new GraphEdge { FromId = "n2", ToId = "n3", Kind = "Call" },
35 new GraphEdge { FromId = "n3", ToId = "n4", Kind = "Call" },
36 new GraphEdge { FromId = "n4", ToId = "n5", Kind = "Call" }
37 ]
38 };
39
40 var result = compositor.Compose(doc, CodeNavigationMapLevelKind.ControlFlow, 280, 120);
41 Assert.True(result.PreferredHeight >= CodeNavigationMapCompositor.DefaultHeightControlFlow);
42 Assert.True(result.PreferredHeight <= CodeNavigationMapCompositor.MaxHeightControlFlow);
43 var sceneVm = result.ToSceneVm(280, result.PreferredHeight);
44 Assert.Equal(doc.Nodes.Count, sceneVm.Nodes.Count);
45 Assert.Equal(CodeNavigationMapGraphPresentationKind.CodeControlFlow, sceneVm.Presentation);
46 }
47
48 [Fact]
49 public void Compose_ControlFlow_LongFlow_CapsPreferredHeight()
50 {
51 var compositor = new CodeNavigationMapCompositor();
52 var nodes = new List<GraphNode>
53 {
54 new() { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" }
55 };
56 var edges = new List<GraphEdge>();
57 for (var i = 1; i <= 25; i++)
58 {
59 nodes.Add(new GraphNode
60 {
61 Id = $"n{i}",
62 Path = @"D:\w\A.cs",
63 Kind = "call_step",
64 Label = $"S{i}"
65 });
66 edges.Add(new GraphEdge { FromId = $"n{i - 1}", ToId = $"n{i}", Kind = "Call" });
67 }
68
69 var doc = new GraphDocument
70 {
71 AnchorPath = @"D:\w\A.cs",
72 Nodes = nodes,
73 Edges = edges
74 };
75
76 var result = compositor.Compose(doc, CodeNavigationMapLevelKind.ControlFlow, 280, 120);
77 Assert.Equal(CodeNavigationMapCompositor.MaxHeightControlFlow, result.PreferredHeight);
78 }
79
80 [Fact]
81 public void Compose_ControlFlow_TallViewport_KeepsIntrinsicPreferredHeightAndReadableStep()
82 {
83 var compositor = new CodeNavigationMapCompositor();
84 var doc = new GraphDocument
85 {
86 AnchorPath = @"D:\w\A.cs",
87 Nodes =
88 [
89 new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" },
90 new GraphNode { Id = "n1", Path = @"D:\w\A.cs", Kind = "call_step", Label = "S1" }
91 ],
92 Edges = [new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" }]
93 };
94
95 var result = compositor.Compose(
96 new CodeNavigationMapCompositionIntent(
97 doc,
98 CodeNavigationMapLevelKind.ControlFlow,
99 CodeNavigationMapDetailLevel.Normal,
100 CodeNavigationMapRelatedGraphLayoutKind.Radial,
101 CodeNavigationMapControlFlowMainAxisKind.Vertical),
102 new SkiaInstrumentViewport(280, 420));
103 Assert.True(result.PreferredHeight < 350);
104 var sceneVm = result.ToSceneVm(280, result.PreferredHeight);
105 var n0 = sceneVm.Nodes.First(n => n.Id == "n0");
106 var n1 = sceneVm.Nodes.First(n => n.Id == "n1");
107 var dy = Math.Abs(n1.Center.Y - n0.Center.Y);
108 // Высокий viewport разрешает увеличенный вертикальный шаг (см. ControlFlowMaxReadableVerticalStepCap).
109 Assert.InRange(dy, 18, 96);
110 }
111
112 [Fact]
113 public void ControlFlowLayout_WideGraphArea_CentersReadableBand()
114 {
115 var engine = new ControlFlowGraphLayoutEngine();
116 var doc = new GraphDocument
117 {
118 AnchorPath = @"D:\w\A.cs",
119 Nodes =
120 [
121 new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" },
122 new GraphNode { Id = "n1", Path = @"D:\w\A.cs", Kind = "condition_step", Label = "L", LegendIndex = 1, LegendText = "a" },
123 new GraphNode { Id = "n2", Path = @"D:\w\A.cs", Kind = "condition_step", Label = "R", LegendIndex = 2, LegendText = "b" }
124 ],
125 Edges =
126 [
127 new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" },
128 new GraphEdge { FromId = "n0", ToId = "n2", Kind = "Call" }
129 ]
130 };
131
132 var scene = engine.Layout(doc, 920, 280);
133 var n1 = scene.Nodes.First(n => n.Id == "n1");
134 var n2 = scene.Nodes.First(n => n.Id == "n2");
135 Assert.True(Math.Abs(n2.Center.X - n1.Center.X) < 400);
136 }
137
138 [Fact]
139 public void Compose_File_SmallGraph_KeepsCompactHeight()
140 {
141 var compositor = new CodeNavigationMapCompositor();
142 var doc = new GraphDocument
143 {
144 AnchorPath = @"D:\w\A.cs",
145 Nodes =
146 [
147 new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" },
148 new GraphNode { Id = "n1", Path = @"D:\w\B.cs", Kind = "project_peer", Label = "B.cs" }
149 ],
150 Edges = [new GraphEdge { FromId = "n0", ToId = "n1", Kind = "related_to" }]
151 };
152
153 var result = compositor.Compose(doc, CodeNavigationMapLevelKind.File, 280, 120);
154 Assert.Equal(120, result.PreferredHeight, 0.1);
155 var sceneVm = result.ToSceneVm(280, result.PreferredHeight);
156 Assert.Equal(2, sceneVm.Nodes.Count);
157 Assert.Equal(CodeNavigationMapGraphPresentationKind.WorkspaceRelatedFiles, sceneVm.Presentation);
158 }
159
160 [Fact]
161 public void Compose_File_DenseGraph_ExpandsPreferredHeight()
162 {
163 var compositor = new CodeNavigationMapCompositor();
164 var nodes = new List<GraphNode>
165 {
166 new() { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" }
167 };
168 for (var i = 1; i <= 14; i++)
169 nodes.Add(new GraphNode { Id = $"n{i}", Path = $@"D:\w\B{i}.cs", Kind = "project_peer", Label = $"B{i}.cs" });
170
171 var doc = new GraphDocument
172 {
173 AnchorPath = @"D:\w\A.cs",
174 Nodes = nodes,
175 Edges = nodes.Skip(1).Select(n => new GraphEdge { FromId = "n0", ToId = n.Id, Kind = "related_to" }).ToList()
176 };
177
178 var result = compositor.Compose(
179 new CodeNavigationMapCompositionIntent(doc, CodeNavigationMapLevelKind.File, CodeNavigationMapDetailLevel.Inspect),
180 new SkiaInstrumentViewport(400, 120));
181 Assert.True(result.PreferredHeight > 120);
182 Assert.True(result.PreferredHeight <= GraphFileLayoutMetrics.MaxHeightFile);
183 }
184
185 [Fact]
186 public void Compose_GenericPipelineEntryPoint_ProducesScene()
187 {
188 var compositor = new CodeNavigationMapCompositor();
189 var doc = new GraphDocument
190 {
191 AnchorPath = @"D:\w\A.cs",
192 Nodes = [new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" }],
193 Edges = []
194 };
195
196 var result = compositor.Compose(
197 new CodeNavigationMapCompositionIntent(doc, CodeNavigationMapLevelKind.File),
198 new SkiaInstrumentViewport(280, 120));
199 Assert.Single(result.ToSceneVm(280, result.PreferredHeight).Nodes);
200 }
201
202 [Fact]
203 public void IntentStage_DetectsLoopEdgeMetrics()
204 {
205 var stage = new CodeNavigationMapIntentStage();
206 var doc = new GraphDocument
207 {
208 AnchorPath = @"D:\w\A.cs",
209 Nodes =
210 [
211 new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" },
212 new GraphNode { Id = "n1", Path = @"D:\w\A.cs", Kind = "call_step", Label = "B" }
213 ],
214 Edges = [new GraphEdge { FromId = "n0", ToId = "n1", Kind = "LoopCall" }]
215 };
216
217 var state = stage.Resolve(new CodeNavigationMapPipelineContext(
218 doc,
219 CodeNavigationMapLevelKind.ControlFlow,
220 new SkiaInstrumentViewport(280, 120)));
221
222 Assert.Equal(1, state.LoopEdgeCount);
223 Assert.Equal(CodeNavigationMapLevelKind.ControlFlow, state.MapLevel);
224 }
225
226 [Fact]
227 public void Compose_ControlFlow_Glance_FiltersMultibranchOnlyBranch()
228 {
229 var compositor = new CodeNavigationMapCompositor();
230 var doc = new GraphDocument
231 {
232 AnchorPath = @"D:\w\A.cs",
233 Nodes =
234 [
235 new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A" },
236 new GraphNode { Id = "n1", Path = @"D:\w\A.cs", Kind = "call_step", Label = "S1" },
237 new GraphNode { Id = "n2", Path = @"D:\w\A.cs", Kind = "call_step", Label = "S2" }
238 ],
239 Edges =
240 [
241 new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" },
242 new GraphEdge { FromId = "n0", ToId = "n2", Kind = "multibranch" }
243 ]
244 };
245
246 var glance = compositor.Compose(doc, CodeNavigationMapLevelKind.ControlFlow, 280, 120, CodeNavigationMapDetailLevel.Glance);
247 var inspect = compositor.Compose(doc, CodeNavigationMapLevelKind.ControlFlow, 280, 120, CodeNavigationMapDetailLevel.Inspect);
248 Assert.Equal(2, glance.ToSceneVm(280, glance.PreferredHeight).Nodes.Count);
249 Assert.Equal(3, inspect.ToSceneVm(280, inspect.PreferredHeight).Nodes.Count);
250 }
251
252 [Fact]
253 public void Compose_ControlFlow_Normal_CollapsesLargeMultiBranchFanOut()
254 {
255 var compositor = new CodeNavigationMapCompositor();
256 var nodes = new List<GraphNode>
257 {
258 new() { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A" },
259 new() { Id = "n1", Path = @"D:\w\A.cs", Kind = "call_step", Label = "B1" },
260 new() { Id = "n2", Path = @"D:\w\A.cs", Kind = "call_step", Label = "B2" },
261 new() { Id = "n3", Path = @"D:\w\A.cs", Kind = "call_step", Label = "B3" },
262 new() { Id = "n4", Path = @"D:\w\A.cs", Kind = "call_step", Label = "B4" },
263 new() { Id = "n5", Path = @"D:\w\A.cs", Kind = "call_step", Label = "B5" },
264 };
265 var edges = new List<GraphEdge>
266 {
267 new() { FromId = "n0", ToId = "n1", Kind = "MultiBranch" },
268 new() { FromId = "n0", ToId = "n2", Kind = "MultiBranch" },
269 new() { FromId = "n0", ToId = "n3", Kind = "MultiBranch" },
270 new() { FromId = "n0", ToId = "n4", Kind = "MultiBranch" },
271 new() { FromId = "n0", ToId = "n5", Kind = "MultiBranch" },
272 };
273 var doc = new GraphDocument { AnchorPath = @"D:\w\A.cs", Nodes = nodes, Edges = edges };
274
275 var normal = compositor.Compose(doc, CodeNavigationMapLevelKind.ControlFlow, 320, 160, CodeNavigationMapDetailLevel.Normal);
276 var vm = normal.ToSceneVm(320, normal.PreferredHeight);
277
278 Assert.Equal(5, vm.Nodes.Count);
279 Assert.Contains(vm.Nodes, n => n.Label == "+2");
280 Assert.Equal(4, vm.Edges.Count(e => e.Kind?.Contains("multibranch", StringComparison.OrdinalIgnoreCase) == true));
281 }
282
283 [Fact]
284 public void Compose_ControlFlow_GlanceVsInspect_PreferredHeightInspectIsTallerWhenIntrinsicExceedsMinClamp()
285 {
286 var compositor = new CodeNavigationMapCompositor();
287 var nodes = new List<GraphNode>
288 {
289 new() { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" }
290 };
291 var edges = new List<GraphEdge>();
292 for (var i = 1; i <= 17; i++)
293 {
294 nodes.Add(new GraphNode
295 {
296 Id = $"n{i}",
297 Path = @"D:\w\A.cs",
298 Kind = "call_step",
299 Label = $"S{i}"
300 });
301 edges.Add(new GraphEdge { FromId = $"n{i - 1}", ToId = $"n{i}", Kind = "Call" });
302 }
303
304 var doc = new GraphDocument
305 {
306 AnchorPath = @"D:\w\A.cs",
307 Nodes = nodes,
308 Edges = edges
309 };
310
311 var glance = compositor.Compose(doc, CodeNavigationMapLevelKind.ControlFlow, 280, 120, CodeNavigationMapDetailLevel.Glance);
312 var inspect = compositor.Compose(doc, CodeNavigationMapLevelKind.ControlFlow, 280, 120, CodeNavigationMapDetailLevel.Inspect);
313 Assert.True(inspect.PreferredHeight > glance.PreferredHeight);
314 }
315
316 [Fact]
317 public void ControlFlowLayout_WithLegend_ReservesColumnAndConditionBranch()
318 {
319 var engine = new ControlFlowGraphLayoutEngine();
320 var doc = new GraphDocument
321 {
322 AnchorPath = @"D:\w\A.cs",
323 Nodes =
324 [
325 new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" },
326 new GraphNode
327 {
328 Id = "n1",
329 Path = @"D:\w\A.cs",
330 Kind = "condition_step",
331 Label = "IF",
332 LegendIndex = 1,
333 LegendText = "x > 0"
334 }
335 ],
336 Edges = [new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" }]
337 };
338
339 var scene = engine.Layout(doc, 400, 200, CodeNavigationMapDetailLevel.Inspect);
340 Assert.Single(scene.Legend);
341 Assert.Equal(1, scene.Legend[0].Index);
342 Assert.Equal("x > 0", scene.Legend[0].Text);
343 Assert.True(scene.UseLegendColumn);
344 Assert.True(scene.ShowLegendConditionKey);
345 Assert.False(scene.ShowLegendReturnKey);
346 Assert.True(scene.LegendColumnLeft < 400);
347 var cond = scene.Nodes.First(n => n.Id == "n1");
348 Assert.Equal(GraphNodeShape.Condition, cond.Shape);
349 }
350
351 [Fact]
352 public void ControlFlowLayout_SkipsReturnInLegendRows_ShowsReturnShapeKey()
353 {
354 var engine = new ControlFlowGraphLayoutEngine();
355 var doc = new GraphDocument
356 {
357 AnchorPath = @"D:\w\A.cs",
358 Nodes =
359 [
360 new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" },
361 new GraphNode
362 {
363 Id = "n1",
364 Path = @"D:\w\A.cs",
365 Kind = "exit_step",
366 Label = "RET",
367 LegendIndex = 1,
368 LegendText = "return"
369 }
370 ],
371 Edges = [new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Exit" }]
372 };
373
374 var scene = engine.Layout(doc, 400, 200, CodeNavigationMapDetailLevel.Normal);
375 Assert.Empty(scene.Legend);
376 Assert.True(scene.UseLegendColumn);
377 Assert.True(scene.ShowLegendReturnKey);
378 Assert.False(scene.ShowLegendConditionKey);
379 Assert.True(scene.LegendColumnLeft < 400);
380 }
381
382 [Fact]
383 public void ControlFlowLayout_Normal_UsesGlyphsWithoutLegendColumn()
384 {
385 var engine = new ControlFlowGraphLayoutEngine();
386 var doc = new GraphDocument
387 {
388 AnchorPath = @"D:\w\A.cs",
389 Nodes =
390 [
391 new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A.cs" },
392 new GraphNode
393 {
394 Id = "n1",
395 Path = @"D:\w\A.cs",
396 Kind = "call_step",
397 Label = "Run",
398 LegendIndex = 1,
399 LegendText = "await Run();",
400 LineStart = 10,
401 LineEnd = 10
402 }
403 ],
404 Edges = [new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" }]
405 };
406
407 var scene = engine.Layout(doc, 400, 200, CodeNavigationMapDetailLevel.Normal);
408 Assert.Empty(scene.Legend);
409 Assert.False(scene.UseLegendColumn);
410 Assert.True(scene.ShowNodeLegendGlyphs);
411 Assert.Equal(10, scene.Nodes.First(n => n.Id == "n1").LineStart);
412 }
413
414 [Fact]
415 public void Compose_ControlFlow_IntentForcesVertical_OverWideShortViewport()
416 {
417 var compositor = new CodeNavigationMapCompositor();
418 GraphNode Node(string id, string label) =>
419 new()
420 {
421 Id = id,
422 Path = @"D:\w\Chain.cs",
423 Kind = "call_step",
424 Label = label
425 };
426
427 var edges = new List<GraphEdge>();
428 var nodes = new List<GraphNode>
429 {
430 new()
431 {
432 Id = "n0",
433 Path = @"D:\w\Chain.cs",
434 Kind = "anchor",
435 Label = "Anchor"
436 }
437 };
438 for (var i = 1; i <= 6; i++)
439 {
440 nodes.Add(Node($"n{i}", $"s{i}"));
441 edges.Add(new GraphEdge { FromId = $"n{i - 1}", ToId = $"n{i}", Kind = "Call" });
442 }
443
444 var doc = new GraphDocument { AnchorPath = @"D:\w\Chain.cs", Nodes = nodes, Edges = edges };
445 var intent = new CodeNavigationMapCompositionIntent(
446 doc,
447 CodeNavigationMapLevelKind.ControlFlow,
448 CodeNavigationMapDetailLevel.Normal,
449 CodeNavigationMapRelatedGraphLayoutKind.Radial,
450 CodeNavigationMapControlFlowMainAxisKind.Vertical);
451
452 var result = compositor.Compose(intent, new SkiaInstrumentViewport(640, 130));
453 Assert.Equal(GraphControlFlowMainAxis.Vertical, result.LayoutScene.ControlFlowMainAxis);
454 }
455
456 [Fact]
457 public void SubgraphJson_ParsesLegendFields()
458 {
459 const string json =
460 """{"mode":"subgraph","graph_kind":"code_intent_code_navigation_map","anchor_path":"D:\\a.cs","nodes":[{"id":"n0","path":"D:\\a.cs","kind":"anchor","label":"a.cs","relative_path":"","rationale":""},{"id":"n1","path":"D:\\a.cs","kind":"condition_step","label":"IF","relative_path":"","rationale":"","legend_index":1,"legend_text":"x > 0"}],"edges":[]}""";
461 Assert.True(GraphDocumentJson.TryParse(json, out var doc, out _));
462 Assert.NotNull(doc);
463 Assert.Equal(GraphKind.CodeIntent, doc!.Kind);
464 var n1 = doc.Nodes.First(n => n.Id == "n1");
465 Assert.Equal(1, n1.LegendIndex);
466 Assert.Equal("x > 0", n1.LegendText);
467 }
468}
469
View only · write via MCP/CIDE