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