| 1 | using CascadeIDE.Cockpit.Graph; |
| 2 | using CascadeIDE.Cockpit.Graph.Layout; |
| 3 | using CascadeIDE.Models; |
| 4 | using CascadeIDE.Services; |
| 5 | using CascadeIDE.Features.WorkspaceNavigation.Application; |
| 6 | using CascadeIDE.ViewModels; |
| 7 | using Xunit; |
| 8 | |
| 9 | namespace CascadeIDE.Tests; |
| 10 | |
| 11 | public sealed class ControlFlowGraphLayoutEngineTests |
| 12 | { |
| 13 | [Fact] |
| 14 | public void Layout_PlacesFlowTopToBottom_ByDepth() |
| 15 | { |
| 16 | var engine = new ControlFlowGraphLayoutEngine(); |
| 17 | var doc = new GraphDocument |
| 18 | { |
| 19 | AnchorPath = @"D:\w\A.cs", |
| 20 | Nodes = |
| 21 | [ |
| 22 | new GraphNode |
| 23 | { |
| 24 | Id = "n0", |
| 25 | Path = @"D:\w\A.cs", |
| 26 | Kind = "anchor", |
| 27 | Label = "A.cs" |
| 28 | }, |
| 29 | new GraphNode |
| 30 | { |
| 31 | Id = "n1", |
| 32 | Path = @"D:\w\A.cs", |
| 33 | Kind = "call_step", |
| 34 | Label = "B" |
| 35 | }, |
| 36 | new GraphNode |
| 37 | { |
| 38 | Id = "n2", |
| 39 | Path = @"D:\w\A.cs", |
| 40 | Kind = "call_step", |
| 41 | Label = "C" |
| 42 | } |
| 43 | ], |
| 44 | Edges = |
| 45 | [ |
| 46 | new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" }, |
| 47 | new GraphEdge { FromId = "n1", ToId = "n2", Kind = "Call" } |
| 48 | ] |
| 49 | }; |
| 50 | |
| 51 | // Высота ≥ ширина — автоматический выбор оси вертикальный поток сверху вниз (широко-низкая панель дала бы горизонталь). |
| 52 | var scene = engine.Layout(doc, 280, 300); |
| 53 | Assert.Equal(3, scene.Nodes.Count); |
| 54 | Assert.Equal(GraphControlFlowMainAxis.Vertical, scene.ControlFlowMainAxis); |
| 55 | |
| 56 | var n0 = scene.Nodes.Single(n => n.Id == "n0"); |
| 57 | var n1 = scene.Nodes.Single(n => n.Id == "n1"); |
| 58 | var n2 = scene.Nodes.Single(n => n.Id == "n2"); |
| 59 | |
| 60 | Assert.True(n0.IsAnchor); |
| 61 | Assert.True(n0.Center.Y < n1.Center.Y); |
| 62 | Assert.True(n1.Center.Y < n2.Center.Y); |
| 63 | Assert.Equal(140, n0.Center.X, 0.5); |
| 64 | } |
| 65 | |
| 66 | [Fact] |
| 67 | public void Layout_ShowsEdgeStyleLegend_WhenGraphHasNonSolidEdges() |
| 68 | { |
| 69 | var engine = new ControlFlowGraphLayoutEngine(); |
| 70 | var doc = new GraphDocument |
| 71 | { |
| 72 | AnchorPath = @"D:\w\A.cs", |
| 73 | Nodes = |
| 74 | [ |
| 75 | new GraphNode |
| 76 | { |
| 77 | Id = "n0", |
| 78 | Path = @"D:\w\A.cs", |
| 79 | Kind = "anchor", |
| 80 | Label = "A.cs" |
| 81 | }, |
| 82 | new GraphNode |
| 83 | { |
| 84 | Id = "n1", |
| 85 | Path = @"D:\w\A.cs", |
| 86 | Kind = "call_step", |
| 87 | Label = "B" |
| 88 | } |
| 89 | ], |
| 90 | Edges = |
| 91 | [ |
| 92 | new GraphEdge { FromId = "n0", ToId = "n1", Kind = "ConditionalCall" } |
| 93 | ] |
| 94 | }; |
| 95 | |
| 96 | var scene = engine.Layout(doc, 400, 200); |
| 97 | Assert.True(scene.ShowLegendEdgeStyleKey); |
| 98 | } |
| 99 | |
| 100 | [Fact] |
| 101 | public void Layout_PlacesSameDepthBranchesSideBySide() |
| 102 | { |
| 103 | var engine = new ControlFlowGraphLayoutEngine(); |
| 104 | var doc = new GraphDocument |
| 105 | { |
| 106 | AnchorPath = @"D:\w\A.cs", |
| 107 | Nodes = |
| 108 | [ |
| 109 | new GraphNode |
| 110 | { |
| 111 | Id = "n0", |
| 112 | Path = @"D:\w\A.cs", |
| 113 | Kind = "anchor", |
| 114 | Label = "A.cs" |
| 115 | }, |
| 116 | new GraphNode |
| 117 | { |
| 118 | Id = "n1", |
| 119 | Path = @"D:\w\A.cs", |
| 120 | Kind = "call_step", |
| 121 | Label = "LeftBranch" |
| 122 | }, |
| 123 | new GraphNode |
| 124 | { |
| 125 | Id = "n2", |
| 126 | Path = @"D:\w\A.cs", |
| 127 | Kind = "call_step", |
| 128 | Label = "RightBranch" |
| 129 | } |
| 130 | ], |
| 131 | Edges = |
| 132 | [ |
| 133 | new GraphEdge { FromId = "n0", ToId = "n1", Kind = "ConditionalCall" }, |
| 134 | new GraphEdge { FromId = "n0", ToId = "n2", Kind = "ConditionalCall" } |
| 135 | ] |
| 136 | }; |
| 137 | |
| 138 | // Вертикальный основной поток: ветки одного уровня — одинаковый Y, разведение по X. |
| 139 | var scene = engine.Layout(doc, 220, 280); |
| 140 | Assert.Equal(GraphControlFlowMainAxis.Vertical, scene.ControlFlowMainAxis); |
| 141 | var n1 = scene.Nodes.Single(n => n.Id == "n1"); |
| 142 | var n2 = scene.Nodes.Single(n => n.Id == "n2"); |
| 143 | Assert.Equal(n1.Center.Y, n2.Center.Y, 0.5); |
| 144 | Assert.True(n1.Center.X < n2.Center.X); |
| 145 | } |
| 146 | |
| 147 | [Fact] |
| 148 | public void Layout_NarrowSlot_ShortensLabelsAndSetsAdaptiveSideFont() |
| 149 | { |
| 150 | var engine = new ControlFlowGraphLayoutEngine(); |
| 151 | var doc = new GraphDocument |
| 152 | { |
| 153 | AnchorPath = @"D:\w\A.cs", |
| 154 | Nodes = |
| 155 | [ |
| 156 | new GraphNode |
| 157 | { |
| 158 | Id = "n0", |
| 159 | Path = @"D:\w\A.cs", |
| 160 | Kind = "anchor", |
| 161 | Label = "A.cs" |
| 162 | }, |
| 163 | new GraphNode |
| 164 | { |
| 165 | Id = "n1", |
| 166 | Path = @"D:\w\A.cs", |
| 167 | Kind = "call_step", |
| 168 | Label = new string('M', 40) |
| 169 | } |
| 170 | ], |
| 171 | Edges = [new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" }] |
| 172 | }; |
| 173 | |
| 174 | var scene = engine.Layout(doc, 118, 200); |
| 175 | Assert.NotNull(scene.SideLabelFontSizePx); |
| 176 | Assert.InRange(scene.SideLabelFontSizePx!.Value, GraphRenderInvariants.CompactSideLabelFontSizeFloor, GraphRenderInvariants.MaxSideLabelFontSize); |
| 177 | var n1 = scene.Nodes.Single(n => n.Id == "n1"); |
| 178 | Assert.True(n1.Label.Length < 40); |
| 179 | Assert.EndsWith("…", n1.Label); |
| 180 | } |
| 181 | |
| 182 | [Fact] |
| 183 | public void Layout_WideSlot_AllowsLongerLabelsThanNarrow() |
| 184 | { |
| 185 | var narrow = GraphControlFlowLayoutMetrics.ResolveLabelCharBudget(118); |
| 186 | var wide = GraphControlFlowLayoutMetrics.ResolveLabelCharBudget(360); |
| 187 | Assert.True(wide > narrow); |
| 188 | } |
| 189 | |
| 190 | [Fact] |
| 191 | public void Layout_LegendColumnLeft_StartsAtMaxInkRightPlusGap_NotReadableBandRight() |
| 192 | { |
| 193 | var engine = new ControlFlowGraphLayoutEngine(); |
| 194 | var doc = new GraphDocument |
| 195 | { |
| 196 | AnchorPath = @"D:\w\A.cs", |
| 197 | Nodes = |
| 198 | [ |
| 199 | new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A" }, |
| 200 | new GraphNode |
| 201 | { |
| 202 | Id = "n1", |
| 203 | Path = @"D:\w\A.cs", |
| 204 | Kind = "condition_step", |
| 205 | Label = "IF", |
| 206 | LegendIndex = 1, |
| 207 | LegendText = "x > 0" |
| 208 | } |
| 209 | ], |
| 210 | Edges = [new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" }] |
| 211 | }; |
| 212 | const double viewportW = 400; |
| 213 | // Выше модель горизонтальной полосы: легенда справа фиксируется от max(Center.X+R), без привязки к концу reading band по X. |
| 214 | var scene = engine.Layout(doc, viewportW, 260); |
| 215 | Assert.True(scene.UseLegendColumn); |
| 216 | var inkSl = GraphControlFlowLayoutMetrics.BesideLegendInkSlack; |
| 217 | var minCl = Math.Max( |
| 218 | GraphControlFlowLayoutMetrics.LegendGap, |
| 219 | GraphControlFlowLayoutMetrics.LegendBesideMinClearance); |
| 220 | var inkR = 0.0; |
| 221 | foreach (var n in scene.Nodes) |
| 222 | inkR = Math.Max(inkR, n.Center.X + n.Radius + inkSl); |
| 223 | var expected = inkR + minCl; |
| 224 | Assert.Equal(expected, scene.LegendColumnLeft, 0.6); |
| 225 | } |
| 226 | |
| 227 | [Fact] |
| 228 | public void Layout_NarrowWidth_PlacesLegendBlockBelowGraph_FullWidthText() |
| 229 | { |
| 230 | var engine = new ControlFlowGraphLayoutEngine(); |
| 231 | var doc = new GraphDocument |
| 232 | { |
| 233 | AnchorPath = @"D:\w\A.cs", |
| 234 | Nodes = |
| 235 | [ |
| 236 | new GraphNode { Id = "n0", Path = @"D:\w\A.cs", Kind = "anchor", Label = "A" }, |
| 237 | new GraphNode |
| 238 | { |
| 239 | Id = "n1", |
| 240 | Path = @"D:\w\A.cs", |
| 241 | Kind = "condition_step", |
| 242 | Label = "IF", |
| 243 | LegendIndex = 1, |
| 244 | LegendText = "pred" |
| 245 | } |
| 246 | ], |
| 247 | Edges = [new GraphEdge { FromId = "n0", ToId = "n1", Kind = "Call" }] |
| 248 | }; |
| 249 | // Мало места справа от «чернила» — блок легенды уходит вниз на полную ширину. |
| 250 | var scene = engine.Layout(doc, 130, 200); |
| 251 | Assert.True(scene.UseLegendColumn); |
| 252 | Assert.Equal(GraphLegendBlockPlacement.BelowGraph, scene.LegendPlacement); |
| 253 | Assert.True(scene.LegendBlockTopY > 0); |
| 254 | Assert.Equal(GraphControlFlowLayoutMetrics.SidePadding, scene.LegendColumnLeft, 0.01); |
| 255 | } |
| 256 | |
| 257 | [Fact] |
| 258 | public void Layout_WideViewport_LinearChain_SelectsHorizontalMainAxis() |
| 259 | { |
| 260 | var engine = new ControlFlowGraphLayoutEngine(); |
| 261 | GraphNode Node(string id, string label) => |
| 262 | new() |
| 263 | { |
| 264 | Id = id, |
| 265 | Path = @"D:\w\Chain.cs", |
| 266 | Kind = "call_step", |
| 267 | Label = label |
| 268 | }; |
| 269 | |
| 270 | var edges = new List<GraphEdge>(); |
| 271 | var nodes = new List<GraphNode> |
| 272 | { |
| 273 | new() |
| 274 | { |
| 275 | Id = "n0", |
| 276 | Path = @"D:\w\Chain.cs", |
| 277 | Kind = "anchor", |
| 278 | Label = "Anchor" |
| 279 | } |
| 280 | }; |
| 281 | for (var i = 1; i <= 6; i++) |
| 282 | { |
| 283 | nodes.Add(Node($"n{i}", $"s{i}")); |
| 284 | edges.Add(new GraphEdge { FromId = $"n{i - 1}", ToId = $"n{i}", Kind = "Call" }); |
| 285 | } |
| 286 | |
| 287 | var doc = new GraphDocument |
| 288 | { |
| 289 | AnchorPath = @"D:\w\Chain.cs", |
| 290 | Nodes = nodes, |
| 291 | Edges = edges |
| 292 | }; |
| 293 | |
| 294 | var scene = engine.Layout(doc, 640, 130); |
| 295 | Assert.Equal(GraphControlFlowMainAxis.Horizontal, scene.ControlFlowMainAxis); |
| 296 | for (var i = 0; i < 6; i++) |
| 297 | { |
| 298 | var a = scene.Nodes.Single(n => n.Id == $"n{i}"); |
| 299 | var b = scene.Nodes.Single(n => n.Id == $"n{i + 1}"); |
| 300 | Assert.True(a.Center.X < b.Center.X); |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | [Fact] |
| 305 | public void Layout_TallViewport_LinearChain_SelectsVerticalMainAxis() |
| 306 | { |
| 307 | var engine = new ControlFlowGraphLayoutEngine(); |
| 308 | GraphNode Node(string id, string label) => |
| 309 | new() |
| 310 | { |
| 311 | Id = id, |
| 312 | Path = @"D:\w\Chain.cs", |
| 313 | Kind = "call_step", |
| 314 | Label = label |
| 315 | }; |
| 316 | |
| 317 | var edges = new List<GraphEdge>(); |
| 318 | var nodes = new List<GraphNode> |
| 319 | { |
| 320 | new() |
| 321 | { |
| 322 | Id = "n0", |
| 323 | Path = @"D:\w\Chain.cs", |
| 324 | Kind = "anchor", |
| 325 | Label = "Anchor" |
| 326 | } |
| 327 | }; |
| 328 | for (var i = 1; i <= 6; i++) |
| 329 | { |
| 330 | nodes.Add(Node($"n{i}", $"s{i}")); |
| 331 | edges.Add(new GraphEdge { FromId = $"n{i - 1}", ToId = $"n{i}", Kind = "Call" }); |
| 332 | } |
| 333 | |
| 334 | var doc = new GraphDocument |
| 335 | { |
| 336 | AnchorPath = @"D:\w\Chain.cs", |
| 337 | Nodes = nodes, |
| 338 | Edges = edges |
| 339 | }; |
| 340 | |
| 341 | var scene = engine.Layout(doc, 200, 420); |
| 342 | Assert.Equal(GraphControlFlowMainAxis.Vertical, scene.ControlFlowMainAxis); |
| 343 | for (var i = 0; i < 6; i++) |
| 344 | { |
| 345 | var a = scene.Nodes.Single(n => n.Id == $"n{i}"); |
| 346 | var b = scene.Nodes.Single(n => n.Id == $"n{i + 1}"); |
| 347 | Assert.True(a.Center.Y < b.Center.Y); |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | [Fact] |
| 352 | public void ChooseMainAxis_PicksAxisBySlackAndAspect() |
| 353 | { |
| 354 | Assert.Equal( |
| 355 | GraphControlFlowMainAxis.Horizontal, |
| 356 | GraphControlFlowLayoutMetrics.ChooseMainAxis(graphWidth: 520, heightForLayout: 118, levelCount: 6, maxNodesOnAnyLevel: 2)); |
| 357 | |
| 358 | Assert.Equal( |
| 359 | GraphControlFlowMainAxis.Vertical, |
| 360 | GraphControlFlowLayoutMetrics.ChooseMainAxis(graphWidth: 178, heightForLayout: 380, levelCount: 6, maxNodesOnAnyLevel: 2)); |
| 361 | } |
| 362 | |
| 363 | [Fact] |
| 364 | public void Layout_SettingsOverride_Vertical_OverridesWideShortHeuristic() |
| 365 | { |
| 366 | var engine = new ControlFlowGraphLayoutEngine(); |
| 367 | GraphNode Node(string id, string label) => |
| 368 | new() |
| 369 | { |
| 370 | Id = id, |
| 371 | Path = @"D:\w\Chain.cs", |
| 372 | Kind = "call_step", |
| 373 | Label = label |
| 374 | }; |
| 375 | |
| 376 | var edges = new List<GraphEdge>(); |
| 377 | var nodes = new List<GraphNode> |
| 378 | { |
| 379 | new() |
| 380 | { |
| 381 | Id = "n0", |
| 382 | Path = @"D:\w\Chain.cs", |
| 383 | Kind = "anchor", |
| 384 | Label = "Anchor" |
| 385 | } |
| 386 | }; |
| 387 | for (var i = 1; i <= 6; i++) |
| 388 | { |
| 389 | nodes.Add(Node($"n{i}", $"s{i}")); |
| 390 | edges.Add(new GraphEdge { FromId = $"n{i - 1}", ToId = $"n{i}", Kind = "Call" }); |
| 391 | } |
| 392 | |
| 393 | var doc = new GraphDocument |
| 394 | { |
| 395 | AnchorPath = @"D:\w\Chain.cs", |
| 396 | Nodes = nodes, |
| 397 | Edges = edges |
| 398 | }; |
| 399 | |
| 400 | var scene = engine.Layout( |
| 401 | doc, |
| 402 | 640, |
| 403 | 130, |
| 404 | CodeNavigationMapDetailLevel.Normal, |
| 405 | GraphControlFlowMainAxis.Vertical); |
| 406 | Assert.Equal(GraphControlFlowMainAxis.Vertical, scene.ControlFlowMainAxis); |
| 407 | Assert.True(scene.Nodes.Single(n => n.Id == "n0").Center.Y < scene.Nodes.Single(n => n.Id == "n1").Center.Y); |
| 408 | } |
| 409 | } |
| 410 | |