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 @@ +