Files
LlamaCasty/ytLive.Tests/WebView2ManagerTests.cs
T
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

231 lines
8.9 KiB
C#

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Threading;
using System.Windows.Media.Imaging;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// WebView2Manager owns the frame-driven web sources (composition-captured, see
/// Services/WebCaptureFrameSource.cs). The controller/visual/GraphicCaptureItem
/// layer is WinRT and only exercisable on Windows at runtime, so these tests pin
/// the pure pieces (transparency script, the alpha-bbox math) and the ONE
/// integration test drives the session plumbing with a fake source: registration,
/// crop-stamped frames, the shared cropped preview bitmap, and coalescing to the
/// latest frame.
/// </summary>
public class WebView2ManagerTests
{
private sealed class FakeWebSource : IScreenCaptureSource
{
public string Key { get; }
public event Action<VideoFrame>? FrameAvailable;
public FakeWebSource(string key) => Key = key;
public Task StartAsync() => Task.CompletedTask;
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
// (same harness as ScreenCaptureManagerTests).
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);
}
}
[Fact]
public void TransparentBackgroundScript_Is_A_Important_Element_Wide_Wipe()
{
// The regression the inline style lost to: a widget repainting a container (or
// html/body) opaque after connect re-backs the capture → "black box" in the
// recording while early frames stayed transparent. The OBS-validated formula is a
// pre-parse <style> with !important outranking every page rule (OBS Forums 2016).
var script = WebView2Manager.TransparentBackgroundScript;
Assert.Contains("background-color:transparent!important", script);
Assert.Contains("background-image:none!important", script);
Assert.Contains("html,body,html *", script);
Assert.Contains("document.createElement('style')", script);
Assert.DoesNotContain(".style.background=", script);
}
[Fact]
public void FindContentBounds_Returns_Bounding_Box_Of_NonTransparent_Pixels()
{
const int w = 10, h = 6;
var pixels = new byte[w * h * 4];
for (int yy = 2; yy <= 4; yy++)
for (int xx = 3; xx <= 6; xx++)
{
var i = (yy * w + xx) * 4;
pixels[i] = 255; // B
pixels[i + 1] = 0; // G
pixels[i + 2] = 0; // R
pixels[i + 3] = 255; // A
}
var (x, y, bw, bh) = WebCaptureFrameSource.FindContentBounds(pixels, w, h, w * 4);
Assert.Equal((3, 2, 4, 3), (x, y, bw, bh));
}
[Fact]
public void FindContentBounds_No_Alpha_Means_No_Crop()
{
const int w = 10, h = 6;
var pixels = new byte[w * h * 4];
var (x, y, bw, bh) = WebCaptureFrameSource.FindContentBounds(pixels, w, h, w * 4);
Assert.Equal((0, 0, w, h), (x, y, bw, bh));
}
[Fact]
public void FindContentBounds_Full_Opacity_Means_No_Crop()
{
const int w = 10, h = 6;
var pixels = new byte[w * h * 4];
for (var i = 3; i < pixels.Length; i += 4)
pixels[i] = 255;
var (x, y, bw, bh) = WebCaptureFrameSource.FindContentBounds(pixels, w, h, w * 4);
Assert.Equal((0, 0, w, h), (x, y, bw, bh));
}
// The ONE integration test for this change: a registered session creates one
// shared WriteableBitmap sized to the widget's CropBounds, publishes it once,
// and back-to-back frames coalesce to the latest (a single pending UI copy,
// mirror of ScreenCaptureManager) — the frame-driven contract the PNG-poll
// redesign replaced the CaptureScheduler cadence with.
[Fact]
public void Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds()
{
using var pump = new DispatcherPump();
FakeWebSource? fake = null;
using var manager = new WebView2Manager(pump.Dispatcher, key => fake = new FakeWebSource(key));
WriteableBitmap? published = null;
var publishedCount = 0;
manager.PreviewBitmapChanged += (_, bitmap) => { published = bitmap; publishedCount++; };
var source = new Source { Type = SourceType.WebSource, Width = 640, Height = 480 };
manager.Register(source);
Assert.NotNull(fake); // the seam instantiated + started the source inline
Assert.Null(manager.GetLatestFrame(source.Id)); // nothing pumped yet
// 4x4 canvas; alpha 255 only in bbox (1,1)-(2,2) → the widget crops to (1,1,2,2).
var first = WidgetFrame(4, 4, frameSeed: 42);
fake!.Pump(first);
pump.Drain();
Assert.NotNull(published);
Assert.Equal(1, publishedCount); // one shared bitmap, published once
var dims = pump.Dispatcher.Invoke(() => new[] { published!.PixelWidth, published.PixelHeight });
Assert.Equal(new[] { 2, 2 }, dims);
var latest = manager.GetLatestFrame(source.Id);
Assert.NotNull(latest);
Assert.Equal((1, 1, 2, 2), latest!.CropBounds); // the source stamps the alpha bbox
Assert.False(latest.IsOpaque); // straight-alpha web contract
var previewBytes = pump.Dispatcher.Invoke(() =>
{
var bytes = new byte[16];
published!.CopyPixels(new System.Windows.Int32Rect(0, 0, 2, 2), bytes, 8, 0);
return bytes;
});
// The preview is a DENSE 2x2 rect (bbox 1,1..2,2 sliced out of the 4x4
// canvas) — rows y=1 and y=2, columns x=1..2.
var expected = CropBytes(first, w: 4, x0: 1, y0: 1, w2: 2, h2: 2);
Assert.Equal(expected, previewBytes);
// Second + third back-to-back: only the latest lands in the preview bitmap.
var second = WidgetFrame(4, 4, frameSeed: 77);
var third = WidgetFrame(4, 4, frameSeed: 99);
fake.Pump(second);
fake.Pump(third);
pump.Drain();
Assert.Equal(1, publishedCount); // same crop box → no new bitmap
Assert.Same(third, manager.GetLatestFrame(source.Id));
var coalesced = 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(CropBytes(third, 4, 1, 1, 2, 2), coalesced);
}
/// <summary>Dense little-endian slice of a w-wide frame for the rect
/// (x0, y0)-(x0+w2-1, y0+h2-1); matches the manager's crop readback so the
/// byte comparison is against the same layout, not the full frame stride.</summary>
private static byte[] CropBytes(VideoFrame frame, int w, int x0, int y0, int w2, int h2)
{
var src = frame.BgraPixels;
var dst = new byte[w2 * h2 * 4];
for (var y = 0; y < h2; y++)
System.Buffer.BlockCopy(src, ((y0 + y) * w + x0) * 4, dst, y * w2 * 4, w2 * 4);
return dst;
}
/// <summary>A 4x4 full canvas where the widget content is bbox (1,1,2,2):
/// alpha 255 there, 0 elsewhere; the content pixels carry a seed so a
/// coalesce mix-up is visible in the byte assertion.</summary>
private static VideoFrame WidgetFrame(int w, int h, byte frameSeed)
{
var pixels = new byte[w * h * 4];
for (var y = 0; y < h; y++)
for (var x = 0; x < w; x++)
{
if (x < 1 || x > 2 || y < 1 || y > 2) continue;
var i = (y * w + x) * 4;
pixels[i] = frameSeed; // B
pixels[i + 1] = (byte)(frameSeed / 3); // G
pixels[i + 2] = 0; // R
pixels[i + 3] = 255; // A
}
return new VideoFrame(w, h, pixels)
{
IsOpaque = false,
CropBounds = (1, 1, 2, 2),
};
}
}