Files
LlamaCasty/ytLive.Tests/CameraManagerTests.cs
T

282 lines
11 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>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();
}
}
}