201 lines
7.7 KiB
C#
201 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();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|