Files
LlamaCasty/ytLive.Tests/RoundClipInteractionTests.cs
gramps c01206fb8a feat: web sources frame-captured via composition (CoreWebView2CompositionController → Windows.Graphics.Capture); PNG poll + CaptureScheduler deleted
The ~30Hz CapturePreviewAsync PNG poll capped real cadence at ~20Hz
(35–165ms full-HD encode+decode), so a 60fps widget still juddered at ~1/6
speed. Replaced polling with frame-driven capture of the composition
controller's root visual — the mechanism WebView2CompositionControl and
Flutter's webview_windows use (graphics_context.cc captures the root
surface_ visual via CreateGraphicsCaptureItemFromVisual; reference:
github.com/microsoft/Windows.UI.Composition.WinUI / flutter-internal
webview_windows). Frames now arrive at the renderer's own pace; capture
memory is epoch'd ring reuse + one crop-sized shared WriteableBitmap.

New Services/WebCaptureFrameSource.cs owns GraphicsCaptureItem + free-
threaded Direct3D11CaptureFramePool + session (Straight alpha readback,
per-frame FindContentBounds → CropBounds). WebView2Manager reworked around
per-session composition controllers + one UI-thread Compositor created via
the CoreMessaging CreateDispatcherQueueController P/Invoke (the 19041
projection lacks CreateOnCurrentThread); internal seam ctor
(Dispatcher, Func<string,IScreenCaptureSource>?) for hermetic tests.
CaptureScheduler.cs deleted; the three SetCaptureInterval cadence hooks
removed; InitWebView2() moved from MainWindow ctor to Loaded (a parent
HWND must exist for the composition controller); the hidden WebViewHostPanel
overlay deleted. TransparentBackgroundScript unchanged.

Tests: WebView2ManagerTests reworked — 4 control-size + scheduler tests
dropped, FindContentBounds tests moved to WebCaptureFrameSource, ONE
integration test (Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds)
drives the seam with a FakeWebSource + background-STA DispatcherPump.
Suite 293/293, 0 warnings.

NOTE: composition path NOT yet verified on a device — the take is the
next step. Web work committed locally only (no push per standing rule).

Docs same-commit: ai.md Slice 14 + supersede marker on Slice 11, HANDOFF,
MyMistakes (CoreMessaging DQ + namespace-landmine recipe), TASK 17,
Controls/ViewModels/Services indexes.
2026-09-14 16:14:00 -07:00

145 lines
5.6 KiB
C#

using System;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
using System.Windows.Threading;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Reproduces the real MainWindow preview to check the round-clip corner
/// handle: whether a click at the corner actually stays inside the preview
/// (doesn't fall through and deselect the source) and whether the round clip
/// renders as a circle rather than an oval.
/// </summary>
[Collection("RealApp")]
public sealed class RoundClipInteractionTests
{
private readonly RealAppHost _app;
public RoundClipInteractionTests(RealAppHost app)
{
_app = app;
}
[Fact]
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
{
_app.Run(Run);
}
private void Run()
{
// Never let the real MainWindow read/write the user's actual layout DB —
// Shutdown() saves the layout, which would persist these test sources
// over the real ones. Point it at a throwaway temp DB instead.
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
window.UpdateLayout();
var scene = vm.StagedScene!;
var webcam = new WebcamSceneConfig
{
WebcamId = "test-camera",
Name = "Webcam",
X = 1408,
Y = 778,
Width = 480,
Height = 270,
};
scene.Elements.Add(webcam);
vm.SelectedElement = webcam;
window.UpdateLayout();
// PreviewGrid/CanvasGrid moved into Controls/PreviewPane.xaml during the
// component refactor — a UserControl owns its own namescope, so window.FindName
// returns null for them. Resolve through the pane (found by its window-level name).
var pane = (System.Windows.Controls.UserControl)window.FindName("PreviewPane")!;
var previewGrid = (Grid)pane.FindName("PreviewGrid")!;
var canvasGrid = (Grid)pane.FindName("CanvasGrid")!;
var toWindow = canvasGrid.TransformToVisual(window);
var corner = new Point(webcam.X + webcam.Width, webcam.Y + webcam.Height);
foreach (var shape in new[] { ClipShape.Traditional, ClipShape.Round })
{
webcam.ClipShape = shape;
window.UpdateLayout();
var cornerInWindow = toWindow.Transform(corner);
// A click on the corner must land inside the preview (descendant
// of PreviewGrid); otherwise Window_PreviewMouseLeftButtonDown
// deselects the source and the handle can never be grabbed.
// UIElement.InputHitTest IS the input pipeline — what Mouse uses.
// Do NOT use VisualTreeHelper.HitTest here: it ignores IsHitTestVisible
// input semantics (the old invisible WebView2 host overlay for web
// sources used to win every visual hit — the wrong-API false bug this
// test "found" for months, root-caused 2026-09-01; the overlay is gone
// with the composition-based web render but the API lesson stays).
var hit = window.InputHitTest(cornerInWindow) as DependencyObject;
var grabbable = hit != null && IsDescendantOf(hit, previewGrid);
Assert.True(grabbable,
$"{shape}: corner click fell through to '{hit?.GetType().Name ?? "null"}' " +
"so the source gets deselected before the resize handler runs");
}
// The round clip must render as a circle (square bounding box), not an oval.
webcam.ClipShape = ClipShape.Round;
window.UpdateLayout();
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
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
private static Image? FindRoundClip(Window window)
{
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)
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (predicate(child)) return child;
var found = Walk(child, predicate);
if (found != null) return found;
}
return null;
}
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
{
while (child != null && !ReferenceEquals(child, ancestor))
child = VisualTreeHelper.GetParent(child);
return child != null;
}
}