Files
LlamaCasty/ytLive.Tests/CameraManagerTests.cs
T

354 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.BackgroundEffects;
namespace ytLive.Tests;
public class CameraManagerTests
{
private sealed class FakeFrameSource : ICameraFrameSource
{
private readonly List<string> _started;
private readonly List<string> _stopped;
private readonly VideoFrame? _pumpOnStart;
private readonly string? _failOnStart;
public string DeviceId { get; }
public event Action<VideoFrame>? FrameAvailable;
public event Action<string>? SourceFailed;
public FakeFrameSource(string deviceId, List<string> started, List<string> stopped,
VideoFrame? pumpOnStart = null, string? failOnStart = null)
{
DeviceId = deviceId;
_started = started;
_stopped = stopped;
_pumpOnStart = pumpOnStart;
_failOnStart = failOnStart;
}
public Task StartAsync()
{
if (_failOnStart != null) throw new InvalidOperationException(_failOnStart);
_started.Add(DeviceId);
if (_pumpOnStart != null) FrameAvailable?.Invoke(_pumpOnStart);
return Task.CompletedTask;
}
public Task StopAsync()
{
_stopped.Add(DeviceId);
return Task.CompletedTask;
}
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
public void Fail(string message) => SourceFailed?.Invoke(message);
}
// The refcount/coalescing tests model a camera whose frame pump is driven by
// the test after acquire, so they skip the first-frame proof (TimeSpan.Zero);
// the proof itself is exercised by the dedicated tests below.
private static CameraManager CreateManager(List<string> started, List<string> stopped)
=> new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped),
null, TimeSpan.Zero);
private sealed class FakeEnumerator : ICameraEnumerator
{
public Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
=> Task.FromResult<IReadOnlyList<CameraDeviceInfo>>(new[]
{
new CameraDeviceInfo("dev1", "Logitech C920"),
});
}
[Fact]
public async Task Refcount_TwoAcquires_OneCaptureUntilLastRelease()
{
var started = new List<string>();
var stopped = new List<string>();
var manager = CreateManager(started, stopped);
Assert.True(await manager.AcquireAsync("dev1"));
Assert.True(await manager.AcquireAsync("dev1"));
Assert.Single(started);
await manager.ReleaseAsync("dev1");
Assert.Empty(stopped);
await manager.ReleaseAsync("dev1");
Assert.Single(stopped);
Assert.Null(manager.GetLatestFrame("dev1"));
}
[Fact]
public async Task FramePump_UpdatesLatestFrame()
{
var started = new List<string>();
var stopped = new List<string>();
FakeFrameSource? captured = null;
var manager = new CameraManager(
new FakeEnumerator(),
id => captured = new FakeFrameSource(id, started, stopped),
null, TimeSpan.Zero);
await manager.AcquireAsync("dev1");
var frame = new VideoFrame(2, 2, new byte[16]);
captured!.Pump(frame);
Assert.Same(frame, manager.GetLatestFrame("dev1"));
}
[Fact]
public async Task Acquire_FailingCamera_ReturnsFalseAndStops()
{
var manager = new CameraManager(
new FakeEnumerator(),
id => new FakeFrameSource(id, new List<string>(), new List<string>(), failOnStart: "camera in use"),
null, TimeSpan.Zero);
string? failedDevice = null;
manager.CameraFailed += (device, _) => failedDevice = device;
Assert.False(await manager.AcquireAsync("dev1"));
Assert.Equal("dev1", failedDevice);
}
[Fact]
public async Task Acquire_EmptyDeviceId_ReturnsFalse()
{
var manager = CreateManager(new List<string>(), new List<string>());
Assert.False(await manager.AcquireAsync(" "));
}
// ─── First-frame proof (the integration test for this change) ───
[Fact]
public async Task Acquire_SilentCamera_NoFirstFrame_FailsAndSurfacesCameraFailed()
{
var stopped = new List<string>();
var manager = new CameraManager(
new FakeEnumerator(),
id => new FakeFrameSource(id, new List<string>(), stopped),
null, TimeSpan.FromMilliseconds(150));
string? failedDevice = null;
string? failedMessage = null;
manager.CameraFailed += (device, message) =>
{
failedDevice = device;
failedMessage = message;
};
// The reader "starts" fine but never delivers a frame — the exact
// silent-empty-box scenario. Must fail, be reported, and be rolled back.
Assert.False(await manager.AcquireAsync("dev1"));
Assert.Equal("dev1", failedDevice);
Assert.Contains("no frames", failedMessage);
Assert.Null(manager.GetLatestFrame("dev1"));
Assert.Contains("dev1", stopped);
}
[Fact]
public async Task Acquire_FirstFrameProvesAlive_ReturnsTrueWithoutWaitingTimeout()
{
var manager = new CameraManager(
new FakeEnumerator(),
id => new FakeFrameSource(id, new List<string>(), new List<string>(),
pumpOnStart: new VideoFrame(2, 2, new byte[16])),
null, TimeSpan.FromSeconds(10));
var started = await manager.AcquireAsync("dev1");
Assert.True(started);
Assert.NotNull(manager.GetLatestFrame("dev1"));
}
[Fact]
public async Task Acquire_SourceFailureAfterStart_SurfacesCameraFailedAndRollsBack()
{
var stopped = new List<string>();
FakeFrameSource? source = null;
var manager = new CameraManager(
new FakeEnumerator(),
id => source = new FakeFrameSource(id, new List<string>(), stopped,
pumpOnStart: new VideoFrame(2, 2, new byte[16])),
null, TimeSpan.Zero);
string? failedMessage = null;
manager.CameraFailed += (_, message) => failedMessage = message;
Assert.True(await manager.AcquireAsync("dev1"));
source!.Fail("capture failed (0x8007001F): device not available");
Assert.Contains("device not available", failedMessage);
Assert.Null(manager.GetLatestFrame("dev1"));
Assert.Contains("dev1", stopped);
}
private sealed class HalfMaskSegmenter : IPersonSegmenter
{
public int Calls;
public SegmentationMask? Segment(VideoFrame frame)
{
Interlocked.Increment(ref Calls);
return new SegmentationMask(2, 1, new[] { 1f, 0f }); // left person, right background
}
}
/// <summary>Returns a FRESH mask per call — like the real segmenter, whose contract test
/// pins the raw per-frame output: {person, person, bg, bg} then {person, 0.2, bg, bg}.</summary>
private sealed class SteppingMaskSegmenter : IPersonSegmenter
{
public int Calls;
public SegmentationMask? Segment(VideoFrame _)
{
var n = Interlocked.Increment(ref Calls);
var v = n == 1 ? new[] { 1f, 1f, 0f, 0f } : new[] { 1f, 0.2f, 0f, 0f };
return new SegmentationMask(4, 1, v);
}
}
/// <summary>TASK 51 slice 4 integration test — the one processing point: with a Remove
/// variant requested, a raw camera frame reaches BOTH consumers processed — the
/// compositor's latest frame for that variant and the variant's preview bitmap on the UI
/// dispatcher — while the raw path keeps the untouched frame.</summary>
[Fact]
public async Task Requested_Remove_Variant_Reaches_Compositor_And_Preview_Raw_Stays_Raw()
{
Dispatcher? ui = null;
using var ready = new ManualResetEventSlim();
var uiThread = new Thread(() =>
{
ui = Dispatcher.CurrentDispatcher;
ready.Set();
Dispatcher.Run();
});
uiThread.SetApartmentState(ApartmentState.STA);
uiThread.IsBackground = true;
uiThread.Start();
ready.Wait();
try
{
FakeFrameSource? source = null;
var segmenter = new HalfMaskSegmenter();
var manager = new CameraManager(
new FakeEnumerator(),
id => source = new FakeFrameSource(id, new List<string>(), new List<string>()),
ui, TimeSpan.Zero, new BackgroundEffectProcessor(segmenter));
var key = WebcamEffectKey.For(BackgroundEffect.Remove, 0.5);
manager.SetRequestedEffects("dev1", new[] { key, WebcamEffectKey.None });
var previewArrived = new TaskCompletionSource<WriteableBitmap>(TaskCreationOptions.RunContinuationsAsynchronously);
manager.EffectPreviewBitmapChanged += (device, k, bitmap) =>
{
if (device == "dev1" && k == key) previewArrived.TrySetResult(bitmap);
};
Assert.True(await manager.AcquireAsync("dev1"));
const int w = 16, h = 4;
var pixels = new byte[w * h * 4];
for (var i = 0; i < pixels.Length; i += 4) { pixels[i] = 50; pixels[i + 1] = 60; pixels[i + 2] = 70; pixels[i + 3] = 255; }
var raw = new VideoFrame(w, h, pixels) { IsOpaque = true };
source!.Pump(raw);
var bitmap = await previewArrived.Task.WaitAsync(TimeSpan.FromSeconds(10));
// Raw consumer: the very same frame, untouched.
Assert.Same(raw, manager.GetLatestFrame("dev1"));
Assert.Same(raw, manager.GetLatestFrame("dev1", WebcamEffectKey.None));
// Compositor consumer: the processed variant.
var processed = manager.GetLatestFrame("dev1", key);
Assert.NotNull(processed);
Assert.False(processed!.IsOpaque);
Assert.Equal(255, processed.BgraPixels[(1 * w + 1) * 4 + 3]); // person side
Assert.Equal(0, processed.BgraPixels[(1 * w + (w - 2)) * 4 + 3]); // background side
// Preview consumer: the variant's bitmap carries the same cut-out.
var previewPixels = new byte[w * h * 4];
var previewWidth = await ui!.InvokeAsync(() =>
{
bitmap.CopyPixels(previewPixels, w * 4, 0);
return bitmap.PixelWidth;
});
Assert.Equal(w, previewWidth);
Assert.Equal(255, previewPixels[(1 * w + 1) * 4 + 3]);
Assert.Equal(0, previewPixels[(1 * w + (w - 2)) * 4 + 3]);
Assert.Same(bitmap, manager.GetPreviewBitmap("dev1", key));
Assert.True(segmenter.Calls >= 1);
// Dropping the request stops the variant.
manager.SetRequestedEffects("dev1", new[] { WebcamEffectKey.None });
Assert.Null(manager.GetLatestFrame("dev1", key));
}
finally
{
ui?.InvokeShutdown();
}
}
/// <summary>TASK 51 jitter fix integration test — the one processing point: two consecutive
/// frames whose raw masks disagree at an edge. The first frame passes through untouched; the
/// second's blend with history (0.75/0.25) keeps a partial alpha where the raw mask would
/// have snapped to 0 — the per-frame judder the smoother damps.</summary>
[Fact]
public async Task Temporal_Smoothing_Damps_Segmentation_Jitter_Across_Frames()
{
FakeFrameSource? source = null;
var segmenter = new SteppingMaskSegmenter();
var manager = new CameraManager(
new FakeEnumerator(),
id => source = new FakeFrameSource(id, new List<string>(), new List<string>()),
null, TimeSpan.Zero, new BackgroundEffectProcessor(segmenter));
var key = WebcamEffectKey.For(BackgroundEffect.Remove, 0.5);
manager.SetRequestedEffects("dev1", new[] { key });
Assert.True(await manager.AcquireAsync("dev1"));
const int w = 16, h = 4;
var pixels = new byte[w * h * 4];
for (var i = 0; i < pixels.Length; i += 4) { pixels[i] = 50; pixels[i + 1] = 60; pixels[i + 2] = 70; pixels[i + 3] = 255; }
var frame = new VideoFrame(w, h, pixels) { IsOpaque = true };
var edge = (0 * w + 6) * 4 + 3; // row 0, x 6: the contested mask boundary
source!.Pump(frame);
var first = await PollLatestFrameAsync(manager, key, distinctFrom: null);
Assert.NotNull(first);
Assert.Equal(255, first!.BgraPixels[edge]); // maiden frame: raw mask, untouched
// A NEW instance — the worker's latest-frame-drop guard treats a re-queued
// identical reference as "no new frame", which is the real camera behavior.
source.Pump(new VideoFrame(w, h, pixels) { IsOpaque = true });
var second = await PollLatestFrameAsync(manager, key, distinctFrom: first);
Assert.NotNull(second);
Assert.Equal(2, segmenter.Calls);
Assert.False(second!.IsOpaque);
Assert.Equal(58, second.BgraPixels[edge]);
Assert.Equal(255, second.BgraPixels[(0 * w + 1) * 4 + 3]); // person core stays opaque
Assert.Equal(0, second.BgraPixels[(0 * w + 14) * 4 + 3]); // background stays cut
// The raw second mask alone would have cut the same pixel to 0 — exactly the flutter
// the smoother removes.
var rawSecond = BackgroundEffectProcessor.Render(frame,
new SegmentationMask(4, 1, new[] { 1f, 0.2f, 0f, 0f }), BackgroundEffect.Remove, 0.5);
Assert.Equal(0, rawSecond.BgraPixels[edge]);
}
private static async Task<VideoFrame?> PollLatestFrameAsync(CameraManager manager,
WebcamEffectKey key, VideoFrame? distinctFrom)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline)
{
var frame = manager.GetLatestFrame("dev1", key);
if (frame != null && !ReferenceEquals(frame, distinctFrom)) return frame;
await Task.Delay(10);
}
return null;
}
}