| 1 | using CascadeIDE.Cockpit.Cds; |
| 2 | using CascadeIDE.Cockpit.Composition.Shell; |
| 3 | using CascadeIDE.Models; |
| 4 | using CascadeIDE.Services.Presentation; |
| 5 | using Xunit; |
| 6 | |
| 7 | using CascadeIDE.Features.Agent.Environment; |
| 8 | |
| 9 | namespace CascadeIDE.Tests; |
| 10 | |
| 11 | /// <summary>Канонические строки <c>presentation</c> и ожидаемое поведение (ADR 0017, 0168).</summary> |
| 12 | public static class PresentationTopologyCatalog |
| 13 | { |
| 14 | public static PresentationGrammarTokens DefaultGrammar { get; } = |
| 15 | PresentationGrammarTokens.FromSettings("()", " ", "+", "P", "F", "M"); |
| 16 | |
| 17 | private static readonly string[] TripleLines = |
| 18 | [ |
| 19 | "(P) (F) (M)", |
| 20 | "(P) (M) (F)", |
| 21 | "(F) (P) (M)", |
| 22 | "(F) (M) (P)", |
| 23 | "(M) (P) (F)", |
| 24 | "(M) (F) (P)", |
| 25 | ]; |
| 26 | |
| 27 | public static IReadOnlyList<TopologyCase> All { get; } = BuildAll(); |
| 28 | |
| 29 | private static IReadOnlyList<TopologyCase> BuildAll() |
| 30 | { |
| 31 | var list = new List<TopologyCase> |
| 32 | { |
| 33 | // --- один экран --- |
| 34 | new( |
| 35 | "(P+F+M)", |
| 36 | MainScreen: 0, |
| 37 | MfdHost: false, |
| 38 | MfdHostScreen: null, |
| 39 | PfdHost: false, |
| 40 | PfdHostScreen: null, |
| 41 | PmSplitHost: false, |
| 42 | PmSplitScreen: null, |
| 43 | MaximizeAtStartup: true, |
| 44 | MainGridAtStartup: PresentationMainGridLayoutFrameBuilder.DefaultColumnDefinitions, |
| 45 | PfdInMainAtStartup: true, |
| 46 | MfdInMainAtStartup: true), |
| 47 | |
| 48 | new( |
| 49 | "(0.2P+0.3F+0.5M)", |
| 50 | MainScreen: 0, |
| 51 | MfdHost: false, |
| 52 | MfdHostScreen: null, |
| 53 | PfdHost: false, |
| 54 | PfdHostScreen: null, |
| 55 | PmSplitHost: false, |
| 56 | PmSplitScreen: null, |
| 57 | MaximizeAtStartup: true, |
| 58 | MainGridAtStartup: "0.2*,4,0.3*,4,0.5*", |
| 59 | PfdInMainAtStartup: true, |
| 60 | MfdInMainAtStartup: true), |
| 61 | |
| 62 | new( |
| 63 | "(F)", |
| 64 | MainScreen: 0, |
| 65 | MfdHost: false, |
| 66 | MfdHostScreen: null, |
| 67 | PfdHost: false, |
| 68 | PfdHostScreen: null, |
| 69 | PmSplitHost: false, |
| 70 | PmSplitScreen: null, |
| 71 | MaximizeAtStartup: false, |
| 72 | MainGridAtStartup: "0,4,*,4,0", |
| 73 | PfdInMainAtStartup: false, |
| 74 | MfdInMainAtStartup: false), |
| 75 | |
| 76 | // --- два экрана: F + M --- |
| 77 | new( |
| 78 | "(F) (M)", |
| 79 | MainScreen: 0, |
| 80 | MfdHost: true, |
| 81 | MfdHostScreen: 1, |
| 82 | PfdHost: false, |
| 83 | PfdHostScreen: null, |
| 84 | PmSplitHost: false, |
| 85 | PmSplitScreen: null, |
| 86 | MaximizeAtStartup: true, |
| 87 | MainGridAtStartup: "0,4,*,4,0", |
| 88 | PfdInMainAtStartup: false, |
| 89 | MfdInMainAtStartup: false), |
| 90 | |
| 91 | new( |
| 92 | "(M) (F)", |
| 93 | MainScreen: 1, |
| 94 | MfdHost: true, |
| 95 | MfdHostScreen: 0, |
| 96 | PfdHost: false, |
| 97 | PfdHostScreen: null, |
| 98 | PmSplitHost: false, |
| 99 | PmSplitScreen: null, |
| 100 | MaximizeAtStartup: true, |
| 101 | MainGridAtStartup: "0,4,*,4,0", |
| 102 | PfdInMainAtStartup: false, |
| 103 | MfdInMainAtStartup: false), |
| 104 | |
| 105 | // --- два экрана: P+F + M --- |
| 106 | new( |
| 107 | "(P+F) (M)", |
| 108 | MainScreen: 0, |
| 109 | MfdHost: true, |
| 110 | MfdHostScreen: 1, |
| 111 | PfdHost: false, |
| 112 | PfdHostScreen: null, |
| 113 | PmSplitHost: false, |
| 114 | PmSplitScreen: null, |
| 115 | MaximizeAtStartup: true, |
| 116 | MainGridAtStartup: "220,4,*,4,0", |
| 117 | PfdInMainAtStartup: true, |
| 118 | MfdInMainAtStartup: false), |
| 119 | |
| 120 | new( |
| 121 | "(0.25P + 0.75F) (M)", |
| 122 | MainScreen: 0, |
| 123 | MfdHost: true, |
| 124 | MfdHostScreen: 1, |
| 125 | PfdHost: false, |
| 126 | PfdHostScreen: null, |
| 127 | PmSplitHost: false, |
| 128 | PmSplitScreen: null, |
| 129 | MaximizeAtStartup: true, |
| 130 | MainGridAtStartup: "0.25*,4,0.75*,4,0", |
| 131 | PfdInMainAtStartup: true, |
| 132 | MfdInMainAtStartup: false), |
| 133 | |
| 134 | // --- два экрана: P+M + F --- |
| 135 | new( |
| 136 | "(P+M)(F)", |
| 137 | MainScreen: 1, |
| 138 | MfdHost: false, |
| 139 | MfdHostScreen: null, |
| 140 | PfdHost: false, |
| 141 | PfdHostScreen: null, |
| 142 | PmSplitHost: true, |
| 143 | PmSplitScreen: 0, |
| 144 | MaximizeAtStartup: true, |
| 145 | MainGridAtStartup: "0,4,*,4,0", |
| 146 | PfdInMainAtStartup: false, |
| 147 | MfdInMainAtStartup: false), |
| 148 | |
| 149 | new( |
| 150 | "(F)(P+M)", |
| 151 | MainScreen: 0, |
| 152 | MfdHost: false, |
| 153 | MfdHostScreen: null, |
| 154 | PfdHost: false, |
| 155 | PfdHostScreen: null, |
| 156 | PmSplitHost: true, |
| 157 | PmSplitScreen: 1, |
| 158 | MaximizeAtStartup: true, |
| 159 | MainGridAtStartup: "0,4,*,4,0", |
| 160 | PfdInMainAtStartup: false, |
| 161 | MfdInMainAtStartup: false), |
| 162 | |
| 163 | new( |
| 164 | "(0.25P + 0.75M)(F)", |
| 165 | MainScreen: 1, |
| 166 | MfdHost: false, |
| 167 | MfdHostScreen: null, |
| 168 | PfdHost: false, |
| 169 | PfdHostScreen: null, |
| 170 | PmSplitHost: true, |
| 171 | PmSplitScreen: 0, |
| 172 | MaximizeAtStartup: true, |
| 173 | MainGridAtStartup: "0,4,*,4,0", |
| 174 | PfdInMainAtStartup: false, |
| 175 | MfdInMainAtStartup: false), |
| 176 | }; |
| 177 | |
| 178 | foreach (var line in TripleLines) |
| 179 | list.Add(BuildTripleCase(line)); |
| 180 | |
| 181 | return list; |
| 182 | } |
| 183 | |
| 184 | private static TopologyCase BuildTripleCase(string line) |
| 185 | { |
| 186 | var parse = PresentationParser.Parse(line, DefaultGrammar); |
| 187 | if (!parse.IsSuccess) |
| 188 | throw new InvalidOperationException($"Catalog triple line failed to parse: {line}"); |
| 189 | |
| 190 | static int IndexOf(PresentationParseResult parse, PresentationAnchorKind kind) |
| 191 | { |
| 192 | for (var i = 0; i < parse.Screens.Count; i++) |
| 193 | { |
| 194 | var screen = parse.Screens[i]; |
| 195 | if (screen.Count == 1 && screen[0].Kind == kind) |
| 196 | return i; |
| 197 | } |
| 198 | |
| 199 | throw new InvalidOperationException($"Anchor {kind} not found in triple line."); |
| 200 | } |
| 201 | |
| 202 | return new TopologyCase( |
| 203 | line, |
| 204 | MainScreen: IndexOf(parse, PresentationAnchorKind.Forward), |
| 205 | MfdHost: true, |
| 206 | MfdHostScreen: IndexOf(parse, PresentationAnchorKind.Mfd), |
| 207 | PfdHost: true, |
| 208 | PfdHostScreen: IndexOf(parse, PresentationAnchorKind.Pfd), |
| 209 | PmSplitHost: false, |
| 210 | PmSplitScreen: null, |
| 211 | MaximizeAtStartup: true, |
| 212 | MainGridAtStartup: "0,4,*,4,0", |
| 213 | PfdInMainAtStartup: false, |
| 214 | MfdInMainAtStartup: false); |
| 215 | } |
| 216 | |
| 217 | public sealed record TopologyCase( |
| 218 | string Line, |
| 219 | int MainScreen, |
| 220 | bool MfdHost, |
| 221 | int? MfdHostScreen, |
| 222 | bool PfdHost, |
| 223 | int? PfdHostScreen, |
| 224 | bool PmSplitHost, |
| 225 | int? PmSplitScreen, |
| 226 | bool MaximizeAtStartup, |
| 227 | string MainGridAtStartup, |
| 228 | bool PfdInMainAtStartup, |
| 229 | bool MfdInMainAtStartup); |
| 230 | } |
| 231 | |
| 232 | public sealed class PresentationTopologyMatrixTests |
| 233 | { |
| 234 | public static IEnumerable<object[]> CatalogCases() => |
| 235 | PresentationTopologyCatalog.All.Select(c => new object[] { c }); |
| 236 | |
| 237 | [Theory] |
| 238 | [MemberData(nameof(CatalogCases))] |
| 239 | public void Parse_Succeeds(PresentationTopologyCatalog.TopologyCase expected) |
| 240 | { |
| 241 | var parse = PresentationParser.Parse(expected.Line, PresentationTopologyCatalog.DefaultGrammar); |
| 242 | Assert.True(parse.IsSuccess, $"Failed to parse: {expected.Line}"); |
| 243 | Assert.True(parse.Screens.Count > 0); |
| 244 | } |
| 245 | |
| 246 | [Theory] |
| 247 | [MemberData(nameof(CatalogCases))] |
| 248 | public void TopologyFlags_MatchExpectation(PresentationTopologyCatalog.TopologyCase expected) |
| 249 | { |
| 250 | var parse = PresentationParser.Parse(expected.Line, PresentationTopologyCatalog.DefaultGrammar); |
| 251 | var flags = PresentationTopologyResolver.ResolveFlags(parse); |
| 252 | |
| 253 | Assert.Equal(expected.MfdHost, flags.MfdHostTopology); |
| 254 | Assert.Equal(expected.PfdHost, flags.PfdHostTopology); |
| 255 | Assert.Equal(expected.PmSplitHost, flags.PmHostTopology); |
| 256 | } |
| 257 | |
| 258 | [Theory] |
| 259 | [MemberData(nameof(CatalogCases))] |
| 260 | public void MainWindowScreenIndex_Matches(PresentationTopologyCatalog.TopologyCase expected) |
| 261 | { |
| 262 | var parse = PresentationParser.Parse(expected.Line, PresentationTopologyCatalog.DefaultGrammar); |
| 263 | Assert.Equal( |
| 264 | expected.MainScreen, |
| 265 | PresentationLayoutAnalyzer.GetMainWindowPresentationScreenIndexOrDefault(parse)); |
| 266 | } |
| 267 | |
| 268 | [Theory] |
| 269 | [MemberData(nameof(CatalogCases))] |
| 270 | public void HostScreenIndices_Match(PresentationTopologyCatalog.TopologyCase expected) |
| 271 | { |
| 272 | var parse = PresentationParser.Parse(expected.Line, PresentationTopologyCatalog.DefaultGrammar); |
| 273 | |
| 274 | if (expected.MfdHostScreen is int mfdIdx) |
| 275 | { |
| 276 | Assert.True( |
| 277 | PresentationLayoutAnalyzer.TryGetMfdHostPresentationScreenIndex(parse.Screens, out var actual), |
| 278 | expected.Line); |
| 279 | Assert.Equal(mfdIdx, actual); |
| 280 | } |
| 281 | else |
| 282 | { |
| 283 | Assert.False(PresentationLayoutAnalyzer.TryGetMfdHostPresentationScreenIndex(parse.Screens, out _)); |
| 284 | } |
| 285 | |
| 286 | if (expected.PfdHostScreen is int pfdIdx) |
| 287 | { |
| 288 | Assert.True( |
| 289 | PresentationLayoutAnalyzer.TryGetPfdHostPresentationScreenIndex(parse.Screens, out var actual), |
| 290 | expected.Line); |
| 291 | Assert.Equal(pfdIdx, actual); |
| 292 | } |
| 293 | else |
| 294 | { |
| 295 | Assert.False(PresentationLayoutAnalyzer.TryGetPfdHostPresentationScreenIndex(parse.Screens, out _)); |
| 296 | } |
| 297 | |
| 298 | if (expected.PmSplitScreen is int pmIdx) |
| 299 | { |
| 300 | Assert.True( |
| 301 | PresentationLayoutAnalyzer.TryGetPmSplitHostPresentationScreenIndex(parse.Screens, out var actual), |
| 302 | expected.Line); |
| 303 | Assert.Equal(pmIdx, actual); |
| 304 | } |
| 305 | else |
| 306 | { |
| 307 | Assert.False(PresentationLayoutAnalyzer.TryGetPmSplitHostPresentationScreenIndex(parse.Screens, out _)); |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | [Theory] |
| 312 | [MemberData(nameof(CatalogCases))] |
| 313 | public void ShouldMaximizeMainWindowAtStartup_Matches(PresentationTopologyCatalog.TopologyCase expected) |
| 314 | { |
| 315 | var parse = PresentationParser.Parse(expected.Line, PresentationTopologyCatalog.DefaultGrammar); |
| 316 | Assert.Equal( |
| 317 | expected.MaximizeAtStartup, |
| 318 | PresentationLayoutAnalyzer.ShouldMaximizeMainWindowAtStartup(parse.Screens)); |
| 319 | } |
| 320 | |
| 321 | [Theory] |
| 322 | [MemberData(nameof(CatalogCases))] |
| 323 | public void MainGridAtStartup_Matches(PresentationTopologyCatalog.TopologyCase expected) |
| 324 | { |
| 325 | var parse = PresentationParser.Parse(expected.Line, PresentationTopologyCatalog.DefaultGrammar); |
| 326 | var flags = PresentationTopologyResolver.ResolveFlags(parse); |
| 327 | var frame = PresentationTopologyResolver.BuildMainWindowGridAtStartup(parse, flags); |
| 328 | |
| 329 | Assert.Equal(expected.MainGridAtStartup, frame.ColumnDefinitions); |
| 330 | } |
| 331 | |
| 332 | [Theory] |
| 333 | [MemberData(nameof(CatalogCases))] |
| 334 | public void ShellVisibilityAtStartup_Matches(PresentationTopologyCatalog.TopologyCase expected) |
| 335 | { |
| 336 | var parse = PresentationParser.Parse(expected.Line, PresentationTopologyCatalog.DefaultGrammar); |
| 337 | var shell = MainWindowShellSurfaceCompositor.Compose( |
| 338 | new MainWindowShellSurfaceCompositionInput( |
| 339 | parse, |
| 340 | IntentSolutionExplorerVisible: true, |
| 341 | IntentChatPanelExpanded: true, |
| 342 | SuppressPfdColumnForPfdHostWindow: expected.PfdHost, |
| 343 | SuppressMfdColumnForMfdHostWindow: expected.MfdHost, |
| 344 | ExpandedMfdWidthPixels: 340, |
| 345 | CollapsedMfdWidthPixels: 8, |
| 346 | DisplaySettings: new DisplaySettings(), |
| 347 | SafetyLevel: AgentSafetyLevel.Confirm)); |
| 348 | |
| 349 | Assert.Equal(expected.PfdInMainAtStartup, shell.PfdSurfaceVisible); |
| 350 | Assert.Equal(expected.MfdInMainAtStartup, shell.MfdColumnVisibleInMainGrid); |
| 351 | } |
| 352 | |
| 353 | [Theory] |
| 354 | [MemberData(nameof(CatalogCases))] |
| 355 | public void CockpitPolicy_RequiresZonesOnMainScreen(PresentationTopologyCatalog.TopologyCase expected) |
| 356 | { |
| 357 | var parse = PresentationParser.Parse(expected.Line, PresentationTopologyCatalog.DefaultGrammar); |
| 358 | |
| 359 | Assert.Equal( |
| 360 | expected.PfdInMainAtStartup, |
| 361 | CockpitPresentationLayoutPolicy.RequiresPfdRegionInMainWindow(parse)); |
| 362 | Assert.Equal( |
| 363 | expected.MfdInMainAtStartup, |
| 364 | CockpitPresentationLayoutPolicy.RequiresMfdRegionInMainWindow(parse)); |
| 365 | } |
| 366 | |
| 367 | [Fact] |
| 368 | public void DedicatedPfM_Unweighted_MainGridHidesMfdTailWhenHostTopology() |
| 369 | { |
| 370 | var parse = PresentationParser.Parse("(P+F) (M)", PresentationTopologyCatalog.DefaultGrammar); |
| 371 | var flags = PresentationTopologyResolver.ResolveFlags(parse); |
| 372 | var frame = PresentationTopologyResolver.BuildMainWindowGridAtStartup(parse, flags); |
| 373 | Assert.Equal("220,4,*,4,0", frame.ColumnDefinitions); |
| 374 | } |
| 375 | |
| 376 | [Fact] |
| 377 | public void ForwardOnlyMainScreen_NeverUsesDefaultThreeColumnGrid() |
| 378 | { |
| 379 | foreach (var c in PresentationTopologyCatalog.All.Where(x => x.MainGridAtStartup == "0,4,*,4,0")) |
| 380 | { |
| 381 | var parse = PresentationParser.Parse(c.Line, PresentationTopologyCatalog.DefaultGrammar); |
| 382 | var flags = PresentationTopologyResolver.ResolveFlags(parse); |
| 383 | var frame = PresentationTopologyResolver.BuildMainWindowGridAtStartup(parse, flags); |
| 384 | |
| 385 | Assert.NotEqual(PresentationMainGridLayoutFrameBuilder.DefaultColumnDefinitions, frame.ColumnDefinitions); |
| 386 | Assert.DoesNotContain("220", frame.ColumnDefinitions); |
| 387 | Assert.DoesNotContain("340", frame.ColumnDefinitions); |
| 388 | } |
| 389 | } |
| 390 | } |
| 391 | |