using System.Threading.Channels;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using TeamsISO.Engine.Domain;
using TeamsISO.Engine.Pipeline;
namespace TeamsISO.Engine.Tests.Pipeline;
///
/// 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.
///
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();
var processed = Channel.CreateUnbounded();
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.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();
var processed = Channel.CreateUnbounded();
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.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);
}
/// Simple deterministic clock for processor tests.
private sealed class FakeClock : IFrameClock
{
public long NowTicks { get; set; }
public ValueTask WaitForNextTickAsync(CancellationToken cancellationToken) => ValueTask.FromResult(true);
}
}