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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user