teamsiso/src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs
Zac Gaetano d91f95379b test: ControlSurfaceServer route table smoke coverage
Adds end-to-end-ish tests that boot the server on an OS-assigned free
port and exercise the route dispatch via HttpClient. Catches
regressions in the route table itself (which is the part of the
control surface that benefits least from unit tests — its bug
surface is the URL → handler mapping, not the handler bodies).

* src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs — minimal
  IIsoController stub that lets the App layer instantiate without
  spinning up the engine + NDI runtime. EnableCalls / DisableCalls /
  RefreshDiscoveryCalled flags make assertions on side effects easy.
* src/tests/TeamsISO.App.Tests/Services/ControlSurfaceServerTests.cs
  (7 cases):
  - GET / → 200 with the server-info JSON (product, endpoints).
  - GET /unknown-path → 200 with body {error:"not found"}. Pinning
    this odd-but-intentional behavior: the catch-all switch arm
    returns NotFound() (an object) so response is non-null and the
    pipeline writes 200 + that body instead of branching to the
    404 path. The body is the disambiguator, matching the rest of
    the surface's "200 + {ok:false,error:…}" convention.
  - GET /participants → 200 with participants:[] when no view-model.
  - POST /presets/refresh-discovery → 200 + StubIsoController.
    RefreshDiscoveryCalled flips true (route → controller round-trip).
  - POST /presets/{missing}/apply → 200 + ok:false +
    error:"preset not found" (missing-preset path).
  - GET /ui → 200 with text/html.
  - OPTIONS /participants → 204 + Access-Control-Allow-Origin:*
    (CORS preflight for browser-based controllers).

TeamsISO.App.Tests.csproj gains UseWPF=true so the test assembly
can transitively compile against the WPF types that
ControlSurfaceServer's signature touches (System.Windows.Threading,
Application.Current). Implicit-using set narrows under UseWPF, so
OscMessageTests gains an explicit `using System.IO` and the new
test file gains `using System.Net.Http`.

Tests: 56 → 90 in App.Tests; Engine.Tests unchanged at 103.
Total green: 193. Build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:52:36 -04:00

107 lines
4.1 KiB
C#

using System.Reactive.Linq;
using System.Reactive.Subjects;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.Pipeline;
namespace TeamsISO.App.Tests.Fakes;
// Minimal IIsoController stub for tests that need to instantiate
// services in the App layer (ControlSurfaceServer, OscBridge, etc.)
// without spinning up the real engine + NDI runtime.
//
// Everything is a sensible no-op default; tests that need a specific
// behaviour (e.g. "EnableIsoAsync was called with these args") subclass
// or replace methods via the action hooks.
internal sealed class StubIsoController : IIsoController
{
private readonly BehaviorSubject<IReadOnlyList<Participant>> _participants =
new(Array.Empty<Participant>());
private readonly BehaviorSubject<EngineAlert?> _alerts = new(default);
public IObservable<IReadOnlyList<Participant>> Participants => _participants;
public IObservable<EngineAlert> Alerts => _alerts.Where(a => a is not null)!;
public FrameProcessingSettings GlobalSettings { get; set; } = new(
TargetFramerate.Fps30, TargetResolution.R1080p, AspectMode.Letterbox, AudioMode.Auto);
public NdiGroupSettings GroupSettings { get; set; } = new(
DiscoveryGroups: null, OutputGroups: null);
public bool RecordingEnabled { get; private set; }
public string? RecordingDirectory { get; private set; }
public Func<Guid, IsoHealthStats>? GetStatsHandler { get; set; }
public Func<Guid, ProcessedFrame?>? GetLatestProcessedFrameHandler { get; set; }
public Func<Guid, FrameProcessingSettings?>? GetIsoOverrideHandler { get; set; }
public IsoHealthStats GetStats(Guid participantId) =>
GetStatsHandler?.Invoke(participantId) ?? IsoHealthStats.Empty;
public ProcessedFrame? GetLatestProcessedFrame(Guid participantId) =>
GetLatestProcessedFrameHandler?.Invoke(participantId);
public FrameProcessingSettings? GetIsoOverride(Guid participantId) =>
GetIsoOverrideHandler?.Invoke(participantId);
public List<(Guid Id, string? Name)> EnableCalls { get; } = new();
public List<Guid> DisableCalls { get; } = new();
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken)
{
EnableCalls.Add((participantId, customName));
return Task.CompletedTask;
}
public Task EnableIsoAsync(Guid participantId, string? customName, bool? recordOverride, CancellationToken cancellationToken)
{
EnableCalls.Add((participantId, customName));
return Task.CompletedTask;
}
public Task DisableIsoAsync(Guid participantId, CancellationToken cancellationToken)
{
DisableCalls.Add(participantId);
return Task.CompletedTask;
}
public Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken)
{
GlobalSettings = settings;
return Task.CompletedTask;
}
public Task SetIsoOverrideAsync(Guid participantId, FrameProcessingSettings? settings, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken)
{
GroupSettings = groupSettings;
return Task.CompletedTask;
}
public bool RefreshDiscoveryCalled { get; private set; }
public void RefreshDiscovery() => RefreshDiscoveryCalled = true;
public void SetRecording(bool enabled, string? outputDirectory)
{
RecordingEnabled = enabled;
RecordingDirectory = outputDirectory;
}
public void AddRecordingMarker(string label) { /* no-op for stub */ }
public ValueTask DisposeAsync()
{
_participants.Dispose();
_alerts.Dispose();
return ValueTask.CompletedTask;
}
// Used by tests to push synthetic participant snapshots through the
// observable chain.
public void PublishParticipants(params Participant[] participants) =>
_participants.OnNext(participants);
}