Some checks failed
CI / build-and-test (push) Failing after 27s
Four polish items + a test pass. 1. Inter Variable (rsms/inter v3.19, OFL) is bundled at Assets/Fonts/Inter.ttf (~800 KB) and registered as a WPF Resource. WildDragonTheme.xaml's Wd.Font.Sans now points at pack://application:,,,/Assets/Fonts/#Inter so the typography matches wilddragon.net regardless of whether the user has Inter installed system-wide. Falls back to Segoe UI Variable Display if the resource is missing. 2. 'Stop all ISOs' button at the right of the participants header. Bound to a new MainViewModel.StopAllIsosCommand that snapshots the enabled list, awaits DisableIsoAsync sequentially, and silently swallows per-pipeline failures (best-effort emergency stop). CanExecute gates on whether any ISO is currently enabled. 3. WindowStateStore service persists the main window's Left/Top/Width/Height/State to %LOCALAPPDATA%\\TeamsISO\\window.json on close and restores it on SourceInitialized. Multi-monitor friendly: a saved position with no corner inside any virtual screen is rejected so a disconnected monitor doesn't strand the window off-screen. 4. Two new unit tests cover FrameProcessor's drops + duplicates accounting. 76/76 unit tests pass (was 74).
95 lines
3.9 KiB
C#
95 lines
3.9 KiB
C#
using System.Threading.Channels;
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using TeamsISO.Engine.Domain;
|
|
using TeamsISO.Engine.Pipeline;
|
|
|
|
namespace TeamsISO.Engine.Tests.Pipeline;
|
|
|
|
/// <summary>
|
|
/// Targets the IsoPipeline stats wiring (FPS ring buffer + drops/dups surfaced
|
|
/// from FrameProcessor). The production-ctor's runner pumps the receiver in a
|
|
/// background thread, so we drive the FrameProcessor directly here — that's
|
|
/// where FramesDropped and FramesDuplicated are computed.
|
|
/// </summary>
|
|
public class FrameProcessorStatsTests
|
|
{
|
|
[Fact]
|
|
public async Task FrameProcessor_DropsBackloggedFrames_WhenInputHasMultipleQueued()
|
|
{
|
|
// Arrange: a raw channel pre-filled with three frames before ProcessOnce runs.
|
|
// The processor should keep only the newest (closest-frame strategy) and report
|
|
// FramesDropped == 2 (the two it threw away).
|
|
var raw = Channel.CreateUnbounded<RawFrame>();
|
|
var processed = Channel.CreateUnbounded<ProcessedFrame>();
|
|
var clock = new FakeClock();
|
|
var settings = FrameProcessingSettings.Default;
|
|
var processor = new FrameProcessor(
|
|
settings,
|
|
new ManagedNearestNeighborFrameScaler(),
|
|
new SolidFrameRenderer(),
|
|
clock,
|
|
raw.Reader,
|
|
processed.Writer,
|
|
slateThreshold: TimeSpan.FromSeconds(2.5),
|
|
NullLogger<FrameProcessor>.Instance);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
raw.Writer.TryWrite(MakeFrame(width: 320, height: 180, ticks: i));
|
|
|
|
// Act
|
|
await processor.ProcessOnceAsync(CancellationToken.None);
|
|
|
|
// Assert
|
|
var stats = processor.Stats;
|
|
stats.FramesIn.Should().Be(3, because: "the processor counts every frame it pulled off the channel");
|
|
stats.FramesOut.Should().Be(1, because: "closest-frame strategy emits one frame per tick");
|
|
stats.FramesDropped.Should().Be(2, because: "two queued frames were superseded by the newest");
|
|
stats.IncomingWidth.Should().Be(320);
|
|
stats.IncomingHeight.Should().Be(180);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FrameProcessor_DuplicatesLastFrame_WhenNoNewArrival()
|
|
{
|
|
// First tick: a single frame. Second tick: nothing new — should re-emit
|
|
// the last frame (within slate threshold) and increment FramesDuplicated.
|
|
var raw = Channel.CreateUnbounded<RawFrame>();
|
|
var processed = Channel.CreateUnbounded<ProcessedFrame>();
|
|
var clock = new FakeClock { NowTicks = 0 };
|
|
var processor = new FrameProcessor(
|
|
FrameProcessingSettings.Default,
|
|
new ManagedNearestNeighborFrameScaler(),
|
|
new SolidFrameRenderer(),
|
|
clock,
|
|
raw.Reader,
|
|
processed.Writer,
|
|
slateThreshold: TimeSpan.FromSeconds(2.5),
|
|
NullLogger<FrameProcessor>.Instance);
|
|
|
|
raw.Writer.TryWrite(MakeFrame(width: 320, height: 180, ticks: 0));
|
|
await processor.ProcessOnceAsync(CancellationToken.None);
|
|
|
|
// Advance clock 100ms; no new frame.
|
|
clock.NowTicks = TimeSpan.FromMilliseconds(100).Ticks;
|
|
await processor.ProcessOnceAsync(CancellationToken.None);
|
|
|
|
var stats = processor.Stats;
|
|
stats.FramesIn.Should().Be(1);
|
|
stats.FramesOut.Should().Be(2);
|
|
stats.FramesDuplicated.Should().Be(1, because: "the second tick re-emitted the last frame");
|
|
}
|
|
|
|
private static RawFrame MakeFrame(int width, int height, long ticks)
|
|
{
|
|
var bytes = new byte[width * height * 4];
|
|
return new RawFrame(width, height, ticks, bytes, PixelFormat.Bgra);
|
|
}
|
|
|
|
/// <summary>Simple deterministic clock for processor tests.</summary>
|
|
private sealed class FakeClock : IFrameClock
|
|
{
|
|
public long NowTicks { get; set; }
|
|
public ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken) => ValueTask.FromResult(true);
|
|
}
|
|
}
|