Three reports from the creator, plus two bugs the real clip exposed. 1. A video layer was INVISIBLE in the preview. The pane's per-layer Image is Collapsed by default and switched on by a whitelist of DataTriggers; MediaSource matched none, so the clip decoded into a bitmap that nothing displayed. Same defect the alert box hit on 2026-09-26 and the scoreboard after it — now recorded at the top of docs/ai/ui-surface.md with a test that asserts the bound Image is Visible (`MediaPreviewDisplayTests`). 2. Play is no longer tied to Loop: the button is always enabled (creator ruling — Loop must not toggle Play). Loop still restarts a finished clip, which is the behaviour that was broken. 3. Geometry is now the LAST section of the properties list for every draggable element, with opacity at the top; the web source's duplicate geometry block is gone (it now uses the shared one, which had been sitting above the media panel). 4. The physical mouse-drag test is deleted. It injected real cursor movement, hijacked whatever machine ran it, and failed whenever the desktop was busy. The arithmetic it covered now lives in `Helpers/DragReorderMath.cs` — pure, and covered by `DragReorderMathTests` (threshold, up/down moves, no-op and out-of-range). The two persistence tests were already synthetic. 5. Real-clip check (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080): the detector REJECTED it — a fully saturated green sits at value 1.0 and the detector's 0.98 brightness ceiling threw it away. White highlights are excluded by the saturation floor, which is the property that actually separates them, so the ceiling is gone. With it fixed, the clip is detected as green (high confidence), and keyed through our own pipe 97.8% of pixels go transparent. Measured: keying with the canonical #00B140 keys 0.0% of this clip — which is why the key colour comes from the measured screen. Tests: 567 passed, 0 failed, clean build 0 warnings. The gate's scope check flags `Assets/Videos/test_greenscreen_moving_subject.mp4` — the creator's working copy, deliberately not committed; the fixture lives under ytLive.Tests/Fixtures.
353 lines
14 KiB
C#
353 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.InteropServices;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media;
|
|
using System.Windows.Threading;
|
|
using Microsoft.Data.Sqlite;
|
|
using Xunit;
|
|
using ytLive.Helpers;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.ViewModels;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// Layer-list drag-to-reorder: the code-behind reorders StagedScene.Elements
|
|
/// directly (RemoveAt/Insert), bypassing SceneGraph's mutation surface, so the
|
|
/// drop must be trapped as a data change and persisted. Real-App + temp-DB
|
|
/// pattern (see WebcamOutputKeyTests); reuses the exact code-behind mutation and
|
|
/// asserts the new order AND each layer's geometry (preview layout) are written
|
|
/// to the DB — immediately at drop, no debounce window.
|
|
/// </summary>
|
|
[Collection("RealApp")]
|
|
public sealed class LayerReorderPersistenceTests
|
|
{
|
|
private readonly RealAppHost _app;
|
|
|
|
public LayerReorderPersistenceTests(RealAppHost app) => _app = app;
|
|
|
|
[Fact]
|
|
public void DragReorder_SurvivesTheDebouncedBackgroundSave()
|
|
{
|
|
_app.Run(Run);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The user's reproduced bug: dragging a layer BELOW the webcam and relaunching
|
|
/// reverted the order. Root cause: Sources and WebcamSceneConfigs each carried
|
|
/// their OWN per-type SortOrder and load appended all Sources before all
|
|
/// configs — a webcam slotted between sources was structurally unrecoverable.
|
|
/// Save now stamps BOTH tables from one shared z-space (the element's index
|
|
/// within scene.Elements); load merges the two lists on that shared z. This
|
|
/// test seeds the real Live layout (sources + HD Pro Webcam C920), physically
|
|
/// reorders "Web Resource-0" down below the webcam, then fresh-loads a NEW
|
|
/// LayoutStore from the same DB — the restart — and asserts the order holds.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CrossTypeReorder_BelowTheWebcam_SurvivesFreshReload()
|
|
{
|
|
_app.Run(RunCrossTypeReload);
|
|
}
|
|
|
|
private void Run()
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-reorder-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
var window = default(MainWindow);
|
|
try
|
|
{
|
|
using (var schema = new LayoutStore(tempDb)) { }
|
|
SqliteConnection.ClearAllPools();
|
|
SeedThreeLayers(tempDb);
|
|
|
|
window = new MainWindow();
|
|
var vm = (MainViewModel)window.DataContext;
|
|
vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
|
|
Assert.True(vm.StagedScene!.Elements.OfType<Source>().Count() >= 4,
|
|
"3 seeded layers + the healed Background must load");
|
|
|
|
// Mirrors Controls/LeftPanel.List_PreviewMouseMove: drag the last layer
|
|
// to the front of the user layers (the healed Background stays pinned
|
|
// at index 0, so index 1 is the first user layer).
|
|
var elements = vm.StagedScene.Elements;
|
|
var dragged = elements[^1];
|
|
elements.RemoveAt(elements.Count - 1);
|
|
elements.Insert(1, dragged);
|
|
var expected = vm.StagedScene.Elements.OfType<Source>().Select(s => s.Name).ToArray();
|
|
|
|
vm.OnSceneElementsReordered();
|
|
|
|
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
|
|
TimeSpan.FromSeconds(10)),
|
|
"the drag reorder must be persisted at drop (immediate save)");
|
|
Assert.Equal(expected, SourceNamesInDb(tempDb));
|
|
|
|
// The drop must persist the dragged layer's PREVIEW LAYOUT too — its
|
|
// X/Y/W/H ride in the same Source row, so one immediate save covers
|
|
// both the new layer list and the preview geometry.
|
|
var draggedSource = vm.StagedScene.Elements.OfType<Source>().Single(s => s.Name == dragged.Name);
|
|
Assert.Equal((draggedSource.X, draggedSource.Y, draggedSource.Width, draggedSource.Height),
|
|
GeometryInDb(tempDb, dragged.Name));
|
|
}
|
|
finally
|
|
{
|
|
if (window != null) window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
|
|
private void RunCrossTypeReload()
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-crosstype-{Guid.NewGuid():N}.db");
|
|
MainViewModel.LayoutPathOverride = tempDb;
|
|
var window = default(MainWindow);
|
|
try
|
|
{
|
|
using (var schema = new LayoutStore(tempDb)) { }
|
|
SqliteConnection.ClearAllPools();
|
|
SeedLiveSceneWithWebcam(tempDb);
|
|
|
|
window = new MainWindow();
|
|
var vm = (MainViewModel)window.DataContext;
|
|
vm.StagedScene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
|
|
|
|
// The saved architecture (live scene = Background + YouTube Chat +
|
|
// Image + Web Resource-0 sources, plus the HD Pro Webcam C920 config
|
|
// row) plus the healed background.
|
|
Assert.True(vm.StagedScene!.Elements.OfType<Source>().Count() >= 4,
|
|
"seeded sources + healed Background must load");
|
|
|
|
// Mirror the user's repro: drag "Web Resource-0" DOWN below the webcam.
|
|
var elements = vm.StagedScene.Elements;
|
|
var webcam = elements.OfType<WebcamSceneConfig>().Single();
|
|
var dragged = elements.OfType<Source>().Single(s => s.Name == "Web Resource-0");
|
|
var from = elements.IndexOf(dragged);
|
|
elements.RemoveAt(from);
|
|
elements.Insert(elements.IndexOf(webcam) + 1, dragged);
|
|
|
|
var expected = elements.Select(e => e.Name).ToArray();
|
|
|
|
vm.OnSceneElementsReordered();
|
|
|
|
// Restart: a brand-new LayoutStore reading the same DB must restore
|
|
// the cross-type order exactly — webcam between Image and Web
|
|
// Resource-0, not appended after all sources. Only persisted elements
|
|
// count: the transient zoom placeholder (TASK 52) is never written,
|
|
// so the DB order naturally has no seat for it.
|
|
expected = elements.Where(e => e is Source or WebcamSceneConfig)
|
|
.Select(e => e.Name).ToArray();
|
|
|
|
Assert.True(PumpUntil(() =>
|
|
TryReadSceneOrder(tempDb, out var names) && names.SequenceEqual(expected),
|
|
TimeSpan.FromSeconds(10)),
|
|
"the webcam-between-sources order must survive a fresh reload");
|
|
}
|
|
finally
|
|
{
|
|
if (window != null) window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads one scene's element order from a FRESH store, both tables
|
|
/// merged on the shared z. False on lock/timeout (save mid-transaction).</summary>
|
|
private static bool TryReadSceneOrder(string path, out string[] names)
|
|
{
|
|
try
|
|
{
|
|
using var store = new LayoutStore(path);
|
|
var scene = store.Load().Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
|
|
names = scene.Elements.Select(e => e.Name).ToArray();
|
|
return true;
|
|
}
|
|
catch (SqliteException)
|
|
{
|
|
names = [];
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Seeds the user's real Live layout: Background, YouTube Chat,
|
|
/// Image, Web Resource-0 Source rows + a Webcam row and its config.</summary>
|
|
private static void SeedLiveSceneWithWebcam(string path)
|
|
{
|
|
var scenes = new (string Id, string Name)[]
|
|
{
|
|
("scene-starting", "Starting"),
|
|
("scene-live", "Live"),
|
|
("scene-brb", "BRB"),
|
|
("scene-chat", "Chat"),
|
|
("scene-ending", "Ending"),
|
|
};
|
|
|
|
var sources = new (string Id, string Type, string Name, int Sort)[]
|
|
{
|
|
("bg-live", "DisplayCapture", "Background", 0),
|
|
("chat-live", "ChatBox", "YouTube Chat", 1),
|
|
("img-live", "Image", "Image", 2),
|
|
("web-live", "WebSource", "Web Resource-0", 3),
|
|
};
|
|
|
|
using var connection = new SqliteConnection($"Data Source={path}");
|
|
connection.Open();
|
|
|
|
foreach (var (id, name) in scenes)
|
|
{
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);";
|
|
cmd.Parameters.AddWithValue("$id", id);
|
|
cmd.Parameters.AddWithValue("$name", name);
|
|
cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name)));
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
using (var source = connection.CreateCommand())
|
|
{
|
|
source.CommandText =
|
|
@"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, Width, Height, IsBackground, SortOrder)
|
|
VALUES ($id, 'scene-live', $type, $name, 1, 640, 480, $bg, $sort);";
|
|
var idP = source.Parameters.Add("$id", SqliteType.Text);
|
|
var typeP = source.Parameters.Add("$type", SqliteType.Text);
|
|
var nameP = source.Parameters.Add("$name", SqliteType.Text);
|
|
var bgP = source.Parameters.Add("$bg", SqliteType.Integer);
|
|
var sortP = source.Parameters.Add("$sort", SqliteType.Integer);
|
|
foreach (var (id, type, name, sort) in sources)
|
|
{
|
|
idP.Value = id;
|
|
typeP.Value = type;
|
|
nameP.Value = name;
|
|
bgP.Value = name == "Background" ? 1 : 0;
|
|
sortP.Value = sort;
|
|
source.ExecuteNonQuery();
|
|
}
|
|
}
|
|
|
|
using (var webcam = connection.CreateCommand())
|
|
{
|
|
webcam.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ('wid-live', 'dev-physical-9', 'HD Pro Webcam C920');";
|
|
webcam.ExecuteNonQuery();
|
|
}
|
|
|
|
using (var config = connection.CreateCommand())
|
|
{
|
|
config.CommandText =
|
|
@"INSERT INTO WebcamSceneConfig
|
|
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity, SortOrder)
|
|
VALUES ('scene-live', 'wid-live', 1, 100, 100, 480, 270, 1, 0);";
|
|
config.ExecuteNonQuery();
|
|
}
|
|
}
|
|
|
|
/// <summary>Pumps the app dispatcher (so the VM's debounce timer fires) until
|
|
/// <paramref name="isDone"/> or the deadline passes. True when the condition
|
|
/// became true, false on deadline.</summary>
|
|
private static bool PumpUntil(Func<bool> isDone, TimeSpan timeout)
|
|
{
|
|
var frame = new DispatcherFrame();
|
|
var poll = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) };
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
poll.Tick += (_, _) =>
|
|
{
|
|
if (isDone() || DateTime.UtcNow >= deadline)
|
|
{
|
|
poll.Stop();
|
|
frame.Continue = false;
|
|
}
|
|
};
|
|
poll.Start();
|
|
Dispatcher.PushFrame(frame);
|
|
return isDone();
|
|
}
|
|
|
|
private static string[] SourceNamesInDb(string path)
|
|
{
|
|
try
|
|
{
|
|
using var connection = new SqliteConnection($"Data Source={path}");
|
|
connection.Open();
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "SELECT Name FROM Source WHERE SceneId = 'scene-live' ORDER BY SortOrder;";
|
|
using var reader = cmd.ExecuteReader();
|
|
var names = new List<string>();
|
|
while (reader.Read()) names.Add(reader.GetString(0));
|
|
return names.ToArray();
|
|
}
|
|
catch (SqliteException)
|
|
{
|
|
// The save may hold the write lock mid-transaction — not yet.
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private static (double X, double Y, double Width, double Height) GeometryInDb(string path, string name)
|
|
{
|
|
try
|
|
{
|
|
using var connection = new SqliteConnection($"Data Source={path}");
|
|
connection.Open();
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "SELECT X, Y, Width, Height FROM Source WHERE SceneId = 'scene-live' AND Name = $name;";
|
|
cmd.Parameters.AddWithValue("$name", name);
|
|
using var reader = cmd.ExecuteReader();
|
|
reader.Read();
|
|
return (reader.GetDouble(0), reader.GetDouble(1), reader.GetDouble(2), reader.GetDouble(3));
|
|
}
|
|
catch (SqliteException)
|
|
{
|
|
// The save may hold the write lock mid-transaction — not yet.
|
|
return (double.NaN, double.NaN, double.NaN, double.NaN);
|
|
}
|
|
}
|
|
|
|
private static void SeedThreeLayers(string path)
|
|
{
|
|
using var connection = new SqliteConnection($"Data Source={path}");
|
|
connection.Open();
|
|
|
|
var scenes = new (string Id, string Name)[]
|
|
{
|
|
("scene-starting", "Starting"),
|
|
("scene-live", "Live"),
|
|
("scene-brb", "BRB"),
|
|
("scene-chat", "Chat"),
|
|
("scene-ending", "Ending"),
|
|
};
|
|
foreach (var (id, name) in scenes)
|
|
{
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);";
|
|
cmd.Parameters.AddWithValue("$id", id);
|
|
cmd.Parameters.AddWithValue("$name", name);
|
|
cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name)));
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
var layers = new[] { "ImgA", "ImgB", "ImgC" };
|
|
using (var source = connection.CreateCommand())
|
|
{
|
|
source.CommandText =
|
|
@"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder)
|
|
VALUES ($id, 'scene-live', 'Image', $name, 1, 0, 0, 640, 480, 0, $sort);";
|
|
var idP = source.Parameters.Add("$id", SqliteType.Text);
|
|
var nameP = source.Parameters.Add("$name", SqliteType.Text);
|
|
var sortP = source.Parameters.Add("$sort", SqliteType.Integer);
|
|
for (var i = 0; i < layers.Length; i++)
|
|
{
|
|
idP.Value = $"img-{i}";
|
|
nameP.Value = layers[i];
|
|
sortP.Value = i;
|
|
source.ExecuteNonQuery();
|
|
}
|
|
}
|
|
}
|
|
}
|