feat(backdrop): Capture Window… in-app window pin (TASK 38, Good Dog ONE test)
Click-to-pin an open window as the full-bleed Live backdrop, via the existing
window:<hwnd> capture path (same render layer as game/desktop). Session-scoped:
window:/picker: keys heal to auto on reload (HWND-recycle hazard); pin wins
while the window enumerates, then auto-fallbacks (game → desktop → static); a
dead window's capture session heals to auto, no dead ends.
Fix: ScreenCaptureSourceFactory.Resolve hex-parsed "window:0x<Hwnd>" WITHOUT
stripping the 0x prefix — NumberStyles.HexNumber rejects it, so every pin
resolved null ("No capture target for this key") and the heal silently rolled
back. Same flaw in IsAliveWindowPin (pin-wins guard was dead). Both fixed.
Commit also carries an out-of-scope prerequisite: PillRadioTests.cs:105 had a
committed stray token ("...PrimaryStartButtonLabel soil;", CS1003) blocking the
whole test-project compile — token removed.
Plus the previous session's uncommitted pricing docs (one-time $29/$49) that
share TASKS.md/ai.md/HANDOFF.md.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 38: the "Capture Window…" backdrop is session-scoped — a window pin
|
||||
/// survives while the window enumerates, but a persisted "window:" key from a
|
||||
/// previous session must heal away on reload (an HWND is recycled by the OS, so
|
||||
/// resurrecting it could capture an unrelated window). Real-App + temp-DB +
|
||||
/// fake-enumerator pattern: the seam is exercised end-to-end through the VM.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class WindowBackdropIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void WindowSelection_Seam_And_SessionScoped_Heal()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public WindowBackdropIntegrationTests(RealAppHost app) => _app = app;
|
||||
|
||||
private sealed class FakeWindowEnumerator : IWindowEnumerator
|
||||
{
|
||||
private readonly IReadOnlyList<WindowInfo> _windows;
|
||||
public FakeWindowEnumerator(params WindowInfo[] windows) => _windows = windows;
|
||||
public IReadOnlyList<WindowInfo> Enumerate() => _windows;
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-windowbackdrop-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
try
|
||||
{
|
||||
using (var schema = new LayoutStore(tempDb)) { }
|
||||
SqliteConnection.ClearAllPools();
|
||||
SeedStaleWindowPin(tempDb);
|
||||
|
||||
// The stale pin pointed at 0x4F00D (from a previous session); today
|
||||
// that exact window is gone (recycled), so the enumerator only sees
|
||||
// an unrelated window the creator might pin instead.
|
||||
var liveWindow = new WindowInfo("Notepad — oops.txt", "notepad", 0xDEADBEEF, 9999);
|
||||
MainViewModel.WindowEnumeratorOverride = new FakeWindowEnumerator(liveWindow);
|
||||
|
||||
try
|
||||
{
|
||||
var window = new MainWindow();
|
||||
try
|
||||
{
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
|
||||
var background = live.Elements.OfType<Source>().Single(s => s.IsBackground);
|
||||
|
||||
// Seam wired: the submenu's item source reflects the enumerator.
|
||||
Assert.Contains(liveWindow, vm.OpenWindows);
|
||||
Assert.Equal(liveWindow, vm.OpenWindows[0]);
|
||||
|
||||
// New session = new session: the persisted window: pin healed
|
||||
// away (to auto-detection or to nothing) — never resurrected
|
||||
// onto a recycled HWND.
|
||||
Assert.False(background.CaptureKey?.StartsWith("window:", StringComparison.OrdinalIgnoreCase) == true,
|
||||
"a previous session's window pin must not survive a reload");
|
||||
|
||||
// Pinning an un-capturable/just-died window must never leave a dead
|
||||
// "window:" pin behind — the transient heal falls back through
|
||||
// the auto chain (fullscreen game → desktop → static art), so
|
||||
// the key either becomes a live capture or null (static).
|
||||
vm.CaptureWindowCommand.Execute(liveWindow);
|
||||
Assert.False(background.CaptureKey?.StartsWith("window:", StringComparison.OrdinalIgnoreCase) == true,
|
||||
"a pin whose window cannot be captured must fall back to auto, not dead-end");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.WindowEnumeratorOverride = null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Seeds a Live scene whose background persisted a stale
|
||||
/// "window:0x..."> capture key, as if a prior session pinned a window that
|
||||
/// is no longer around.</summary>
|
||||
private static void SeedStaleWindowPin(string path)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={path}");
|
||||
connection.Open();
|
||||
|
||||
using (var scene = connection.CreateCommand())
|
||||
{
|
||||
scene.CommandText =
|
||||
"INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ('scene-live', 'Live', 1, 0);";
|
||||
scene.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var bg = connection.CreateCommand())
|
||||
{
|
||||
bg.CommandText =
|
||||
@"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height,
|
||||
IsBackground, CaptureKey, SortOrder)
|
||||
VALUES ('bg-live', 'scene-live', 'DisplayCapture', 'Background', 1,
|
||||
0, 0, 1920, 1080, 1, 'window:0x4F00D', 0);";
|
||||
bg.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user