fix(reorder): trap layer-list drag drop as a data change and persist it
List_PreviewMouseMove reorders StagedScene.Elements via RemoveAt/Insert, bypassing SceneGraph's mutation surface, but never scheduled a save — the new z-order was lost on restart. The drop now sets _dragReordered and EndListDrag funnels it through MainViewModel.OnSceneElementsReordered() (InvalidateBake + ScheduleSave, same background-save path as every other mutation). Integration test (RealApp + temp DB) reproduces the exact code-behind mutation and asserts the debounced save lands the new Source SortOrder. Derivation reference: standard WPF ItemsControl drag-reorder pattern (OBS layering semantics: bottom-most layer = index 0).
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
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 is written to the DB after the debounced background save.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
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 by the debounced background save");
|
||||
Assert.Equal(expected, SourceNamesInDb(tempDb));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (window != null) window.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 debounced save may hold the write lock mid-transaction — not yet.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user