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:
2026-08-07 14:15:50 -07:00
parent e037ba027b
commit ad9b3e1e48
27 changed files with 2232 additions and 116 deletions
+118
View File
@@ -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);
}
}
+145
View File
@@ -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.
+10 -5
View File
@@ -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)
+77
View File
@@ -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);
}
}
+189
View File
@@ -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);
}
}
+39 -7
View File
@@ -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()
{