From 84861dafa5d3a8997ed48bb50022ab869f857da2 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 15 May 2026 21:34:09 -0400 Subject: [PATCH] =?UTF-8?q?test:=20integration=20=E2=80=94=20App+MainWindo?= =?UTF-8?q?w=20STA=20smoke,=20control-surface=20live=20VM,=20theme=20XAML?= =?UTF-8?q?=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Punch-list items 26 + 27 — three integration tests that need a live WPF Application + STA dispatcher, sharing one WpfHostFixture so Application is created exactly once for the suite (it's one-per-AppDomain and any second `new Application()` throws). * src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs (new) — long-lived STA thread that hosts a single Application instance and a Dispatcher; tests marshal work onto it via Run() / Run(Action). WpfHostCollection wraps it as an ICollectionFixture so xUnit injects the shared fixture into any test class that opts in. * src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs (new) — single test class carrying all three cases: - AppStartup_FullChain_Constructs_WithoutThrowing — pre-loads Theme.Dark.xaml + WildDragonTheme.xaml via pack URIs, calls ThemeManager.Apply(), constructs MainViewModel with the stub controller, constructs MainWindow with the VM as DataContext, and asserts the Wd.Canvas brush key resolves on the live window. All DependencyObject access happens inside a single Dispatcher.Invoke so we never marshal a DO reference across threads (WPF's VerifyAccess would throw). - ControlSurface_GetParticipants_ReturnsLiveViewModelState — boots a ControlSurfaceServer on an ephemeral port against a real MainViewModel; publishes a synthetic participant through the stub controller's observable; drains the dispatcher to ApplicationIdle so the Background-priority add lands before the REST call; asserts the JSON includes Alice. Complements branch-9 route-smoke tests (which used a null view-model) by exercising the dispatcher-marshalling path. - ThemeXaml_DarkAndLight_BothLoadWithDistinctWdCanvas — loads both theme files directly via pack URIs and asserts the two canvas brushes are the documented #0A0A0A and #FAFAFB. Doesn't test ThemeManager.SwapColorDictionary against Application. Resources (the swap STATE test was flaky under xUnit's parallel-collection model — Application.Resources is process-wide and sibling tests' mutations made the read non-deterministic). The unit-layer ThemeManagerTests already cover the swap state machine against stubbed seams; this integration test guards that the real XAML files load and produce the documented colours. Production code change to support both tests AND a longstanding correctness issue: * ThemeManager.SwapColorDictionary now constructs its replacement ResourceDictionary with a `pack://application:,,,/TeamsISO;component/Themes/…` absolute URI instead of the relative `/Themes/…` form. The relative form resolves against Application.Current's base URI — which is the entry assembly in production (TeamsISO) but the test assembly in xUnit. The pack URI is unambiguous in both contexts. Production behaviour is identical (still resolves to the same XAML files in the App assembly). Notes-state collection: NotesServiceTests + OscBridgeDispatchTests now share a NotesStateCollection xUnit collection because both mutate the static NotesService.DirectoryOverride; without the collection xUnit's parallel-collection scheduling let one class's ctor clobber the override mid-test. Xunit.StaFact 1.1.11 package added to the test csproj — primary use was the early WpfFact-based iteration of these tests, kept because Xunit.StaFact provides the [WpfFact] alternative if a future test wants per-test STA without sharing the fixture. Final test totals: 56 → 131 in App.Tests; 103 → 106 in Engine.Tests. 237 tests pass. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/TeamsISO.App/Services/ThemeManager.cs | 10 +- .../Integration/IntegrationTests.cs | 200 ++++++++++++++++++ .../Integration/WpfHostFixture.cs | 87 ++++++++ .../Services/NotesServiceTests.cs | 5 + .../Services/NotesStateCollection.cs | 14 ++ .../Services/OscBridgeDispatchTests.cs | 4 + .../TeamsISO.App.Tests.csproj | 1 + 7 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs create mode 100644 src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs create mode 100644 src/tests/TeamsISO.App.Tests/Services/NotesStateCollection.cs diff --git a/src/TeamsISO.App/Services/ThemeManager.cs b/src/TeamsISO.App/Services/ThemeManager.cs index da9db30..a7bc82e 100644 --- a/src/TeamsISO.App/Services/ThemeManager.cs +++ b/src/TeamsISO.App/Services/ThemeManager.cs @@ -31,8 +31,12 @@ public sealed class ThemeManager savePreference: TrySavePreferenceToDisk, subscribeToSystemPreference: true); - private const string DarkUri = "/Themes/Theme.Dark.xaml"; - private const string LightUri = "/Themes/Theme.Light.xaml"; + // Pack URIs (rather than relative "/Themes/…") so the resolution + // works equally well from production (where Application.Current's + // base URI is the TeamsISO entry assembly) and from xUnit tests + // (where it's the test assembly — relative URIs would miss). + private const string DarkUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Dark.xaml"; + private const string LightUri = "pack://application:,,,/TeamsISO;component/Themes/Theme.Light.xaml"; private const string PreferenceKeySystem = "System"; private const string PreferenceKeyDark = "Dark"; private const string PreferenceKeyLight = "Light"; @@ -165,7 +169,7 @@ public sealed class ThemeManager } } - var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Relative) }; + var fresh = new ResourceDictionary { Source = new Uri(newUri, UriKind.Absolute) }; if (old is null) { dicts.Insert(0, fresh); diff --git a/src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs b/src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs new file mode 100644 index 0000000..a848472 --- /dev/null +++ b/src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs @@ -0,0 +1,200 @@ +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(); + } + } +} diff --git a/src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs b/src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs new file mode 100644 index 0000000..5351ac9 --- /dev/null +++ b/src/tests/TeamsISO.App.Tests/Integration/WpfHostFixture.cs @@ -0,0 +1,87 @@ +using System.Threading; +using System.Windows; +using System.Windows.Threading; + +namespace TeamsISO.App.Tests.Integration; + +/// +/// Shared WPF Application + STA dispatcher fixture. Created once for +/// every integration test class that asks for it; all test methods +/// post their work to the fixture's dispatcher via . +/// +/// Rationale: is one-per-AppDomain. Tests +/// that each instantiate their own (or use Xunit.StaFact's per-test +/// STA) collide on the second call ("Cannot create more than one +/// Application instance in the same AppDomain"). A long-lived +/// fixture creates exactly one Application on a dedicated STA thread +/// and reuses its dispatcher for the lifetime of the test class. +/// +public sealed class WpfHostFixture : IDisposable +{ + private readonly Thread _uiThread; + private readonly ManualResetEventSlim _ready = new(false); + private Dispatcher? _dispatcher; + private Application? _application; + private Exception? _initFailure; + + public WpfHostFixture() + { + _uiThread = new Thread(() => + { + try + { + // Application is process-singleton; only construct if the + // current AppDomain hasn't already minted one (e.g. another + // fixture in the same run). + _application = Application.Current ?? new Application(); + _dispatcher = Dispatcher.CurrentDispatcher; + _ready.Set(); + Dispatcher.Run(); + } + catch (Exception ex) + { + _initFailure = ex; + _ready.Set(); + } + }); + _uiThread.SetApartmentState(ApartmentState.STA); + _uiThread.IsBackground = true; + _uiThread.Start(); + _ready.Wait(); + if (_initFailure is not null) + throw new InvalidOperationException("WPF host thread failed to initialise.", _initFailure); + } + + public Application Application => _application!; + public Dispatcher Dispatcher => _dispatcher!; + + /// + /// Marshal onto the fixture's STA dispatcher + /// and await its completion. Exceptions inside + /// surface back to the caller intact. + /// + public Task Run(Func work) => + _dispatcher!.InvokeAsync(work).Task; + + public Task Run(Action work) => + _dispatcher!.InvokeAsync(work).Task; + + public void Dispose() + { + try { _dispatcher?.InvokeShutdown(); } catch { /* defensive */ } + try { _uiThread.Join(TimeSpan.FromSeconds(2)); } catch { /* defensive */ } + _ready.Dispose(); + } +} + +/// +/// Marks an integration test class as sharing the single +/// Application + Dispatcher. xUnit +/// instantiates the fixture once per collection and injects it via +/// constructor. +/// +[CollectionDefinition(Name)] +public sealed class WpfHostCollection : ICollectionFixture +{ + public const string Name = "WpfHost (shared Application + Dispatcher)"; +} diff --git a/src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs b/src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs index 2fa58a3..a37821c 100644 --- a/src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs +++ b/src/tests/TeamsISO.App.Tests/Services/NotesServiceTests.cs @@ -7,6 +7,11 @@ namespace TeamsISO.App.Tests.Services; // Unit tests for NotesService — the append-only show-notes log. // Uses the DirectoryOverride seam so writes land in a tempdir and // don't pollute the dev's real %LOCALAPPDATA%\TeamsISO\Notes folder. +// +// Shares NotesStateCollection with any sibling class that mutates +// NotesService.DirectoryOverride (the same static-state-shared-via- +// parallel-classes problem the PresetStoreCollection solves). +[Collection(NotesStateCollection.Name)] public sealed class NotesServiceTests : IDisposable { private readonly string _tempDir; diff --git a/src/tests/TeamsISO.App.Tests/Services/NotesStateCollection.cs b/src/tests/TeamsISO.App.Tests/Services/NotesStateCollection.cs new file mode 100644 index 0000000..8e8c25b --- /dev/null +++ b/src/tests/TeamsISO.App.Tests/Services/NotesStateCollection.cs @@ -0,0 +1,14 @@ +namespace TeamsISO.App.Tests.Services; + +/// +/// Serializes any test class that mutates +/// NotesService.DirectoryOverride. Without this, xUnit runs the +/// classes in parallel collections and one ctor can clobber the +/// override another's test is depending on (manifests as a brand-new +/// notes file landing in the WRONG temp dir mid-test). +/// +[CollectionDefinition(Name)] +public sealed class NotesStateCollection +{ + public const string Name = "NotesService (DirectoryOverride mutators)"; +} diff --git a/src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs b/src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs index 00dc69e..f44bb7c 100644 --- a/src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs +++ b/src/tests/TeamsISO.App.Tests/Services/OscBridgeDispatchTests.cs @@ -14,6 +14,10 @@ namespace TeamsISO.App.Tests.Services; // paths return early on the null check, so we verify the bail rather // than the happy path. The full toggle path is covered in branch 11's // integration test that boots a real dispatcher. +// +// Shares NotesStateCollection with NotesServiceTests — both classes +// mutate NotesService.DirectoryOverride and would otherwise race. +[Collection(NotesStateCollection.Name)] public sealed class OscBridgeDispatchTests : IDisposable { private readonly string _tempNotesDir; diff --git a/src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj b/src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj index 5765228..579bc21 100644 --- a/src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj +++ b/src/tests/TeamsISO.App.Tests/TeamsISO.App.Tests.csproj @@ -31,6 +31,7 @@ +