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