using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Text.Json; using System.Windows; using System.Windows.Media; using FluentAssertions; using TeamsISO.App.Services; using TeamsISO.App.Tests.Fakes; using TeamsISO.App.ViewModels; using TeamsISO.Engine.Domain; using Xunit; namespace TeamsISO.App.Tests.Integration; // End-to-end-ish integration tests that need a live WPF Application + // STA dispatcher. All three live in one class + share a // WpfHostFixture so Application is created exactly once for the // suite (Application is one-per-AppDomain — multiple test classes // trying to construct it independently collide). // // Coverage per the punch list: // • App-startup headless smoke — construct App's bootstrap layers // on STA, verify XAML resource resolution + theme apply + VM // wiring + MainWindow construction. // • ControlSurface integration — boot the server on an ephemeral // port, populate a real view-model, hit /participants, verify // the JSON includes the live participant. // • Theme swap — Dark → Light dictionary swap, brush key resolves // to a different value afterward. [Collection(WpfHostCollection.Name)] public sealed class IntegrationTests { private readonly WpfHostFixture _wpf; public IntegrationTests(WpfHostFixture wpf) => _wpf = wpf; private static int PickFreePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); try { return ((IPEndPoint)listener.LocalEndpoint).Port; } finally { listener.Stop(); } } private async Task SeedDarkThemeAsync() { await _wpf.Run(() => { var dicts = _wpf.Application.Resources.MergedDictionaries; dicts.Clear(); dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml", UriKind.Absolute), }); }); } [Fact] public async Task ThemeXaml_DarkAndLight_BothLoadWithDistinctWdCanvas() { // Verifies the real XAML files load via pack URIs (the // production code path) and that the two theme files // produce different brushes for the same key. End-to-end // exercise of the resource pipeline that doesn't depend on // Application.Resources global state — both dicts are // loaded fresh in this call. // // We don't test ThemeManager.SwapColorDictionary here // because Application.Resources is process-wide and // sibling-test mutations make the state observably non- // deterministic in xUnit's parallel-collection model; // ThemeManagerTests (Services/) cover the swap state // machine against stubbed seams. This test guards the // distinct-XAML-files claim, which is what would otherwise // get refactored out by accident. await _wpf.Run(() => { var darkDict = new ResourceDictionary { Source = new Uri( "pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml", UriKind.Absolute), }; var lightDict = new ResourceDictionary { Source = new Uri( "pack://application:,,,/TeamsISO;component/Themes/Theme.Light.xaml", UriKind.Absolute), }; var darkCanvas = ((SolidColorBrush)darkDict ["Wd.Canvas"]).Color; var lightCanvas = ((SolidColorBrush)lightDict["Wd.Canvas"]).Color; darkCanvas.Should().Be(Color.FromRgb(0x0A, 0x0A, 0x0A), "Theme.Dark.xaml's Wd.Canvas is the documented #0A0A0A"); lightCanvas.Should().Be(Color.FromRgb(0xFA, 0xFA, 0xFB), "Theme.Light.xaml's Wd.Canvas is the documented #FAFAFB"); }); } [Fact] public async Task AppStartup_FullChain_Constructs_WithoutThrowing() { // Headless smoke for the App.OnStartup wiring sequence: // 1. Application + theme resources are loaded. // 2. ThemeManager.Apply() resolves brush keys end-to-end. // 3. MainViewModel constructs against a stub controller. // 4. MainWindow ctor resolves DataContext + finds the brushes // its templates reference. await SeedDarkThemeAsync(); await _wpf.Run(() => { _wpf.Application.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri( "pack://application:,,,/TeamsISO;component/Themes/WildDragonTheme.xaml", UriKind.Absolute), }); }); // Everything DependencyObject-touching has to run on the STA // dispatcher (Window / DataContext / TryFindResource all // VerifyAccess). Do the assertions inside the Run callback so // we never marshal a DependencyObject reference back to the // test thread. await _wpf.Run(() => { var tm = new ThemeManager( isSystemDark: () => true, loadPreference: () => "Dark", savePreference: _ => { }, subscribeToSystemPreference: false); tm.Apply(); var controller = new StubIsoController(); var vm = new MainViewModel(controller, _wpf.Dispatcher); try { var window = new MainWindow(vm); vm.Settings.Should().NotBeNull("MainViewModel wires GlobalSettingsViewModel"); vm.AlertBanner.Should().NotBeNull(); window.DataContext.Should().BeSameAs(vm); window.TryFindResource("Wd.Canvas").Should().NotBeNull( "Wd.Canvas is defined in Theme.Dark.xaml and used by MainWindow.xaml"); } finally { vm.Dispose(); } }); } [Fact] public async Task ControlSurface_GetParticipants_ReturnsLiveViewModelState() { var controller = new StubIsoController(); var vm = await _wpf.Run(() => new MainViewModel(controller, _wpf.Dispatcher)); // Publish a participant through the controller observable and // wait for the dispatcher to drain the InvokeAsync(Background) // marshal that adds Alice to the Participants collection. controller.PublishParticipants(new Participant( Id: Guid.NewGuid(), DisplayName: "Alice", CurrentSource: null, FirstSeen: DateTimeOffset.UtcNow, LastSeen: DateTimeOffset.UtcNow)); // Drain the queue at ApplicationIdle so the Background-priority // add has time to complete before we look. await _wpf.Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.ApplicationIdle).Task; var server = new ControlSurfaceServer(controller, () => vm, logger: null); var port = PickFreePort(); server.Start(port); await Task.Delay(50); using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; try { var res = await client.GetAsync("/participants"); res.StatusCode.Should().Be(HttpStatusCode.OK); var body = await res.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); var participants = doc.RootElement.GetProperty("participants"); participants.GetArrayLength().Should().Be(1); participants[0].GetProperty("displayName").GetString().Should().Be("Alice"); } finally { server.Stop(); await _wpf.Run(() => vm.Dispose()); await controller.DisposeAsync(); } } }