Screen backdrop (schema v5/v6) + five-scene catalog + webcam polish: live desktop/game capture as a permanent non-deletable bottom layer (Source.IsBackdrop), auto full-screen game detection (Win32FullScreenDetector) else primary display, GraphicsCapturePicker re-designation, refcounted shared ScreenCaptureManager, Live-only backdrop by policy (HasBackdrop + v5-v6 backfill + EnforceBackdropPolicy, checkbox gone), SceneCatalog (Starting/Live/BRB/Chat/Ending) with + button re-adding missing scenes, webcam mid-session transparent-container fix (GetPreviewBitmap propagation), Chat half-screen-area cap, 'Add Webcam' always opens the picker (SwapWebcamIdentityAsync, no silent resurrect), WindowsRuntimeMarshal frame-read + 5s-throttled errors, docs updated, tests (65 passing)
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The backdrop is a permanent, non-deletable bottom layer holding live
|
||||
/// desktop/game capture, and it exists only in the canonical Live scene (see
|
||||
/// <see cref="SceneCatalog"/>). Every backdrop-enabled scene
|
||||
/// (<see cref="Scene.HasBackdrop"/>) is healed to have exactly one, inserted
|
||||
/// first; it is never reorderable or removable by the UI. Scenes without the
|
||||
/// flag never get one.
|
||||
/// </summary>
|
||||
public class BackdropTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnsureBackdrop_OnEmptyScene_InsertsFullFrameBackdrop()
|
||||
{
|
||||
var scene = new Scene { Name = "S", HasBackdrop = true };
|
||||
|
||||
var backdrop = MainViewModel.EnsureBackdrop(scene);
|
||||
|
||||
Assert.NotNull(backdrop);
|
||||
Assert.Single(scene.Elements);
|
||||
Assert.Same(backdrop, scene.Elements[0]);
|
||||
Assert.True(backdrop.IsBackdrop);
|
||||
Assert.True(backdrop.IsEnabled);
|
||||
Assert.Equal(SourceType.DisplayCapture, backdrop.Type);
|
||||
Assert.Equal(0, backdrop.X);
|
||||
Assert.Equal(0, backdrop.Y);
|
||||
Assert.Equal(1920, backdrop.Width);
|
||||
Assert.Equal(1080, backdrop.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureBackdrop_ExistingBackdrop_IsIdempotent()
|
||||
{
|
||||
var scene = new Scene { Name = "S", HasBackdrop = true };
|
||||
var first = MainViewModel.EnsureBackdrop(scene);
|
||||
var second = MainViewModel.EnsureBackdrop(scene);
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.Single(scene.Elements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureBackdrop_HealsMissingBackdropAtIndexZero()
|
||||
{
|
||||
var scene = new Scene { Name = "S", HasBackdrop = true };
|
||||
var image = new Source { Name = "Logo", Type = SourceType.Image };
|
||||
scene.Elements.Add(image);
|
||||
|
||||
MainViewModel.EnsureBackdrop(scene);
|
||||
|
||||
Assert.Equal(2, scene.Elements.Count);
|
||||
Assert.True(((Source)scene.Elements[0]).IsBackdrop);
|
||||
Assert.Same(image, scene.Elements[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureBackdrop_SceneWithoutHasBackdrop_ReturnsNullAndInsertsNothing()
|
||||
{
|
||||
var scene = new Scene { Name = "Ending", HasBackdrop = false };
|
||||
|
||||
var backdrop = MainViewModel.EnsureBackdrop(scene);
|
||||
|
||||
Assert.Null(backdrop);
|
||||
Assert.Empty(scene.Elements);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SourceType.DisplayCapture, true)]
|
||||
[InlineData(SourceType.WindowCapture, true)]
|
||||
[InlineData(SourceType.Background, false)]
|
||||
[InlineData(SourceType.Image, false)]
|
||||
[InlineData(SourceType.TextOverlay, false)]
|
||||
public void Source_IsLiveCapture_OnlyForDisplayAndWindow(SourceType type, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, new Source { Type = type }.IsLiveCapture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Source_DisplaySource_IsVideoImageSource_WhenLiveCapture()
|
||||
{
|
||||
var source = new Source { Type = SourceType.DisplayCapture };
|
||||
var bitmap = new System.Windows.Media.Imaging.WriteableBitmap(
|
||||
2, 2, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
|
||||
source.VideoImageSource = bitmap;
|
||||
|
||||
Assert.Same(bitmap, source.DisplaySource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Source_DisplaySource_IsImageSource_WhenNotLiveCapture()
|
||||
{
|
||||
var source = new Source { Type = SourceType.Background };
|
||||
var bitmap = new System.Windows.Media.Imaging.WriteableBitmap(
|
||||
2, 2, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
|
||||
source.VideoImageSource = bitmap;
|
||||
|
||||
Assert.NotSame(bitmap, source.DisplaySource);
|
||||
Assert.Null(source.DisplaySource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Source_TypeChange_RaisesDisplaySource()
|
||||
{
|
||||
var source = new Source { Type = SourceType.Image };
|
||||
var raised = new List<string>();
|
||||
source.PropertyChanged += (_, e) => raised.Add(e.PropertyName ?? string.Empty);
|
||||
|
||||
source.Type = SourceType.DisplayCapture;
|
||||
|
||||
Assert.Contains(nameof(Source.IsLiveCapture), raised);
|
||||
Assert.Contains(nameof(Source.DisplaySource), raised);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,151 @@ public class LayoutStorePersistenceTests
|
||||
}
|
||||
}
|
||||
|
||||
// The backdrop (schema v5): permanent bottom layer holding live desktop/game
|
||||
// capture. IsBackdrop + CaptureKey must survive a save + reload so a
|
||||
// re-designated target isn't lost on restart.
|
||||
[Fact]
|
||||
public void Backdrop_IsBackdrop_And_CaptureKey_Survive_Save_And_Reload()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
using var store = new LayoutStore(path);
|
||||
var backdrop = new Source
|
||||
{
|
||||
Name = "Backdrop",
|
||||
Type = SourceType.DisplayCapture,
|
||||
IsBackdrop = true,
|
||||
IsEnabled = true,
|
||||
X = 0,
|
||||
Y = 0,
|
||||
Width = 1920,
|
||||
Height = 1080,
|
||||
MonitorIndex = 1,
|
||||
CaptureKey = "monitor:1",
|
||||
};
|
||||
var scene = new Scene { Name = "Starting" };
|
||||
scene.Elements.Add(backdrop);
|
||||
store.Save(new[] { scene }, null);
|
||||
|
||||
var reloaded = store.Load();
|
||||
var restored = Assert.IsType<Source>(Assert.Single(reloaded[0].Elements));
|
||||
|
||||
Assert.True(restored.IsBackdrop);
|
||||
Assert.Equal("monitor:1", restored.CaptureKey);
|
||||
Assert.Equal(1, restored.MonitorIndex);
|
||||
Assert.Equal(SourceType.DisplayCapture, restored.Type);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Per-scene backdrop switch (schema v6): a scene whose backdrop was turned
|
||||
// off must stay off across a save + reload.
|
||||
[Fact]
|
||||
public void Scene_HasBackdrop_Survives_Save_And_Reload()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
using var store = new LayoutStore(path);
|
||||
var on = new Scene { Name = "Live", HasBackdrop = true };
|
||||
var off = new Scene { Name = "Ending", HasBackdrop = false };
|
||||
store.Save(new[] { on, off }, null);
|
||||
|
||||
var reloaded = store.Load();
|
||||
|
||||
Assert.True(reloaded.First(s => s.Name == "Live").HasBackdrop);
|
||||
Assert.False(reloaded.First(s => s.Name == "Ending").HasBackdrop);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
// v5 → v6 one-time backfill: pre-v6 DBs already have backdrops sitting in the
|
||||
// non-Live canonical scenes (Starting/BRB/Chat/Ending). Adding the HasBackdrop
|
||||
// column turns those scenes off and drops their backdrop sources; Live keeps
|
||||
// its backdrop.
|
||||
[Fact]
|
||||
public void V5_Database_Backfill_Removes_NonLive_Backdrops()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
using (var conn = new SqliteConnection($"Data Source={path}"))
|
||||
{
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE Scene (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Name TEXT NOT NULL,
|
||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE Source (
|
||||
Id TEXT PRIMARY KEY,
|
||||
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||
AssetId TEXT,
|
||||
Type TEXT NOT NULL,
|
||||
Name TEXT NOT NULL,
|
||||
IsEnabled INTEGER NOT NULL DEFAULT 1,
|
||||
X REAL NOT NULL DEFAULT 0,
|
||||
Y REAL NOT NULL DEFAULT 0,
|
||||
Width REAL NOT NULL DEFAULT 0,
|
||||
Height REAL NOT NULL DEFAULT 0,
|
||||
Opacity REAL NOT NULL DEFAULT 1,
|
||||
MonitorIndex INTEGER,
|
||||
DeviceId TEXT,
|
||||
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||
IsBackdrop INTEGER NOT NULL DEFAULT 0,
|
||||
CaptureKey TEXT,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-starting', 'Starting', 0);
|
||||
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-live', 'Live', 1);
|
||||
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-chat', 'Chat', 2);
|
||||
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
|
||||
VALUES ('b-starting', 's-starting', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
|
||||
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
|
||||
VALUES ('b-live', 's-live', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
|
||||
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
|
||||
VALUES ('b-chat', 's-chat', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
|
||||
PRAGMA user_version = 5;
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var store = new LayoutStore(path);
|
||||
var scenes = store.Load();
|
||||
|
||||
var starting = Assert.Single(scenes, s => s.Name == "Starting");
|
||||
Assert.False(starting.HasBackdrop);
|
||||
Assert.DoesNotContain(starting.Elements, e => e is Source { IsBackdrop: true });
|
||||
|
||||
var chat = Assert.Single(scenes, s => s.Name == "Chat");
|
||||
Assert.False(chat.HasBackdrop);
|
||||
Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackdrop: true });
|
||||
|
||||
var live = Assert.Single(scenes, s => s.Name == "Live");
|
||||
Assert.True(live.HasBackdrop);
|
||||
Assert.Single(live.Elements, e => e is Source { IsBackdrop: true });
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Round-to-rect restore is persisted (schema v4): a Round webcam resized to a
|
||||
// square saves its pre-Round rect dims, and a reloaded config restores them on
|
||||
// toggle-back instead of staying square.
|
||||
|
||||
@@ -101,9 +101,14 @@ public sealed class RoundClipInteractionTests
|
||||
// The round clip must render as a circle (square bounding box), not an oval.
|
||||
webcam.ClipShape = ClipShape.Round;
|
||||
window.UpdateLayout();
|
||||
var ellipse = FindRoundEllipse(window);
|
||||
Assert.NotNull(ellipse);
|
||||
Assert.Equal(ellipse!.RenderSize.Width, ellipse.RenderSize.Height, 1.0);
|
||||
var clip = FindRoundClip(window);
|
||||
Assert.NotNull(clip);
|
||||
var geometry = Assert.IsType<EllipseGeometry>(clip!.Clip);
|
||||
Assert.Equal(0.5, geometry.Center.X, 1.0);
|
||||
Assert.Equal(0.5, geometry.Center.Y, 1.0);
|
||||
Assert.Equal(0.5, geometry.RadiusX, 1.0);
|
||||
Assert.Equal(0.5, geometry.RadiusY, 1.0);
|
||||
Assert.Equal(clip.ActualWidth, clip.ActualHeight, 1.0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -114,9 +119,9 @@ public sealed class RoundClipInteractionTests
|
||||
}
|
||||
}
|
||||
|
||||
private static Ellipse? FindRoundEllipse(Window window)
|
||||
private static Image? FindRoundClip(Window window)
|
||||
{
|
||||
return Walk(window, element => element is Ellipse e && e.IsVisible) as Ellipse;
|
||||
return Walk(window, element => element is Image i && i.IsVisible && i.Clip is EllipseGeometry) as Image;
|
||||
}
|
||||
|
||||
private static DependencyObject? Walk(DependencyObject parent, Func<DependencyObject, bool> predicate)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The app is a five-scene product: Starting/Live/BRB/Chat/Ending. Scenes are
|
||||
/// worked on by name; you can delete them but never add beyond the set, and the
|
||||
/// live backdrop belongs to Live alone.
|
||||
/// </summary>
|
||||
public class SceneCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void SceneCatalog_Defines_The_Five_Canonical_Scenes()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[] { "Starting", "Live", "BRB", "Chat", "Ending" },
|
||||
SceneCatalog.All);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Starting", true)]
|
||||
[InlineData("Live", true)]
|
||||
[InlineData("BRB", true)]
|
||||
[InlineData("Chat", true)]
|
||||
[InlineData("Ending", true)]
|
||||
[InlineData("live", true)]
|
||||
[InlineData("My Scene", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void SceneCatalog_IsCanonical_Matches_By_Name(string? name, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SceneCatalog.IsCanonical(name));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Starting", false)]
|
||||
[InlineData("Live", true)]
|
||||
[InlineData("BRB", false)]
|
||||
[InlineData("Chat", false)]
|
||||
[InlineData("Ending", false)]
|
||||
public void SceneCatalog_HasBackdrop_Only_For_Live(string name, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SceneCatalog.HasBackdrop(name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforceBackdropPolicy_Removes_Backdrop_From_NonLive_Scenes()
|
||||
{
|
||||
var chat = new Scene { Name = "Chat", HasBackdrop = true };
|
||||
var live = new Scene { Name = "Live", HasBackdrop = true };
|
||||
var chatBackdrop = MainViewModel.EnsureBackdrop(chat)!;
|
||||
var liveBackdrop = MainViewModel.EnsureBackdrop(live)!;
|
||||
Assert.Contains(chat.Elements, e => ReferenceEquals(e, chatBackdrop));
|
||||
|
||||
MainViewModel.EnforceBackdropPolicy(new[] { chat, live });
|
||||
|
||||
Assert.False(chat.HasBackdrop);
|
||||
Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackdrop: true });
|
||||
Assert.True(live.HasBackdrop);
|
||||
Assert.Contains(live.Elements, e => ReferenceEquals(e, liveBackdrop));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforceBackdropPolicy_Marks_Live_And_Clears_A_Pre_Policy_Database()
|
||||
{
|
||||
var starting = new Scene { Name = "Starting", HasBackdrop = true };
|
||||
MainViewModel.EnsureBackdrop(starting);
|
||||
var live = new Scene { Name = "Live", HasBackdrop = false };
|
||||
|
||||
MainViewModel.EnforceBackdropPolicy(new[] { starting, live });
|
||||
|
||||
Assert.False(starting.HasBackdrop);
|
||||
Assert.True(live.HasBackdrop);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Threading;
|
||||
using System.Windows.Threading;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ScreenCaptureManager is the app-wide owner of screen-capture sessions,
|
||||
/// refcounted by target key and coalesced onto the UI dispatcher. These tests
|
||||
/// pin that contract with a fake IScreenCaptureSource (the WinRT pool/session
|
||||
/// layer is exercised only on Windows at runtime).
|
||||
/// </summary>
|
||||
public class ScreenCaptureManagerTests
|
||||
{
|
||||
private sealed class FakeScreenSource : IScreenCaptureSource
|
||||
{
|
||||
private readonly List<string>? _started;
|
||||
private readonly List<string>? _stopped;
|
||||
|
||||
public string Key { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
public FakeScreenSource(string key, List<string>? started = null, List<string>? stopped = null)
|
||||
{
|
||||
Key = key;
|
||||
_started = started;
|
||||
_stopped = stopped;
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
_started?.Add(Key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
_stopped?.Add(Key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||
}
|
||||
|
||||
private sealed class FailingScreenSource : IScreenCaptureSource
|
||||
{
|
||||
public string Key { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
public FailingScreenSource(string key) => Key = key;
|
||||
|
||||
public Task StartAsync() => throw new InvalidOperationException("access denied");
|
||||
|
||||
public Task StopAsync() => Task.CompletedTask;
|
||||
|
||||
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||
}
|
||||
|
||||
// A real Dispatcher pumping on a background STA thread, so the manager's
|
||||
// BeginInvoke queue can be drained deterministically from the test thread.
|
||||
private sealed class DispatcherPump : IDisposable
|
||||
{
|
||||
private readonly Thread _thread;
|
||||
private readonly ManualResetEventSlim _ready = new(false);
|
||||
private Dispatcher? _dispatcher;
|
||||
|
||||
public Dispatcher Dispatcher => _dispatcher!;
|
||||
|
||||
public DispatcherPump()
|
||||
{
|
||||
_thread = new Thread(() =>
|
||||
{
|
||||
_dispatcher = Dispatcher.CurrentDispatcher;
|
||||
_ready.Set();
|
||||
Dispatcher.Run();
|
||||
});
|
||||
_thread.IsBackground = true;
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
_ready.Wait();
|
||||
}
|
||||
|
||||
// Queued behind anything already posted at Render/higher, so pending
|
||||
// bitmap copies run first.
|
||||
public void Drain() => Dispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispatcher.InvokeShutdown();
|
||||
_thread.Join(3000);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Pixels(params byte[] raw) => raw;
|
||||
|
||||
[Fact]
|
||||
public async Task Refcount_TwoAcquires_OneCaptureUntilLastRelease()
|
||||
{
|
||||
var started = new List<string>();
|
||||
var stopped = new List<string>();
|
||||
var manager = new ScreenCaptureManager(key => new FakeScreenSource(key, started, stopped));
|
||||
|
||||
Assert.True(await manager.AcquireAsync("monitor:0"));
|
||||
Assert.True(await manager.AcquireAsync("monitor:0"));
|
||||
Assert.Single(started);
|
||||
|
||||
await manager.ReleaseAsync("monitor:0");
|
||||
Assert.Empty(stopped);
|
||||
|
||||
await manager.ReleaseAsync("monitor:0");
|
||||
Assert.Single(stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleaseAll_StopsEvenWithMultipleRefs()
|
||||
{
|
||||
var started = new List<string>();
|
||||
var stopped = new List<string>();
|
||||
var manager = new ScreenCaptureManager(key => new FakeScreenSource(key, started, stopped));
|
||||
|
||||
await manager.AcquireAsync("monitor:0");
|
||||
await manager.AcquireAsync("monitor:0");
|
||||
await manager.ReleaseAllAsync("monitor:0");
|
||||
|
||||
Assert.Single(stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_FailingSource_ReturnsFalseAndRaisesCaptureFailed()
|
||||
{
|
||||
var manager = new ScreenCaptureManager(key => new FailingScreenSource(key));
|
||||
string? failedKey = null;
|
||||
manager.CaptureFailed += (key, _) => failedKey = key;
|
||||
|
||||
Assert.False(await manager.AcquireAsync("monitor:0"));
|
||||
Assert.Equal("monitor:0", failedKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_EmptyKey_ReturnsFalse()
|
||||
{
|
||||
var manager = new ScreenCaptureManager(key => new FakeScreenSource(key));
|
||||
Assert.False(await manager.AcquireAsync(" "));
|
||||
}
|
||||
|
||||
// The one integration test for this branch: one target creates one shared
|
||||
// WriteableBitmap, published once, and back-to-back frames coalesce to the
|
||||
// latest (a single pending UI copy per session).
|
||||
[Fact]
|
||||
public async Task Frames_ShareOneBitmap_AndCoalesceToLatest()
|
||||
{
|
||||
using var pump = new DispatcherPump();
|
||||
FakeScreenSource? captured = null;
|
||||
var manager = new ScreenCaptureManager(key => captured = new FakeScreenSource(key), pump.Dispatcher);
|
||||
|
||||
WriteableBitmap? published = null;
|
||||
var publishedCount = 0;
|
||||
manager.PreviewBitmapChanged += (_, bitmap) => { published = bitmap; publishedCount++; };
|
||||
|
||||
Assert.True(await manager.AcquireAsync("monitor:0"));
|
||||
|
||||
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
|
||||
captured!.Pump(first);
|
||||
pump.Drain();
|
||||
|
||||
Assert.NotNull(published);
|
||||
Assert.Equal(1, publishedCount);
|
||||
|
||||
var dims = pump.Dispatcher.Invoke(() => new[] { published!.PixelWidth, published.PixelHeight });
|
||||
Assert.Equal(new[] { 2, 2 }, dims);
|
||||
|
||||
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
|
||||
var third = new VideoFrame(2, 2, Pixels(9, 0, 0, 255, 10, 0, 0, 255, 11, 0, 0, 255, 12, 0, 0, 255));
|
||||
captured.Pump(second);
|
||||
captured.Pump(third);
|
||||
pump.Drain();
|
||||
|
||||
// The bitmap is frozen to the dispatcher thread; copy its pixels there.
|
||||
var result = pump.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
published!.CopyPixels(new System.Windows.Int32Rect(0, 0, 2, 2), bytes, 8, 0);
|
||||
return bytes;
|
||||
});
|
||||
Assert.Equal(third.BgraPixels, result);
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,21 @@ namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The webcam size safeguard: no scene placement may exceed half the 1920×1080
|
||||
/// master frame (960×540), nor drop below 10% of it (192×108). Enforced at
|
||||
/// resize (MainWindow) and defensively again on every layout load.
|
||||
/// master frame (960×540) — except the Chat scene, where it may reach half the
|
||||
/// screen AREA (~1358×764 @16:9) — nor drop below 10% of the frame (192×108).
|
||||
/// The cap is picked by canonical scene name. Enforced at resize (MainWindow)
|
||||
/// and defensively again on every layout load.
|
||||
/// </summary>
|
||||
public class WebcamSafeguardTests
|
||||
{
|
||||
private const string DefaultScene = "Starting";
|
||||
private const string ChatScene = "Chat";
|
||||
|
||||
[Fact]
|
||||
public void Oversize_Webcam_Is_Capped_To_Half_The_Frame()
|
||||
{
|
||||
var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 };
|
||||
MainViewModel.ClampWebcamToBounds(webcam);
|
||||
MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
|
||||
Assert.Equal(MainViewModel.WebcamMaxWidth, webcam.Width);
|
||||
Assert.Equal(MainViewModel.WebcamMaxHeight, webcam.Height);
|
||||
}
|
||||
@@ -24,7 +29,7 @@ public class WebcamSafeguardTests
|
||||
public void Wide_Webcam_Scales_To_Fit_Both_Dimensions()
|
||||
{
|
||||
var webcam = new WebcamSceneConfig { Width = 1000, Height = 500 };
|
||||
MainViewModel.ClampWebcamToBounds(webcam);
|
||||
MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
|
||||
Assert.Equal(960, webcam.Width);
|
||||
Assert.Equal(480, webcam.Height);
|
||||
}
|
||||
@@ -33,7 +38,7 @@ public class WebcamSafeguardTests
|
||||
public void Tiny_Webcam_Is_Brought_Up_To_The_Minimum()
|
||||
{
|
||||
var webcam = new WebcamSceneConfig { Width = 10, Height = 10 };
|
||||
MainViewModel.ClampWebcamToBounds(webcam);
|
||||
MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
|
||||
Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Width);
|
||||
Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Height);
|
||||
}
|
||||
@@ -42,7 +47,7 @@ public class WebcamSafeguardTests
|
||||
public void Short_Wide_Webcam_Meets_Both_Minimums()
|
||||
{
|
||||
var webcam = new WebcamSceneConfig { Width = 100, Height = 20 };
|
||||
MainViewModel.ClampWebcamToBounds(webcam);
|
||||
MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
|
||||
Assert.True(webcam.Width >= MainViewModel.WebcamMinWidth);
|
||||
Assert.Equal(MainViewModel.WebcamMinHeight, webcam.Height);
|
||||
}
|
||||
@@ -51,12 +56,39 @@ public class WebcamSafeguardTests
|
||||
public void Round_Border_Size_Tracks_The_Shorter_Clamped_Dimension()
|
||||
{
|
||||
var webcam = new WebcamSceneConfig { Width = 1920, Height = 500 };
|
||||
MainViewModel.ClampWebcamToBounds(webcam);
|
||||
MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
|
||||
Assert.Equal(960, webcam.Width);
|
||||
Assert.Equal(250, webcam.Height);
|
||||
Assert.Equal(250, webcam.RoundBorderSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chat_Webcam_May_Reach_Half_The_Screen_Area()
|
||||
{
|
||||
var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 };
|
||||
MainViewModel.ClampWebcamToBounds(webcam, ChatScene);
|
||||
Assert.Equal(MainViewModel.WebcamChatMaxWidth, webcam.Width);
|
||||
Assert.Equal(MainViewModel.WebcamChatMaxHeight, webcam.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chat_Webcam_At_The_Default_Size_Is_Not_Grown_Or_Shrunk()
|
||||
{
|
||||
var webcam = new WebcamSceneConfig { Width = 480, Height = 270 };
|
||||
MainViewModel.ClampWebcamToBounds(webcam, ChatScene);
|
||||
Assert.Equal(480, webcam.Width);
|
||||
Assert.Equal(270, webcam.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renamed_Chat_Scene_Loses_The_Larger_Cap()
|
||||
{
|
||||
var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 };
|
||||
MainViewModel.ClampWebcamToBounds(webcam, "chit-chat");
|
||||
Assert.Equal(MainViewModel.WebcamMaxWidth, webcam.Width);
|
||||
Assert.Equal(MainViewModel.WebcamMaxHeight, webcam.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_Then_Back_To_Rect_Restores_The_Original_Aspect()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user