teamsiso/src/tests/TeamsISO.App.Tests/Integration/IntegrationTests.cs
Zac Gaetano 84861dafa5
Some checks failed
CI / build-and-test (push) Failing after 30s
test: integration — App+MainWindow STA smoke, control-surface live VM, theme XAML load
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<T>() /
  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) <noreply@anthropic.com>
2026-05-15 21:34:09 -04:00

200 lines
7.7 KiB
C#

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();
}
}
}