| 1 | using CascadeIDE.Cockpit.Cds; |
| 2 | using CascadeIDE.Cockpit.Channels.TraceFlow; |
| 3 | using CascadeIDE.Cockpit.Composition.TraceFlow; |
| 4 | using CascadeIDE.Cockpit.Graph; |
| 5 | using CascadeIDE.Features.WorkspaceNavigation.Application; |
| 6 | using CascadeIDE.Models; |
| 7 | using Xunit; |
| 8 | |
| 9 | namespace CascadeIDE.Tests; |
| 10 | |
| 11 | public sealed class WorkspaceNavigationMapRefreshComposerTests |
| 12 | { |
| 13 | private static WorkspaceNavigationMapRefreshComposer.Dependencies TestDeps() => |
| 14 | new( |
| 15 | new CodeNavigationMapCompositor(), |
| 16 | new TraceFlowChannelCoordinator( |
| 17 | [ |
| 18 | new CodeFlowTraceChannel(), |
| 19 | new UnitTestTraceChannel() |
| 20 | ]), |
| 21 | new TraceFlowCdsRouter(), |
| 22 | new TraceFlowSurfaceCompositor()); |
| 23 | |
| 24 | [Fact] |
| 25 | public void Malformed_json_returns_friendly_status_without_invoking_cds() |
| 26 | { |
| 27 | var r = WorkspaceNavigationMapRefreshComposer.Compose( |
| 28 | TestDeps(), |
| 29 | "{bad", |
| 30 | useSubgraphMode: false, |
| 31 | wantList: false, |
| 32 | currentPath: null, |
| 33 | solutionPath: null, |
| 34 | CodeNavigationMapLevelKind.File, |
| 35 | graphWidth: 100, |
| 36 | graphHeight: 100, |
| 37 | CodeNavigationMapDetailLevel.Normal, |
| 38 | CodeNavigationMapRelatedGraphLayoutKind.Radial, |
| 39 | CodeNavigationMapControlFlowMainAxisKind.Auto, |
| 40 | new CodeNavigationMapSettings(), |
| 41 | new WorkspaceNavigationMapRefreshComposer.TraceSignals(0, null), |
| 42 | cockpitSurfaceCapturedOnUi: null); |
| 43 | |
| 44 | Assert.Contains("разобрать", r.Status); |
| 45 | Assert.Null(r.Scene); |
| 46 | Assert.Empty(r.ListRows); |
| 47 | } |
| 48 | |
| 49 | [Fact] |
| 50 | public void Error_document_skips_scene_and_lists_and_uses_message() |
| 51 | { |
| 52 | var json = """{"error":"other","message":"short reason"}"""; |
| 53 | var r = WorkspaceNavigationMapRefreshComposer.Compose( |
| 54 | TestDeps(), |
| 55 | json, |
| 56 | useSubgraphMode: false, |
| 57 | wantList: false, |
| 58 | currentPath: null, |
| 59 | solutionPath: null, |
| 60 | CodeNavigationMapLevelKind.File, |
| 61 | graphWidth: 100, |
| 62 | graphHeight: 100, |
| 63 | CodeNavigationMapDetailLevel.Normal, |
| 64 | CodeNavigationMapRelatedGraphLayoutKind.Radial, |
| 65 | CodeNavigationMapControlFlowMainAxisKind.Auto, |
| 66 | new CodeNavigationMapSettings(), |
| 67 | new WorkspaceNavigationMapRefreshComposer.TraceSignals(0, null), |
| 68 | cockpitSurfaceCapturedOnUi: null); |
| 69 | |
| 70 | Assert.Equal("short reason", r.Status); |
| 71 | Assert.Null(r.Scene); |
| 72 | } |
| 73 | |
| 74 | [Fact] |
| 75 | public void File_level_subgraph_from_builder_composes_graph_scene() |
| 76 | { |
| 77 | var tmp = Path.Combine(Path.GetTempPath(), "cide-navmap-" + Guid.NewGuid().ToString("N")); |
| 78 | Directory.CreateDirectory(tmp); |
| 79 | try |
| 80 | { |
| 81 | var anchor = Path.Combine(tmp, "A.cs"); |
| 82 | var peer = Path.Combine(tmp, "B.cs"); |
| 83 | File.WriteAllText(anchor, "class A {}"); |
| 84 | File.WriteAllText(peer, "class B {}"); |
| 85 | var paths = new[] { anchor, peer }; |
| 86 | var json = WorkspaceNavigationMapContextJsonBuilder.Build( |
| 87 | CodeNavigationMapLevelKind.File, |
| 88 | wantGraph: true, |
| 89 | currentPath: anchor, |
| 90 | editorText: null, |
| 91 | cursorLine: null, |
| 92 | cursorColumn: null, |
| 93 | paths, |
| 94 | solutionPath: null, |
| 95 | navSettings: null); |
| 96 | |
| 97 | Assert.True(GraphDocumentJson.TryParse(json, out _, out var parseErr), parseErr); |
| 98 | |
| 99 | var r = WorkspaceNavigationMapRefreshComposer.Compose( |
| 100 | TestDeps(), |
| 101 | json, |
| 102 | useSubgraphMode: true, |
| 103 | wantList: false, |
| 104 | currentPath: anchor, |
| 105 | solutionPath: null, |
| 106 | CodeNavigationMapLevelKind.File, |
| 107 | graphWidth: 280, |
| 108 | graphHeight: 120, |
| 109 | CodeNavigationMapDetailLevel.Normal, |
| 110 | CodeNavigationMapRelatedGraphLayoutKind.Radial, |
| 111 | CodeNavigationMapControlFlowMainAxisKind.Auto, |
| 112 | new CodeNavigationMapSettings(), |
| 113 | new WorkspaceNavigationMapRefreshComposer.TraceSignals(0, null), |
| 114 | cockpitSurfaceCapturedOnUi: null); |
| 115 | |
| 116 | Assert.DoesNotContain("разобрать", r.Status); |
| 117 | Assert.NotNull(r.Scene); |
| 118 | Assert.True(r.Scene!.Nodes.Count >= 2); |
| 119 | } |
| 120 | finally |
| 121 | { |
| 122 | try |
| 123 | { |
| 124 | Directory.Delete(tmp, recursive: true); |
| 125 | } |
| 126 | catch |
| 127 | { |
| 128 | // best-effort cleanup |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | [Fact] |
| 134 | public void Control_flow_subgraph_with_file_level_does_not_throw_generic_parse_error() |
| 135 | { |
| 136 | const string json = """ |
| 137 | { |
| 138 | "mode":"subgraph", |
| 139 | "graph_kind":"code_intent_code_navigation_map", |
| 140 | "anchor_path":"D:/w/Program.cs", |
| 141 | "nodes":[ |
| 142 | {"id":"n0","path":"D:/w/Program.cs","kind":"anchor","label":"Program.cs"}, |
| 143 | {"id":"n1","path":"D:/w/Program.cs","kind":"call_step","label":"S1","line_start":1,"line_end":2} |
| 144 | ], |
| 145 | "edges":[{"from_id":"n0","to_id":"n1","kind":"Call"}] |
| 146 | } |
| 147 | """; |
| 148 | |
| 149 | var r = WorkspaceNavigationMapRefreshComposer.Compose( |
| 150 | TestDeps(), |
| 151 | json, |
| 152 | useSubgraphMode: true, |
| 153 | wantList: false, |
| 154 | currentPath: "D:/w/Program.cs", |
| 155 | solutionPath: null, |
| 156 | CodeNavigationMapLevelKind.File, |
| 157 | graphWidth: 280, |
| 158 | graphHeight: 120, |
| 159 | CodeNavigationMapDetailLevel.Normal, |
| 160 | CodeNavigationMapRelatedGraphLayoutKind.TopDown, |
| 161 | CodeNavigationMapControlFlowMainAxisKind.Auto, |
| 162 | new CodeNavigationMapSettings(), |
| 163 | new WorkspaceNavigationMapRefreshComposer.TraceSignals(0, null), |
| 164 | cockpitSurfaceCapturedOnUi: null); |
| 165 | |
| 166 | Assert.DoesNotContain("разобрать", r.Status); |
| 167 | Assert.NotNull(r.Scene); |
| 168 | } |
| 169 | |
| 170 | [Fact] |
| 171 | public void File_level_zero_viewport_does_not_throw_parse_error() |
| 172 | { |
| 173 | const string json = """ |
| 174 | { |
| 175 | "mode":"subgraph", |
| 176 | "graph_kind":"related_files", |
| 177 | "anchor_path":"D:/w/A.cs", |
| 178 | "nodes":[ |
| 179 | {"id":"n0","path":"D:/w/A.cs","kind":"anchor","label":"A.cs"}, |
| 180 | {"id":"n1","path":"D:/w/B.cs","kind":"project_peer","label":"B.cs"} |
| 181 | ], |
| 182 | "edges":[{"from_id":"n0","to_id":"n1","kind":"related_to"}] |
| 183 | } |
| 184 | """; |
| 185 | |
| 186 | var r = WorkspaceNavigationMapRefreshComposer.Compose( |
| 187 | TestDeps(), |
| 188 | json, |
| 189 | useSubgraphMode: true, |
| 190 | wantList: false, |
| 191 | currentPath: "D:/w/A.cs", |
| 192 | solutionPath: null, |
| 193 | CodeNavigationMapLevelKind.File, |
| 194 | graphWidth: 0, |
| 195 | graphHeight: 0, |
| 196 | CodeNavigationMapDetailLevel.Normal, |
| 197 | CodeNavigationMapRelatedGraphLayoutKind.Radial, |
| 198 | CodeNavigationMapControlFlowMainAxisKind.Auto, |
| 199 | new CodeNavigationMapSettings(), |
| 200 | new WorkspaceNavigationMapRefreshComposer.TraceSignals(0, null), |
| 201 | cockpitSurfaceCapturedOnUi: null); |
| 202 | |
| 203 | Assert.DoesNotContain("разобрать", r.Status); |
| 204 | } |
| 205 | |
| 206 | [Fact] |
| 207 | public void File_level_nan_viewport_height_does_not_throw_parse_error() |
| 208 | { |
| 209 | const string json = """ |
| 210 | { |
| 211 | "mode":"subgraph", |
| 212 | "graph_kind":"related_files", |
| 213 | "anchor_path":"D:/w/A.cs", |
| 214 | "nodes":[{"id":"n0","path":"D:/w/A.cs","kind":"anchor","label":"A.cs"}], |
| 215 | "edges":[] |
| 216 | } |
| 217 | """; |
| 218 | |
| 219 | var r = WorkspaceNavigationMapRefreshComposer.Compose( |
| 220 | TestDeps(), |
| 221 | json, |
| 222 | useSubgraphMode: true, |
| 223 | wantList: false, |
| 224 | currentPath: "D:/w/A.cs", |
| 225 | solutionPath: null, |
| 226 | CodeNavigationMapLevelKind.File, |
| 227 | graphWidth: 280, |
| 228 | graphHeight: double.NaN, |
| 229 | CodeNavigationMapDetailLevel.Normal, |
| 230 | CodeNavigationMapRelatedGraphLayoutKind.Radial, |
| 231 | CodeNavigationMapControlFlowMainAxisKind.Auto, |
| 232 | new CodeNavigationMapSettings(), |
| 233 | new WorkspaceNavigationMapRefreshComposer.TraceSignals(0, null), |
| 234 | cockpitSurfaceCapturedOnUi: null); |
| 235 | |
| 236 | Assert.DoesNotContain("разобрать", r.Status); |
| 237 | } |
| 238 | |
| 239 | [Fact] |
| 240 | public void File_level_related_wire_with_subgraph_mode_still_composes_graph() |
| 241 | { |
| 242 | const string json = """ |
| 243 | { |
| 244 | "mode":"related", |
| 245 | "anchor_path":"D:/w/A.cs", |
| 246 | "items":[ |
| 247 | {"path":"D:/w/B.cs","kind":"project_peer","rationale":"peer"} |
| 248 | ] |
| 249 | } |
| 250 | """; |
| 251 | |
| 252 | var r = WorkspaceNavigationMapRefreshComposer.Compose( |
| 253 | TestDeps(), |
| 254 | json, |
| 255 | useSubgraphMode: true, |
| 256 | wantList: false, |
| 257 | currentPath: "D:/w/A.cs", |
| 258 | solutionPath: null, |
| 259 | CodeNavigationMapLevelKind.File, |
| 260 | graphWidth: 280, |
| 261 | graphHeight: 120, |
| 262 | CodeNavigationMapDetailLevel.Normal, |
| 263 | CodeNavigationMapRelatedGraphLayoutKind.Radial, |
| 264 | CodeNavigationMapControlFlowMainAxisKind.Auto, |
| 265 | new CodeNavigationMapSettings(), |
| 266 | new WorkspaceNavigationMapRefreshComposer.TraceSignals(0, null), |
| 267 | cockpitSurfaceCapturedOnUi: null); |
| 268 | |
| 269 | Assert.DoesNotContain("разобрать", r.Status); |
| 270 | Assert.NotNull(r.Scene); |
| 271 | Assert.Equal(2, r.Scene!.Nodes.Count); |
| 272 | } |
| 273 | |
| 274 | /// <summary> |
| 275 | /// Сквозной путь UI refresh: related wire + top_down + много связей (регрессия Math.Clamp в hierarchy layout). |
| 276 | /// </summary> |
| 277 | [Fact] |
| 278 | public void File_level_related_wire_top_down_many_items_composes_without_parse_error() |
| 279 | { |
| 280 | var items = new System.Text.StringBuilder(); |
| 281 | for (var i = 1; i <= 37; i++) |
| 282 | { |
| 283 | if (i > 1) |
| 284 | items.Append(','); |
| 285 | items.Append($$"""{"path":"D:/w/B{{i}}.cs","kind":"project_peer","rationale":"peer"}"""); |
| 286 | } |
| 287 | |
| 288 | var json = $$""" |
| 289 | { |
| 290 | "mode":"related", |
| 291 | "anchor_path":"D:/w/A.cs", |
| 292 | "items":[{{items}}] |
| 293 | } |
| 294 | """; |
| 295 | |
| 296 | var r = WorkspaceNavigationMapRefreshComposer.Compose( |
| 297 | TestDeps(), |
| 298 | json, |
| 299 | useSubgraphMode: true, |
| 300 | wantList: false, |
| 301 | currentPath: "D:/w/A.cs", |
| 302 | solutionPath: null, |
| 303 | CodeNavigationMapLevelKind.File, |
| 304 | graphWidth: 280, |
| 305 | graphHeight: 120, |
| 306 | CodeNavigationMapDetailLevel.Normal, |
| 307 | CodeNavigationMapRelatedGraphLayoutKind.TopDown, |
| 308 | CodeNavigationMapControlFlowMainAxisKind.Auto, |
| 309 | new CodeNavigationMapSettings(), |
| 310 | new WorkspaceNavigationMapRefreshComposer.TraceSignals(0, null), |
| 311 | cockpitSurfaceCapturedOnUi: null); |
| 312 | |
| 313 | Assert.DoesNotContain("разобрать", r.Status); |
| 314 | Assert.DoesNotContain("cannot be greater than", r.Status, StringComparison.OrdinalIgnoreCase); |
| 315 | Assert.NotNull(r.Scene); |
| 316 | Assert.Equal(38, r.Scene!.Nodes.Count); |
| 317 | } |
| 318 | } |
| 319 | |