| 1 | using System.Text.Json; |
| 2 | using CascadeIDE.Features.Shell.Application; |
| 3 | using CascadeIDE.Models; |
| 4 | using CascadeIDE.Services.Presentation; |
| 5 | using Tomlyn; |
| 6 | using Xunit; |
| 7 | |
| 8 | namespace CascadeIDE.Tests; |
| 9 | |
| 10 | /// <summary> |
| 11 | /// Контракт TOML: вложенные секции (<c>[markdown.diagrams]</c>, <c>[display.screens.grammar]</c>) и snake_case. |
| 12 | /// </summary> |
| 13 | public sealed class CascadeIdeSettingsTomlDeserializeTests |
| 14 | { |
| 15 | private static readonly TomlSerializerOptions Options = new() |
| 16 | { |
| 17 | PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, |
| 18 | }; |
| 19 | |
| 20 | private static CascadeIdeSettings Deserialize(string text) => |
| 21 | TomlSerializer.Deserialize<CascadeIdeSettings>(text, Options) |
| 22 | ?? throw new InvalidOperationException("Deserialize returned null"); |
| 23 | |
| 24 | [Fact] |
| 25 | public void Deserialize_FontsIntercomAndEditor_ParsesExpected() |
| 26 | { |
| 27 | const string text = |
| 28 | """ |
| 29 | [fonts.intercom] |
| 30 | prose_pt = 13 |
| 31 | prose_pt_forward = 12 |
| 32 | composer_pt = 15 |
| 33 | chrome_title_pt = 14 |
| 34 | chrome_subtitle_pt = 11 |
| 35 | chrome_heading_pt = 16 |
| 36 | card_title_pt = 15 |
| 37 | panel_title_pt = 16 |
| 38 | panel_input_pt = 14 |
| 39 | prose_family = "Segoe UI" |
| 40 | mono_family = "Cascadia Mono,Consolas" |
| 41 | chip_id_family = "Consolas" |
| 42 | |
| 43 | [fonts.editor] |
| 44 | size_pt = 14 |
| 45 | family = "Consolas,Cascadia Code,monospace" |
| 46 | """; |
| 47 | |
| 48 | var s = Deserialize(text); |
| 49 | Assert.Equal(13, s.Fonts.Intercom.ProsePt); |
| 50 | Assert.Equal(12, s.Fonts.Intercom.ProsePtForward); |
| 51 | Assert.Equal(15, s.Fonts.Intercom.ComposerPt); |
| 52 | Assert.Equal(14, s.Fonts.Intercom.ChromeTitlePt); |
| 53 | Assert.Equal(15f, s.Fonts.Intercom.ResolveComposerPt(forwardHost: false)); |
| 54 | Assert.Equal(14f, s.Fonts.Intercom.ResolveChromeTitlePt()); |
| 55 | Assert.Equal(15f, s.Fonts.Intercom.ResolveCardTitleLineHeight(forwardHost: false)); |
| 56 | Assert.Equal(16f, s.Fonts.Intercom.ResolvePanelTitlePt()); |
| 57 | Assert.Equal(14f, s.Fonts.Intercom.ResolvePanelInputPt()); |
| 58 | Assert.Equal("Segoe UI", s.Fonts.Intercom.ProseFamily); |
| 59 | Assert.Equal("Cascadia Mono,Consolas", s.Fonts.Intercom.MonoFamily); |
| 60 | Assert.Equal("Consolas", s.Fonts.Intercom.ChipIdFamily); |
| 61 | Assert.Equal(14, s.Fonts.Editor.SizePt); |
| 62 | Assert.Equal("Consolas,Cascadia Code,monospace", s.Fonts.Editor.Family); |
| 63 | Assert.Equal(12f, s.Fonts.Intercom.ResolveProsePt(forwardHost: true)); |
| 64 | Assert.Equal(13f, s.Fonts.Intercom.ResolveProsePt(forwardHost: false)); |
| 65 | Assert.Equal(14f, s.Fonts.Editor.ResolveSizePt()); |
| 66 | Assert.Equal("Segoe UI", s.Fonts.Intercom.ResolveProseFamily()); |
| 67 | Assert.Equal("Consolas,Cascadia Code,monospace", s.Fonts.Editor.ResolveFamily()); |
| 68 | } |
| 69 | |
| 70 | [Fact] |
| 71 | public void Deserialize_LoggingIntercomSendTrace_ParsesExpected() |
| 72 | { |
| 73 | const string text = |
| 74 | """ |
| 75 | [logging.intercom] |
| 76 | send_trace = true |
| 77 | """; |
| 78 | |
| 79 | var s = Deserialize(text); |
| 80 | Assert.True(s.Logging.Intercom.SendTrace); |
| 81 | } |
| 82 | |
| 83 | [Fact] |
| 84 | public void Deserialize_DisplayScreensAndMarkdownNested_ParsesExpected() |
| 85 | { |
| 86 | const string text = |
| 87 | """ |
| 88 | [display.screens] |
| 89 | topology = "(P+F) (M)" |
| 90 | |
| 91 | [display.screens.grammar] |
| 92 | pfd = "P" |
| 93 | forward = "F" |
| 94 | mfd = "M" |
| 95 | |
| 96 | [markdown.diagrams] |
| 97 | kroki = true |
| 98 | kroki_url = "https://kroki.io" |
| 99 | """; |
| 100 | |
| 101 | var s = Deserialize(text); |
| 102 | Assert.Equal("(P+F) (M)", s.Display.Screens.Topology); |
| 103 | Assert.Equal("P", s.Display.Screens.Grammar.Pfd); |
| 104 | Assert.True(s.Markdown.Diagrams.Kroki); |
| 105 | Assert.Equal("https://kroki.io", s.Markdown.Diagrams.KrokiUrl); |
| 106 | } |
| 107 | |
| 108 | [Fact] |
| 109 | public void Serialize_RoundTrip_PreservesDisplayScreensTopology() |
| 110 | { |
| 111 | var original = new CascadeIdeSettings |
| 112 | { |
| 113 | Display = new DisplaySettings |
| 114 | { |
| 115 | Screens = new DisplayScreensSettings |
| 116 | { |
| 117 | Topology = "(0.5PFD + 0.5Forward) (MFD)", |
| 118 | }, |
| 119 | }, |
| 120 | }; |
| 121 | var toml = TomlSerializer.Serialize(original, Options); |
| 122 | Assert.Contains("[display.screens]", toml, StringComparison.Ordinal); |
| 123 | var roundtrip = Deserialize(toml); |
| 124 | Assert.Equal("(0.5PFD + 0.5Forward) (MFD)", roundtrip.GetEffectivePresentationLine()); |
| 125 | } |
| 126 | |
| 127 | [Fact] |
| 128 | public void Deserialize_DisplayScreensTopology_AndGrammar_UsedForEffective() |
| 129 | { |
| 130 | const string text = |
| 131 | """ |
| 132 | [display.screens] |
| 133 | topology = "(P)(F)(M)" |
| 134 | |
| 135 | [display.screens.grammar] |
| 136 | pfd = "P" |
| 137 | forward = "F" |
| 138 | mfd = "M" |
| 139 | """; |
| 140 | |
| 141 | var s = Deserialize(text); |
| 142 | Assert.Equal("(P)(F)(M)", s.GetEffectivePresentationLine()); |
| 143 | var g = s.GetEffectivePresentationGrammar(); |
| 144 | Assert.Equal("P", g.Pfd); |
| 145 | Assert.Equal("F", g.Forward); |
| 146 | Assert.Equal("M", g.Mfd); |
| 147 | } |
| 148 | |
| 149 | [Fact] |
| 150 | public void Deserialize_CodeNavigationMapSection_ParsesViewAndDepth() |
| 151 | { |
| 152 | const string text = |
| 153 | """ |
| 154 | [code_navigation_map] |
| 155 | view = "both" |
| 156 | depth = "controlFlow" |
| 157 | detail_level = "glance" |
| 158 | """; |
| 159 | |
| 160 | var s = Deserialize(text); |
| 161 | Assert.True(s.CodeNavigationMap.WantsCodeNavigationMapList); |
| 162 | Assert.True(s.CodeNavigationMap.WantsCodeNavigationMapGraph); |
| 163 | Assert.True(s.CodeNavigationMap.IsControlFlowDepth); |
| 164 | Assert.Equal("both", CodeNavigationMapSettings.NormalizeView(s.CodeNavigationMap.View)); |
| 165 | Assert.Equal(CodeNavigationMapLevelKind.ControlFlow, CodeNavigationMapSettings.NormalizeDepth(s.CodeNavigationMap.Depth)); |
| 166 | Assert.Equal(CodeNavigationMapDetailLevel.Glance, s.CodeNavigationMap.NormalizedDetailLevel); |
| 167 | } |
| 168 | |
| 169 | [Fact] |
| 170 | public void Deserialize_CodeNavigationMapSection_ParsesControlFlowMainAxis() |
| 171 | { |
| 172 | const string text = |
| 173 | """ |
| 174 | [code_navigation_map] |
| 175 | control_flow_main_axis = "vertical" |
| 176 | """; |
| 177 | |
| 178 | var s = Deserialize(text); |
| 179 | Assert.Equal(CodeNavigationMapControlFlowMainAxisKind.Vertical, s.CodeNavigationMap.NormalizedControlFlowMainAxis); |
| 180 | } |
| 181 | |
| 182 | [Fact] |
| 183 | public void Deserialize_DisplayScreensTopology_WithoutGrammar_ParsesAsTwoScreens() |
| 184 | { |
| 185 | const string text = |
| 186 | """ |
| 187 | [display.screens] |
| 188 | topology = "(P+F) (M)" |
| 189 | """; |
| 190 | |
| 191 | var s = Deserialize(text); |
| 192 | Assert.Equal("(P+F) (M)", s.GetEffectivePresentationLine()); |
| 193 | var grammar = PresentationGrammarTokens.FromSettings( |
| 194 | s.Display.Screens.Grammar.Brackets, |
| 195 | s.Display.Screens.Grammar.BetweenScreens, |
| 196 | s.Display.Screens.Grammar.BetweenZones, |
| 197 | s.Display.Screens.Grammar.Pfd, |
| 198 | s.Display.Screens.Grammar.Forward, |
| 199 | s.Display.Screens.Grammar.Mfd); |
| 200 | var parse = PresentationParser.Parse(s.GetEffectivePresentationLine(), grammar); |
| 201 | Assert.True(parse.IsSuccess); |
| 202 | Assert.Equal(2, parse.Screens.Count); |
| 203 | Assert.True(PresentationLayoutAnalyzer.IsDedicatedMfdSecondScreenPreset(parse.Screens)); |
| 204 | } |
| 205 | |
| 206 | [Fact] |
| 207 | public void Deserialize_AiNested_Adr0083_ParsesExpected() |
| 208 | { |
| 209 | const string text = |
| 210 | """ |
| 211 | [ai] |
| 212 | mode = "local" |
| 213 | |
| 214 | [ai.local] |
| 215 | backend = "ollama" |
| 216 | |
| 217 | [ai.local.ollama] |
| 218 | model = "qwen2.5-coder:7b" |
| 219 | |
| 220 | [ai.chat] |
| 221 | settings_presentation = "mfd" |
| 222 | show_thinking_in_history = true |
| 223 | """; |
| 224 | |
| 225 | var s = Deserialize(text); |
| 226 | Assert.Equal("local", s.Ai.Mode); |
| 227 | Assert.Equal("ollama", s.Ai.Local.Backend); |
| 228 | Assert.Equal("qwen2.5-coder:7b", s.Ai.Local.Ollama.Model); |
| 229 | Assert.Equal("mfd", s.Ai.Chat.SettingsPresentation); |
| 230 | Assert.True(s.Ai.Chat.ShowThinkingInHistory); |
| 231 | } |
| 232 | |
| 233 | [Fact] |
| 234 | public void Deserialize_LanguagesCSharp_executable_env_ParsesExpected() |
| 235 | { |
| 236 | const string text = |
| 237 | """ |
| 238 | [languages.csharp] |
| 239 | mode = "Custom" |
| 240 | |
| 241 | [languages.csharp.custom] |
| 242 | executable = "fallback.exe" |
| 243 | executable_env = "CASCADE_IDE_CSHARP_EXECUTABLE" |
| 244 | """; |
| 245 | |
| 246 | var s = Deserialize(text); |
| 247 | Assert.Equal("fallback.exe", s.Languages.CSharp.Custom.Executable); |
| 248 | Assert.Equal("CASCADE_IDE_CSHARP_EXECUTABLE", s.Languages.CSharp.Custom.ExecutableEnv); |
| 249 | } |
| 250 | |
| 251 | [Fact] |
| 252 | public void Deserialize_LanguagesCSharpNestedMode_ParsesExpected() |
| 253 | { |
| 254 | const string text = |
| 255 | """ |
| 256 | [languages.csharp] |
| 257 | mode = "OmniSharp" |
| 258 | |
| 259 | [languages.csharp.omni_sharp] |
| 260 | executable = "C:\\omnisharp-win-x64-net6.0\\OmniSharp.exe" |
| 261 | arguments = "--languageserver --hostPID 1234" |
| 262 | """; |
| 263 | |
| 264 | var s = Deserialize(text); |
| 265 | var runtime = s.Languages.CSharp.ResolveForRuntime(); |
| 266 | Assert.Equal("OmniSharp", runtime.Mode); |
| 267 | Assert.Equal("C:\\omnisharp-win-x64-net6.0\\OmniSharp.exe", runtime.Executable); |
| 268 | Assert.Equal("--languageserver --hostPID 1234", runtime.Arguments); |
| 269 | } |
| 270 | |
| 271 | [Fact] |
| 272 | public void Deserialize_LanguagesMarkdownNestedMode_ParsesExpected() |
| 273 | { |
| 274 | const string text = |
| 275 | """ |
| 276 | [languages.markdown] |
| 277 | mode = "Marksman" |
| 278 | |
| 279 | [languages.markdown.marksman] |
| 280 | executable = "C:\\tools\\marksman.exe" |
| 281 | arguments = "--stdio" |
| 282 | """; |
| 283 | |
| 284 | var s = Deserialize(text); |
| 285 | var runtime = s.Languages.Markdown.ResolveForRuntime(); |
| 286 | Assert.Equal("Marksman", runtime.Mode); |
| 287 | Assert.Equal("C:\\tools\\marksman.exe", runtime.Executable); |
| 288 | Assert.Equal("--stdio", runtime.Arguments); |
| 289 | } |
| 290 | |
| 291 | [Fact] |
| 292 | public void Deserialize_AgentNotes_ConfigPath_ParsesExpected() |
| 293 | { |
| 294 | const string toml = """ |
| 295 | [agent_notes] |
| 296 | config_path = "D:/agent-notes-mcp/agent-notes-mcp.toml" |
| 297 | """; |
| 298 | var s = CascadeTomlSerializer.Deserialize<CascadeIdeSettings>(toml)!; |
| 299 | Assert.Equal("D:/agent-notes-mcp/agent-notes-mcp.toml", s.AgentNotes.ConfigPath); |
| 300 | } |
| 301 | |
| 302 | [Fact] |
| 303 | public void Deserialize_AgentNotes_KbBaseOverlayPath_ParsesExpected() |
| 304 | { |
| 305 | const string text = |
| 306 | """ |
| 307 | [agent_notes] |
| 308 | kb_base_overlay_path = "D:\\vault\\agent-notes" |
| 309 | """; |
| 310 | |
| 311 | var s = Deserialize(text); |
| 312 | Assert.Equal("D:\\vault\\agent-notes", s.AgentNotes.KbBaseOverlayPath); |
| 313 | } |
| 314 | |
| 315 | [Fact] |
| 316 | public void Deserialize_IntercomAttachmentsCodeNavigate_ParsesExpected() |
| 317 | { |
| 318 | const string text = |
| 319 | """ |
| 320 | [intercom.attachments.code] |
| 321 | navigate = "select" |
| 322 | """; |
| 323 | |
| 324 | var s = Deserialize(text); |
| 325 | Assert.True(s.Intercom.Attachments.Code.DefaultNavigateSelects()); |
| 326 | Assert.Equal("select", s.Intercom.Attachments.Code.Navigate); |
| 327 | } |
| 328 | |
| 329 | [Fact] |
| 330 | public void Deserialize_IntercomAttachmentsCodeRevealLoadSolution_ParsesExpected() |
| 331 | { |
| 332 | const string text = |
| 333 | """ |
| 334 | [intercom.attachments.code] |
| 335 | reveal_load_solution = "never" |
| 336 | """; |
| 337 | |
| 338 | var s = Deserialize(text); |
| 339 | Assert.False(s.Intercom.Attachments.Code.ShouldLoadSolutionBeforeReveal()); |
| 340 | Assert.Equal("never", s.Intercom.Attachments.Code.RevealLoadSolution); |
| 341 | } |
| 342 | |
| 343 | [Fact] |
| 344 | public void Deserialize_IntercomFeedMetrics_ParsesCompact() |
| 345 | { |
| 346 | const string text = |
| 347 | """ |
| 348 | [intercom] |
| 349 | feed_metrics = "compact" |
| 350 | """; |
| 351 | |
| 352 | var s = Deserialize(text); |
| 353 | Assert.Equal(IntercomFeedMetricsModes.Compact, s.Intercom.FeedMetrics); |
| 354 | Assert.False(s.Intercom.UseComfortableFeedMetrics()); |
| 355 | } |
| 356 | |
| 357 | [Fact] |
| 358 | public void Deserialize_IntercomAttachmentsCode_DefaultsWhenSectionMissing() |
| 359 | { |
| 360 | const string text = |
| 361 | """ |
| 362 | [hybrid_index] |
| 363 | enabled = false |
| 364 | """; |
| 365 | |
| 366 | var s = Deserialize(text); |
| 367 | Assert.False(s.Intercom.Attachments.Code.DefaultNavigateSelects()); |
| 368 | Assert.Equal("reveal", s.Intercom.Attachments.Code.Navigate); |
| 369 | Assert.True(s.Intercom.Attachments.Code.ShouldLoadSolutionBeforeReveal()); |
| 370 | } |
| 371 | |
| 372 | [Fact] |
| 373 | public void Deserialize_HybridIndexSection_ParsesExpected() |
| 374 | { |
| 375 | const string text = |
| 376 | """ |
| 377 | [hybrid_index] |
| 378 | enabled = true |
| 379 | index_dir = ".hci" |
| 380 | debounce_ms = 1234 |
| 381 | auto_reindex_on_solution_open = false |
| 382 | watch_files = false |
| 383 | scope_mode = "workspace" |
| 384 | pause_when_mcp_stdio_host = true |
| 385 | """; |
| 386 | |
| 387 | var s = Deserialize(text); |
| 388 | Assert.True(s.HybridIndex.Enabled); |
| 389 | Assert.Equal(".hci", s.HybridIndex.IndexDir); |
| 390 | Assert.Equal(1234, s.HybridIndex.DebounceMs); |
| 391 | Assert.False(s.HybridIndex.AutoReindexOnSolutionOpen); |
| 392 | Assert.False(s.HybridIndex.WatchFiles); |
| 393 | Assert.Equal("workspace", s.HybridIndex.ScopeMode); |
| 394 | Assert.True(s.HybridIndex.PauseWhenMcpStdioHost); |
| 395 | } |
| 396 | |
| 397 | [Fact] |
| 398 | public void CascadeIdeSettings_Clone_CopiesHybridIndex() |
| 399 | { |
| 400 | var s = new CascadeIdeSettings |
| 401 | { |
| 402 | HybridIndex = new HybridIndexSettings |
| 403 | { |
| 404 | Enabled = false, |
| 405 | IndexDir = ".hci2", |
| 406 | DebounceMs = 999, |
| 407 | AutoReindexOnSolutionOpen = false, |
| 408 | WatchFiles = false, |
| 409 | ScopeMode = "workspace", |
| 410 | PauseWhenMcpStdioHost = true, |
| 411 | }, |
| 412 | }; |
| 413 | var c = (CascadeIdeSettings)s.Clone(); |
| 414 | Assert.False(c.HybridIndex.Enabled); |
| 415 | Assert.Equal(".hci2", c.HybridIndex.IndexDir); |
| 416 | Assert.Equal(999, c.HybridIndex.DebounceMs); |
| 417 | Assert.False(c.HybridIndex.AutoReindexOnSolutionOpen); |
| 418 | Assert.False(c.HybridIndex.WatchFiles); |
| 419 | Assert.Equal("workspace", c.HybridIndex.ScopeMode); |
| 420 | Assert.True(c.HybridIndex.PauseWhenMcpStdioHost); |
| 421 | } |
| 422 | |
| 423 | [Fact] |
| 424 | public void CascadeIdeSettings_Is_ComparesHybridIndex() |
| 425 | { |
| 426 | var a = new CascadeIdeSettings(); |
| 427 | var b = new CascadeIdeSettings(); |
| 428 | Assert.True(a.Is(b)); |
| 429 | |
| 430 | b.HybridIndex.DebounceMs = 123; |
| 431 | Assert.False(a.Is(b)); |
| 432 | } |
| 433 | |
| 434 | [Fact] |
| 435 | public void Deserialize_CommandPaletteGoToSearch_ParsesBackend() |
| 436 | { |
| 437 | const string text = |
| 438 | """ |
| 439 | [command_palette.go_to_search] |
| 440 | backend = "hci" |
| 441 | """; |
| 442 | |
| 443 | var s = Deserialize(text); |
| 444 | Assert.Equal("hci", s.CommandPalette.GoToSearch.Backend); |
| 445 | Assert.Equal(CommandPaletteGoToSearchBackendKind.Hci, CommandPaletteGoToSearchBackendNormalizer.Parse(s.CommandPalette.GoToSearch.Backend)); |
| 446 | } |
| 447 | |
| 448 | [Fact] |
| 449 | public void NormalizeHybridIndexScopeMode_DefaultsExpected() |
| 450 | { |
| 451 | Assert.Equal("workspace", ShellSettingsPresentationProjection.NormalizeHybridIndexScopeMode("workspace")); |
| 452 | Assert.Equal("workspace+solution", ShellSettingsPresentationProjection.NormalizeHybridIndexScopeMode("")); |
| 453 | Assert.Equal("workspace+solution", ShellSettingsPresentationProjection.NormalizeHybridIndexScopeMode("garbage")); |
| 454 | } |
| 455 | |
| 456 | [Fact] |
| 457 | public void Deserialize_CodeNavigationMapControlFlowGrain_ParsesExpected() |
| 458 | { |
| 459 | const string text = |
| 460 | """ |
| 461 | [code_navigation_map] |
| 462 | control_flow_grain = "detailed" |
| 463 | """; |
| 464 | |
| 465 | var s = Deserialize(text); |
| 466 | Assert.Equal("detailed", s.CodeNavigationMap.ControlFlowGrain); |
| 467 | Assert.Equal(CodeNavigationMapControlFlowGrainKind.Detailed, s.CodeNavigationMap.NormalizedControlFlowGrain); |
| 468 | } |
| 469 | |
| 470 | [Fact] |
| 471 | public void Deserialize_CodeNavigationMapControlFlowGrain_Unknown_NormalizesIntent() |
| 472 | { |
| 473 | const string text = |
| 474 | """ |
| 475 | [code_navigation_map] |
| 476 | control_flow_grain = "micro_cfg" |
| 477 | """; |
| 478 | |
| 479 | var s = Deserialize(text); |
| 480 | Assert.Equal("micro_cfg", s.CodeNavigationMap.ControlFlowGrain); |
| 481 | Assert.Equal(CodeNavigationMapControlFlowGrainKind.Intent, s.CodeNavigationMap.NormalizedControlFlowGrain); |
| 482 | } |
| 483 | |
| 484 | } |
| 485 | |