5a1993b6db
A webcam dragged between sources reverted to the bottom of the stack on every relaunch: Source and WebcamSceneConfig each carried independent per-type SortOrder counters, and Load appended all Sources before all configs. Save now stamps both tables' SortOrder from the element's index within scene.Elements; Load merges the two tables' rows by that shared z (sources-first tie-break preserves legacy rows). Cross-type reorder now survives a fresh LayoutStore reload. Task 37 queued: defaults vs current layout split (creator directive) — capture out-of-scope work in TASKS.md rather than folding it in.
460 lines
19 KiB
C#
460 lines
19 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Same use case as above but driven with REAL injected mouse input
|
|
/// (SetCursorPos + mouse_event against the desktop) so the actual
|
|
/// List_PreviewMouseLeftButtonDown/Move/Up handlers — the seam no synthetic
|
|
/// event can exercise, since e.GetPosition reads the physical cursor — must
|
|
/// run. Requires an interactive desktop session: if the window is covered or
|
|
/// the session is locked, the pointer no-ops and the drag never lands.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RealMouseDrag_OnTheLayerList_PersistsTheReorder()
|
|
{
|
|
_app.Run(RunRealDrag);
|
|
}
|
|
|
|
private void RunRealDrag()
|
|
{
|
|
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-realdrag-{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");
|
|
|
|
window.Show();
|
|
window.Activate();
|
|
window.Topmost = true;
|
|
window.UpdateLayout();
|
|
|
|
var panel = (UserControl)window.FindName("LeftPanel")!;
|
|
var list = (FocusPreservingListBox)panel.FindName("SourceList")!;
|
|
list.UpdateLayout();
|
|
|
|
// Elements = [Background, ImgA, ImgB, ImgC]. Physically drag ImgC
|
|
// (row 3) up onto ImgA (row 1): the handlers reorder live elements to
|
|
// [Background, ImgC, ImgA, ImgB] and EndListDrag must persist that.
|
|
var from = RowScreenPoint(list, 3);
|
|
var to = RowScreenPoint(list, 1);
|
|
|
|
SetCursorPos((int)from.X, (int)from.Y);
|
|
LetInputSettle(80);
|
|
mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero);
|
|
LetInputSettle(120);
|
|
SetCursorPos((int)to.X, (int)to.Y);
|
|
LetInputSettle(250);
|
|
mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero);
|
|
LetInputSettle(400);
|
|
|
|
var expected = vm.StagedScene.Elements.OfType<Source>().Select(s => s.Name).ToArray();
|
|
Assert.Equal("Background", expected[0]);
|
|
Assert.Equal("ImgC", expected[1]);
|
|
|
|
Assert.True(PumpUntil(() => SourceNamesInDb(tempDb).SequenceEqual(expected),
|
|
TimeSpan.FromSeconds(10)),
|
|
"a real mouse drag-and-drop must be persisted by the background save");
|
|
Assert.Equal(expected, SourceNamesInDb(tempDb));
|
|
|
|
// The same drop must persist the PREVIEW LAYOUT too — each layer's
|
|
// geometry (X/Y/W/H) rides in the same Source rows as the SortOrder.
|
|
var dragged = vm.StagedScene.Elements.OfType<Source>().Single(s => s.Name == "ImgC");
|
|
Assert.Equal((dragged.X, dragged.Y, dragged.Width, dragged.Height),
|
|
GeometryInDb(tempDb, "ImgC"));
|
|
}
|
|
finally
|
|
{
|
|
if (window != null) window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
|
|
/// <summary>Screen (device-pixel) point one-third into the list row — over the
|
|
/// name TextBlock, away from the per-row buttons/rename TextBox.</summary>
|
|
private static Point RowScreenPoint(FocusPreservingListBox list, int index)
|
|
{
|
|
var item = (ListBoxItem)list.ItemContainerGenerator.ContainerFromIndex(index)!;
|
|
var inList = item.TransformToVisual(list)
|
|
.Transform(new Point(Math.Min(30, item.ActualWidth - 12), item.ActualHeight / 2));
|
|
return list.PointToScreen(inList);
|
|
}
|
|
|
|
/// <summary>Pumps the app dispatcher while sleeping in small steps, so real
|
|
/// input messages posted to the thread queue are processed mid-gesture.</summary>
|
|
private static void LetInputSettle(int milliseconds)
|
|
{
|
|
var frame = new DispatcherFrame();
|
|
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(milliseconds) };
|
|
timer.Tick += (_, _) =>
|
|
{
|
|
timer.Stop();
|
|
frame.Continue = false;
|
|
};
|
|
timer.Start();
|
|
Dispatcher.PushFrame(frame);
|
|
}
|
|
|
|
private const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
|
private const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool SetCursorPos(int x, int y);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
|
|
|
|
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.
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
} |