fix(persist): one shared z-order across Source + WebcamSceneConfig rows

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.
This commit is contained in:
2026-09-20 09:23:46 -07:00
parent f91bf8715d
commit 5a1993b6db
7 changed files with 327 additions and 160 deletions
@@ -37,6 +37,23 @@ public sealed class LayerReorderPersistenceTests
_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
@@ -199,6 +216,145 @@ public sealed class LayerReorderPersistenceTests
}
}
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>