From 1c6fc3223d7b5957e924465eda6d4fa391ff2759 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 10 Nov 2026 17:48:43 -0800 Subject: [PATCH] feat(37): restore the default layout when the build ID changes A mismatched or missing current-build stamp restores the immutable default snapshot atomically before the VM loads scenes. The new integration case edits current on build A, opens on build B, and verifies the factory layout returns. 610 tests green, clean build with 0 warnings. Reference: OBS scene collections keep scenes/sources as a separate saved set: https://obsproject.com/kb/scene-collections --- Services/LayoutStore.Snapshots.cs | 54 ++++++++++++++++++++++-- ytLive.Tests/LayoutStoreSnapshotTests.cs | 32 ++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/Services/LayoutStore.Snapshots.cs b/Services/LayoutStore.Snapshots.cs index 3846174..b6042ff 100644 --- a/Services/LayoutStore.Snapshots.cs +++ b/Services/LayoutStore.Snapshots.cs @@ -56,10 +56,18 @@ public partial class LayoutStore : IDisposable /// after its factory scenes have been seeded. public void PrepareLayoutSnapshots(string buildId) { - if (GetSnapshotState(DefaultsInitializedKey) != "1" && HasStoredScenes()) + ArgumentException.ThrowIfNullOrWhiteSpace(buildId); + var defaultsInitialized = GetSnapshotState(DefaultsInitializedKey) == "1"; + if (!defaultsInitialized && HasStoredScenes()) + { CaptureDefaults(); + defaultsInitialized = true; + } - if (string.IsNullOrWhiteSpace(GetSnapshotState(CurrentBuildIdKey))) + var currentBuildId = GetSnapshotState(CurrentBuildIdKey); + if (defaultsInitialized && !string.Equals(currentBuildId, buildId, StringComparison.Ordinal)) + RestoreDefaults(buildId); + else if (!defaultsInitialized && string.IsNullOrWhiteSpace(currentBuildId)) SetSnapshotState(CurrentBuildIdKey, buildId); } @@ -121,6 +129,45 @@ public partial class LayoutStore : IDisposable tx.Commit(); } + private void RestoreDefaults(string buildId) + { + var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames); + using var tx = _connection.BeginTransaction(); + + foreach (var table in SnapshotTables.Reverse()) + { + using var delete = _connection.CreateCommand(); + delete.Transaction = tx; + delete.CommandText = $"DELETE FROM {table};"; + delete.ExecuteNonQuery(); + } + + foreach (var table in SnapshotTables) + { + var columns = string.Join(", ", columnsByTable[table].Select(QuoteIdentifier)); + using var copy = _connection.CreateCommand(); + copy.Transaction = tx; + copy.CommandText = $"INSERT INTO {table} ({columns}) SELECT {columns} FROM Default{table};"; + copy.ExecuteNonQuery(); + } + + using (var clearSettings = _connection.CreateCommand()) + { + clearSettings.Transaction = tx; + clearSettings.CommandText = "DELETE FROM Settings WHERE Key LIKE 'BackgroundPath_%' OR Key LIKE 'BackgroundUseDefault_%';"; + clearSettings.ExecuteNonQuery(); + } + using (var copySettings = _connection.CreateCommand()) + { + copySettings.Transaction = tx; + copySettings.CommandText = "INSERT INTO Settings (Key, Value) SELECT Key, Value FROM DefaultLayoutSetting;"; + copySettings.ExecuteNonQuery(); + } + + SetSnapshotState(CurrentBuildIdKey, buildId, tx); + tx.Commit(); + } + private bool HasStoredScenes() { using var cmd = _connection.CreateCommand(); @@ -136,9 +183,10 @@ public partial class LayoutStore : IDisposable return cmd.ExecuteScalar() as string; } - private void SetSnapshotState(string key, string value) + private void SetSnapshotState(string key, string value, SqliteTransaction? transaction = null) { using var cmd = _connection.CreateCommand(); + cmd.Transaction = transaction; cmd.CommandText = """ INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, $value) ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value; diff --git a/ytLive.Tests/LayoutStoreSnapshotTests.cs b/ytLive.Tests/LayoutStoreSnapshotTests.cs index dc17c4a..3febf26 100644 --- a/ytLive.Tests/LayoutStoreSnapshotTests.cs +++ b/ytLive.Tests/LayoutStoreSnapshotTests.cs @@ -48,6 +48,38 @@ public sealed class LayoutStoreSnapshotTests } } + [Fact] + public void DifferentBuild_RestoresTheDefaultSnapshotBeforeLoading() + { + var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-snapshot-{Guid.NewGuid():N}.db"); + try + { + using (var store = new LayoutStore(path)) + { + store.Save(new[] { MakeScene("Factory title") }, null, null); + store.PrepareLayoutSnapshots("build-a"); + store.Save(new[] { MakeScene("Creator edit") }, null, null); + } + + using (var reopened = new LayoutStore(path)) + { + reopened.PrepareLayoutSnapshots("build-b"); + var restored = Assert.Single(reopened.Load()); + Assert.Equal("Factory title", Assert.Single(restored.Elements.OfType()).Name); + + using var connection = GetConnection(path); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT Value FROM LayoutSnapshotState WHERE Key = 'CurrentBuildId';"; + Assert.Equal("build-b", command.ExecuteScalar()); + } + } + finally + { + SqliteConnection.ClearAllPools(); + try { File.Delete(path); } catch { /* best-effort cleanup */ } + } + } + private static Scene MakeScene(string sourceName) => new() {