From b10445d0241a83c7d4b32dab65ec24a9f9d2e963 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 12:05:48 +0900 Subject: [PATCH 01/35] feat(project-system): recover malformed element files without ever rewriting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a scene whose .belm sidecar no longer parses previously failed the whole project open. Such elements now load as disabled fallback elements that retain the original raw text: open_project reports a warning naming the file and parser error while healthy elements keep loading and rendering. Recovered elements are unpersistable at the CoreSerializer level, so every save path — Scene.Serialize, the editor's Ctrl+S child loop, the auto-save service, and toolkit saves — leaves the un-parseable file byte-identical on disk, and the auto-save delete branch exempts them so removing a recovered element cannot destroy the recoverable sidecar. Their element Id comes from a quote-aware top-level scan of the raw text when present, or a deterministic UUIDv5 of the filename, so repeated opens agree, and the fallback's declarative projection carries a valid $type and Id so edits to unrelated elements reconcile normally. --- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 77 ++++- src/Beutl.Core/CoreObject.cs | 2 + .../Serialization/CoreSerializer.cs | 5 + src/Beutl.Editor/AutoSaveService.cs | 2 +- .../ProjectSystem/Scene.cs | 192 +++++++++++- .../Tools/SessionToolsTests.cs | 295 +++++++++++++++++- .../MalformedElementRecoveryTests.cs | 163 ++++++++++ 7 files changed, 728 insertions(+), 8 deletions(-) create mode 100644 tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 76762f4af6..f4efa22a53 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +using System.Collections; +using System.ComponentModel; using System.Globalization; using Beutl.AgentToolkit.Common; using Beutl.AgentToolkit.Reconciliation; @@ -6,7 +7,9 @@ using Beutl.AgentToolkit.Sessions; using Beutl.AgentToolkit.Workspace; using Beutl.Editor; +using Beutl.Engine; using Beutl.ProjectSystem; +using Beutl.Serialization; using ModelContextProtocol.Server; namespace Beutl.AgentToolkit.Tools; @@ -15,7 +18,10 @@ public sealed record SceneSummary(string SceneId, string Name, int Width, int He public sealed record SessionSummary(IReadOnlyList Scenes); -public sealed record OpenProjectResponse(string Session, string Source, SessionSummary Summary); +public sealed record OpenProjectResponse(string Session, string Source, SessionSummary Summary) +{ + public IReadOnlyList Warnings { get; init; } = []; +} public sealed record CreateProjectResponse(string Session, string SavedPath, SessionSummary Summary); @@ -71,10 +77,75 @@ public ValueTask> OpenProject(string path, Cance return new OpenProjectResponse( result.Session.SessionId, result.Session.Source.ToString(), - CreateSummary(result.Session, result.Project)); + CreateSummary(result.Session, result.Project)) + { + Warnings = CollectDeserializationWarnings(result.Project) + }; }); } + private static IReadOnlyList CollectDeserializationWarnings(Project project) + { + var warnings = new List(); + foreach (Scene scene in project.Items.OfType()) + { + foreach (Element element in scene.Children) + { + var fallbacks = new List(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + foreach (EngineObject obj in element.Objects) + { + CollectFallbacks(obj, visited, fallbacks); + } + + string elementFile = element.Uri is { IsFile: true } uri + ? Path.GetFileName(uri.LocalPath) + : element.Name; + foreach (IFallback fallback in fallbacks) + { + string error = string.IsNullOrWhiteSpace(fallback.ErrorMessage) + ? fallback.Reason.ToString() + : fallback.ErrorMessage; + warnings.Add( + $"Element file '{elementFile}' contains content that could not be deserialized: {error}"); + } + } + } + + return warnings; + } + + private static void CollectFallbacks( + object? value, + ISet visited, + ICollection fallbacks) + { + if (value is null or string || !visited.Add(value)) + return; + + if (value is IFallback fallback) + { + fallbacks.Add(fallback); + return; + } + + if (value is EngineObject engineObject) + { + foreach (IProperty property in engineObject.Properties) + { + CollectFallbacks(property.CurrentValue, visited, fallbacks); + } + } + + if (value is IEnumerable enumerable) + { + foreach (object? item in enumerable) + { + CollectFallbacks(item, visited, fallbacks); + } + } + } + [McpServerTool(Name = "create_project")] [Description("Creates and saves a new Beutl .bep project with one scene, then makes it the active editing session. In the in-app host the project opens in the Beutl editor (single open project, LiveEditor session); in the stdio host it becomes a file-backed session. Paths without an extension are saved as .bep; .beutl is reserved for project packages. The output path is restricted to BEUTL_WORKSPACE.")] public ValueTask> CreateProject( diff --git a/src/Beutl.Core/CoreObject.cs b/src/Beutl.Core/CoreObject.cs index 3818b1637b..d3b84af264 100644 --- a/src/Beutl.Core/CoreObject.cs +++ b/src/Beutl.Core/CoreObject.cs @@ -74,6 +74,8 @@ public string Name public Uri? Uri { get; set; } + internal bool IsStorageWriteSuppressed { get; set; } + private Dictionary Values => _values ??= []; private Dictionary Errors => _errors ??= []; diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 961f3c66af..99dd978d5d 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -227,6 +227,11 @@ public static void PopulateFromUri(ICoreSerializable obj, Type type, Uri uri) public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = null) where T : ICoreSerializable { + if (obj is CoreObject { IsStorageWriteSuppressed: true }) + { + return; + } + if (uri.Scheme == "file") { if (obj is CoreObject coreObj) diff --git a/src/Beutl.Editor/AutoSaveService.cs b/src/Beutl.Editor/AutoSaveService.cs index 5082bb0456..61a43524b1 100644 --- a/src/Beutl.Editor/AutoSaveService.cs +++ b/src/Beutl.Editor/AutoSaveService.cs @@ -41,7 +41,7 @@ public void SaveObjects(IEnumerable objectsToSave) { if (obj is IHierarchical hierarchical && hierarchical.HierarchicalRoot == null) { - if (obj.Uri!.Scheme == "file") + if (!obj.IsStorageWriteSuppressed && obj.Uri!.Scheme == "file") { var path = obj.Uri.LocalPath; if (File.Exists(path)) diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 6668d59fc9..c471ec3e02 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -1,12 +1,17 @@ -using System.Collections.Immutable; +using System.Collections.Concurrent; +using System.Collections.Immutable; using System.Collections.Specialized; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Beutl.Collections; using Beutl.Configuration; +using Beutl.Engine; using Beutl.Language; using Beutl.Media; using Beutl.Serialization; @@ -43,6 +48,10 @@ public enum ElementOverlapHandling public class Scene : ProjectItem, INotifyEdited { + private static readonly Guid s_recoveredElementNamespace = new("dfad2f76-1d04-5593-ae3b-f371fb1f42ee"); + private static readonly Regex s_idPattern = new( + "\"Id\"\\s*:\\s*\"(?[0-9a-fA-F-]{36})\"", + RegexOptions.CultureInvariant); public static readonly CoreProperty FrameSizeProperty; public static readonly CoreProperty ChildrenProperty; public static readonly CoreProperty StartProperty; @@ -52,6 +61,8 @@ public class Scene : ProjectItem, INotifyEdited public static readonly CoreProperty> MarkersProperty; private readonly List _includeElements = ["**/*.belm"]; private readonly List _excludeElements = []; + private readonly ConcurrentDictionary _recoveredElements + = new(ReferenceEqualityComparer.Instance); private readonly Elements _children; private readonly HierarchicalList _layers; private readonly HierarchicalList _markers; @@ -544,7 +555,10 @@ static void Process(JsonObject jobject, string jsonName, List list) { foreach (Element item in Children) { - CoreSerializer.StoreToUri(item, item.Uri!); + if (!_recoveredElements.ContainsKey(item)) + { + CoreSerializer.StoreToUri(item, item.Uri!); + } } } @@ -670,13 +684,185 @@ private void SyncronizeFiles(IEnumerable pathToElement) Children.Remove(item); } - Children.AddRange(urisAdd.AsParallel().Select(CoreSerializer.RestoreFromUri)); + Children.AddRange(urisAdd.AsParallel().Select(RestoreElementOrFallback)); activity?.SetTag("addCount", urisAdd.Length); activity?.SetTag("removeCount", elementsRemove.Length); activity?.SetTag("childrenCount", Children.Count); } + private Element RestoreElementOrFallback(Uri uri) + { + string rawText = File.ReadAllText(uri.LocalPath); + try + { + Element element = CoreSerializer.RestoreFromUri(uri); + IFallback[] fallbacks = element.EnumerateAllChildren().ToArray(); + if (fallbacks.Length > 0) + { + foreach (IFallback fallback in fallbacks) + { + EnsureFallbackProjection(fallback); + } + + MarkRecoveredElement(element, rawText); + } + + return element; + } + catch (JsonException ex) + { + var element = new Element + { + Id = ResolveRecoveredElementId(rawText, uri), + Name = Path.GetFileNameWithoutExtension(uri.LocalPath), + Uri = uri, + IsEnabled = false, + }; + var fallback = new FallbackEngineObject + { + Name = "Unreadable element data", + Reason = FallbackReason.DeserializationFailed, + ErrorMessage = $"{ex.GetType().Name}: {ex.Message}", + }; + fallback.Json = CreateFallbackProjection(fallback); + element.AddObject(fallback); + MarkRecoveredElement(element, rawText); + return element; + } + } + + private void MarkRecoveredElement(Element element, string rawText) + { + element.IsStorageWriteSuppressed = true; + _recoveredElements[element] = new RecoveredElementSource(rawText); + } + + private static void EnsureFallbackProjection(IFallback fallback) + { + if (fallback is not CoreObject coreObject) + { + return; + } + + JsonObject json = fallback.Json ?? new JsonObject(); + json.WriteDiscriminator(coreObject.GetType()); + json[nameof(CoreObject.Id)] = coreObject.Id.ToString(); + fallback.Json = json; + } + + private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback) + { + var json = new JsonObject + { + [nameof(CoreObject.Id)] = fallback.Id.ToString(), + [nameof(CoreObject.Name)] = fallback.Name, + }; + json.WriteDiscriminator(typeof(FallbackEngineObject)); + return json; + } + + private Guid ResolveRecoveredElementId(string rawText, Uri uri) + { + MatchCollection matches = s_idPattern.Matches(rawText); + Match? topLevelMatch = FindTopLevelIdMatch(rawText, matches); + if (topLevelMatch != null + && Guid.TryParse(topLevelMatch.Groups["id"].Value, out Guid topLevelId)) + { + return topLevelId; + } + + if (matches.Count > 0 + && Guid.TryParse(matches[0].Groups["id"].Value, out Guid firstId)) + { + return firstId; + } + + string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; + string relativePath = Path.GetRelativePath(sceneDirectory, uri.LocalPath); + return CreateVersion5Guid(s_recoveredElementNamespace, relativePath); + } + + private static Match? FindTopLevelIdMatch(string rawText, MatchCollection matches) + { + int matchIndex = 0; + int objectDepth = 0; + bool inString = false; + bool escaped = false; + + for (int i = 0; i < rawText.Length && matchIndex < matches.Count; i++) + { + Match match = matches[matchIndex]; + if (i == match.Index) + { + if (!inString && objectDepth == 1) + { + return match; + } + + matchIndex++; + } + + char current = rawText[i]; + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (current == '\\') + { + escaped = true; + } + else if (current == '"') + { + inString = false; + } + } + else if (current == '"') + { + inString = true; + } + else if (current == '{') + { + objectDepth++; + } + else if (current == '}' && objectDepth > 0) + { + objectDepth--; + } + } + + return null; + } + + private static Guid CreateVersion5Guid(Guid namespaceId, string name) + { + byte[] namespaceBytes = namespaceId.ToByteArray(); + SwapGuidByteOrder(namespaceBytes); + byte[] nameBytes = Encoding.UTF8.GetBytes(name); + byte[] source = new byte[namespaceBytes.Length + nameBytes.Length]; + namespaceBytes.CopyTo(source, 0); + nameBytes.CopyTo(source, namespaceBytes.Length); + + byte[] hash = SHA1.HashData(source); + hash[6] = (byte)((hash[6] & 0x0f) | 0x50); + hash[8] = (byte)((hash[8] & 0x3f) | 0x80); + Array.Resize(ref hash, 16); + SwapGuidByteOrder(hash); + return new Guid(hash); + } + + private static void SwapGuidByteOrder(Span bytes) + { + (bytes[0], bytes[3]) = (bytes[3], bytes[0]); + (bytes[1], bytes[2]) = (bytes[2], bytes[1]); + (bytes[4], bytes[5]) = (bytes[5], bytes[4]); + (bytes[6], bytes[7]) = (bytes[7], bytes[6]); + } + + private sealed record RecoveredElementSource(string RawText); + private void UpdateInclude() { string dirPath = Path.GetDirectoryName(Uri!.LocalPath)!; diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index 553884cca0..c929fe3051 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -1,17 +1,251 @@ -using Beutl.AgentToolkit.Common; +using System.Text.Json; +using System.Text.Json.Nodes; +using Beutl.AgentToolkit.Common; using Beutl.AgentToolkit.Documents; using Beutl.AgentToolkit.Rendering; +using Beutl.AgentToolkit.Schema; using Beutl.AgentToolkit.Sessions; using Beutl.AgentToolkit.Tests.Helpers; using Beutl.AgentToolkit.Tools; using Beutl.AgentToolkit.Workspace; using Beutl.Editor; +using Beutl.Graphics; +using Beutl.Graphics.Shapes; +using Beutl.Media; using Beutl.ProjectSystem; +using Beutl.Serialization; namespace Beutl.AgentToolkit.Tests.Tools; public sealed class SessionToolsTests { + [Test] + public async Task Open_project_warns_about_corrupt_element_and_render_still_remains_available() + { + string root = CreateWorkspace(); + string projectPath = Path.Combine(root, "corrupt-element.bep"); + Project project = ProjectOperations.CreateProject(new ProjectCreateOptions( + projectPath, + 64, + 64, + 30, + TimeSpan.FromSeconds(1))); + Scene scene = project.Items.OfType().Single(); + var element = new Element + { + Name = "Corrupt element", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine( + Path.GetDirectoryName(scene.Uri!.LocalPath)!, + "corrupt-element.belm")) + }; + element.AddObject(new RectShape + { + Width = { CurrentValue = 32 }, + Height = { CurrentValue = 32 }, + Fill = { CurrentValue = Brushes.White } + }); + scene.Children.Add(element); + ProjectOperations.Save(project); + + string elementPath = element.Uri!.LocalPath; + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject drawableJson = elementJson[nameof(Element.Objects)]!.AsArray()[0]!.AsObject(); + drawableJson[nameof(RectShape.Width)] = "not-a-number"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + + var manager = new AgentSessionManager(); + using var source = new FileSessionSource(); + SessionTools sessionTools = CreateSessionTools(source, manager, root); + + ToolResult opened = await sessionTools.OpenProject(projectPath); + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); + Assert.That(opened.Value, Is.Not.Null, opened.Error?.Message); + JsonObject responseJson = JsonSerializer.SerializeToNode(opened.Value)!.AsObject(); + + var stillRenderer = new StillRenderer(); + var motionAnalyzer = new MotionVariationAnalyzer(stillRenderer); + var renderTools = new RenderTools( + manager, + new WorkspaceGuard(root), + new DestructiveGuard(), + stillRenderer, + new StoryboardRenderer(), + motionAnalyzer, + new AudioRhythmAnalyzer(), + new QualityAnalyzer(motionAnalyzer, stillRenderer), + new VideoExporter(new EncoderRegistration()), + new RenderJobManager()); + string outputPath = Path.Combine(root, "corrupt-element.png"); + var rendered = await renderTools.RenderStill( + outputPath, + cancellationToken: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); + Assert.That( + responseJson["Warnings"]?.AsArray().Select(static item => item!.GetValue()), + Has.Some.Contains(Path.GetFileName(elementPath)).And.Some.Contains("could not be converted")); + Assert.That(rendered.IsError, Is.Not.True); + Assert.That(File.Exists(outputPath), Is.True); + }); + } + + [Test] + public async Task Open_project_warns_about_malformed_element_json_and_keeps_healthy_elements() + { + string root = CreateWorkspace(); + string projectPath = Path.Combine(root, "malformed-element.bep"); + Project project = ProjectOperations.CreateProject(new ProjectCreateOptions( + projectPath, + 64, + 64, + 30, + TimeSpan.FromSeconds(1))); + Scene scene = project.Items.OfType().Single(); + string sceneDirectory = Path.GetDirectoryName(scene.Uri!.LocalPath)!; + var healthy = new Element + { + Name = "Healthy element", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(sceneDirectory, "healthy-element.belm")), + }; + healthy.AddObject(new RectShape + { + Width = { CurrentValue = 32 }, + Height = { CurrentValue = 32 }, + Fill = { CurrentValue = Brushes.White }, + }); + var malformed = new Element + { + Name = "Malformed element", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(sceneDirectory, "malformed-element.belm")), + }; + malformed.AddObject(new RectShape + { + Width = { CurrentValue = 16 }, + Height = { CurrentValue = 16 }, + Fill = { CurrentValue = Brushes.Red }, + }); + scene.Children.Add(healthy); + scene.Children.Add(malformed); + ProjectOperations.Save(project); + File.WriteAllText(malformed.Uri!.LocalPath, "{ this is not valid JSON"); + + var manager = new AgentSessionManager(); + using var source = new FileSessionSource(); + SessionTools sessionTools = CreateSessionTools(source, manager, root); + + ToolResult opened = await sessionTools.OpenProject(projectPath); + JsonObject responseJson = JsonSerializer.SerializeToNode(opened.Value)!.AsObject(); + + var stillRenderer = new StillRenderer(); + var motionAnalyzer = new MotionVariationAnalyzer(stillRenderer); + var renderTools = new RenderTools( + manager, + new WorkspaceGuard(root), + new DestructiveGuard(), + stillRenderer, + new StoryboardRenderer(), + motionAnalyzer, + new AudioRhythmAnalyzer(), + new QualityAnalyzer(motionAnalyzer, stillRenderer), + new VideoExporter(new EncoderRegistration()), + new RenderJobManager()); + string outputPath = Path.Combine(root, "malformed-element.png"); + var rendered = await renderTools.RenderStill( + outputPath, + cancellationToken: CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(opened.Value!.Summary.Scenes.Single().Elements, Is.EqualTo(2)); + Assert.That( + responseJson["Warnings"]?.AsArray().Select(static item => item!.GetValue()), + Has.Some.Contains(Path.GetFileName(malformed.Uri.LocalPath)) + .And.Some.Contains("JsonReaderException") + .And.Some.Contains("invalid start")); + Assert.That(rendered.IsError, Is.Not.True); + Assert.That(File.Exists(outputPath), Is.True); + }); + } + + [Test] + public async Task Apply_edit_can_rename_healthy_element_while_malformed_element_is_recovered() + { + string root = CreateWorkspace(); + RecoveredProjectFixture fixture = CreateProjectWithMalformedElement(root); + var manager = new AgentSessionManager(); + using var source = new FileSessionSource(); + SessionTools sessionTools = CreateSessionTools(source, manager, root); + ToolResult opened = await sessionTools.OpenProject(fixture.ProjectPath); + var editTools = new EditTools(manager); + JsonObject patch = new() + { + ["Elements"] = new JsonArray(new JsonObject + { + [nameof(CoreObject.Id)] = fixture.HealthyId.ToString(), + [nameof(CoreObject.Name)] = "Renamed healthy element", + }), + }; + + ToolResult applied = editTools.ApplyEdit( + patch: patch, + schemaVersion: SchemaVersion.Current); + ToolResult saved = sessionTools.SaveProject(opened.Value!.Session); + + Assert.Multiple(() => + { + Assert.That(applied.IsSuccess, Is.True, applied.Error?.Message); + Assert.That(saved.IsSuccess, Is.True, saved.Error?.Message); + Assert.That(File.ReadAllBytes(fixture.MalformedPath), Is.EqualTo(fixture.MalformedBytes)); + Assert.That( + ((Scene)manager.CurrentSession!.Root).Children.Single(item => item.Id == fixture.HealthyId).Name, + Is.EqualTo("Renamed healthy element")); + }); + } + + [Test] + public async Task Delete_recovered_element_and_save_excludes_it_without_deleting_its_sidecar() + { + string root = CreateWorkspace(); + RecoveredProjectFixture fixture = CreateProjectWithMalformedElement(root); + byte[] healthyBytes = File.ReadAllBytes(fixture.HealthyPath); + var manager = new AgentSessionManager(); + using var source = new FileSessionSource(); + SessionTools sessionTools = CreateSessionTools(source, manager, root); + ToolResult opened = await sessionTools.OpenProject(fixture.ProjectPath); + var editTools = new EditTools(manager); + JsonObject patch = new() + { + ["Elements"] = new JsonArray(new JsonObject + { + [nameof(CoreObject.Id)] = fixture.MalformedId.ToString(), + ["$delete"] = true, + }), + }; + + ToolResult deleted = editTools.ApplyEdit( + patch: patch, + schemaVersion: SchemaVersion.Current); + ToolResult saved = sessionTools.SaveProject(opened.Value!.Session); + + Project reopenedProject = CoreSerializer.RestoreFromUri(new Uri(fixture.ProjectPath)); + Scene reopenedScene = reopenedProject.Items.OfType().Single(); + Assert.Multiple(() => + { + Assert.That(deleted.IsSuccess, Is.True, deleted.Error?.Message); + Assert.That(saved.IsSuccess, Is.True, saved.Error?.Message); + Assert.That(File.ReadAllBytes(fixture.MalformedPath), Is.EqualTo(fixture.MalformedBytes), + "Declarative deletion excludes the recovered sidecar; it does not destroy the opaque source file."); + Assert.That(File.ReadAllBytes(fixture.HealthyPath), Is.EqualTo(healthyBytes)); + Assert.That(reopenedScene.Children.Select(static item => item.Id), Does.Not.Contain(fixture.MalformedId)); + Assert.That(reopenedScene.Children.Select(static item => item.Id), Does.Contain(fixture.HealthyId)); + }); + } + [Test] public async Task Create_project_starts_file_backed_session_for_document_tools() { @@ -437,6 +671,65 @@ private static string CreateWorkspace() return path; } + private static RecoveredProjectFixture CreateProjectWithMalformedElement(string root) + { + string projectPath = Path.Combine(root, "recovered-project.bep"); + Project project = ProjectOperations.CreateProject(new ProjectCreateOptions( + projectPath, + 64, + 64, + 30, + TimeSpan.FromSeconds(1))); + Scene scene = project.Items.OfType().Single(); + string sceneDirectory = Path.GetDirectoryName(scene.Uri!.LocalPath)!; + var healthy = new Element + { + Name = "Healthy element", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(sceneDirectory, "healthy-element.belm")), + }; + healthy.AddObject(new RectShape + { + Width = { CurrentValue = 32 }, + Height = { CurrentValue = 32 }, + Fill = { CurrentValue = Brushes.White }, + }); + var malformed = new Element + { + Name = "Malformed element", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(sceneDirectory, "malformed-element.belm")), + }; + malformed.AddObject(new RectShape + { + Width = { CurrentValue = 16 }, + Height = { CurrentValue = 16 }, + Fill = { CurrentValue = Brushes.Red }, + }); + scene.Children.Add(healthy); + scene.Children.Add(malformed); + ProjectOperations.Save(project); + + byte[] malformedBytes = System.Text.Encoding.UTF8.GetBytes( + $"{{\"Id\":\"{malformed.Id}\",\"Name\":\"Malformed element\",\"Objects\":["); + File.WriteAllBytes(malformed.Uri!.LocalPath, malformedBytes); + return new RecoveredProjectFixture( + projectPath, + healthy.Uri!.LocalPath, + healthy.Id, + malformed.Uri.LocalPath, + malformed.Id, + malformedBytes); + } + + private sealed record RecoveredProjectFixture( + string ProjectPath, + string HealthyPath, + Guid HealthyId, + string MalformedPath, + Guid MalformedId, + byte[] MalformedBytes); + private sealed class DispatchingProjectGateway : IProjectSessionGateway { public DispatchingLiveSession? LastSession { get; private set; } diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs new file mode 100644 index 0000000000..6fb911982c --- /dev/null +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -0,0 +1,163 @@ +using System.Text.Json.Nodes; +using Beutl.Editor; +using Beutl.Graphics.Shapes; +using Beutl.ProjectSystem; +using Beutl.Serialization; + +namespace Beutl.UnitTests.ProjectSystem; + +public sealed class MalformedElementRecoveryTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + "malformed-element-recovery-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, true); + } + } + + [Test] + public void Save_PreservesMalformedElementSidecarBytes() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); + File.WriteAllBytes(elementPath, corruptBytes); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + recovered.Children.Single().Name = "Recovered placeholder"; + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); + } + + [Test] + public void Restore_MalformedElementWithoutReadableId_UsesStableId() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText(elementPath, "{ this is not valid JSON"); + + Guid first = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + Guid second = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void Save_PreservesDeserializationFallbackSidecarBytes() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + json[nameof(CoreObject.Name)] = "Hand-formatted element"; + json[nameof(Element.Objects)]!.AsArray()[0]!.AsObject()[nameof(RectShape.Width)] = "invalid-width"; + string fallbackSource = json.ToJsonString(); + File.WriteAllText(elementPath, fallbackSource); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + Assert.That(recovered.Children.Single().Objects.Single(), Is.InstanceOf()); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + } + + [Test] + public void DirectElementSave_PreservesMalformedElementSidecarBytes() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); + File.WriteAllBytes(elementPath, corruptBytes); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + recovered.Name = "Recovered placeholder"; + CoreSerializer.StoreToUri(recovered, recovered.Uri!); + + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); + } + + [Test] + public void AutoSave_PreservesMalformedElementSidecarBytes() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); + File.WriteAllBytes(elementPath, corruptBytes); + + Scene scene = CoreSerializer.RestoreFromUri(sceneUri); + var app = new BeutlApplication { Project = new Project() }; + app.Project!.Items.Add(scene); + Element recovered = scene.Children.Single(); + recovered.Name = "Recovered placeholder"; + using var service = new AutoSaveService(); + service.SaveObjects([recovered]); + + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); + } + + [Test] + public void AutoSave_RemovedMalformedElementPreservesSidecarBytes() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); + File.WriteAllBytes(elementPath, corruptBytes); + + Scene scene = CoreSerializer.RestoreFromUri(sceneUri); + var app = new BeutlApplication { Project = new Project() }; + app.Project!.Items.Add(scene); + Element recovered = scene.Children.Single(); + scene.Children.Remove(recovered); + using var service = new AutoSaveService(); + service.SaveObjects([recovered]); + + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); + } + + [Test] + public void Restore_MalformedElementPrefersTopLevelId() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + var nestedId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + var topLevelId = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + File.WriteAllText( + elementPath, + $$"""{"Objects":[{"Id":"{{nestedId}}"}],"Id":"{{topLevelId}}","Broken":["""); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + + Assert.That(recovered.Id, Is.EqualTo(topLevelId)); + } + + private (Uri SceneUri, string ElementPath) CreatePersistedScene() + { + var sceneUri = new Uri(Path.Combine(_root, "scene.scene")); + string elementPath = Path.Combine(_root, "element.belm"); + var scene = new Scene(64, 64, "Scene") + { + Uri = sceneUri, + }; + var element = new Element + { + Name = "Element", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(new RectShape + { + Width = { CurrentValue = 32 }, + Height = { CurrentValue = 32 }, + }); + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + return (sceneUri, elementPath); + } +} From 168821348195641c5bf77364586aed532c6b47ed Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 12:56:32 +0900 Subject: [PATCH 02/35] fix(review): harden malformed-element recovery per review findings Save-as now copies a recovered element's retained raw bytes to the new location instead of silently skipping the write, so a saved-as project keeps the element while the original file stays untouched. Recovery catches the full deserialization-domain failure set (a resolvable but non-Element $type surfaced as InvalidCastException and aborted the whole open), reads sidecar text only on the recovery path instead of doubling every healthy element's I/O, and never adopts a nested or quoted Id when no top-level Id exists. open_project collects deserialization warnings on the session thread, and the tests assert open success before use, a non-empty recovered Id, save-as rehoming, and non-Element-discriminator recovery. --- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 4 +- src/Beutl.Core/CoreObject.cs | 4 +- .../Serialization/CoreSerializer.cs | 19 +++++- .../Serialization/SuppressedStorageSource.cs | 7 +++ src/Beutl.Editor/AutoSaveService.cs | 2 +- .../ProjectSystem/Scene.cs | 35 +++++------ .../Tools/SessionToolsTests.cs | 3 + .../MalformedElementRecoveryTests.cs | 61 ++++++++++++++++++- 8 files changed, 110 insertions(+), 25 deletions(-) create mode 100644 src/Beutl.Core/Serialization/SuppressedStorageSource.cs diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index f4efa22a53..1b38c251be 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -79,7 +79,9 @@ public ValueTask> OpenProject(string path, Cance result.Session.Source.ToString(), CreateSummary(result.Session, result.Project)) { - Warnings = CollectDeserializationWarnings(result.Project) + // The traversal walks the live graph, so it must run on the session thread — a live + // editor may mutate the collections concurrently. + Warnings = result.Session.ReadOnSession(() => CollectDeserializationWarnings(result.Project)) }; }); } diff --git a/src/Beutl.Core/CoreObject.cs b/src/Beutl.Core/CoreObject.cs index d3b84af264..b4b79fc1fa 100644 --- a/src/Beutl.Core/CoreObject.cs +++ b/src/Beutl.Core/CoreObject.cs @@ -74,7 +74,9 @@ public string Name public Uri? Uri { get; set; } - internal bool IsStorageWriteSuppressed { get; set; } + // Non-null while this object stands in for a file the serializer must not regenerate: + // StoreToUri skips the source location and copies the raw text verbatim to any new one. + internal SuppressedStorageSource? SuppressedStorageSource { get; set; } private Dictionary Values => _values ??= []; diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 99dd978d5d..1e65893085 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -227,8 +227,25 @@ public static void PopulateFromUri(ICoreSerializable obj, Type type, Uri uri) public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = null) where T : ICoreSerializable { - if (obj is CoreObject { IsStorageWriteSuppressed: true }) + if (obj is CoreObject { SuppressedStorageSource: { } suppressed } suppressedObj) { + if (uri == suppressed.SourceUri || uri.Scheme != "file") + { + return; + } + + // Rehomed (save-as): the retained bytes move verbatim so the new project copy keeps the + // element, while the original file stays untouched. + string rehomedPath = uri.LocalPath; + string? rehomedDirectory = Path.GetDirectoryName(rehomedPath); + if (rehomedDirectory != null) + { + Directory.CreateDirectory(rehomedDirectory); + } + + File.WriteAllText(rehomedPath, suppressed.RawText); + suppressedObj.Uri = uri; + suppressedObj.SuppressedStorageSource = suppressed with { SourceUri = uri }; return; } diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs new file mode 100644 index 0000000000..7be46801eb --- /dev/null +++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs @@ -0,0 +1,7 @@ +namespace Beutl; + +/// +/// The retained on-disk content of an object the serializer must not regenerate, together with the +/// location those bytes came from. +/// +internal sealed record SuppressedStorageSource(string RawText, Uri SourceUri); diff --git a/src/Beutl.Editor/AutoSaveService.cs b/src/Beutl.Editor/AutoSaveService.cs index 61a43524b1..f06bcc3e98 100644 --- a/src/Beutl.Editor/AutoSaveService.cs +++ b/src/Beutl.Editor/AutoSaveService.cs @@ -41,7 +41,7 @@ public void SaveObjects(IEnumerable objectsToSave) { if (obj is IHierarchical hierarchical && hierarchical.HierarchicalRoot == null) { - if (!obj.IsStorageWriteSuppressed && obj.Uri!.Scheme == "file") + if (obj.SuppressedStorageSource is null && obj.Uri!.Scheme == "file") { var path = obj.Uri.LocalPath; if (File.Exists(path)) diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index c471ec3e02..008773d56e 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -61,8 +61,6 @@ public class Scene : ProjectItem, INotifyEdited public static readonly CoreProperty> MarkersProperty; private readonly List _includeElements = ["**/*.belm"]; private readonly List _excludeElements = []; - private readonly ConcurrentDictionary _recoveredElements - = new(ReferenceEqualityComparer.Instance); private readonly Elements _children; private readonly HierarchicalList _layers; private readonly HierarchicalList _markers; @@ -555,10 +553,7 @@ static void Process(JsonObject jobject, string jsonName, List list) { foreach (Element item in Children) { - if (!_recoveredElements.ContainsKey(item)) - { - CoreSerializer.StoreToUri(item, item.Uri!); - } + CoreSerializer.StoreToUri(item, item.Uri!); } } @@ -693,7 +688,6 @@ private void SyncronizeFiles(IEnumerable pathToElement) private Element RestoreElementOrFallback(Uri uri) { - string rawText = File.ReadAllText(uri.LocalPath); try { Element element = CoreSerializer.RestoreFromUri(uri); @@ -705,13 +699,20 @@ private Element RestoreElementOrFallback(Uri uri) EnsureFallbackProjection(fallback); } - MarkRecoveredElement(element, rawText); + MarkRecoveredElement(element, File.ReadAllText(uri.LocalPath), uri); } return element; } - catch (JsonException ex) + // Deserialization-domain failures recover; filesystem failures still propagate. A resolvable + // but non-Element $type surfaces as InvalidCastException, an unresolvable one as + // InvalidOperationException / NotSupportedException. + catch (Exception ex) when (ex is JsonException + or InvalidCastException + or InvalidOperationException + or NotSupportedException) { + string rawText = File.ReadAllText(uri.LocalPath); var element = new Element { Id = ResolveRecoveredElementId(rawText, uri), @@ -727,15 +728,14 @@ private Element RestoreElementOrFallback(Uri uri) }; fallback.Json = CreateFallbackProjection(fallback); element.AddObject(fallback); - MarkRecoveredElement(element, rawText); + MarkRecoveredElement(element, rawText, uri); return element; } } - private void MarkRecoveredElement(Element element, string rawText) + private static void MarkRecoveredElement(Element element, string rawText, Uri uri) { - element.IsStorageWriteSuppressed = true; - _recoveredElements[element] = new RecoveredElementSource(rawText); + element.SuppressedStorageSource = new SuppressedStorageSource(rawText, uri); } private static void EnsureFallbackProjection(IFallback fallback) @@ -764,6 +764,8 @@ private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback private Guid ResolveRecoveredElementId(string rawText, Uri uri) { + // Only a top-level Id may name the element: a nested object's or quoted Id would collide + // with live objects, so anything else falls through to the deterministic filename Guid. MatchCollection matches = s_idPattern.Matches(rawText); Match? topLevelMatch = FindTopLevelIdMatch(rawText, matches); if (topLevelMatch != null @@ -772,12 +774,6 @@ private Guid ResolveRecoveredElementId(string rawText, Uri uri) return topLevelId; } - if (matches.Count > 0 - && Guid.TryParse(matches[0].Groups["id"].Value, out Guid firstId)) - { - return firstId; - } - string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; string relativePath = Path.GetRelativePath(sceneDirectory, uri.LocalPath); return CreateVersion5Guid(s_recoveredElementNamespace, relativePath); @@ -861,7 +857,6 @@ private static void SwapGuidByteOrder(Span bytes) (bytes[6], bytes[7]) = (bytes[7], bytes[6]); } - private sealed record RecoveredElementSource(string RawText); private void UpdateInclude() { diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index c929fe3051..f834b4e808 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -139,6 +139,7 @@ public async Task Open_project_warns_about_malformed_element_json_and_keeps_heal SessionTools sessionTools = CreateSessionTools(source, manager, root); ToolResult opened = await sessionTools.OpenProject(projectPath); + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); JsonObject responseJson = JsonSerializer.SerializeToNode(opened.Value)!.AsObject(); var stillRenderer = new StillRenderer(); @@ -181,6 +182,7 @@ public async Task Apply_edit_can_rename_healthy_element_while_malformed_element_ using var source = new FileSessionSource(); SessionTools sessionTools = CreateSessionTools(source, manager, root); ToolResult opened = await sessionTools.OpenProject(fixture.ProjectPath); + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); var editTools = new EditTools(manager); JsonObject patch = new() { @@ -217,6 +219,7 @@ public async Task Delete_recovered_element_and_save_excludes_it_without_deleting using var source = new FileSessionSource(); SessionTools sessionTools = CreateSessionTools(source, manager, root); ToolResult opened = await sessionTools.OpenProject(fixture.ProjectPath); + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); var editTools = new EditTools(manager); JsonObject patch = new() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 6fb911982c..365119c65e 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -51,7 +51,66 @@ public void Restore_MalformedElementWithoutReadableId_UsesStableId() Guid first = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; Guid second = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; - Assert.That(second, Is.EqualTo(first)); + Assert.Multiple(() => + { + Assert.That(first, Is.Not.EqualTo(Guid.Empty)); + Assert.That(second, Is.EqualTo(first)); + }); + } + + [Test] + public void Restore_MalformedElementWithOnlyNestedId_DoesNotAdoptIt() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + var nestedId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + File.WriteAllText( + elementPath, + $$"""{"Objects":[{"Id":"{{nestedId}}"}],"Broken":["""); + + Guid recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + + Assert.Multiple(() => + { + Assert.That(recovered, Is.Not.EqualTo(nestedId)); + Assert.That(recovered, Is.Not.EqualTo(Guid.Empty)); + }); + } + + [Test] + public void Restore_ResolvableNonElementDiscriminator_RecoversInsteadOfFailing() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + """{"$type":"[Beutl.Engine]Beutl.Graphics.Shapes:RectShape","Id":"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0"}"""); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + Assert.That(recovered.Children.Single().IsEnabled, Is.False); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + + [Test] + public void SaveAs_CopiesRecoveredSidecarBytesToTheNewLocation() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); + File.WriteAllBytes(elementPath, corruptBytes); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + string rehomedPath = Path.Combine(_root, "rehomed", Path.GetFileName(elementPath)); + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + + Assert.Multiple(() => + { + Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(corruptBytes)); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); + }); } [Test] From b8acc3245f1dffa8c73151d59370049ea45b105d Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 13:24:42 +0900 Subject: [PATCH 03/35] fix(review): preserve recovered sidecars as raw bytes and reject bad discriminators before deserializing - SuppressedStorageSource now retains raw bytes, so rehoming keeps a BOM, foreign encodings, and undecodable bytes verbatim. - The rehome path no longer mutates the suppression record; the source location stays skip-protected even if a failed multi-file save rolls Uri back. - TypeFormat.ToType returns null for unparsable names instead of leaking parser exceptions, and the legacy discriminator fill-in keys on string presence so garbage $type recovers instead of loading as the default. - RestoreFromUri rejects a discriminator type incompatible with the expected type before instantiating it, preventing wrong-type load side effects (e.g. a Scene declared in a .belm globbing element files). --- .../Serialization/CoreSerializer.cs | 19 ++++- .../Serialization/SuppressedStorageSource.cs | 7 +- src/Beutl.Core/TypeFormat.cs | 15 +++- .../ProjectSystem/Scene.cs | 15 ++-- .../MalformedElementRecoveryTests.cs | 85 +++++++++++++++++++ 5 files changed, 126 insertions(+), 15 deletions(-) diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 1e65893085..402640fd0d 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -158,7 +158,10 @@ public static object RestoreFromUri(Uri uri, Type type) // 互換性処理 // 1.x で作成されたファイルでは一部のオブジェクトに $type が付与されないため、 // 期待される型に基づいてディスクリミネータを補完する。 - if (!node.TryGetDiscriminator(out Type? _)) + // Presence is checked on the raw string: a present-but-unparsable discriminator must fail + // as an unknown type, not silently deserialize as the legacy default and overwrite the + // original data on the next save. + if (!node.TryGetDiscriminator(out string? _)) { if (type == typeof(ProjectItem)) { @@ -176,6 +179,14 @@ public static object RestoreFromUri(Uri uri, Type type) throw new InvalidOperationException("Discriminator not found in JSON object."); } + if (!type.IsAssignableFrom(actualType)) + { + // Reject before instantiating: deserializing the declared type first would run its own + // load side effects (e.g. a Scene declared in a .belm globs and reopens element files). + throw new InvalidCastException( + $"Discriminator type '{actualType}' is not assignable to the expected type '{type}'."); + } + try { var obj = Activator.CreateInstance(actualType) as ICoreSerializable @@ -235,7 +246,8 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n } // Rehomed (save-as): the retained bytes move verbatim so the new project copy keeps the - // element, while the original file stays untouched. + // element. The suppression record is never mutated — the source location stays + // skip-protected even if a failed multi-file save rolls Uri back afterwards. string rehomedPath = uri.LocalPath; string? rehomedDirectory = Path.GetDirectoryName(rehomedPath); if (rehomedDirectory != null) @@ -243,9 +255,8 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n Directory.CreateDirectory(rehomedDirectory); } - File.WriteAllText(rehomedPath, suppressed.RawText); + File.WriteAllBytes(rehomedPath, suppressed.RawBytes); suppressedObj.Uri = uri; - suppressedObj.SuppressedStorageSource = suppressed with { SourceUri = uri }; return; } diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs index 7be46801eb..e03f66d1fc 100644 --- a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs +++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs @@ -1,7 +1,8 @@ namespace Beutl; /// -/// The retained on-disk content of an object the serializer must not regenerate, together with the -/// location those bytes came from. +/// The retained on-disk bytes of an object the serializer must not regenerate, together with the +/// location those bytes came from. The source location is never rewritten; any other location +/// receives a verbatim copy. /// -internal sealed record SuppressedStorageSource(string RawText, Uri SourceUri); +internal sealed record SuppressedStorageSource(byte[] RawBytes, Uri SourceUri); diff --git a/src/Beutl.Core/TypeFormat.cs b/src/Beutl.Core/TypeFormat.cs index 540a8cf4df..3224ec28b4 100644 --- a/src/Beutl.Core/TypeFormat.cs +++ b/src/Beutl.Core/TypeFormat.cs @@ -10,8 +10,19 @@ internal static class TypeFormat public static Type? ToType(string fullName) { fullName = fullName.Replace("Beutl.Embedding.FFmpeg", "Beutl.Extensions.FFmpeg"); - List tokens = new TypeNameTokenizer(fullName).Tokenize(); - return new TypeNameParser(tokens).Parse(); + try + { + List tokens = new TypeNameTokenizer(fullName).Tokenize(); + return new TypeNameParser(tokens).Parse(); + } + // The tokenizer/parser index freely and throw on ill-formed names (e.g. "x" from a + // hand-edited file); every caller already treats null as "unknown type". + catch (Exception ex) when (ex is IndexOutOfRangeException + or ArgumentOutOfRangeException + or InvalidOperationException) + { + return null; + } } public static string ToString(Type type) diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 008773d56e..855bbc1810 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -699,7 +699,7 @@ private Element RestoreElementOrFallback(Uri uri) EnsureFallbackProjection(fallback); } - MarkRecoveredElement(element, File.ReadAllText(uri.LocalPath), uri); + MarkRecoveredElement(element, File.ReadAllBytes(uri.LocalPath), uri); } return element; @@ -712,10 +712,13 @@ or InvalidCastException or InvalidOperationException or NotSupportedException) { - string rawText = File.ReadAllText(uri.LocalPath); + // Raw bytes, not text: the sidecar must survive rehoming byte-identically even when it + // holds a BOM, another encoding, or undecodable bytes. The lossy decode is only scanned + // for a top-level Id. + byte[] rawBytes = File.ReadAllBytes(uri.LocalPath); var element = new Element { - Id = ResolveRecoveredElementId(rawText, uri), + Id = ResolveRecoveredElementId(Encoding.UTF8.GetString(rawBytes), uri), Name = Path.GetFileNameWithoutExtension(uri.LocalPath), Uri = uri, IsEnabled = false, @@ -728,14 +731,14 @@ or InvalidOperationException }; fallback.Json = CreateFallbackProjection(fallback); element.AddObject(fallback); - MarkRecoveredElement(element, rawText, uri); + MarkRecoveredElement(element, rawBytes, uri); return element; } } - private static void MarkRecoveredElement(Element element, string rawText, Uri uri) + private static void MarkRecoveredElement(Element element, byte[] rawBytes, Uri uri) { - element.SuppressedStorageSource = new SuppressedStorageSource(rawText, uri); + element.SuppressedStorageSource = new SuppressedStorageSource(rawBytes, uri); } private static void EnsureFallbackProjection(IFallback fallback) diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 365119c65e..9be1fb8a4e 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -95,6 +95,91 @@ public void Restore_ResolvableNonElementDiscriminator_RecoversInsteadOfFailing() }); } + [Test] + public void Restore_UnparsableDiscriminator_RecoversInsteadOfFailing() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + """{"$type":"x","Id":"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0"}"""); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + Assert.That(recovered.Children.Single().IsEnabled, Is.False); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + + [Test] + public void Restore_SceneDiscriminatorWithSelfInclude_RecoversWithoutRecursing() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + """{"$type":"[Beutl.ProjectSystem]:Scene","Id":"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0","Elements":{"Include":["element.belm"]}}"""); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + Assert.That(recovered.Children.Single().IsEnabled, Is.False); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + + [Test] + public void SaveAs_PreservesBomAndNonUtf8SidecarBytes() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = + [ + 0xEF, 0xBB, 0xBF, + .. "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8, + 0xFF, 0xFE, 0x00, + ]; + File.WriteAllBytes(elementPath, corruptBytes); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + string rehomedPath = Path.Combine(_root, "rehomed", Path.GetFileName(elementPath)); + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + + Assert.Multiple(() => + { + Assert.That(recovered.Id, Is.EqualTo(new Guid("85f4d478-e16d-4cb1-ab71-ee1a90a03fe0"))); + Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(corruptBytes)); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); + }); + } + + [Test] + public void StoreToUri_AfterRehome_KeepsTheOriginalSkipProtected() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); + File.WriteAllBytes(elementPath, corruptBytes); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + string rehomedPath = Path.Combine(_root, "rehomed", Path.GetFileName(elementPath)); + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + + // A failed multi-file save-as rolls Uri back to the original; the next ordinary save must + // still treat the original sidecar as the protected source, not a rehome target. + File.Delete(elementPath); + CoreSerializer.StoreToUri(recovered, new Uri(elementPath)); + + Assert.Multiple(() => + { + Assert.That(File.Exists(elementPath), Is.False); + Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(corruptBytes)); + }); + } + [Test] public void SaveAs_CopiesRecoveredSidecarBytesToTheNewLocation() { From e663648f57a1b219f50af000c9d470682f37aad9 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 13:50:27 +0900 Subject: [PATCH 04/35] fix(review): harden recovery detection, identity, and rehome writes - The legacy discriminator fill-in keys on the $type/@type property key alone, so a non-string or blank discriminator recovers instead of loading as the legacy default. - The recovery filter inverts to catch every non-filesystem failure (value converters throw freely, e.g. FormatException from Color.Parse); IOException/UnauthorizedAccessException still propagate. - Fallback creation records a thread-local incident so elements whose only fallback lives outside the hierarchy (e.g. a plain property or keyframe value) are still byte-frozen. - A recovered element that surfaces an Id another element owns yields it and falls back to its deterministic path-derived identity. - The rehome branch is create-only: it never overwrites an existing file, preserving manual repairs at the destination. - The toolkit's Save As keeps element sidecar file names so path-derived recovery identities stay stable across rehoming. --- .../Sessions/FileEditingSession.cs | 8 +- .../Serialization/CoreSerializer.cs | 18 +- .../Serialization/DeserializationIncidents.cs | 16 ++ .../FallbackDeserializationHelper.cs | 1 + .../JsonSerializationContext.Deserialize.cs | 1 + .../ProjectSystem/Scene.cs | 47 +++++- .../Sessions/FileEditingSessionTests.cs | 32 ++++ .../MalformedElementRecoveryTests.cs | 157 ++++++++++++++++++ .../DeserializationIncidentsTests.cs | 38 +++++ 9 files changed, 305 insertions(+), 13 deletions(-) create mode 100644 src/Beutl.Core/Serialization/DeserializationIncidents.cs create mode 100644 tests/Beutl.UnitTests/Serialization/DeserializationIncidentsTests.cs diff --git a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs index ae34c11e19..00b8045065 100644 --- a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs +++ b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs @@ -154,9 +154,15 @@ private void SetProjectPathCore(string projectPath) sceneName, usedDirs); scene.Uri = new Uri(scenePath); + string sceneDirectory = Path.GetDirectoryName(scenePath)!; foreach (Element element in scene.Children) { - element.Uri = null; + // Keep each sidecar's file name across Save As: a recovered element's stable + // fallback identity is derived from its scene-relative path, so a regenerated + // random name would change the element's Id when the copy is reopened. + element.Uri = element.Uri is { IsFile: true } previousUri + ? new Uri(Path.Combine(sceneDirectory, Path.GetFileName(previousUri.LocalPath))) + : null; } index++; diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 402640fd0d..831a9f1f74 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -83,6 +83,7 @@ public static object DeserializeFromJsonObject(JsonObject json, Type baseType, C if (obj is IFallback fallbackObj) { fallbackObj.Reason = FallbackReason.TypeNotFound; + DeserializationIncidents.RecordFallback(); } return obj; @@ -158,10 +159,10 @@ public static object RestoreFromUri(Uri uri, Type type) // 互換性処理 // 1.x で作成されたファイルでは一部のオブジェクトに $type が付与されないため、 // 期待される型に基づいてディスクリミネータを補完する。 - // Presence is checked on the raw string: a present-but-unparsable discriminator must fail - // as an unknown type, not silently deserialize as the legacy default and overwrite the - // original data on the next save. - if (!node.TryGetDiscriminator(out string? _)) + // Presence is checked on the property key alone: a present-but-unparsable or non-string + // discriminator must fail as an unknown type, not silently deserialize as the legacy + // default and overwrite the original data on the next save. + if (!jsonObject.ContainsKey("$type") && !jsonObject.ContainsKey("@type")) { if (type == typeof(ProjectItem)) { @@ -203,6 +204,7 @@ public static object RestoreFromUri(Uri uri, Type type) if (obj is IFallback fallbackObj) { fallbackObj.Reason = FallbackReason.TypeNotFound; + DeserializationIncidents.RecordFallback(); } return obj; @@ -249,6 +251,14 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n // element. The suppression record is never mutated — the source location stays // skip-protected even if a failed multi-file save rolls Uri back afterwards. string rehomedPath = uri.LocalPath; + if (File.Exists(rehomedPath)) + { + // An existing file may hold a manual repair of the recovered content (or an earlier + // verbatim copy); never overwrite it. + suppressedObj.Uri = uri; + return; + } + string? rehomedDirectory = Path.GetDirectoryName(rehomedPath); if (rehomedDirectory != null) { diff --git a/src/Beutl.Core/Serialization/DeserializationIncidents.cs b/src/Beutl.Core/Serialization/DeserializationIncidents.cs new file mode 100644 index 0000000000..1ce0a90e28 --- /dev/null +++ b/src/Beutl.Core/Serialization/DeserializationIncidents.cs @@ -0,0 +1,16 @@ +namespace Beutl.Serialization; + +/// +/// Thread-local tally of fallback substitutions, letting a caller detect fallbacks created in +/// positions a hierarchical traversal of the deserialized result cannot reach (e.g. plain +/// property values such as keyframe values). +/// +internal static class DeserializationIncidents +{ + [ThreadStatic] + private static int t_fallbackCount; + + internal static int FallbackCount => t_fallbackCount; + + internal static void RecordFallback() => t_fallbackCount++; +} diff --git a/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs b/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs index 1bf111f1b2..4caa70e740 100644 --- a/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs +++ b/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs @@ -29,6 +29,7 @@ internal static class FallbackDeserializationHelper fallback.Reason = FallbackReason.DeserializationFailed; fallback.ErrorMessage = exception?.Message; + DeserializationIncidents.RecordFallback(); return fallback; } } diff --git a/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs b/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs index c6a3922b1f..10c79c5147 100644 --- a/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs +++ b/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs @@ -110,6 +110,7 @@ private static bool TryDeserializeCoreSerializable( if (instance is IFallback fallbackObj) { fallbackObj.Reason = FallbackReason.TypeNotFound; + DeserializationIncidents.RecordFallback(); } result = instance; diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 855bbc1810..f9090ac4c8 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -680,19 +680,53 @@ private void SyncronizeFiles(IEnumerable pathToElement) } Children.AddRange(urisAdd.AsParallel().Select(RestoreElementOrFallback)); + ReassignDuplicateRecoveredIds(); activity?.SetTag("addCount", urisAdd.Length); activity?.SetTag("removeCount", elementsRemove.Length); activity?.SetTag("childrenCount", Children.Count); } + private void ReassignDuplicateRecoveredIds() + { + // A corrupt sidecar can surface an Id another element already owns; the recovered element + // yields it to the healthy claimant so Id-based reconciliation and group references stay + // unambiguous, and falls back to its deterministic path-derived identity. + var claimedIds = new HashSet(); + foreach (Element child in Children) + { + if (child.SuppressedStorageSource is null) + { + claimedIds.Add(child.Id); + } + } + + string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; + foreach (Element child in Children) + { + if (child.SuppressedStorageSource is null || claimedIds.Add(child.Id)) + { + continue; + } + + child.Id = CreateVersion5Guid( + s_recoveredElementNamespace, + Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)); + claimedIds.Add(child.Id); + } + } + private Element RestoreElementOrFallback(Uri uri) { + int fallbackCountBefore = DeserializationIncidents.FallbackCount; try { Element element = CoreSerializer.RestoreFromUri(uri); IFallback[] fallbacks = element.EnumerateAllChildren().ToArray(); - if (fallbacks.Length > 0) + + // The incident tally also catches fallbacks stored outside the hierarchy (e.g. a + // keyframe value), which the child traversal cannot see. + if (fallbacks.Length > 0 || DeserializationIncidents.FallbackCount != fallbackCountBefore) { foreach (IFallback fallback in fallbacks) { @@ -704,13 +738,10 @@ private Element RestoreElementOrFallback(Uri uri) return element; } - // Deserialization-domain failures recover; filesystem failures still propagate. A resolvable - // but non-Element $type surfaces as InvalidCastException, an unresolvable one as - // InvalidOperationException / NotSupportedException. - catch (Exception ex) when (ex is JsonException - or InvalidCastException - or InvalidOperationException - or NotSupportedException) + // Any non-filesystem failure is a content problem the recovery path must absorb — value + // converters throw freely (e.g. FormatException from Color.Parse); filesystem failures + // still propagate so a genuinely unreadable project keeps failing loudly. + catch (Exception ex) when (ex is not (IOException or UnauthorizedAccessException)) { // Raw bytes, not text: the sidecar must survive rehoming byte-identically even when it // holds a BOM, another encoding, or undecodable bytes. The lossy decode is only scanned diff --git a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs index 686c145fce..60479645ac 100644 --- a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs @@ -54,6 +54,38 @@ public void SetProjectPath_does_not_overwrite_existing_sidecar_file() }); } + [Test] + public void SetProjectPath_keeps_element_sidecar_file_names() + { + string root = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + using var source = new FileSessionSource(); + FileEditingSession session = source.CreateProject(new ProjectCreateOptions( + Path.Combine(root, "demo.bep"), 640, 360, 30, TimeSpan.FromSeconds(2), Name: "demo")); + Scene scene = session.Project.Items.OfType().Single(); + var element = new Element + { + Name = "clip", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(Path.GetDirectoryName(scene.Uri!.LocalPath)!, "clip.belm")), + }; + scene.Children.Add(element); + session.Save(skipConflictCheck: true); + string originalFileName = Path.GetFileName(element.Uri!.LocalPath); + + session.SetProjectPath(Path.Combine(root, "copy.bep")); + + // A recovered element's fallback identity is derived from the scene-relative sidecar path, + // so Save As must not regenerate the file name. + Assert.Multiple(() => + { + Assert.That(Path.GetFileName(element.Uri!.LocalPath), Is.EqualTo(originalFileName)); + Assert.That( + Path.GetDirectoryName(element.Uri.LocalPath), + Is.EqualTo(Path.GetDirectoryName(scene.Uri!.LocalPath))); + }); + } + [Test] public void Failed_plain_save_restores_the_original_uri_state() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 9be1fb8a4e..fb54e1e6f2 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using Beutl.Editor; using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; using Beutl.ProjectSystem; using Beutl.Serialization; @@ -180,6 +181,127 @@ public void StoreToUri_AfterRehome_KeepsTheOriginalSkipProtected() }); } + [Test] + public void Restore_NonStringDiscriminator_RecoversInsteadOfLoadingLegacyDefault() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + """{"$type":123,"Id":"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0"}"""); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + Assert.That(recovered.Children.Single().IsEnabled, Is.False); + Assert.That( + recovered.Children.Single().Id, + Is.EqualTo(new Guid("85f4d478-e16d-4cb1-ab71-ee1a90a03fe0"))); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + + [Test] + public void Restore_InvalidElementScalarValue_RecoversInsteadOfFailing() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + json[nameof(Element.AccentColor)] = "not-a-color"; + File.WriteAllText(elementPath, json.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + Assert.That(recovered.Children.Single().IsEnabled, Is.False); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + + [Test] + public void Save_PreservesSidecarBytesWhenFallbackIsOutsideTheHierarchy() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Scene loaded = CoreSerializer.RestoreFromUri(sceneUri); + Element loadedElement = loaded.Children.Single(); + var shape = (RectShape)loadedElement.Objects.Single(); + shape.Transform.CurrentValue = new RotationTransform(); + CoreSerializer.StoreToUri(loadedElement, loadedElement.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + Assert.That( + ReplaceDiscriminator(json, "Transformation", "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"), + Is.True); + File.WriteAllText(elementPath, json.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + } + + [Test] + public void Restore_DuplicateTopLevelId_YieldsToTheHealthyElement() + { + var sceneUri = new Uri(Path.Combine(_root, "scene.scene")); + string element1Path = Path.Combine(_root, "element1.belm"); + string element2Path = Path.Combine(_root, "element2.belm"); + var scene = new Scene(64, 64, "Scene") { Uri = sceneUri }; + scene.Children.Add(new Element + { + Name = "One", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(element1Path), + }); + scene.Children.Add(new Element + { + Name = "Two", + Start = TimeSpan.FromSeconds(1), + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(element2Path), + }); + Guid healthyId = scene.Children[0].Id; + CoreSerializer.StoreToUri(scene, sceneUri); + File.WriteAllText(element2Path, $$"""{"Id":"{{healthyId}}","Objects":["""); + + Scene firstLoad = CoreSerializer.RestoreFromUri(sceneUri); + Scene secondLoad = CoreSerializer.RestoreFromUri(sceneUri); + + Element healthy = firstLoad.Children.Single(c => c.Name == "One"); + Element corrupt = firstLoad.Children.Single(c => c.Name != "One"); + Element corruptAgain = secondLoad.Children.Single(c => c.Name != "One"); + Assert.Multiple(() => + { + Assert.That(healthy.Id, Is.EqualTo(healthyId)); + Assert.That(corrupt.Id, Is.Not.EqualTo(healthyId)); + Assert.That(corrupt.Id, Is.Not.EqualTo(Guid.Empty)); + Assert.That(corruptAgain.Id, Is.EqualTo(corrupt.Id)); + }); + } + + [Test] + public void StoreToUri_RehomeTarget_NeverOverwritesAnExistingFile() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); + File.WriteAllBytes(elementPath, corruptBytes); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + string rehomedPath = Path.Combine(_root, "rehomed", Path.GetFileName(elementPath)); + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + + byte[] repairedBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":[]}"u8.ToArray(); + File.WriteAllBytes(rehomedPath, repairedBytes); + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + + Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(repairedBytes)); + } + [Test] public void SaveAs_CopiesRecoveredSidecarBytesToTheNewLocation() { @@ -281,6 +403,41 @@ public void Restore_MalformedElementPrefersTopLevelId() Assert.That(recovered.Id, Is.EqualTo(topLevelId)); } + private static bool ReplaceDiscriminator(JsonNode node, string containsToken, string replacement) + { + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue("$type", out JsonNode? typeNode) + && typeNode is JsonValue typeValue + && typeValue.TryGetValue(out string? typeName) + && typeName.Contains(containsToken)) + { + obj["$type"] = replacement; + return true; + } + + foreach ((string _, JsonNode? child) in obj) + { + if (child != null && ReplaceDiscriminator(child, containsToken, replacement)) + { + return true; + } + } + } + else if (node is JsonArray array) + { + foreach (JsonNode? child in array) + { + if (child != null && ReplaceDiscriminator(child, containsToken, replacement)) + { + return true; + } + } + } + + return false; + } + private (Uri SceneUri, string ElementPath) CreatePersistedScene() { var sceneUri = new Uri(Path.Combine(_root, "scene.scene")); diff --git a/tests/Beutl.UnitTests/Serialization/DeserializationIncidentsTests.cs b/tests/Beutl.UnitTests/Serialization/DeserializationIncidentsTests.cs new file mode 100644 index 0000000000..dea79929f4 --- /dev/null +++ b/tests/Beutl.UnitTests/Serialization/DeserializationIncidentsTests.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Nodes; +using Beutl.Graphics.Transformation; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Serialization; + +public sealed class DeserializationIncidentsTests +{ + [Test] + public void TryCreateFallback_RecordsAnIncident() + { + int before = DeserializationIncidents.FallbackCount; + + ICoreSerializable? fallback = FallbackDeserializationHelper.TryCreateFallback( + typeof(Transform), null, new JsonObject()); + + Assert.Multiple(() => + { + Assert.That(fallback, Is.InstanceOf()); + Assert.That(DeserializationIncidents.FallbackCount, Is.EqualTo(before + 1)); + }); + } + + [Test] + public void TryCreateFallback_WithoutFallbackType_RecordsNothing() + { + int before = DeserializationIncidents.FallbackCount; + + ICoreSerializable? fallback = FallbackDeserializationHelper.TryCreateFallback( + typeof(CoreObject), null, new JsonObject()); + + Assert.Multiple(() => + { + Assert.That(fallback, Is.Null); + Assert.That(DeserializationIncidents.FallbackCount, Is.EqualTo(before)); + }); + } +} From 13d9a6212ae4edbc6204fa2e515ed7ac00fae98d Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 14:54:20 +0900 Subject: [PATCH 05/35] fix(review): make the rehome write atomic and null-guard generic discriminator parsing - The create-only rehome now opens the destination with FileMode.CreateNew, closing the exists-check/write race; an already-existing file is treated as the protected skip path and other IO failures still propagate. - TypeNameParser.ParseNestedType returns null when the assembly or nested type cannot be resolved instead of calling MakeGenericType on null, and ToType's filter also absorbs ArgumentException so malformed generic discriminators read as unknown types. --- .../Serialization/CoreSerializer.cs | 20 +++++++++++-------- src/Beutl.Core/TypeFormat.cs | 6 ++++++ .../MalformedElementRecoveryTests.cs | 19 ++++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 831a9f1f74..f635087c45 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -251,7 +251,18 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n // element. The suppression record is never mutated — the source location stays // skip-protected even if a failed multi-file save rolls Uri back afterwards. string rehomedPath = uri.LocalPath; - if (File.Exists(rehomedPath)) + string? rehomedDirectory = Path.GetDirectoryName(rehomedPath); + if (rehomedDirectory != null) + { + Directory.CreateDirectory(rehomedDirectory); + } + + try + { + using var stream = new FileStream(rehomedPath, FileMode.CreateNew, FileAccess.Write); + stream.Write(suppressed.RawBytes); + } + catch (IOException) when (File.Exists(rehomedPath)) { // An existing file may hold a manual repair of the recovered content (or an earlier // verbatim copy); never overwrite it. @@ -259,13 +270,6 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n return; } - string? rehomedDirectory = Path.GetDirectoryName(rehomedPath); - if (rehomedDirectory != null) - { - Directory.CreateDirectory(rehomedDirectory); - } - - File.WriteAllBytes(rehomedPath, suppressed.RawBytes); suppressedObj.Uri = uri; return; } diff --git a/src/Beutl.Core/TypeFormat.cs b/src/Beutl.Core/TypeFormat.cs index 3224ec28b4..0f116d6ee9 100644 --- a/src/Beutl.Core/TypeFormat.cs +++ b/src/Beutl.Core/TypeFormat.cs @@ -19,6 +19,7 @@ internal static class TypeFormat // hand-edited file); every caller already treats null as "unknown type". catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentOutOfRangeException + or ArgumentException or InvalidOperationException) { return null; @@ -335,6 +336,11 @@ private static string TakeTypeNameTokens(Span tokens, out Span gen type = _assembly?.GetType($"{_namespace ?? ""}.{typeName}{suffix}")!; } + if (type == null) + { + return null; + } + if (genericArgs.Length > 0) { type = type.MakeGenericType(genericArgs); diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index fb54e1e6f2..4f59e669ae 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -115,6 +115,25 @@ public void Restore_UnparsableDiscriminator_RecoversInsteadOfFailing() }); } + [Test] + public void Restore_UnresolvableGenericDiscriminator_RecoversInsteadOfFailing() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + """{"$type":"[NoSuchAssembly]Ns:Foo<[System.Private.CoreLib]System:Int32>","Id":"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0"}"""); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + Assert.That(recovered.Children.Single().IsEnabled, Is.False); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + [Test] public void Restore_SceneDiscriminatorWithSelfInclude_RecoversWithoutRecursing() { From 29f89260e69096a95be3f8b6d1d74eb5f2a8fe91 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 16:05:58 +0900 Subject: [PATCH 06/35] fix(review): guard nested discriminators, animation warnings, delete command, and nested rehome paths - Nested deserialization (TryDeserializeCoreSerializable and DeserializeFromJsonObject) rejects a discriminator type that is not assignable to the expected base before instantiating it, so a Scene declared inside Objects becomes a fallback instead of recursively reopening its own sidecar. - open_project warning collection traverses each property's keyframe animation values, surfacing fallbacks that live only in keyframes. - Scene's DeleteCommand skips File.Delete for elements carrying a suppressed storage source, so deleting a recovered element keeps the retained sidecar bytes. - Save As preserves each sidecar's scene-relative subpath (falling back to the basename for rooted/escaping paths), keeping path-derived recovery identities stable for nested layouts. --- .../Sessions/FileEditingSession.cs | 27 +++++-- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 8 ++ .../Serialization/CoreSerializer.cs | 6 ++ .../JsonSerializationContext.Deserialize.cs | 6 ++ .../ProjectSystem/Scene.cs | 2 +- .../Sessions/FileEditingSessionTests.cs | 27 +++++++ .../Tools/SessionToolsTests.cs | 68 +++++++++++++++++ .../MalformedElementRecoveryTests.cs | 73 +++++++++++++++++++ 8 files changed, 211 insertions(+), 6 deletions(-) diff --git a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs index 00b8045065..ea38f2422c 100644 --- a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs +++ b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs @@ -153,16 +153,33 @@ private void SetProjectPathCore(string projectPath) Path.Combine(projectDirectory, projectName), sceneName, usedDirs); + string? previousSceneDirectory = scene.Uri is { IsFile: true } previousSceneUri + ? Path.GetDirectoryName(previousSceneUri.LocalPath) + : null; scene.Uri = new Uri(scenePath); string sceneDirectory = Path.GetDirectoryName(scenePath)!; foreach (Element element in scene.Children) { - // Keep each sidecar's file name across Save As: a recovered element's stable + // Keep each sidecar's relative path across Save As: a recovered element's stable // fallback identity is derived from its scene-relative path, so a regenerated - // random name would change the element's Id when the copy is reopened. - element.Uri = element.Uri is { IsFile: true } previousUri - ? new Uri(Path.Combine(sceneDirectory, Path.GetFileName(previousUri.LocalPath))) - : null; + // path would change the element's Id when the copy is reopened. + if (element.Uri is { IsFile: true } previousUri) + { + string relativePath = previousSceneDirectory != null + ? Path.GetRelativePath(previousSceneDirectory, previousUri.LocalPath) + : Path.GetFileName(previousUri.LocalPath); + if (Path.IsPathRooted(relativePath) + || relativePath.StartsWith("..", StringComparison.Ordinal)) + { + relativePath = Path.GetFileName(previousUri.LocalPath); + } + + element.Uri = new Uri(Path.Combine(sceneDirectory, relativePath)); + } + else + { + element.Uri = null; + } } index++; diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 1b38c251be..4e62f3b26c 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -6,6 +6,7 @@ using Beutl.AgentToolkit.Rendering; using Beutl.AgentToolkit.Sessions; using Beutl.AgentToolkit.Workspace; +using Beutl.Animation; using Beutl.Editor; using Beutl.Engine; using Beutl.ProjectSystem; @@ -136,6 +137,13 @@ private static void CollectFallbacks( foreach (IProperty property in engineObject.Properties) { CollectFallbacks(property.CurrentValue, visited, fallbacks); + if (property.Animation is IKeyFrameAnimation animation) + { + foreach (IKeyFrame keyFrame in animation.KeyFrames) + { + CollectFallbacks(keyFrame.Value, visited, fallbacks); + } + } } } diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index f635087c45..f0f2e49765 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -67,6 +67,12 @@ public static object DeserializeFromJsonObject(JsonObject json, Type baseType, C try { + if (!baseType.IsAssignableFrom(actualType)) + { + throw new InvalidCastException( + $"Discriminator type '{actualType}' is not assignable to the expected type '{baseType}'."); + } + var obj = Activator.CreateInstance(actualType) as ICoreSerializable ?? throw new InvalidOperationException($"Could not create instance of type {actualType.FullName}."); diff --git a/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs b/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs index 10c79c5147..dd0865e656 100644 --- a/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs +++ b/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs @@ -88,6 +88,12 @@ private static bool TryDeserializeCoreSerializable( try { + if (!baseType.IsAssignableFrom(actualType)) + { + throw new InvalidCastException( + $"Discriminator type '{actualType}' is not assignable to the expected type '{baseType}'."); + } + var instance = Activator.CreateInstance(actualType) as ICoreSerializable ?? throw new InvalidOperationException( $"Could not create instance of type {actualType.FullName}."); diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index f9090ac4c8..47d2b6e5e7 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -1250,7 +1250,7 @@ public void Do() if (_element != null) { string fileName = _element.Uri!.LocalPath; - if (File.Exists(fileName)) + if (_element.SuppressedStorageSource is null && File.Exists(fileName)) { File.Delete(fileName); } diff --git a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs index 60479645ac..8c0ab0a23e 100644 --- a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs @@ -86,6 +86,33 @@ public void SetProjectPath_keeps_element_sidecar_file_names() }); } + [Test] + public void SetProjectPath_keeps_element_sidecar_relative_subpaths() + { + string root = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + using var source = new FileSessionSource(); + FileEditingSession session = source.CreateProject(new ProjectCreateOptions( + Path.Combine(root, "demo.bep"), 640, 360, 30, TimeSpan.FromSeconds(2), Name: "demo")); + Scene scene = session.Project.Items.OfType().Single(); + string relativePath = Path.Combine("sub", "clip.belm"); + var element = new Element + { + Name = "clip", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(Path.GetDirectoryName(scene.Uri!.LocalPath)!, relativePath)), + }; + scene.Children.Add(element); + session.Save(skipConflictCheck: true); + + session.SetProjectPath(Path.Combine(root, "copy.bep")); + + string newSceneDirectory = Path.GetDirectoryName(scene.Uri!.LocalPath)!; + Assert.That( + Path.GetRelativePath(newSceneDirectory, element.Uri!.LocalPath), + Is.EqualTo(relativePath)); + } + [Test] public void Failed_plain_save_restores_the_original_uri_state() { diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index f834b4e808..6e680c82f2 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -8,7 +8,9 @@ using Beutl.AgentToolkit.Tests.Helpers; using Beutl.AgentToolkit.Tools; using Beutl.AgentToolkit.Workspace; +using Beutl.Animation; using Beutl.Editor; +using Beutl.Engine; using Beutl.Graphics; using Beutl.Graphics.Shapes; using Beutl.Media; @@ -92,6 +94,62 @@ public async Task Open_project_warns_about_corrupt_element_and_render_still_rema }); } + [Test] + public async Task Open_project_warns_about_fallback_in_animation_keyframe_value() + { + string root = CreateWorkspace(); + string projectPath = Path.Combine(root, "animation-fallback.bep"); + Project project = ProjectOperations.CreateProject(new ProjectCreateOptions( + projectPath, + 64, + 64, + 30, + TimeSpan.FromSeconds(1))); + Scene scene = project.Items.OfType().Single(); + var holder = new AnimatedValueHolder(); + var animation = new KeyFrameAnimation(); + animation.KeyFrames.Add(new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = new RectShape(), + }, out _); + holder.AnimatedValue.Animation = animation; + var element = new Element + { + Name = "Animated fallback", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine( + Path.GetDirectoryName(scene.Uri!.LocalPath)!, + "animation-fallback.belm")), + }; + element.AddObject(holder); + scene.Children.Add(element); + ProjectOperations.Save(project); + + string elementPath = element.Uri!.LocalPath; + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject objectJson = elementJson[nameof(Element.Objects)]!.AsArray()[0]!.AsObject(); + JsonObject animationJson = objectJson["Animations"]![nameof(AnimatedValueHolder.AnimatedValue)]!.AsObject(); + JsonObject keyFrameJson = animationJson[nameof(KeyFrameAnimation.KeyFrames)]!.AsArray()[0]!.AsObject(); + keyFrameJson[nameof(IKeyFrame.Value)]!.AsObject()["$type"] + = "[Beutl.Engine]Beutl.Engine:MissingAnimatedValue"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + + var manager = new AgentSessionManager(); + using var source = new FileSessionSource(); + SessionTools sessionTools = CreateSessionTools(source, manager, root); + + ToolResult opened = await sessionTools.OpenProject(projectPath); + + Assert.Multiple(() => + { + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); + Assert.That( + opened.Value!.Warnings, + Has.Some.Contains(Path.GetFileName(elementPath)).And.Some.Contains(nameof(FallbackReason.TypeNotFound))); + }); + } + [Test] public async Task Open_project_warns_about_malformed_element_json_and_keeps_healthy_elements() { @@ -725,6 +783,16 @@ private static RecoveredProjectFixture CreateProjectWithMalformedElement(string malformedBytes); } + public sealed class AnimatedValueHolder : EngineObject + { + public AnimatedValueHolder() + { + ScanProperties(); + } + + public IProperty AnimatedValue { get; } = Property.CreateAnimatable(); + } + private sealed record RecoveredProjectFixture( string ProjectPath, string HealthyPath, diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 4f59e669ae..4e3bea1625 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using Beutl.Editor; +using Beutl.Engine; using Beutl.Graphics.Shapes; using Beutl.Graphics.Transformation; using Beutl.ProjectSystem; @@ -153,6 +154,46 @@ public void Restore_SceneDiscriminatorWithSelfInclude_RecoversWithoutRecursing() }); } + [Test] + public void Restore_NestedSceneDiscriminatorWithSelfInclude_RecoversWithoutRecursing() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + elementJson[nameof(Element.Objects)] = new JsonArray(new JsonObject + { + ["$type"] = "[Beutl.ProjectSystem]:Scene", + [nameof(CoreObject.Uri)] = "element.belm", + ["Elements"] = new JsonObject + { + ["Include"] = new JsonArray("element.belm"), + }, + }); + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + Assert.That(recovered.Children.Single().Objects.Single(), Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + + [Test] + public void DeserializeFromJsonObject_UnassignableDiscriminatorUsesFallback() + { + var json = new JsonObject + { + ["$type"] = "[Beutl.ProjectSystem]:Scene", + }; + + object restored = CoreSerializer.DeserializeFromJsonObject(json, typeof(EngineObject)); + + Assert.That(restored, Is.InstanceOf()); + } + [Test] public void SaveAs_PreservesBomAndNonUtf8SidecarBytes() { @@ -407,6 +448,38 @@ public void AutoSave_RemovedMalformedElementPreservesSidecarBytes() Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); } + [Test] + public void DeleteChild_PreservesRecoveredSidecarAndDeletesNormalSidecar() + { + (Uri sceneUri, string recoveredPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); + File.WriteAllBytes(recoveredPath, corruptBytes); + + Scene scene = CoreSerializer.RestoreFromUri(sceneUri); + Element recovered = scene.Children.Single(); + string normalPath = Path.Combine(_root, "normal.belm"); + var normal = new Element + { + Name = "Normal", + Start = TimeSpan.FromSeconds(1), + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(normalPath), + }; + normal.AddObject(new RectShape()); + CoreSerializer.StoreToUri(normal, normal.Uri!); + scene.Children.Add(normal); + + scene.DeleteChild(recovered); + scene.DeleteChild(normal); + + Assert.Multiple(() => + { + Assert.That(File.ReadAllBytes(recoveredPath), Is.EqualTo(corruptBytes)); + Assert.That(File.Exists(normalPath), Is.False); + Assert.That(scene.Children, Is.Empty); + }); + } + [Test] public void Restore_MalformedElementPrefersTopLevelId() { From be2f05d0a24a71a9487f817d5ca05094a198638e Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 17:17:26 +0900 Subject: [PATCH 07/35] fix(review): tighten rehome failure handling, recovered-id determinism, and recovery scanning - A rehome write failure after FileMode.CreateNew succeeded deletes the partial file and rethrows instead of being misread as a pre-existing repair; only a create-phase failure with the file present skips. - Recovered-id dedupe processes children in stable sidecar-path order (the parallel load is unordered) and derives further deterministic UUIDv5 candidates when the path-derived replacement is itself taken. - Fallback projections keep the original discriminator; the runtime fallback type is written only when the projection has none. - Save As containment resolves the full path instead of a '..' prefix test, so directories like '..assets' keep their subpath. - The top-level Id scan tracks array nesting, so a root-array sidecar's inner Id is never adopted. --- .../Sessions/FileEditingSession.cs | 11 +- .../Serialization/CoreSerializer.cs | 24 ++- .../ProjectSystem/Scene.cs | 52 ++++++- .../Sessions/FileEditingSessionTests.cs | 27 ++++ .../MalformedElementRecoveryTests.cs | 141 ++++++++++++++++-- 5 files changed, 228 insertions(+), 27 deletions(-) diff --git a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs index ea38f2422c..3f087003ba 100644 --- a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs +++ b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs @@ -168,13 +168,16 @@ private void SetProjectPathCore(string projectPath) string relativePath = previousSceneDirectory != null ? Path.GetRelativePath(previousSceneDirectory, previousUri.LocalPath) : Path.GetFileName(previousUri.LocalPath); - if (Path.IsPathRooted(relativePath) - || relativePath.StartsWith("..", StringComparison.Ordinal)) + string sceneRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sceneDirectory)); + string resolvedPath = Path.GetFullPath(Path.Combine(sceneRoot, relativePath)); + if (!resolvedPath.StartsWith( + sceneRoot + Path.DirectorySeparatorChar, + PathComparison.ForCurrentPlatform)) { - relativePath = Path.GetFileName(previousUri.LocalPath); + resolvedPath = Path.Combine(sceneRoot, Path.GetFileName(previousUri.LocalPath)); } - element.Uri = new Uri(Path.Combine(sceneDirectory, relativePath)); + element.Uri = new Uri(resolvedPath); } else { diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index f0f2e49765..fd50ab92c2 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -263,10 +263,10 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n Directory.CreateDirectory(rehomedDirectory); } + FileStream stream; try { - using var stream = new FileStream(rehomedPath, FileMode.CreateNew, FileAccess.Write); - stream.Write(suppressed.RawBytes); + stream = new FileStream(rehomedPath, FileMode.CreateNew, FileAccess.Write); } catch (IOException) when (File.Exists(rehomedPath)) { @@ -276,6 +276,26 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n return; } + try + { + using (stream) + { + stream.Write(suppressed.RawBytes); + } + } + catch + { + try + { + File.Delete(rehomedPath); + } + catch + { + } + + throw; + } + suppressedObj.Uri = uri; return; } diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 47d2b6e5e7..ab2ddaf780 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -48,6 +48,7 @@ public enum ElementOverlapHandling public class Scene : ProjectItem, INotifyEdited { + private const int MaxRecoveredIdCollisionAttempts = 1024; private static readonly Guid s_recoveredElementNamespace = new("dfad2f76-1d04-5593-ae3b-f371fb1f42ee"); private static readonly Regex s_idPattern = new( "\"Id\"\\s*:\\s*\"(?[0-9a-fA-F-]{36})\"", @@ -702,17 +703,39 @@ private void ReassignDuplicateRecoveredIds() } string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; - foreach (Element child in Children) + var recoveredChildren = Children + .Where(static child => child.SuppressedStorageSource is not null) + .Select(child => ( + Child: child, + RelativePath: Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath))) + .OrderBy(static item => item.RelativePath, StringComparer.Ordinal); + foreach ((Element child, string relativePath) in recoveredChildren) { - if (child.SuppressedStorageSource is null || claimedIds.Add(child.Id)) + if (claimedIds.Add(child.Id)) { continue; } - child.Id = CreateVersion5Guid( - s_recoveredElementNamespace, - Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)); - claimedIds.Add(child.Id); + bool assigned = false; + for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++) + { + string candidateName = attempt == 0 + ? relativePath + : $"{relativePath}#{attempt}"; + Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName); + if (claimedIds.Add(candidate)) + { + child.Id = candidate; + assigned = true; + break; + } + } + + if (!assigned) + { + throw new InvalidOperationException( + $"Could not assign a unique recovered element Id for '{relativePath}'."); + } } } @@ -780,7 +803,11 @@ private static void EnsureFallbackProjection(IFallback fallback) } JsonObject json = fallback.Json ?? new JsonObject(); - json.WriteDiscriminator(coreObject.GetType()); + if (!json.ContainsKey("$type") && !json.ContainsKey("@type")) + { + json.WriteDiscriminator(coreObject.GetType()); + } + json[nameof(CoreObject.Id)] = coreObject.Id.ToString(); fallback.Json = json; } @@ -817,6 +844,7 @@ private Guid ResolveRecoveredElementId(string rawText, Uri uri) { int matchIndex = 0; int objectDepth = 0; + int arrayDepth = 0; bool inString = false; bool escaped = false; @@ -825,7 +853,7 @@ private Guid ResolveRecoveredElementId(string rawText, Uri uri) Match match = matches[matchIndex]; if (i == match.Index) { - if (!inString && objectDepth == 1) + if (!inString && objectDepth == 1 && arrayDepth == 0) { return match; } @@ -861,6 +889,14 @@ private Guid ResolveRecoveredElementId(string rawText, Uri uri) { objectDepth--; } + else if (current == '[') + { + arrayDepth++; + } + else if (current == ']' && arrayDepth > 0) + { + arrayDepth--; + } } return null; diff --git a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs index 8c0ab0a23e..cf0a1dfde7 100644 --- a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs @@ -113,6 +113,33 @@ public void SetProjectPath_keeps_element_sidecar_relative_subpaths() Is.EqualTo(relativePath)); } + [Test] + public void SetProjectPath_keeps_element_sidecar_subpaths_starting_with_two_dots() + { + string root = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + using var source = new FileSessionSource(); + FileEditingSession session = source.CreateProject(new ProjectCreateOptions( + Path.Combine(root, "demo.bep"), 640, 360, 30, TimeSpan.FromSeconds(2), Name: "demo")); + Scene scene = session.Project.Items.OfType().Single(); + string relativePath = Path.Combine("..assets", "clip.belm"); + var element = new Element + { + Name = "clip", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(Path.GetDirectoryName(scene.Uri!.LocalPath)!, relativePath)), + }; + scene.Children.Add(element); + session.Save(skipConflictCheck: true); + + session.SetProjectPath(Path.Combine(root, "copy.bep")); + + string newSceneDirectory = Path.GetDirectoryName(scene.Uri!.LocalPath)!; + Assert.That( + Path.GetRelativePath(newSceneDirectory, element.Uri!.LocalPath), + Is.EqualTo(relativePath)); + } + [Test] public void Failed_plain_save_restores_the_original_uri_state() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 4e3bea1625..e2be843d0b 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -344,6 +344,99 @@ public void Restore_DuplicateTopLevelId_YieldsToTheHealthyElement() }); } + [Test] + public void Restore_RecoveredElementsSharingTopLevelId_AreAssignedStableUniqueIdsByPath() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("a.belm", "b.belm"); + var contestedId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + foreach (string elementPath in elementPaths) + { + File.WriteAllText(elementPath, $$"""{"Id":"{{contestedId}}","Objects":["""); + } + + Dictionary first = GetIdsBySidecarName( + CoreSerializer.RestoreFromUri(sceneUri)); + Dictionary second = GetIdsBySidecarName( + CoreSerializer.RestoreFromUri(sceneUri)); + + Assert.Multiple(() => + { + Assert.That(first.Values, Is.Unique); + Assert.That(second.Values, Is.Unique); + Assert.That(first["a.belm"], Is.EqualTo(contestedId)); + Assert.That(first["b.belm"], Is.Not.EqualTo(contestedId)); + Assert.That(second["a.belm"], Is.EqualTo(first["a.belm"])); + Assert.That(second["b.belm"], Is.EqualTo(first["b.belm"])); + }); + } + + [Test] + public void Restore_RecoveredReplacementIdCollision_DerivesStableUniqueCandidate() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("a.belm", "b.belm"); + string aPath = elementPaths[0]; + string bPath = elementPaths[1]; + File.WriteAllText(bPath, "{ this is not valid JSON"); + + Guid bPathId = GetIdsBySidecarName( + CoreSerializer.RestoreFromUri(sceneUri))["b.belm"]; + File.WriteAllText(aPath, $$"""{"Id":"{{bPathId}}","Objects":["""); + + Dictionary first = GetIdsBySidecarName( + CoreSerializer.RestoreFromUri(sceneUri)); + Dictionary second = GetIdsBySidecarName( + CoreSerializer.RestoreFromUri(sceneUri)); + + Assert.Multiple(() => + { + Assert.That(first.Values, Is.Unique); + Assert.That(second.Values, Is.Unique); + Assert.That(first["a.belm"], Is.EqualTo(bPathId)); + Assert.That(first["b.belm"], Is.Not.EqualTo(bPathId)); + Assert.That(second["a.belm"], Is.EqualTo(first["a.belm"])); + Assert.That(second["b.belm"], Is.EqualTo(first["b.belm"])); + }); + } + + [Test] + public void Restore_NestedFallbackProjection_PreservesOriginalDiscriminator() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + const string OriginalType = "[Beutl.Engine]Beutl.Graphics.Effects:NoSuchEffect"; + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + json[nameof(Element.Objects)] = new JsonArray(new JsonObject + { + ["$type"] = OriginalType, + [nameof(CoreObject.Id)] = Guid.NewGuid().ToString(), + }); + File.WriteAllText(elementPath, json.ToJsonString()); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + var fallback = (IFallback)recovered.Children.Single().Objects.Single(); + + Assert.That(fallback.Json!["$type"]!.GetValue(), Is.EqualTo(OriginalType)); + } + + [Test] + public void Restore_RootArrayId_DoesNotAdoptInnerIdAndRemainsStable() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + var innerId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + File.WriteAllText(elementPath, $$"""[{"Id":"{{innerId}}"}]"""); + + Guid first = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + Guid second = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + + Assert.Multiple(() => + { + Assert.That(first, Is.Not.EqualTo(innerId)); + Assert.That(first, Is.Not.EqualTo(Guid.Empty)); + Assert.That(second, Is.EqualTo(first)); + }); + } + [Test] public void StoreToUri_RehomeTarget_NeverOverwritesAnExistingFile() { @@ -531,26 +624,48 @@ private static bool ReplaceDiscriminator(JsonNode node, string containsToken, st } private (Uri SceneUri, string ElementPath) CreatePersistedScene() + { + (Uri sceneUri, string[] elementPaths) = CreatePersistedSceneWithElements("element.belm"); + return (sceneUri, elementPaths[0]); + } + + private (Uri SceneUri, string[] ElementPaths) CreatePersistedSceneWithElements( + params string[] elementFileNames) { var sceneUri = new Uri(Path.Combine(_root, "scene.scene")); - string elementPath = Path.Combine(_root, "element.belm"); var scene = new Scene(64, 64, "Scene") { Uri = sceneUri, }; - var element = new Element + string[] elementPaths = new string[elementFileNames.Length]; + for (int i = 0; i < elementFileNames.Length; i++) { - Name = "Element", - Length = TimeSpan.FromSeconds(1), - Uri = new Uri(elementPath), - }; - element.AddObject(new RectShape - { - Width = { CurrentValue = 32 }, - Height = { CurrentValue = 32 }, - }); - scene.Children.Add(element); + string elementPath = Path.Combine(_root, elementFileNames[i]); + var element = new Element + { + Name = Path.GetFileNameWithoutExtension(elementPath), + Start = TimeSpan.FromSeconds(i), + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(new RectShape + { + Width = { CurrentValue = 32 }, + Height = { CurrentValue = 32 }, + }); + scene.Children.Add(element); + elementPaths[i] = elementPath; + } + CoreSerializer.StoreToUri(scene, sceneUri); - return (sceneUri, elementPath); + return (sceneUri, elementPaths); + } + + private static Dictionary GetIdsBySidecarName(Scene scene) + { + return scene.Children.ToDictionary( + child => Path.GetFileName(child.Uri!.LocalPath), + child => child.Id, + StringComparer.Ordinal); } } From 59bd61dd4fb816bb4b919e9c3584ce70dbc39872 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 18:01:11 +0900 Subject: [PATCH 08/35] fix(review): stabilize recovered identities cross-platform and unblock edits with non-hierarchical fallbacks - Recovered-id UUIDv5 inputs and the dedupe ordering key normalize the scene-relative path to forward slashes, so identities match across Windows and Unix. - Collision remaps persist in the scene file (RecoveredElementIds, path -> Guid): a remap applies on load regardless of the current claimant set, entries are pruned when their sidecar heals or leaves, and a persisted id a healthy element now owns is dropped in favor of a fresh deterministic derivation. - Recovery warnings name the scene-relative sidecar path, so same-named files in different subdirectories are distinguishable. - Reconciler baseline collection uses the same full serialized-graph traversal as its sandbox (property values and keyframe animation values), so a pre-existing non-hierarchical fallback no longer rejects every apply_edit. - The sealed-baseType discriminator short-circuit is kept and documented: sealed wrappers such as Optional carry the wrapped payload's $type on their own node and interpret it themselves. --- .../Reconciliation/Reconciler.cs | 198 ++++++++++-------- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 5 +- .../Serialization/CoreSerializer.cs | 3 + .../ProjectSystem/Scene.cs | 95 ++++++++- .../ReconcilerIdIntegrityTests.cs | 59 ++++++ .../Tools/SessionToolsTests.cs | 60 +++++- .../MalformedElementRecoveryTests.cs | 53 +++++ 7 files changed, 373 insertions(+), 100 deletions(-) diff --git a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs index 23c895ea05..f5ac5aee45 100644 --- a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs +++ b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs @@ -681,21 +681,16 @@ private static CoreObject CloneCurrentRoot(IEditingSession session, JsonObject c private static HashSet CollectFallbackIds(CoreObject root) { var ids = new HashSet(); - if (root is IHierarchical hierarchical) + var visited = new HashSet(ReferenceEqualityComparer.Instance); + TraverseSerializedGraph(root, "$", visited, (node, _) => { - foreach (IFallback fallback in hierarchical.EnumerateAllChildren()) + if (node is IFallback) { - if (fallback is CoreObject coreObject) - { - ids.Add(coreObject.Id); - } + ids.Add(node.Id); } - } - if (root is IFallback rootFallback) - { - ids.Add(((CoreObject)rootFallback).Id); - } + return false; + }); return ids; } @@ -705,112 +700,135 @@ private static HashSet CollectFallbackIds(CoreObject root) string path, HashSet existingFallbackIds) { - var visited = new HashSet(); - return FindFirstNewFallbackCore(root, path, existingFallbackIds, visited); + FallbackOccurrence? result = null; + var visited = new HashSet(ReferenceEqualityComparer.Instance); + TraverseSerializedGraph(root, path, visited, (node, nodePath) => + { + if (node is not IFallback fallback || existingFallbackIds.Contains(node.Id)) + { + return false; + } + + fallback.TryGetTypeName(out string? fallbackTypeName); + result = new FallbackOccurrence( + nodePath, + node.Id, + fallbackTypeName, + fallback.Reason.ToString(), + fallback.ErrorMessage); + return true; + }); + return result; } - private static FallbackOccurrence? FindFirstNewFallbackCore( - CoreObject node, + private static bool TraverseSerializedGraph( + object? value, string path, - HashSet existingFallbackIds, - HashSet visited) + HashSet visited, + Func visitCoreObject) { - if (!visited.Add(node.Id)) + if (value is null or string) { - return null; + return false; } - if (node is IFallback fallback && !existingFallbackIds.Contains(node.Id)) + if (!value.GetType().IsValueType && !visited.Add(value)) { - fallback.TryGetTypeName(out string? fallbackTypeName); - return new FallbackOccurrence( - path, - node.Id, - fallbackTypeName, - fallback.Reason.ToString(), - fallback.ErrorMessage); + return false; } - switch (node) + if (value is CoreObject coreObject) { - case Scene scene: - for (int i = 0; i < scene.Children.Count; i++) - { - if (FindFirstNewFallbackCore( - scene.Children[i], - $"{path}/Elements[{i}]", - existingFallbackIds, - visited) is { } occurrence) - { - return occurrence; - } - } - break; + if (visitCoreObject(coreObject, path)) + { + return true; + } - case Element element: - for (int i = 0; i < element.Objects.Count; i++) - { - if (FindFirstNewFallbackCore( - element.Objects[i], - $"{path}/Objects[{i}]", - existingFallbackIds, - visited) is { } occurrence) + switch (coreObject) + { + case Scene scene: + for (int i = 0; i < scene.Children.Count; i++) { - return occurrence; + if (TraverseSerializedGraph( + scene.Children[i], + $"{path}/Elements[{i}]", + visited, + visitCoreObject)) + { + return true; + } } - } - break; + break; - case EngineObject engineObject: - foreach (IProperty property in engineObject.Properties) - { - if (FindFirstNewFallbackInValue( - property.CurrentValue, - $"{path}/{property.Name}", - existingFallbackIds, - visited) is { } occurrence) + case Element element: + for (int i = 0; i < element.Objects.Count; i++) { - return occurrence; + if (TraverseSerializedGraph( + element.Objects[i], + $"{path}/Objects[{i}]", + visited, + visitCoreObject)) + { + return true; + } } - } - break; - } - - return null; - } + break; - private static FallbackOccurrence? FindFirstNewFallbackInValue( - object? value, - string path, - HashSet existingFallbackIds, - HashSet visited) - { - switch (value) - { - case CoreObject coreObject: - return FindFirstNewFallbackCore(coreObject, path, existingFallbackIds, visited); - case IEnumerable enumerable when value is not string: - { - int index = 0; - foreach (object? item in enumerable) + case EngineObject engineObject: + foreach (IProperty property in engineObject.Properties) { - if (FindFirstNewFallbackInValue( - item, - $"{path}[{index}]", - existingFallbackIds, - visited) is { } occurrence) + if (TraverseSerializedGraph( + property.CurrentValue, + $"{path}/{property.Name}", + visited, + visitCoreObject)) { - return occurrence; + return true; } - index++; + if (property.Animation is IKeyFrameAnimation animation) + { + int index = 0; + foreach (IKeyFrame keyFrame in animation.KeyFrames) + { + if (TraverseSerializedGraph( + keyFrame.Value, + $"{path}/Animations/{property.Name}/KeyFrames[{index}]/Value", + visited, + visitCoreObject)) + { + return true; + } + + index++; + } + } } - break; + } + + return false; + } + + if (value is IEnumerable enumerable) + { + int index = 0; + foreach (object? item in enumerable) + { + if (TraverseSerializedGraph( + item, + $"{path}[{index}]", + visited, + visitCoreObject)) + { + return true; } + + index++; + } } - return null; + return false; } private static string CreateFallbackHint(FallbackOccurrence occurrence) diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 4e62f3b26c..6ae6598e91 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -102,7 +102,10 @@ private static IReadOnlyList CollectDeserializationWarnings(Project proj } string elementFile = element.Uri is { IsFile: true } uri - ? Path.GetFileName(uri.LocalPath) + && scene.Uri is { IsFile: true } sceneUri + ? Path.GetRelativePath( + Path.GetDirectoryName(sceneUri.LocalPath)!, + uri.LocalPath).Replace('\\', '/') : element.Name; foreach (IFallback fallback in fallbacks) { diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index fd50ab92c2..3020120c93 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -59,6 +59,9 @@ public static string ConvertToJsonString(JsonObject jsonNode) public static object DeserializeFromJsonObject(JsonObject json, Type baseType, CoreSerializerOptions? options = null) { + // A sealed baseType deliberately ignores any present discriminator: sealed wrapper types + // (e.g. Optional) legitimately carry the wrapped payload's $type on their own node and + // interpret it themselves during Deserialize. Type? actualType = baseType.IsSealed ? baseType : json.GetDiscriminator(baseType); if (actualType == null) { diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index ab2ddaf780..265fbd97ed 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -49,6 +49,7 @@ public enum ElementOverlapHandling public class Scene : ProjectItem, INotifyEdited { private const int MaxRecoveredIdCollisionAttempts = 1024; + private const string RecoveredElementIdsKey = "RecoveredElementIds"; private static readonly Guid s_recoveredElementNamespace = new("dfad2f76-1d04-5593-ae3b-f371fb1f42ee"); private static readonly Regex s_idPattern = new( "\"Id\"\\s*:\\s*\"(?[0-9a-fA-F-]{36})\"", @@ -65,6 +66,7 @@ public class Scene : ProjectItem, INotifyEdited private readonly Elements _children; private readonly HierarchicalList _layers; private readonly HierarchicalList _markers; + private readonly Dictionary _recoveredElementIds = new(StringComparer.Ordinal); private TimeSpan _start = TimeSpan.FromMinutes(0); private TimeSpan _duration = TimeSpan.FromMinutes(5); private PixelSize _frameSize; @@ -549,6 +551,17 @@ static void Process(JsonObject jobject, string jsonName, List list) context.SetValue("Height", FrameSize.Height); context.SetValue("Groups", Groups.Select(ids => string.Join(':', ids)).ToArray()); context.SetValue(nameof(Markers), Markers); + PruneRecoveredElementIds(); + if (_recoveredElementIds.Count > 0) + { + var recoveredElementIds = new JsonObject(); + foreach ((string path, Guid id) in _recoveredElementIds.OrderBy(static item => item.Key, StringComparer.Ordinal)) + { + recoveredElementIds[path] = id.ToString(); + } + + context.SetValue(RecoveredElementIdsKey, recoveredElementIds); + } if (context.Mode.HasFlag(CoreSerializationMode.SaveReferencedObjects)) { @@ -606,6 +619,20 @@ static void Process(Func add, JsonNode node, List list) FrameSize = new PixelSize(context.GetValue("Width"), context.GetValue("Height")); } + _recoveredElementIds.Clear(); + if (context.GetValue(RecoveredElementIdsKey) is JsonObject recoveredElementIds) + { + foreach ((string path, JsonNode? idNode) in recoveredElementIds) + { + if (idNode is JsonValue idValue + && idValue.TryGetValue(out string? idText) + && Guid.TryParse(idText, out Guid id)) + { + _recoveredElementIds[NormalizeRelativePath(path)] = id; + } + } + } + if (context.GetValue(nameof(Elements)) is { } elementsJson) { if (elementsJson is JsonObject elementsObject) @@ -690,9 +717,6 @@ private void SyncronizeFiles(IEnumerable pathToElement) private void ReassignDuplicateRecoveredIds() { - // A corrupt sidecar can surface an Id another element already owns; the recovered element - // yields it to the healthy claimant so Id-based reconciliation and group references stay - // unambiguous, and falls back to its deterministic path-derived identity. var claimedIds = new HashSet(); foreach (Element child in Children) { @@ -707,10 +731,44 @@ private void ReassignDuplicateRecoveredIds() .Where(static child => child.SuppressedStorageSource is not null) .Select(child => ( Child: child, - RelativePath: Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath))) - .OrderBy(static item => item.RelativePath, StringComparer.Ordinal); + RelativePath: NormalizeRelativePath( + Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)))) + .OrderBy(static item => item.RelativePath, StringComparer.Ordinal) + .ToArray(); + var recoveredPaths = recoveredChildren + .Select(static item => item.RelativePath) + .ToHashSet(StringComparer.Ordinal); + foreach (string path in _recoveredElementIds.Keys.Where(path => !recoveredPaths.Contains(path)).ToArray()) + { + _recoveredElementIds.Remove(path); + } + + var persistedChildren = new HashSet(); + foreach ((Element child, string relativePath) in recoveredChildren) + { + if (_recoveredElementIds.TryGetValue(relativePath, out Guid persistedId)) + { + // A persisted remap that a healthy element now owns is stale; drop it so the + // derivation loop assigns a fresh deterministic identity instead of a duplicate. + if (claimedIds.Add(persistedId)) + { + child.Id = persistedId; + persistedChildren.Add(child); + } + else + { + _recoveredElementIds.Remove(relativePath); + } + } + } + foreach ((Element child, string relativePath) in recoveredChildren) { + if (persistedChildren.Contains(child)) + { + continue; + } + if (claimedIds.Add(child.Id)) { continue; @@ -726,6 +784,7 @@ private void ReassignDuplicateRecoveredIds() if (claimedIds.Add(candidate)) { child.Id = candidate; + _recoveredElementIds[relativePath] = candidate; assigned = true; break; } @@ -836,10 +895,34 @@ private Guid ResolveRecoveredElementId(string rawText, Uri uri) } string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; - string relativePath = Path.GetRelativePath(sceneDirectory, uri.LocalPath); + string relativePath = NormalizeRelativePath(Path.GetRelativePath(sceneDirectory, uri.LocalPath)); return CreateVersion5Guid(s_recoveredElementNamespace, relativePath); } + private void PruneRecoveredElementIds() + { + if (_recoveredElementIds.Count == 0) + { + return; + } + + string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; + var recoveredPaths = Children + .Where(static child => child.SuppressedStorageSource is not null) + .Select(child => NormalizeRelativePath( + Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath))) + .ToHashSet(StringComparer.Ordinal); + foreach (string path in _recoveredElementIds.Keys.Where(path => !recoveredPaths.Contains(path)).ToArray()) + { + _recoveredElementIds.Remove(path); + } + } + + private static string NormalizeRelativePath(string path) + { + return path.Replace('\\', '/'); + } + private static Match? FindTopLevelIdMatch(string rawText, MatchCollection matches) { int matchIndex = 0; diff --git a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs index 7d6011a290..8214eb9fbd 100644 --- a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs @@ -4,6 +4,7 @@ using Beutl.AgentToolkit.Sessions; using Beutl.AgentToolkit.Tests.Helpers; using Beutl.AgentToolkit.Tools; +using Beutl.Engine; using Beutl.Graphics.Shapes; using Beutl.Graphics.Transformation; using Beutl.ProjectSystem; @@ -176,6 +177,52 @@ public void Apply_edit_still_works_on_document_with_preexisting_duplicate_ids() }); } + [Test] + public void Apply_edit_allows_preexisting_fallback_in_nonhierarchical_property_value() + { + Scene scene = CreateSceneWithElement(out Element healthy); + string directory = Path.GetDirectoryName(scene.Uri!.LocalPath)!; + var carrier = new Element + { + Start = TimeSpan.FromSeconds(4), + Length = TimeSpan.FromSeconds(4), + Uri = new Uri(Path.Combine(directory, "carrier.belm")), + }; + var holder = new NonHierarchicalValueHolder(); + var fallback = new FallbackEngineObject(); + fallback.Json = new JsonObject + { + ["$type"] = "[Beutl.Engine]Beutl.Engine:MissingPropertyValue", + [nameof(CoreObject.Id)] = fallback.Id.ToString(), + }; + holder.Value.CurrentValue = fallback; + carrier.AddObject(holder); + scene.Children.Add(carrier); + + using var session = new AgentToolkitTestSession(scene); + var manager = new AgentSessionManager(); + manager.UseSource(new AgentToolkitTestSessionSource(session)); + var tools = new EditTools(manager); + JsonObject renamePatch = new() + { + ["Elements"] = new JsonArray(new JsonObject + { + ["Id"] = healthy.Id.ToString(), + ["Name"] = "Renamed healthy element", + }), + }; + + ToolResult renamed = tools.ApplyEdit( + patch: renamePatch, + schemaVersion: SchemaVersion.Current); + + Assert.Multiple(() => + { + Assert.That(renamed.IsSuccess, Is.True, renamed.Error?.Message); + Assert.That(healthy.Name, Is.EqualTo("Renamed healthy element")); + }); + } + private static JsonObject CreateDocumentWithIdlessRect(out string mintPath) { mintPath = "$/Elements[0]/Objects[0]"; @@ -211,4 +258,16 @@ private static Scene CreateSceneWithElement(out Element element) scene.Children.Add(element); return scene; } + + public sealed class NonHierarchicalValueHolder : EngineObject + { + public NonHierarchicalValueHolder() + { + Value.SetAttributes(nameof(Value), []); + Value.SetValidator(Value.CreateValidator([])); + RegisterProperty(Value); + } + + public IProperty Value { get; } = Property.Create(); + } } diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index 6e680c82f2..e332f7e78a 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -51,6 +51,9 @@ public async Task Open_project_warns_about_corrupt_element_and_render_still_rema ProjectOperations.Save(project); string elementPath = element.Uri!.LocalPath; + string elementRelativePath = Path.GetRelativePath( + Path.GetDirectoryName(scene.Uri!.LocalPath)!, + elementPath).Replace('\\', '/'); JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); JsonObject drawableJson = elementJson[nameof(Element.Objects)]!.AsArray()[0]!.AsObject(); drawableJson[nameof(RectShape.Width)] = "not-a-number"; @@ -88,7 +91,7 @@ public async Task Open_project_warns_about_corrupt_element_and_render_still_rema Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); Assert.That( responseJson["Warnings"]?.AsArray().Select(static item => item!.GetValue()), - Has.Some.Contains(Path.GetFileName(elementPath)).And.Some.Contains("could not be converted")); + Has.Some.Contains(elementRelativePath).And.Some.Contains("could not be converted")); Assert.That(rendered.IsError, Is.Not.True); Assert.That(File.Exists(outputPath), Is.True); }); @@ -127,6 +130,9 @@ public async Task Open_project_warns_about_fallback_in_animation_keyframe_value( ProjectOperations.Save(project); string elementPath = element.Uri!.LocalPath; + string elementRelativePath = Path.GetRelativePath( + Path.GetDirectoryName(scene.Uri!.LocalPath)!, + elementPath).Replace('\\', '/'); JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); JsonObject objectJson = elementJson[nameof(Element.Objects)]!.AsArray()[0]!.AsObject(); JsonObject animationJson = objectJson["Animations"]![nameof(AnimatedValueHolder.AnimatedValue)]!.AsObject(); @@ -146,7 +152,7 @@ public async Task Open_project_warns_about_fallback_in_animation_keyframe_value( Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); Assert.That( opened.Value!.Warnings, - Has.Some.Contains(Path.GetFileName(elementPath)).And.Some.Contains(nameof(FallbackReason.TypeNotFound))); + Has.Some.Contains(elementRelativePath).And.Some.Contains(nameof(FallbackReason.TypeNotFound))); }); } @@ -191,6 +197,9 @@ public async Task Open_project_warns_about_malformed_element_json_and_keeps_heal scene.Children.Add(malformed); ProjectOperations.Save(project); File.WriteAllText(malformed.Uri!.LocalPath, "{ this is not valid JSON"); + string malformedRelativePath = Path.GetRelativePath( + sceneDirectory, + malformed.Uri.LocalPath).Replace('\\', '/'); var manager = new AgentSessionManager(); using var source = new FileSessionSource(); @@ -223,7 +232,7 @@ public async Task Open_project_warns_about_malformed_element_json_and_keeps_heal Assert.That(opened.Value!.Summary.Scenes.Single().Elements, Is.EqualTo(2)); Assert.That( responseJson["Warnings"]?.AsArray().Select(static item => item!.GetValue()), - Has.Some.Contains(Path.GetFileName(malformed.Uri.LocalPath)) + Has.Some.Contains(malformedRelativePath) .And.Some.Contains("JsonReaderException") .And.Some.Contains("invalid start")); Assert.That(rendered.IsError, Is.Not.True); @@ -231,6 +240,51 @@ public async Task Open_project_warns_about_malformed_element_json_and_keeps_heal }); } + [Test] + public async Task Open_project_warning_paths_distinguish_same_named_sidecars_in_different_directories() + { + string root = CreateWorkspace(); + string projectPath = Path.Combine(root, "duplicate-sidecar-names.bep"); + Project project = ProjectOperations.CreateProject(new ProjectCreateOptions( + projectPath, + 64, + 64, + 30, + TimeSpan.FromSeconds(1))); + Scene scene = project.Items.OfType().Single(); + string sceneDirectory = Path.GetDirectoryName(scene.Uri!.LocalPath)!; + string firstPath = Path.Combine(sceneDirectory, "first", "clip.belm"); + string secondPath = Path.Combine(sceneDirectory, "second", "clip.belm"); + scene.Children.Add(new Element + { + Name = "First clip", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(firstPath), + }); + scene.Children.Add(new Element + { + Name = "Second clip", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(secondPath), + }); + ProjectOperations.Save(project); + File.WriteAllText(firstPath, "{ this is not valid JSON"); + File.WriteAllText(secondPath, "{ this is not valid JSON"); + + var manager = new AgentSessionManager(); + using var source = new FileSessionSource(); + SessionTools sessionTools = CreateSessionTools(source, manager, root); + + ToolResult opened = await sessionTools.OpenProject(projectPath); + + Assert.Multiple(() => + { + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); + Assert.That(opened.Value!.Warnings, Has.Some.Contains("first/clip.belm")); + Assert.That(opened.Value.Warnings, Has.Some.Contains("second/clip.belm")); + }); + } + [Test] public async Task Apply_edit_can_rename_healthy_element_while_malformed_element_is_recovered() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index e2be843d0b..ae591d217b 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -400,6 +400,59 @@ public void Restore_RecoveredReplacementIdCollision_DerivesStableUniqueCandidate }); } + [Test] + public void Restore_MalformedSubdirectoryElementIdUsesForwardSlashRelativePath() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements(Path.Combine("subdirectory", "clip.belm")); + File.WriteAllText(elementPaths[0], "{ this is not valid JSON"); + + Guid first = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + Guid second = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + + Assert.Multiple(() => + { + Assert.That(first, Is.EqualTo(new Guid("b23f930b-40c4-51ca-a013-59c3c3798f02"))); + Assert.That(second, Is.EqualTo(first)); + Assert.That(first, Is.Not.EqualTo(new Guid("10a2473b-45a8-5459-a5e2-9ea28f691f53"))); + }); + } + + [Test] + public void Restore_PersistedRecoveredRemapSurvivesClaimantRemoval() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("healthy.belm", "recovered.belm"); + Scene original = CoreSerializer.RestoreFromUri(sceneUri); + Element healthy = original.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + File.WriteAllText( + elementPaths[1], + $$"""{"Id":"{{healthy.Id}}","Objects":["""); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recovered = recoveredScene.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + Guid remappedId = recovered.Id; + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + JsonObject persistedScene = JsonNode.Parse(File.ReadAllText(sceneUri.LocalPath))!.AsObject(); + JsonObject persistedIds = persistedScene["RecoveredElementIds"]!.AsObject(); + recoveredScene.DeleteChild( + recoveredScene.Children.Single(child => child.Uri!.LocalPath == elementPaths[0])); + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + + Assert.Multiple(() => + { + Assert.That(remappedId, Is.Not.EqualTo(healthy.Id)); + Assert.That( + persistedIds["recovered.belm"]!.GetValue(), + Is.EqualTo(remappedId.ToString())); + Assert.That(reloaded.Children, Has.Count.EqualTo(1)); + Assert.That(reloaded.Children.Single().Id, Is.EqualTo(remappedId)); + }); + } + [Test] public void Restore_NestedFallbackProjection_PreservesOriginalDiscriminator() { From 0b0eb8ff337420177c7d66c93ef49cd00725a577 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 18:29:52 +0900 Subject: [PATCH 09/35] fix(review): reserve recovered ids scene-wide, persist them authoritatively, and copy atomically - A zero-GUID top-level Id reads as unreadable and falls back to the deterministic path-derived identity. - Recovered-id claims are seeded from the scene's own Id, healthy element Ids, and their descendant CoreObject Ids. - The persisted RecoveredElementIds map is authoritative for every recovered element and is rebuilt at serialize time from current paths, so rehoming or flattening a sidecar keeps its identity. - Rehome copies write to a temp sibling and File.Move without overwrite, so an interrupted copy leaves no partial file that a later save would mistake for a manual repair. --- .../Serialization/CoreSerializer.cs | 43 ++++---- .../ProjectSystem/Scene.cs | 37 ++++--- .../MalformedElementRecoveryTests.cs | 100 ++++++++++++++++++ 3 files changed, 145 insertions(+), 35 deletions(-) diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 3020120c93..fc706c3ed1 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -260,43 +260,50 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n // element. The suppression record is never mutated — the source location stays // skip-protected even if a failed multi-file save rolls Uri back afterwards. string rehomedPath = uri.LocalPath; - string? rehomedDirectory = Path.GetDirectoryName(rehomedPath); - if (rehomedDirectory != null) + if (File.Exists(rehomedPath)) { - Directory.CreateDirectory(rehomedDirectory); + suppressedObj.Uri = uri; + return; } - FileStream stream; - try - { - stream = new FileStream(rehomedPath, FileMode.CreateNew, FileAccess.Write); - } - catch (IOException) when (File.Exists(rehomedPath)) + string? rehomedDirectory = Path.GetDirectoryName(rehomedPath); + if (rehomedDirectory != null) { - // An existing file may hold a manual repair of the recovered content (or an earlier - // verbatim copy); never overwrite it. - suppressedObj.Uri = uri; - return; + Directory.CreateDirectory(rehomedDirectory); } + string tempPath = $"{rehomedPath}.{Guid.NewGuid():N}.tmp"; try { - using (stream) + using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None)) { stream.Write(suppressed.RawBytes); + stream.Flush(flushToDisk: true); + } + + try + { + File.Move(tempPath, rehomedPath, overwrite: false); + } + catch (IOException) when (File.Exists(rehomedPath)) + { + suppressedObj.Uri = uri; + return; } } - catch + finally { try { - File.Delete(rehomedPath); + File.Delete(tempPath); } catch { } - - throw; } suppressedObj.Uri = uri; diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 265fbd97ed..d127cf04f8 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -551,7 +551,7 @@ static void Process(JsonObject jobject, string jsonName, List list) context.SetValue("Height", FrameSize.Height); context.SetValue("Groups", Groups.Select(ids => string.Join(':', ids)).ToArray()); context.SetValue(nameof(Markers), Markers); - PruneRecoveredElementIds(); + RebuildRecoveredElementIds(); if (_recoveredElementIds.Count > 0) { var recoveredElementIds = new JsonObject(); @@ -717,12 +717,16 @@ private void SyncronizeFiles(IEnumerable pathToElement) private void ReassignDuplicateRecoveredIds() { - var claimedIds = new HashSet(); + var claimedIds = new HashSet { Guid.Empty, Id }; foreach (Element child in Children) { if (child.SuppressedStorageSource is null) { claimedIds.Add(child.Id); + foreach (CoreObject descendant in child.EnumerateAllChildren()) + { + claimedIds.Add(descendant.Id); + } } } @@ -784,7 +788,6 @@ private void ReassignDuplicateRecoveredIds() if (claimedIds.Add(candidate)) { child.Id = candidate; - _recoveredElementIds[relativePath] = candidate; assigned = true; break; } @@ -796,6 +799,11 @@ private void ReassignDuplicateRecoveredIds() $"Could not assign a unique recovered element Id for '{relativePath}'."); } } + + foreach ((Element child, string relativePath) in recoveredChildren) + { + _recoveredElementIds[relativePath] = child.Id; + } } private Element RestoreElementOrFallback(Uri uri) @@ -889,7 +897,8 @@ private Guid ResolveRecoveredElementId(string rawText, Uri uri) MatchCollection matches = s_idPattern.Matches(rawText); Match? topLevelMatch = FindTopLevelIdMatch(rawText, matches); if (topLevelMatch != null - && Guid.TryParse(topLevelMatch.Groups["id"].Value, out Guid topLevelId)) + && Guid.TryParse(topLevelMatch.Groups["id"].Value, out Guid topLevelId) + && topLevelId != Guid.Empty) { return topLevelId; } @@ -899,22 +908,16 @@ private Guid ResolveRecoveredElementId(string rawText, Uri uri) return CreateVersion5Guid(s_recoveredElementNamespace, relativePath); } - private void PruneRecoveredElementIds() + private void RebuildRecoveredElementIds() { - if (_recoveredElementIds.Count == 0) - { - return; - } - string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; - var recoveredPaths = Children - .Where(static child => child.SuppressedStorageSource is not null) - .Select(child => NormalizeRelativePath( - Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath))) - .ToHashSet(StringComparer.Ordinal); - foreach (string path in _recoveredElementIds.Keys.Where(path => !recoveredPaths.Contains(path)).ToArray()) + _recoveredElementIds.Clear(); + foreach (Element child in Children.Where( + static child => child.SuppressedStorageSource is not null)) { - _recoveredElementIds.Remove(path); + string relativePath = NormalizeRelativePath( + Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)); + _recoveredElementIds[relativePath] = child.Id; } } diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index ae591d217b..efc8c6524a 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -60,6 +60,24 @@ public void Restore_MalformedElementWithoutReadableId_UsesStableId() }); } + [Test] + public void Restore_MalformedElementWithEmptyTopLevelId_UsesStableNonEmptyId() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + """{"Id":"00000000-0000-0000-0000-000000000000","Objects":["""); + + Guid first = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + Guid second = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + + Assert.Multiple(() => + { + Assert.That(first, Is.Not.EqualTo(Guid.Empty)); + Assert.That(second, Is.EqualTo(first)); + }); + } + [Test] public void Restore_MalformedElementWithOnlyNestedId_DoesNotAdoptIt() { @@ -344,6 +362,54 @@ public void Restore_DuplicateTopLevelId_YieldsToTheHealthyElement() }); } + [Test] + public void Restore_TopLevelIdMatchingSceneId_IsReassignedStably() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Guid sceneId = CoreSerializer.RestoreFromUri(sceneUri).Id; + File.WriteAllText(elementPath, $$"""{"Id":"{{sceneId}}","Objects":["""); + + Guid first = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + Guid second = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + + Assert.Multiple(() => + { + Assert.That(first, Is.Not.EqualTo(sceneId)); + Assert.That(first, Is.Not.EqualTo(Guid.Empty)); + Assert.That(second, Is.EqualTo(first)); + }); + } + + [Test] + public void Restore_TopLevelIdMatchingHealthyDescendantId_IsReassignedStably() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("healthy.belm", "recovered.belm"); + Scene original = CoreSerializer.RestoreFromUri(sceneUri); + Guid descendantId = original.Children + .Single(child => child.Uri!.LocalPath == elementPaths[0]) + .Objects + .Single() + .Id; + File.WriteAllText( + elementPaths[1], + $$"""{"Id":"{{descendantId}}","Objects":["""); + + Guid first = CoreSerializer.RestoreFromUri(sceneUri).Children + .Single(child => child.Uri!.LocalPath == elementPaths[1]) + .Id; + Guid second = CoreSerializer.RestoreFromUri(sceneUri).Children + .Single(child => child.Uri!.LocalPath == elementPaths[1]) + .Id; + + Assert.Multiple(() => + { + Assert.That(first, Is.Not.EqualTo(descendantId)); + Assert.That(first, Is.Not.EqualTo(Guid.Empty)); + Assert.That(second, Is.EqualTo(first)); + }); + } + [Test] public void Restore_RecoveredElementsSharingTopLevelId_AreAssignedStableUniqueIdsByPath() { @@ -453,6 +519,35 @@ public void Restore_PersistedRecoveredRemapSurvivesClaimantRemoval() }); } + [Test] + public void Save_RebuildsRecoveredElementIdMapAfterRehome() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText(elementPath, "{ this is not valid JSON"); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recovered = recoveredScene.Children.Single(); + Guid recoveredId = recovered.Id; + string rehomedPath = Path.Combine(_root, "renamed.belm"); + File.Move(elementPath, rehomedPath); + recovered.Uri = new Uri(rehomedPath); + + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + JsonObject persistedScene = JsonNode.Parse(File.ReadAllText(sceneUri.LocalPath))!.AsObject(); + JsonObject persistedIds = persistedScene["RecoveredElementIds"]!.AsObject(); + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + + Assert.Multiple(() => + { + Assert.That(persistedIds, Has.Count.EqualTo(1)); + Assert.That( + persistedIds["renamed.belm"]!.GetValue(), + Is.EqualTo(recoveredId.ToString())); + Assert.That(reloaded.Children.Single().Id, Is.EqualTo(recoveredId)); + }); + } + [Test] public void Restore_NestedFallbackProjection_PreservesOriginalDiscriminator() { @@ -523,6 +618,11 @@ public void SaveAs_CopiesRecoveredSidecarBytesToTheNewLocation() { Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(corruptBytes)); Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); + Assert.That( + Directory.GetFiles( + Path.GetDirectoryName(rehomedPath)!, + $"{Path.GetFileName(rehomedPath)}.*.tmp"), + Is.Empty); }); } From defc57d331ef88202e64281127be793e374c2fa5 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 19:07:41 +0900 Subject: [PATCH 10/35] fix(review): project all recovered fallbacks and expose structured recovery incidents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fallback projection normalization walks the full serialized graph (hierarchical children, property values, keyframe values), so an ID-less fallback outside the hierarchy gets its instance Id into the projection and reconciliation no longer regenerates it under a new Id. - open_project additionally returns recoveryIncidents — structured { elementFile, reason, typeName, message } records beside the presentation warnings — and the MCP contract documents both. --- .../contracts/mcp-tools.md | 2 +- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 38 +++++++++-- .../ProjectSystem/Scene.cs | 64 +++++++++++++++++-- .../ReconcilerIdIntegrityTests.cs | 23 ++++--- .../Tools/SessionToolsTests.cs | 21 +++++- .../MalformedElementRecoveryTests.cs | 33 ++++++---- 6 files changed, 147 insertions(+), 34 deletions(-) diff --git a/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md b/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md index f4f1e61101..cddc3584b0 100644 --- a/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md +++ b/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md @@ -97,7 +97,7 @@ A `session` may be **file-opened** (headless server) or **live** (in-app host bo ### `open_project` - **Input**: `{ "path": string }` (read — unrestricted). -- **Output**: `{ "session": string, "source": "File", "summary": { scenes, elements, duration, frameSize } }`. +- **Output**: `{ "session": string, "source": "File", "summary": { scenes, elements, duration, frameSize }, "warnings": string[], "recoveryIncidents": [{ "elementFile": string, "reason": "TypeNotFound" | "DeserializationFailed", "typeName": string | null, "message": string | null }] }`. `warnings` remains the presentation-oriented recovery summary; `recoveryIncidents` exposes the same incidents as stable structured data. `elementFile` is a forward-slash scene-relative path when available, otherwise the element name. `typeName` is the original serialized discriminator when available, otherwise `null`; `message` is the unmodified nullable deserialization error rather than a presentation fallback. - **Errors**: `media_not_found`, `schema_version_mismatch` (project written by an incompatible schema — surfaced, not silently dropped, per FR-031/FR-013). ### `attach_active_editor` *(in-app host only)* diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 6ae6598e91..9da21d8806 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -19,9 +19,17 @@ public sealed record SceneSummary(string SceneId, string Name, int Width, int He public sealed record SessionSummary(IReadOnlyList Scenes); +public sealed record RecoveryIncident( + string ElementFile, + string Reason, + string? TypeName, + string? Message); + public sealed record OpenProjectResponse(string Session, string Source, SessionSummary Summary) { public IReadOnlyList Warnings { get; init; } = []; + + public IReadOnlyList RecoveryIncidents { get; init; } = []; } public sealed record CreateProjectResponse(string Session, string SavedPath, SessionSummary Summary); @@ -75,21 +83,23 @@ public ValueTask> OpenProject(string path, Cance } ProjectSessionResult result = await projects.OpenProjectAsync(fullPath, cancellationToken).ConfigureAwait(false); + DeserializationWarningCollection recovery = result.Session.ReadOnSession( + () => CollectDeserializationWarnings(result.Project)); return new OpenProjectResponse( result.Session.SessionId, result.Session.Source.ToString(), CreateSummary(result.Session, result.Project)) { - // The traversal walks the live graph, so it must run on the session thread — a live - // editor may mutate the collections concurrently. - Warnings = result.Session.ReadOnSession(() => CollectDeserializationWarnings(result.Project)) + Warnings = recovery.Warnings, + RecoveryIncidents = recovery.RecoveryIncidents, }; }); } - private static IReadOnlyList CollectDeserializationWarnings(Project project) + private static DeserializationWarningCollection CollectDeserializationWarnings(Project project) { var warnings = new List(); + var incidents = new List(); foreach (Scene scene in project.Items.OfType()) { foreach (Element element in scene.Children) @@ -109,6 +119,20 @@ private static IReadOnlyList CollectDeserializationWarnings(Project proj : element.Name; foreach (IFallback fallback in fallbacks) { + fallback.TryGetTypeName(out string? typeName); + if (string.Equals( + typeName, + IdentityHelper.WriteDiscriminator(fallback.GetType()), + StringComparison.Ordinal)) + { + typeName = null; + } + + incidents.Add(new RecoveryIncident( + elementFile, + fallback.Reason.ToString(), + typeName, + fallback.ErrorMessage)); string error = string.IsNullOrWhiteSpace(fallback.ErrorMessage) ? fallback.Reason.ToString() : fallback.ErrorMessage; @@ -118,9 +142,13 @@ private static IReadOnlyList CollectDeserializationWarnings(Project proj } } - return warnings; + return new DeserializationWarningCollection(warnings, incidents); } + private sealed record DeserializationWarningCollection( + IReadOnlyList Warnings, + IReadOnlyList RecoveryIncidents); + private static void CollectFallbacks( object? value, ISet visited, diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index d127cf04f8..82cdd81e96 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -1,4 +1,5 @@ -using System.Collections.Concurrent; +using System.Collections; +using System.Collections.Concurrent; using System.Collections.Immutable; using System.Collections.Specialized; using System.ComponentModel; @@ -9,6 +10,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; +using Beutl.Animation; using Beutl.Collections; using Beutl.Configuration; using Beutl.Engine; @@ -812,10 +814,8 @@ private Element RestoreElementOrFallback(Uri uri) try { Element element = CoreSerializer.RestoreFromUri(uri); - IFallback[] fallbacks = element.EnumerateAllChildren().ToArray(); + IFallback[] fallbacks = EnumerateSerializedGraphFallbacks(element).ToArray(); - // The incident tally also catches fallbacks stored outside the hierarchy (e.g. a - // keyframe value), which the child traversal cannot see. if (fallbacks.Length > 0 || DeserializationIncidents.FallbackCount != fallbackCountBefore) { foreach (IFallback fallback in fallbacks) @@ -862,6 +862,62 @@ private static void MarkRecoveredElement(Element element, byte[] rawBytes, Uri u element.SuppressedStorageSource = new SuppressedStorageSource(rawBytes, uri); } + private static IEnumerable EnumerateSerializedGraphFallbacks(Element element) + { + var fallbacks = new List(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + CollectSerializedGraphFallbacks(element, visited, fallbacks); + return fallbacks; + } + + private static void CollectSerializedGraphFallbacks( + object? value, + ISet visited, + ICollection fallbacks) + { + if (value is null or string + || (!value.GetType().IsValueType && !visited.Add(value))) + { + return; + } + + if (value is IFallback fallback) + { + fallbacks.Add(fallback); + } + + if (value is IHierarchical hierarchical) + { + foreach (IHierarchical child in hierarchical.HierarchicalChildren) + { + CollectSerializedGraphFallbacks(child, visited, fallbacks); + } + } + + if (value is EngineObject engineObject) + { + foreach (IProperty property in engineObject.Properties) + { + CollectSerializedGraphFallbacks(property.CurrentValue, visited, fallbacks); + if (property.Animation is IKeyFrameAnimation animation) + { + foreach (IKeyFrame keyFrame in animation.KeyFrames) + { + CollectSerializedGraphFallbacks(keyFrame.Value, visited, fallbacks); + } + } + } + } + + if (value is IEnumerable enumerable) + { + foreach (object? item in enumerable) + { + CollectSerializedGraphFallbacks(item, visited, fallbacks); + } + } + } + private static void EnsureFallbackProjection(IFallback fallback) { if (fallback is not CoreObject coreObject) diff --git a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs index 8214eb9fbd..c9fb6cecc6 100644 --- a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs @@ -189,17 +189,22 @@ public void Apply_edit_allows_preexisting_fallback_in_nonhierarchical_property_v Uri = new Uri(Path.Combine(directory, "carrier.belm")), }; var holder = new NonHierarchicalValueHolder(); - var fallback = new FallbackEngineObject(); - fallback.Json = new JsonObject - { - ["$type"] = "[Beutl.Engine]Beutl.Engine:MissingPropertyValue", - [nameof(CoreObject.Id)] = fallback.Id.ToString(), - }; - holder.Value.CurrentValue = fallback; + holder.Value.CurrentValue = new RectShape(); carrier.AddObject(holder); scene.Children.Add(carrier); + CoreSerializer.StoreToUri(scene, scene.Uri!); - using var session = new AgentToolkitTestSession(scene); + JsonObject carrierJson = JsonNode.Parse(File.ReadAllText(carrier.Uri.LocalPath))!.AsObject(); + JsonObject valueJson = carrierJson[nameof(Element.Objects)]!.AsArray()[0]! + [nameof(NonHierarchicalValueHolder.Value)]!.AsObject(); + valueJson["$type"] = "[Beutl.Engine]Beutl.Engine:MissingPropertyValue"; + valueJson.Remove(nameof(CoreObject.Id)); + File.WriteAllText(carrier.Uri.LocalPath, carrierJson.ToJsonString()); + + Scene recovered = CoreSerializer.RestoreFromUri(scene.Uri!); + Element recoveredHealthy = recovered.Children.Single(item => item.Id == healthy.Id); + + using var session = new AgentToolkitTestSession(recovered); var manager = new AgentSessionManager(); manager.UseSource(new AgentToolkitTestSessionSource(session)); var tools = new EditTools(manager); @@ -219,7 +224,7 @@ public void Apply_edit_allows_preexisting_fallback_in_nonhierarchical_property_v Assert.Multiple(() => { Assert.That(renamed.IsSuccess, Is.True, renamed.Error?.Message); - Assert.That(healthy.Name, Is.EqualTo("Renamed healthy element")); + Assert.That(recoveredHealthy.Name, Is.EqualTo("Renamed healthy element")); }); } diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index e332f7e78a..1e726708e3 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -100,6 +100,7 @@ public async Task Open_project_warns_about_corrupt_element_and_render_still_rema [Test] public async Task Open_project_warns_about_fallback_in_animation_keyframe_value() { + const string MissingType = "[Beutl.Engine]Beutl.Engine:MissingAnimatedValue"; string root = CreateWorkspace(); string projectPath = Path.Combine(root, "animation-fallback.bep"); Project project = ProjectOperations.CreateProject(new ProjectCreateOptions( @@ -137,8 +138,7 @@ public async Task Open_project_warns_about_fallback_in_animation_keyframe_value( JsonObject objectJson = elementJson[nameof(Element.Objects)]!.AsArray()[0]!.AsObject(); JsonObject animationJson = objectJson["Animations"]![nameof(AnimatedValueHolder.AnimatedValue)]!.AsObject(); JsonObject keyFrameJson = animationJson[nameof(KeyFrameAnimation.KeyFrames)]!.AsArray()[0]!.AsObject(); - keyFrameJson[nameof(IKeyFrame.Value)]!.AsObject()["$type"] - = "[Beutl.Engine]Beutl.Engine:MissingAnimatedValue"; + keyFrameJson[nameof(IKeyFrame.Value)]!.AsObject()["$type"] = MissingType; File.WriteAllText(elementPath, elementJson.ToJsonString()); var manager = new AgentSessionManager(); @@ -153,6 +153,12 @@ public async Task Open_project_warns_about_fallback_in_animation_keyframe_value( Assert.That( opened.Value!.Warnings, Has.Some.Contains(elementRelativePath).And.Some.Contains(nameof(FallbackReason.TypeNotFound))); + Assert.That(opened.Value.RecoveryIncidents, Has.Count.EqualTo(1)); + Assert.That(opened.Value.RecoveryIncidents[0].ElementFile, Is.EqualTo(elementRelativePath)); + Assert.That(opened.Value.RecoveryIncidents[0].Reason, + Is.EqualTo(nameof(FallbackReason.TypeNotFound))); + Assert.That(opened.Value.RecoveryIncidents[0].TypeName, Is.EqualTo(MissingType)); + Assert.That(opened.Value.RecoveryIncidents[0].Message, Is.Null); }); } @@ -226,6 +232,9 @@ public async Task Open_project_warns_about_malformed_element_json_and_keeps_heal var rendered = await renderTools.RenderStill( outputPath, cancellationToken: CancellationToken.None); + var recoveredFallback = (IFallback)((Scene)manager.CurrentSession!.Root) + .Children.Single(item => item.Uri!.LocalPath == malformed.Uri.LocalPath) + .Objects.Single(); Assert.Multiple(() => { @@ -235,6 +244,12 @@ public async Task Open_project_warns_about_malformed_element_json_and_keeps_heal Has.Some.Contains(malformedRelativePath) .And.Some.Contains("JsonReaderException") .And.Some.Contains("invalid start")); + Assert.That(opened.Value.RecoveryIncidents, Has.Count.EqualTo(1)); + Assert.That(opened.Value.RecoveryIncidents[0].ElementFile, Is.EqualTo(malformedRelativePath)); + Assert.That(opened.Value.RecoveryIncidents[0].Reason, + Is.EqualTo(nameof(FallbackReason.DeserializationFailed))); + Assert.That(opened.Value.RecoveryIncidents[0].TypeName, Is.Null); + Assert.That(opened.Value.RecoveryIncidents[0].Message, Is.EqualTo(recoveredFallback.ErrorMessage)); Assert.That(rendered.IsError, Is.Not.True); Assert.That(File.Exists(outputPath), Is.True); }); @@ -282,6 +297,8 @@ public async Task Open_project_warning_paths_distinguish_same_named_sidecars_in_ Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); Assert.That(opened.Value!.Warnings, Has.Some.Contains("first/clip.belm")); Assert.That(opened.Value.Warnings, Has.Some.Contains("second/clip.belm")); + Assert.That(opened.Value.RecoveryIncidents.Select(static item => item.ElementFile), + Is.EquivalentTo(new[] { "first/clip.belm", "second/clip.belm" })); }); } diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index efc8c6524a..85f08bb83c 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -301,7 +301,7 @@ public void Restore_InvalidElementScalarValue_RecoversInsteadOfFailing() } [Test] - public void Save_PreservesSidecarBytesWhenFallbackIsOutsideTheHierarchy() + public void Restore_IdlessFallbackOutsideHierarchy_ProjectsRuntimeIdAndPreservesSidecarBytes() { (Uri sceneUri, string elementPath) = CreatePersistedScene(); Scene loaded = CoreSerializer.RestoreFromUri(sceneUri); @@ -311,16 +311,24 @@ public void Save_PreservesSidecarBytesWhenFallbackIsOutsideTheHierarchy() CoreSerializer.StoreToUri(loadedElement, loadedElement.Uri!); JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); - Assert.That( - ReplaceDiscriminator(json, "Transformation", "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"), - Is.True); + JsonObject transformJson = FindObjectByDiscriminator(json, "Transformation")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + transformJson.Remove(nameof(CoreObject.Id)); File.WriteAllText(elementPath, json.ToJsonString()); byte[] originalBytes = File.ReadAllBytes(elementPath); Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + var recoveredShape = (RectShape)recovered.Children.Single().Objects.Single(); + var fallback = (IFallback)recoveredShape.Transform.CurrentValue!; + var fallbackObject = (CoreObject)fallback; CoreSerializer.StoreToUri(recovered, sceneUri); - Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + Assert.Multiple(() => + { + Assert.That(fallback.Json![nameof(CoreObject.Id)]!.GetValue(), + Is.EqualTo(fallbackObject.Id.ToString())); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); } [Test] @@ -741,7 +749,7 @@ public void Restore_MalformedElementPrefersTopLevelId() Assert.That(recovered.Id, Is.EqualTo(topLevelId)); } - private static bool ReplaceDiscriminator(JsonNode node, string containsToken, string replacement) + private static JsonObject? FindObjectByDiscriminator(JsonNode node, string containsToken) { if (node is JsonObject obj) { @@ -750,15 +758,14 @@ private static bool ReplaceDiscriminator(JsonNode node, string containsToken, st && typeValue.TryGetValue(out string? typeName) && typeName.Contains(containsToken)) { - obj["$type"] = replacement; - return true; + return obj; } foreach ((string _, JsonNode? child) in obj) { - if (child != null && ReplaceDiscriminator(child, containsToken, replacement)) + if (child != null && FindObjectByDiscriminator(child, containsToken) is { } result) { - return true; + return result; } } } @@ -766,14 +773,14 @@ private static bool ReplaceDiscriminator(JsonNode node, string containsToken, st { foreach (JsonNode? child in array) { - if (child != null && ReplaceDiscriminator(child, containsToken, replacement)) + if (child != null && FindObjectByDiscriminator(child, containsToken) is { } result) { - return true; + return result; } } } - return false; + return null; } private (Uri SceneUri, string ElementPath) CreatePersistedScene() From e69843fe1f2b2eed9d012fbbcc3ee235f765b579 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 19:37:19 +0900 Subject: [PATCH 11/35] fix(review): deduplicate recovered elements' descendant ids deterministically - Recovered-id claims now cover every element's full serialized-graph descendants; healthy elements and their objects claim first. - A recovered element's descendant whose Id another scene object owns is reassigned to a deterministic UUIDv5 derived from the sidecar's relative path and the original Id, stable across loads, with fallback projections refreshed to the final Id. The byte-frozen sidecar is untouched. --- .../ProjectSystem/Scene.cs | 109 +++++++++++---- .../MalformedElementRecoveryTests.cs | 130 +++++++++++++++++- 2 files changed, 214 insertions(+), 25 deletions(-) diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 82cdd81e96..5f50877ee1 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -719,19 +719,6 @@ private void SyncronizeFiles(IEnumerable pathToElement) private void ReassignDuplicateRecoveredIds() { - var claimedIds = new HashSet { Guid.Empty, Id }; - foreach (Element child in Children) - { - if (child.SuppressedStorageSource is null) - { - claimedIds.Add(child.Id); - foreach (CoreObject descendant in child.EnumerateAllChildren()) - { - claimedIds.Add(descendant.Id); - } - } - } - string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; var recoveredChildren = Children .Where(static child => child.SuppressedStorageSource is not null) @@ -741,6 +728,32 @@ private void ReassignDuplicateRecoveredIds() Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)))) .OrderBy(static item => item.RelativePath, StringComparer.Ordinal) .ToArray(); + + var claimedIds = new HashSet { Guid.Empty, Id }; + var seenDescendants = new HashSet(ReferenceEqualityComparer.Instance); + foreach (Element child in Children.Where( + static child => child.SuppressedStorageSource is null)) + { + claimedIds.Add(child.Id); + foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + { + seenDescendants.Add(descendant); + claimedIds.Add(descendant.Id); + } + } + + var duplicateRecoveredDescendants = new HashSet(ReferenceEqualityComparer.Instance); + foreach ((Element child, string _) in recoveredChildren) + { + foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + { + if (seenDescendants.Add(descendant) && !claimedIds.Add(descendant.Id)) + { + duplicateRecoveredDescendants.Add(descendant); + } + } + } + var recoveredPaths = recoveredChildren .Select(static item => item.RelativePath) .ToHashSet(StringComparer.Ordinal); @@ -806,6 +819,42 @@ private void ReassignDuplicateRecoveredIds() { _recoveredElementIds[relativePath] = child.Id; } + + foreach ((Element child, string relativePath) in recoveredChildren) + { + foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + { + if (!duplicateRecoveredDescendants.Remove(descendant)) + { + continue; + } + + Guid originalId = descendant.Id; + bool assigned = false; + for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++) + { + string candidateName = $"{relativePath}!{originalId:D}#{attempt}"; + Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName); + if (claimedIds.Add(candidate)) + { + descendant.Id = candidate; + assigned = true; + break; + } + } + + if (!assigned) + { + throw new InvalidOperationException( + $"Could not assign a unique recovered descendant Id for '{relativePath}'."); + } + + if (descendant is IFallback fallback) + { + EnsureFallbackProjection(fallback); + } + } + } } private Element RestoreElementOrFallback(Uri uri) @@ -864,16 +913,28 @@ private static void MarkRecoveredElement(Element element, byte[] rawBytes, Uri u private static IEnumerable EnumerateSerializedGraphFallbacks(Element element) { - var fallbacks = new List(); + return EnumerateSerializedGraphObjects(element).OfType(); + } + + private static IEnumerable EnumerateSerializedGraphDescendants(Element element) + { + return EnumerateSerializedGraphObjects(element) + .OfType() + .Where(value => !ReferenceEquals(value, element)); + } + + private static IEnumerable EnumerateSerializedGraphObjects(Element element) + { + var objects = new List(); var visited = new HashSet(ReferenceEqualityComparer.Instance); - CollectSerializedGraphFallbacks(element, visited, fallbacks); - return fallbacks; + CollectSerializedGraphObjects(element, visited, objects); + return objects; } - private static void CollectSerializedGraphFallbacks( + private static void CollectSerializedGraphObjects( object? value, ISet visited, - ICollection fallbacks) + ICollection objects) { if (value is null or string || (!value.GetType().IsValueType && !visited.Add(value))) @@ -881,16 +942,16 @@ private static void CollectSerializedGraphFallbacks( return; } - if (value is IFallback fallback) + if (value is CoreObject or IFallback) { - fallbacks.Add(fallback); + objects.Add(value); } if (value is IHierarchical hierarchical) { foreach (IHierarchical child in hierarchical.HierarchicalChildren) { - CollectSerializedGraphFallbacks(child, visited, fallbacks); + CollectSerializedGraphObjects(child, visited, objects); } } @@ -898,12 +959,12 @@ private static void CollectSerializedGraphFallbacks( { foreach (IProperty property in engineObject.Properties) { - CollectSerializedGraphFallbacks(property.CurrentValue, visited, fallbacks); + CollectSerializedGraphObjects(property.CurrentValue, visited, objects); if (property.Animation is IKeyFrameAnimation animation) { foreach (IKeyFrame keyFrame in animation.KeyFrames) { - CollectSerializedGraphFallbacks(keyFrame.Value, visited, fallbacks); + CollectSerializedGraphObjects(keyFrame.Value, visited, objects); } } } @@ -913,7 +974,7 @@ private static void CollectSerializedGraphFallbacks( { foreach (object? item in enumerable) { - CollectSerializedGraphFallbacks(item, visited, fallbacks); + CollectSerializedGraphObjects(item, visited, objects); } } } diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 85f08bb83c..1f3f7e7bc8 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Nodes; +using System.Collections; +using System.Text.Json.Nodes; +using Beutl.Animation; using Beutl.Editor; using Beutl.Engine; using Beutl.Graphics.Shapes; @@ -575,6 +577,60 @@ public void Restore_NestedFallbackProjection_PreservesOriginalDiscriminator() Assert.That(fallback.Json!["$type"]!.GetValue(), Is.EqualTo(OriginalType)); } + [Test] + public void Restore_RecoveredNestedFallbackDuplicateId_IsReassignedStably() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("healthy.belm", "recovered.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element healthySource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + Element recoveredSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + Guid healthyId = healthySource.Id; + var recoveredShapeSource = (RectShape)recoveredSource.Objects.Single(); + recoveredShapeSource.Transform.CurrentValue = new RotationTransform { Id = healthyId }; + CoreSerializer.StoreToUri(recoveredSource, recoveredSource.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[1]))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + transformJson[nameof(CoreObject.Id)] = healthyId.ToString(); + File.WriteAllText(elementPaths[1], json.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPaths[1]); + + Scene firstLoad = CoreSerializer.RestoreFromUri(sceneUri); + Element healthy = firstLoad.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + IFallback fallback = GetTransformFallback(firstLoad, elementPaths[1]); + var fallbackObject = (CoreObject)fallback; + Guid reassignedId = fallbackObject.Id; + Guid[] firstIds = EnumerateElementGraphs(firstLoad).Select(obj => obj.Id).ToArray(); + + CoreSerializer.StoreToUri(firstLoad, sceneUri); + Scene secondLoad = CoreSerializer.RestoreFromUri(sceneUri); + Element healthyAgain = secondLoad.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + IFallback fallbackAgain = GetTransformFallback(secondLoad, elementPaths[1]); + var fallbackObjectAgain = (CoreObject)fallbackAgain; + Guid[] secondIds = EnumerateElementGraphs(secondLoad).Select(obj => obj.Id).ToArray(); + + Assert.Multiple(() => + { + Assert.That(healthy.Id, Is.EqualTo(healthyId)); + Assert.That(firstIds, Is.Unique); + Assert.That(firstIds, Does.Not.Contain(Guid.Empty)); + Assert.That(secondIds, Is.Unique); + Assert.That(secondIds, Does.Not.Contain(Guid.Empty)); + Assert.That(reassignedId, Is.Not.EqualTo(healthyId)); + Assert.That(healthyAgain.Id, Is.EqualTo(healthyId)); + Assert.That(fallbackObjectAgain.Id, Is.EqualTo(reassignedId)); + Assert.That( + fallback.Json![nameof(CoreObject.Id)]!.GetValue(), + Is.EqualTo(reassignedId.ToString())); + Assert.That( + fallbackAgain.Json![nameof(CoreObject.Id)]!.GetValue(), + Is.EqualTo(reassignedId.ToString())); + Assert.That(File.ReadAllBytes(elementPaths[1]), Is.EqualTo(originalBytes)); + }); + } + [Test] public void Restore_RootArrayId_DoesNotAdoptInnerIdAndRemainsStable() { @@ -783,6 +839,78 @@ public void Restore_MalformedElementPrefersTopLevelId() return null; } + private static IFallback GetTransformFallback(Scene scene, string elementPath) + { + Element element = scene.Children.Single(child => child.Uri!.LocalPath == elementPath); + var shape = (RectShape)element.Objects.Single(); + return (IFallback)shape.Transform.CurrentValue!; + } + + private static IEnumerable EnumerateElementGraphs(Scene scene) + { + foreach (Element element in scene.Children) + { + var objects = new List(); + CollectElementGraphObjects( + element, + new HashSet(ReferenceEqualityComparer.Instance), + objects); + + foreach (CoreObject obj in objects) + { + yield return obj; + } + } + } + + private static void CollectElementGraphObjects( + object? value, + ISet visited, + ICollection objects) + { + if (value is null or string + || (!value.GetType().IsValueType && !visited.Add(value))) + { + return; + } + + if (value is CoreObject coreObject) + { + objects.Add(coreObject); + } + + if (value is IHierarchical hierarchical) + { + foreach (IHierarchical child in hierarchical.HierarchicalChildren) + { + CollectElementGraphObjects(child, visited, objects); + } + } + + if (value is EngineObject engineObject) + { + foreach (IProperty property in engineObject.Properties) + { + CollectElementGraphObjects(property.CurrentValue, visited, objects); + if (property.Animation is IKeyFrameAnimation animation) + { + foreach (IKeyFrame keyFrame in animation.KeyFrames) + { + CollectElementGraphObjects(keyFrame.Value, visited, objects); + } + } + } + } + + if (value is IEnumerable enumerable) + { + foreach (object? item in enumerable) + { + CollectElementGraphObjects(item, visited, objects); + } + } + } + private (Uri SceneUri, string ElementPath) CreatePersistedScene() { (Uri sceneUri, string[] elementPaths) = CreatePersistedSceneWithElements("element.belm"); From c285943d00ff8e3376b3ad069480d512c721e156 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 20:12:04 +0900 Subject: [PATCH 12/35] fix(review): survive repairs, migrations, and wrapped IO across the recovery lifecycle - Serializing a Uri-less in-memory scene no longer throws; the recovered id rebuild skips gracefully. - An unresolvable keyframe Easing records a deserialization incident, so the element is byte-frozen instead of silently losing the string. - Repairing the last fallback (paste or fallback editor SetJsonString) clears the element's storage suppression, so saves resume once the graph is clean. - A repaired sidecar loading healthy with a new id migrates group references from the persisted placeholder id to the actual id. - Recovered descendant remaps persist (RecoveredDescendantIds keyed by path!originalId) and apply before duplicate detection, surviving claimant removal deterministically. - The recovery filter unwraps TargetInvocationException/AggregateException chains, so a plugin constructor's wrapped IO failure propagates. --- .../Services/ElementObjectService.cs | 6 + src/Beutl.Engine/Animation/KeyFrame.cs | 10 +- .../ProjectSystem/Scene.cs | 204 ++++++++++++++++-- .../Properties/AssemblyInfo.cs | 1 + .../Editors/AudioEffectEditorViewModel.cs | 4 +- .../ViewModels/Editors/BaseEditorViewModel.cs | 8 + .../Editors/BrushEditorViewModel.cs | 4 +- .../Editors/CoreObjectEditorViewModel.cs | 4 +- .../Editors/FilterEffectEditorViewModel.cs | 4 +- .../Editors/GeometryEditorViewModel.cs | 4 +- .../Editors/TransformEditorViewModel.cs | 4 +- .../Services/ElementObjectServiceTests.cs | 34 +++ .../MalformedElementRecoveryTests.cs | 203 +++++++++++++++++ 13 files changed, 468 insertions(+), 22 deletions(-) diff --git a/src/Beutl.Editor/Services/ElementObjectService.cs b/src/Beutl.Editor/Services/ElementObjectService.cs index c86864e9f9..a01eb394ed 100644 --- a/src/Beutl.Editor/Services/ElementObjectService.cs +++ b/src/Beutl.Editor/Services/ElementObjectService.cs @@ -80,11 +80,17 @@ public ObjectPasteOutcome PasteOver(Element element, int index, string json) try { + EngineObject previous = element.Objects[index]; EngineObject? obj = Activator.CreateInstance(type) as EngineObject; if (obj is null) return ObjectPasteOutcome.MissingType; CoreSerializer.PopulateFromJsonObject(obj, type, newJson); element.Objects[index] = obj; + if (previous is IFallback) + { + Scene.TryResumeElementPersistence(element); + } + _historyManager.Commit(CommandNames.PasteObject); return ObjectPasteOutcome.Pasted; } diff --git a/src/Beutl.Engine/Animation/KeyFrame.cs b/src/Beutl.Engine/Animation/KeyFrame.cs index 401c947f59..d1f904dc1a 100644 --- a/src/Beutl.Engine/Animation/KeyFrame.cs +++ b/src/Beutl.Engine/Animation/KeyFrame.cs @@ -54,9 +54,13 @@ public override void Deserialize(ICoreSerializationContext context) if (easingNode is JsonValue easingTypeValue && easingTypeValue.TryGetValue(out string? easingType)) { - Type type = TypeFormat.ToType(easingType) ?? typeof(LinearEasing); - - if (Activator.CreateInstance(type) is Easing easing) + Type? type = TypeFormat.ToType(easingType); + if (type is null || !type.IsAssignableTo(typeof(Easing))) + { + DeserializationIncidents.RecordFallback(); + Easing = new LinearEasing(); + } + else if (Activator.CreateInstance(type) is Easing easing) { Easing = easing; } diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 5f50877ee1..5b86d60e72 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -51,6 +51,7 @@ public enum ElementOverlapHandling public class Scene : ProjectItem, INotifyEdited { private const int MaxRecoveredIdCollisionAttempts = 1024; + private const string RecoveredDescendantIdsKey = "RecoveredDescendantIds"; private const string RecoveredElementIdsKey = "RecoveredElementIds"; private static readonly Guid s_recoveredElementNamespace = new("dfad2f76-1d04-5593-ae3b-f371fb1f42ee"); private static readonly Regex s_idPattern = new( @@ -68,7 +69,11 @@ public class Scene : ProjectItem, INotifyEdited private readonly Elements _children; private readonly HierarchicalList _layers; private readonly HierarchicalList _markers; + private readonly Dictionary _recoveredDescendantIds = new(StringComparer.Ordinal); + private readonly Dictionary _recoveredDescendantRemaps + = new(ReferenceEqualityComparer.Instance); private readonly Dictionary _recoveredElementIds = new(StringComparer.Ordinal); + private readonly Dictionary _pendingRecoveredElementIdMigrations = []; private TimeSpan _start = TimeSpan.FromMinutes(0); private TimeSpan _duration = TimeSpan.FromMinutes(5); private PixelSize _frameSize; @@ -565,6 +570,19 @@ static void Process(JsonObject jobject, string jsonName, List list) context.SetValue(RecoveredElementIdsKey, recoveredElementIds); } + if (_recoveredDescendantIds.Count > 0) + { + var recoveredDescendantIds = new JsonObject(); + foreach ((string key, Guid id) in _recoveredDescendantIds.OrderBy( + static item => item.Key, + StringComparer.Ordinal)) + { + recoveredDescendantIds[key] = id.ToString(); + } + + context.SetValue(RecoveredDescendantIdsKey, recoveredDescendantIds); + } + if (context.Mode.HasFlag(CoreSerializationMode.SaveReferencedObjects)) { foreach (Element item in Children) @@ -621,6 +639,9 @@ static void Process(Func add, JsonNode node, List list) FrameSize = new PixelSize(context.GetValue("Width"), context.GetValue("Height")); } + _pendingRecoveredElementIdMigrations.Clear(); + _recoveredDescendantIds.Clear(); + _recoveredDescendantRemaps.Clear(); _recoveredElementIds.Clear(); if (context.GetValue(RecoveredElementIdsKey) is JsonObject recoveredElementIds) { @@ -635,6 +656,19 @@ static void Process(Func add, JsonNode node, List list) } } + if (context.GetValue(RecoveredDescendantIdsKey) is JsonObject recoveredDescendantIds) + { + foreach ((string key, JsonNode? idNode) in recoveredDescendantIds) + { + if (idNode is JsonValue idValue + && idValue.TryGetValue(out string? idText) + && Guid.TryParse(idText, out Guid id)) + { + _recoveredDescendantIds[NormalizeRelativePath(key)] = id; + } + } + } + if (context.GetValue(nameof(Elements)) is { } elementsJson) { if (elementsJson is JsonObject elementsObject) @@ -676,6 +710,7 @@ static void Process(Func add, JsonNode node, List list) { var ids = group.Split(':') .Select(s => Guid.TryParse(s, out Guid id) ? id : Guid.Empty) + .Select(id => _pendingRecoveredElementIdMigrations.GetValueOrDefault(id, id)) .Where(i => i != Guid.Empty && Children.Any(e => e.Id == i)) .ToImmutableHashSet(); if (ids.Count >= 2) @@ -731,8 +766,15 @@ private void ReassignDuplicateRecoveredIds() var claimedIds = new HashSet { Guid.Empty, Id }; var seenDescendants = new HashSet(ReferenceEqualityComparer.Instance); - foreach (Element child in Children.Where( - static child => child.SuppressedStorageSource is null)) + var healthyChildren = Children + .Where(static child => child.SuppressedStorageSource is null) + .Select(child => ( + Child: child, + RelativePath: NormalizeRelativePath( + Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)))) + .OrderBy(static item => item.RelativePath, StringComparer.Ordinal) + .ToArray(); + foreach ((Element child, string _) in healthyChildren) { claimedIds.Add(child.Id); foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) @@ -742,15 +784,49 @@ private void ReassignDuplicateRecoveredIds() } } - var duplicateRecoveredDescendants = new HashSet(ReferenceEqualityComparer.Instance); - foreach ((Element child, string _) in recoveredChildren) + var persistedDescendantIds = new Dictionary( + _recoveredDescendantIds, + StringComparer.Ordinal); + _recoveredDescendantIds.Clear(); + _recoveredDescendantRemaps.Clear(); + var pendingDescendantRemaps + = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach ((Element child, string relativePath) in recoveredChildren) { foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { - if (seenDescendants.Add(descendant) && !claimedIds.Add(descendant.Id)) + if (!seenDescendants.Add(descendant)) { - duplicateRecoveredDescendants.Add(descendant); + continue; } + + Guid originalId = descendant.Id; + string remapKey = CreateRecoveredDescendantKey(relativePath, originalId); + if (persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId)) + { + if (claimedIds.Add(persistedId)) + { + descendant.Id = persistedId; + RecordRecoveredDescendantRemap(descendant, remapKey, originalId, persistedId); + continue; + } + + pendingDescendantRemaps[descendant] = (relativePath, originalId); + } + else if (!claimedIds.Add(originalId)) + { + pendingDescendantRemaps[descendant] = (relativePath, originalId); + } + } + } + + foreach ((Element child, string relativePath) in healthyChildren) + { + if (_recoveredElementIds.Remove(relativePath, out Guid placeholderId) + && placeholderId != child.Id) + { + _pendingRecoveredElementIdMigrations.TryAdd(placeholderId, child.Id); } } @@ -824,20 +900,26 @@ private void ReassignDuplicateRecoveredIds() { foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { - if (!duplicateRecoveredDescendants.Remove(descendant)) + if (!pendingDescendantRemaps.Remove( + descendant, + out (string RelativePath, Guid OriginalId) remap)) { continue; } - Guid originalId = descendant.Id; bool assigned = false; for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++) { - string candidateName = $"{relativePath}!{originalId:D}#{attempt}"; + string candidateName = $"{remap.RelativePath}!{remap.OriginalId:D}#{attempt}"; Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName); if (claimedIds.Add(candidate)) { descendant.Id = candidate; + RecordRecoveredDescendantRemap( + descendant, + CreateRecoveredDescendantKey(remap.RelativePath, remap.OriginalId), + remap.OriginalId, + candidate); assigned = true; break; } @@ -857,6 +939,20 @@ private void ReassignDuplicateRecoveredIds() } } + private void RecordRecoveredDescendantRemap( + CoreObject descendant, + string remapKey, + Guid originalId, + Guid assignedId) + { + _recoveredDescendantIds[remapKey] = assignedId; + _recoveredDescendantRemaps[descendant] = (originalId, assignedId); + if (descendant is IFallback fallback) + { + EnsureFallbackProjection(fallback); + } + } + private Element RestoreElementOrFallback(Uri uri) { int fallbackCountBefore = DeserializationIncidents.FallbackCount; @@ -880,7 +976,7 @@ private Element RestoreElementOrFallback(Uri uri) // Any non-filesystem failure is a content problem the recovery path must absorb — value // converters throw freely (e.g. FormatException from Color.Parse); filesystem failures // still propagate so a genuinely unreadable project keeps failing loudly. - catch (Exception ex) when (ex is not (IOException or UnauthorizedAccessException)) + catch (Exception ex) when (!ContainsFileSystemFailure(ex)) { // Raw bytes, not text: the sidecar must survive rehoming byte-identically even when it // holds a BOM, another encoding, or undecodable bytes. The lossy decode is only scanned @@ -911,6 +1007,51 @@ private static void MarkRecoveredElement(Element element, byte[] rawBytes, Uri u element.SuppressedStorageSource = new SuppressedStorageSource(rawBytes, uri); } + internal static bool TryResumeElementPersistence(Element element) + { + if (element.SuppressedStorageSource is null + || EnumerateSerializedGraphFallbacks(element).Any()) + { + return false; + } + + element.SuppressedStorageSource = null; + return true; + } + + private static bool ContainsFileSystemFailure(Exception exception) + { + var pending = new Stack(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + pending.Push(exception); + while (pending.TryPop(out Exception? current)) + { + if (!visited.Add(current)) + { + continue; + } + + if (current is IOException or UnauthorizedAccessException) + { + return true; + } + + if (current is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + { + pending.Push(inner); + } + } + else if (current.InnerException is { } inner) + { + pending.Push(inner); + } + } + + return false; + } + private static IEnumerable EnumerateSerializedGraphFallbacks(Element element) { return EnumerateSerializedGraphObjects(element).OfType(); @@ -1027,17 +1168,54 @@ private Guid ResolveRecoveredElementId(string rawText, Uri uri) private void RebuildRecoveredElementIds() { - string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; + if (Uri is null) + { + return; + } + + var recoveredChildren = Children.Where( + static child => child.SuppressedStorageSource is not null) + .ToArray(); + if (recoveredChildren.Length == 0 + && _recoveredElementIds.Count == 0 + && _recoveredDescendantIds.Count == 0 + && _recoveredDescendantRemaps.Count == 0) + { + return; + } + + string sceneDirectory = Path.GetDirectoryName(Uri.LocalPath)!; + var descendantRemaps = new Dictionary( + _recoveredDescendantRemaps, + ReferenceEqualityComparer.Instance); + _recoveredDescendantIds.Clear(); + _recoveredDescendantRemaps.Clear(); _recoveredElementIds.Clear(); - foreach (Element child in Children.Where( - static child => child.SuppressedStorageSource is not null)) + foreach (Element child in recoveredChildren) { string relativePath = NormalizeRelativePath( Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)); _recoveredElementIds[relativePath] = child.Id; + foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + { + if (descendantRemaps.TryGetValue( + descendant, + out (Guid OriginalId, Guid AssignedId) remap) + && descendant.Id == remap.AssignedId) + { + string remapKey = CreateRecoveredDescendantKey(relativePath, remap.OriginalId); + _recoveredDescendantIds[remapKey] = remap.AssignedId; + _recoveredDescendantRemaps[descendant] = remap; + } + } } } + private static string CreateRecoveredDescendantKey(string relativePath, Guid originalId) + { + return $"{relativePath}!{originalId:D}"; + } + private static string NormalizeRelativePath(string path) { return path.Replace('\\', '/'); diff --git a/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs b/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs index fccf3009d5..90b2374a22 100644 --- a/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs +++ b/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Beutl")] +[assembly: InternalsVisibleTo("Beutl.Editor")] [assembly: InternalsVisibleTo("Beutl.NodeGraph")] [assembly: InternalsVisibleTo("Beutl.Editor.Components")] [assembly: InternalsVisibleTo("Beutl.UnitTests")] diff --git a/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs index 228d2494ba..af21e3ad7c 100644 --- a/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs @@ -122,7 +122,9 @@ public AudioEffectEditorViewModel(IPropertyAdapter property) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + AudioEffect? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); + ResumeElementPersistenceAfterFallbackReplacement(previous); } public override void Accept(IPropertyEditorContextVisitor visitor) diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index 242d8d055e..d78d4db5ce 100644 --- a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs @@ -185,6 +185,14 @@ protected BaseEditorViewModel(IPropertyAdapter property) protected ImmutableArray GetStorables() => [_element]; + protected void ResumeElementPersistenceAfterFallbackReplacement(object? previous) + { + if (previous is IFallback && _element is not null) + { + Scene.TryResumeElementPersistence(_element); + } + } + public void Dispose() { if (!IsDisposed) diff --git a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs index d3c2c574ac..653fb304c1 100644 --- a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs @@ -151,7 +151,9 @@ private void AcceptChildren(PropertiesEditorViewModel? obj) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + Brush? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); + ResumeElementPersistenceAfterFallbackReplacement(previous); } public void UpdateBrushPreview() diff --git a/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs index 6d0683fd2a..66bc76a8fb 100644 --- a/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs @@ -152,7 +152,9 @@ public CoreObjectEditorViewModel(IPropertyAdapter property) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + T? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); + ResumeElementPersistenceAfterFallbackReplacement(previous); } public void SetNull() diff --git a/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs index 448c4b6b4f..914438b917 100644 --- a/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs @@ -280,7 +280,9 @@ public override void WriteToJson(JsonObject json) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + FilterEffect? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); + ResumeElementPersistenceAfterFallbackReplacement(previous); } protected override void Dispose(bool disposing) diff --git a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs index d24add847a..dc00aa8e5c 100644 --- a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs @@ -101,7 +101,9 @@ public GeometryEditorViewModel(IPropertyAdapter property) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + Geometry? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); + ResumeElementPersistenceAfterFallbackReplacement(previous); } public override void Accept(IPropertyEditorContextVisitor visitor) diff --git a/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs index 3ae2f08260..960ea2b2f2 100644 --- a/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs @@ -212,7 +212,9 @@ public TransformEditorViewModel(IPropertyAdapter property) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + Transform? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); + ResumeElementPersistenceAfterFallbackReplacement(previous); } public override void Accept(IPropertyEditorContextVisitor visitor) diff --git a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs index 1ea43dec4d..d5fa3de970 100644 --- a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs +++ b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs @@ -201,6 +201,40 @@ public void PasteOver_ValidJson_ReplacesAndCommits() }); } + [Test] + public void PasteOver_LastFallback_ClearsPersistenceSuppression() + { + _service.Add(_element, new FallbackEngineObject()); + _element.SuppressedStorageSource = new SuppressedStorageSource([], _element.Uri!); + string json = CoreSerializer.SerializeToJsonString(new TestEngineObject()); + + ObjectPasteOutcome outcome = _service.PasteOver(_element, 0, json); + + Assert.Multiple(() => + { + Assert.That(outcome, Is.EqualTo(ObjectPasteOutcome.Pasted)); + Assert.That(_element.SuppressedStorageSource, Is.Null); + }); + } + + [Test] + public void PasteOver_WhenAnotherFallbackRemains_KeepsPersistenceSuppression() + { + _service.Add(_element, new FallbackEngineObject()); + _service.Add(_element, new FallbackEngineObject()); + var suppression = new SuppressedStorageSource([], _element.Uri!); + _element.SuppressedStorageSource = suppression; + string json = CoreSerializer.SerializeToJsonString(new TestEngineObject()); + + ObjectPasteOutcome outcome = _service.PasteOver(_element, 0, json); + + Assert.Multiple(() => + { + Assert.That(outcome, Is.EqualTo(ObjectPasteOutcome.Pasted)); + Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); + }); + } + [Test] public void SetEnabled_NoChange_NoCommit() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 1f3f7e7bc8..864be6008f 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1,6 +1,8 @@ using System.Collections; +using System.Collections.Immutable; using System.Text.Json.Nodes; using Beutl.Animation; +using Beutl.Animation.Easings; using Beutl.Editor; using Beutl.Engine; using Beutl.Graphics.Shapes; @@ -14,6 +16,14 @@ public sealed class MalformedElementRecoveryTests { private string _root = null!; + private sealed class IOExceptionElement : Element + { + public IOExceptionElement() + { + throw new IOException("Constructor could not access its storage."); + } + } + [SetUp] public void SetUp() { @@ -32,6 +42,20 @@ public void TearDown() } } + [Test] + public void Serialize_UriLessSceneWithEmbeddedElements_Succeeds() + { + var options = new CoreSerializerOptions + { + Mode = CoreSerializationMode.Write | CoreSerializationMode.EmbedReferencedObjects, + }; + + JsonObject? json = null; + Assert.DoesNotThrow(() => json = CoreSerializer.SerializeToJsonObject(new Scene(), options)); + + Assert.That(json!["Elements"], Is.Not.Null); + } + [Test] public void Save_PreservesMalformedElementSidecarBytes() { @@ -46,6 +70,39 @@ public void Save_PreservesMalformedElementSidecarBytes() Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); } + [Test] + public void Save_UnresolvableKeyFrameEasing_PreservesSidecarBytes() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Element element = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var shape = (RectShape)element.Objects.Single(); + var animation = new KeyFrameAnimation(); + animation.KeyFrames.Add(new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = 32, + }); + shape.Width.Animation = animation; + CoreSerializer.StoreToUri(element, element.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject keyFrameJson = FindObjectWithProperty(json, nameof(KeyFrame.Easing))!; + keyFrameJson[nameof(KeyFrame.Easing)] = "[Missing.Assembly]Missing.Namespace:MissingEasing"; + File.WriteAllText(elementPath, json.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + var recoveredShape = (RectShape)recovered.Children.Single().Objects.Single(); + var recoveredAnimation = (KeyFrameAnimation)recoveredShape.Width.Animation!; + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + Assert.That(recoveredAnimation.KeyFrames.Single().Easing, Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + [Test] public void Restore_MalformedElementWithoutReadableId_UsesStableId() { @@ -155,6 +212,23 @@ public void Restore_UnresolvableGenericDiscriminator_RecoversInsteadOfFailing() }); } + [Test] + public void Restore_ElementConstructorIOException_PropagatesWrappedFailure() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + var json = new JsonObject + { + ["$type"] = TypeFormat.ToString(typeof(IOExceptionElement)), + [nameof(CoreObject.Id)] = Guid.NewGuid().ToString(), + }; + File.WriteAllText(elementPath, json.ToJsonString()); + + Exception? exception = Assert.Catch(() => CoreSerializer.RestoreFromUri(sceneUri)); + + Assert.That(exception, Is.Not.Null); + Assert.That(ContainsException(exception!), Is.True); + } + [Test] public void Restore_SceneDiscriminatorWithSelfInclude_RecoversWithoutRecursing() { @@ -529,6 +603,87 @@ public void Restore_PersistedRecoveredRemapSurvivesClaimantRemoval() }); } + [Test] + public void Restore_RepairedElementIdMigratesPersistedGroup() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("recovered.belm", "healthy.belm"); + File.WriteAllText(elementPaths[0], "{ this is not valid JSON"); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element placeholder = recoveredScene.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Element healthy = recoveredScene.Children.Single( + child => child.Uri!.LocalPath == elementPaths[1]); + recoveredScene.Groups.Add(ImmutableHashSet.Create(placeholder.Id, healthy.Id)); + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + Guid repairedId = Guid.NewGuid(); + var repaired = new Element + { + Id = repairedId, + Name = "Repaired", + Start = TimeSpan.Zero, + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPaths[0]), + }; + repaired.AddObject(new RectShape()); + CoreSerializer.StoreToUri(repaired, repaired.Uri!); + + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + + Assert.Multiple(() => + { + Assert.That(reloaded.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]).Id, + Is.EqualTo(repairedId)); + Assert.That(reloaded.Groups, Has.Count.EqualTo(1)); + Assert.That(reloaded.Groups.Single(), + Is.EqualTo(ImmutableHashSet.Create(repairedId, healthy.Id))); + }); + } + + [Test] + public void Restore_PersistedRecoveredDescendantRemapSurvivesClaimantRemoval() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("healthy.belm", "recovered.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element healthySource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + Guid claimantId = healthySource.Objects.Single().Id; + Element recoveredSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + var recoveredShapeSource = (RectShape)recoveredSource.Objects.Single(); + recoveredShapeSource.Transform.CurrentValue = new RotationTransform { Id = claimantId }; + CoreSerializer.StoreToUri(recoveredSource, recoveredSource.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[1]))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + transformJson[nameof(CoreObject.Id)] = claimantId.ToString(); + File.WriteAllText(elementPaths[1], json.ToJsonString()); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Guid remappedId = ((CoreObject)GetTransformFallback(recoveredScene, elementPaths[1])).Id; + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + recoveredScene.DeleteChild( + recoveredScene.Children.Single(child => child.Uri!.LocalPath == elementPaths[0])); + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + JsonObject persistedScene = JsonNode.Parse(File.ReadAllText(sceneUri.LocalPath))!.AsObject(); + JsonObject persistedIds = persistedScene["RecoveredDescendantIds"]!.AsObject(); + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + Guid reloadedId = ((CoreObject)GetTransformFallback(reloaded, elementPaths[1])).Id; + + Assert.Multiple(() => + { + Assert.That(remappedId, Is.Not.EqualTo(claimantId)); + Assert.That( + persistedIds[$"recovered.belm!{claimantId:D}"]!.GetValue(), + Is.EqualTo(remappedId.ToString())); + Assert.That(reloaded.Children, Has.Count.EqualTo(1)); + Assert.That(reloadedId, Is.EqualTo(remappedId)); + }); + } + [Test] public void Save_RebuildsRecoveredElementIdMapAfterRehome() { @@ -839,6 +994,54 @@ public void Restore_MalformedElementPrefersTopLevelId() return null; } + private static JsonObject? FindObjectWithProperty(JsonNode node, string propertyName) + { + if (node is JsonObject obj) + { + if (obj.ContainsKey(propertyName)) + { + return obj; + } + + foreach ((string _, JsonNode? child) in obj) + { + if (child != null && FindObjectWithProperty(child, propertyName) is { } result) + { + return result; + } + } + } + else if (node is JsonArray array) + { + foreach (JsonNode? child in array) + { + if (child != null && FindObjectWithProperty(child, propertyName) is { } result) + { + return result; + } + } + } + + return null; + } + + private static bool ContainsException(Exception exception) + where TException : Exception + { + if (exception is TException) + { + return true; + } + + if (exception is AggregateException aggregate + && aggregate.InnerExceptions.Any(ContainsException)) + { + return true; + } + + return exception.InnerException is { } inner && ContainsException(inner); + } + private static IFallback GetTransformFallback(Scene scene, string elementPath) { Element element = scene.Children.Single(child => child.Uri!.LocalPath == elementPath); From 1f6048ed1b411db41b4b5ee0a41d189b2465b8ff Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 20:54:02 +0900 Subject: [PATCH 13/35] fix(review): stabilize remap keys, undoable repairs, and richer recovery incidents - Descendant remaps key on path!originalId#occurrence, so two descendants sharing a serialized id keep distinct persisted identities without swapping across sessions. - Save As reserves unique destinations when flattened escaping sidecars share a basename. - Recovery incidents carry sceneId/sceneName so multi-scene projects stay distinguishable; the contract documents them. - Clearing storage suppression after a fallback repair is recorded in the same history transaction, so undoing the repair restores the freeze. - The recovery placeholder projection preserves an extractable top-level $type string, so incidents report the original declared type. --- .../contracts/mcp-tools.md | 2 +- .../Sessions/FileEditingSession.cs | 17 ++++ src/Beutl.AgentToolkit/Tools/SessionTools.cs | 4 + .../Services/ElementObjectService.cs | 7 +- .../ProjectSystem/Scene.cs | 87 ++++++++++++++----- .../Editors/AudioEffectEditorViewModel.cs | 1 - .../ViewModels/Editors/BaseEditorViewModel.cs | 10 ++- .../Editors/BrushEditorViewModel.cs | 2 +- .../Editors/CoreObjectEditorViewModel.cs | 1 - .../Editors/FilterEffectEditorViewModel.cs | 1 - .../Editors/GeometryEditorViewModel.cs | 1 - .../Editors/TransformEditorViewModel.cs | 1 - .../Sessions/FileEditingSessionTests.cs | 57 ++++++++++++ .../Tools/SessionToolsTests.cs | 61 +++++++++++++ .../Services/ElementObjectServiceTests.cs | 25 ++++++ .../MalformedElementRecoveryTests.cs | 49 ++++++++++- 16 files changed, 292 insertions(+), 34 deletions(-) diff --git a/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md b/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md index cddc3584b0..53129bd40f 100644 --- a/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md +++ b/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md @@ -97,7 +97,7 @@ A `session` may be **file-opened** (headless server) or **live** (in-app host bo ### `open_project` - **Input**: `{ "path": string }` (read — unrestricted). -- **Output**: `{ "session": string, "source": "File", "summary": { scenes, elements, duration, frameSize }, "warnings": string[], "recoveryIncidents": [{ "elementFile": string, "reason": "TypeNotFound" | "DeserializationFailed", "typeName": string | null, "message": string | null }] }`. `warnings` remains the presentation-oriented recovery summary; `recoveryIncidents` exposes the same incidents as stable structured data. `elementFile` is a forward-slash scene-relative path when available, otherwise the element name. `typeName` is the original serialized discriminator when available, otherwise `null`; `message` is the unmodified nullable deserialization error rather than a presentation fallback. +- **Output**: `{ "session": string, "source": "File", "summary": { scenes, elements, duration, frameSize }, "warnings": string[], "recoveryIncidents": [{ "sceneId": string, "sceneName": string, "elementFile": string, "reason": "TypeNotFound" | "DeserializationFailed", "typeName": string | null, "message": string | null }] }`. `warnings` remains the presentation-oriented recovery summary; `recoveryIncidents` exposes the same incidents as stable structured data. `sceneId` and `sceneName` identify the containing scene when different scenes contain the same relative element path. `elementFile` is a forward-slash scene-relative path when available, otherwise the element name. `typeName` is the original serialized discriminator when available, otherwise `null`; `message` is the unmodified nullable deserialization error rather than a presentation fallback. - **Errors**: `media_not_found`, `schema_version_mismatch` (project written by an incompatible schema — surfaced, not silently dropped, per FR-031/FR-013). ### `attach_active_editor` *(in-app host only)* diff --git a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs index 3f087003ba..ec31427d1e 100644 --- a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs +++ b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs @@ -158,6 +158,8 @@ private void SetProjectPathCore(string projectPath) : null; scene.Uri = new Uri(scenePath); string sceneDirectory = Path.GetDirectoryName(scenePath)!; + var assignedElementPaths = new HashSet( + StringComparer.FromComparison(PathComparison.ForCurrentPlatform)); foreach (Element element in scene.Children) { // Keep each sidecar's relative path across Save As: a recovered element's stable @@ -177,6 +179,7 @@ private void SetProjectPathCore(string projectPath) resolvedPath = Path.Combine(sceneRoot, Path.GetFileName(previousUri.LocalPath)); } + resolvedPath = ReserveUniqueElementPath(resolvedPath, assignedElementPaths); element.Uri = new Uri(resolvedPath); } else @@ -191,6 +194,20 @@ private void SetProjectPathCore(string projectPath) AcceptExternalStamp(); } + private static string ReserveUniqueElementPath(string path, ISet assignedPaths) + { + string candidate = path; + string directory = Path.GetDirectoryName(path)!; + string name = Path.GetFileNameWithoutExtension(path); + string extension = Path.GetExtension(path); + for (int suffix = 2; !assignedPaths.Add(candidate); suffix++) + { + candidate = Path.Combine(directory, $"{name}-{suffix}{extension}"); + } + + return candidate; + } + // Save As rehomes the project/scene/element URIs and then writes; run capture → rehome → save — // and the restore on failure — as one critical section so a concurrent Invoke cannot mutate the // graph between the steps or observe a half-captured/half-restored URI set. diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 9da21d8806..8f54b54b30 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -20,6 +20,8 @@ public sealed record SceneSummary(string SceneId, string Name, int Width, int He public sealed record SessionSummary(IReadOnlyList Scenes); public sealed record RecoveryIncident( + string SceneId, + string SceneName, string ElementFile, string Reason, string? TypeName, @@ -129,6 +131,8 @@ private static DeserializationWarningCollection CollectDeserializationWarnings(P } incidents.Add(new RecoveryIncident( + scene.Id.ToString(), + scene.Name, elementFile, fallback.Reason.ToString(), typeName, diff --git a/src/Beutl.Editor/Services/ElementObjectService.cs b/src/Beutl.Editor/Services/ElementObjectService.cs index a01eb394ed..d03c4add65 100644 --- a/src/Beutl.Editor/Services/ElementObjectService.cs +++ b/src/Beutl.Editor/Services/ElementObjectService.cs @@ -86,9 +86,12 @@ public ObjectPasteOutcome PasteOver(Element element, int index, string json) CoreSerializer.PopulateFromJsonObject(obj, type, newJson); element.Objects[index] = obj; - if (previous is IFallback) + if (previous is IFallback + && Scene.TryResumeElementPersistence(element) is { } suppression) { - Scene.TryResumeElementPersistence(element); + _historyManager.Record( + () => element.SuppressedStorageSource = null, + () => element.SuppressedStorageSource = suppression); } _historyManager.Commit(CommandNames.PasteObject); diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 5b86d60e72..0046087cae 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -57,6 +57,9 @@ public class Scene : ProjectItem, INotifyEdited private static readonly Regex s_idPattern = new( "\"Id\"\\s*:\\s*\"(?[0-9a-fA-F-]{36})\"", RegexOptions.CultureInvariant); + private static readonly Regex s_typePattern = new( + "\"\\$type\"\\s*:\\s*(?\"(?:\\\\.|[^\"\\\\])*\")", + RegexOptions.CultureInvariant); public static readonly CoreProperty FrameSizeProperty; public static readonly CoreProperty ChildrenProperty; public static readonly CoreProperty StartProperty; @@ -790,10 +793,11 @@ private void ReassignDuplicateRecoveredIds() _recoveredDescendantIds.Clear(); _recoveredDescendantRemaps.Clear(); var pendingDescendantRemaps - = new Dictionary( + = new Dictionary( ReferenceEqualityComparer.Instance); foreach ((Element child, string relativePath) in recoveredChildren) { + var occurrences = new Dictionary(); foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { if (!seenDescendants.Add(descendant)) @@ -802,7 +806,9 @@ var pendingDescendantRemaps } Guid originalId = descendant.Id; - string remapKey = CreateRecoveredDescendantKey(relativePath, originalId); + int occurrence = occurrences.GetValueOrDefault(originalId); + occurrences[originalId] = occurrence + 1; + string remapKey = CreateRecoveredDescendantKey(relativePath, originalId, occurrence); if (persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId)) { if (claimedIds.Add(persistedId)) @@ -812,11 +818,11 @@ var pendingDescendantRemaps continue; } - pendingDescendantRemaps[descendant] = (relativePath, originalId); + pendingDescendantRemaps[descendant] = (remapKey, originalId); } else if (!claimedIds.Add(originalId)) { - pendingDescendantRemaps[descendant] = (relativePath, originalId); + pendingDescendantRemaps[descendant] = (remapKey, originalId); } } } @@ -902,7 +908,7 @@ var pendingDescendantRemaps { if (!pendingDescendantRemaps.Remove( descendant, - out (string RelativePath, Guid OriginalId) remap)) + out (string RemapKey, Guid OriginalId) remap)) { continue; } @@ -910,14 +916,16 @@ var pendingDescendantRemaps bool assigned = false; for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++) { - string candidateName = $"{remap.RelativePath}!{remap.OriginalId:D}#{attempt}"; + string candidateName = attempt == 0 + ? remap.RemapKey + : $"{remap.RemapKey}#{attempt}"; Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName); if (claimedIds.Add(candidate)) { descendant.Id = candidate; RecordRecoveredDescendantRemap( descendant, - CreateRecoveredDescendantKey(remap.RelativePath, remap.OriginalId), + remap.RemapKey, remap.OriginalId, candidate); assigned = true; @@ -980,11 +988,12 @@ private Element RestoreElementOrFallback(Uri uri) { // Raw bytes, not text: the sidecar must survive rehoming byte-identically even when it // holds a BOM, another encoding, or undecodable bytes. The lossy decode is only scanned - // for a top-level Id. + // for top-level recovery metadata. byte[] rawBytes = File.ReadAllBytes(uri.LocalPath); + string rawText = Encoding.UTF8.GetString(rawBytes); var element = new Element { - Id = ResolveRecoveredElementId(Encoding.UTF8.GetString(rawBytes), uri), + Id = ResolveRecoveredElementId(rawText, uri), Name = Path.GetFileNameWithoutExtension(uri.LocalPath), Uri = uri, IsEnabled = false, @@ -995,7 +1004,7 @@ private Element RestoreElementOrFallback(Uri uri) Reason = FallbackReason.DeserializationFailed, ErrorMessage = $"{ex.GetType().Name}: {ex.Message}", }; - fallback.Json = CreateFallbackProjection(fallback); + fallback.Json = CreateFallbackProjection(fallback, TryGetTopLevelTypeName(rawText)); element.AddObject(fallback); MarkRecoveredElement(element, rawBytes, uri); return element; @@ -1007,16 +1016,16 @@ private static void MarkRecoveredElement(Element element, byte[] rawBytes, Uri u element.SuppressedStorageSource = new SuppressedStorageSource(rawBytes, uri); } - internal static bool TryResumeElementPersistence(Element element) + internal static SuppressedStorageSource? TryResumeElementPersistence(Element element) { - if (element.SuppressedStorageSource is null + if (element.SuppressedStorageSource is not { } source || EnumerateSerializedGraphFallbacks(element).Any()) { - return false; + return null; } element.SuppressedStorageSource = null; - return true; + return source; } private static bool ContainsFileSystemFailure(Exception exception) @@ -1137,23 +1146,49 @@ private static void EnsureFallbackProjection(IFallback fallback) fallback.Json = json; } - private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback) + private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback, string? typeName = null) { var json = new JsonObject { [nameof(CoreObject.Id)] = fallback.Id.ToString(), [nameof(CoreObject.Name)] = fallback.Name, }; - json.WriteDiscriminator(typeof(FallbackEngineObject)); + if (typeName is not null) + { + json["$type"] = typeName; + } + else + { + json.WriteDiscriminator(typeof(FallbackEngineObject)); + } + return json; } + private static string? TryGetTopLevelTypeName(string rawText) + { + Match? match = FindTopLevelMatch(rawText, s_typePattern.Matches(rawText)); + if (match is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(match.Groups["type"].Value); + } + catch (JsonException) + { + return null; + } + } + private Guid ResolveRecoveredElementId(string rawText, Uri uri) { // Only a top-level Id may name the element: a nested object's or quoted Id would collide // with live objects, so anything else falls through to the deterministic filename Guid. MatchCollection matches = s_idPattern.Matches(rawText); - Match? topLevelMatch = FindTopLevelIdMatch(rawText, matches); + Match? topLevelMatch = FindTopLevelMatch(rawText, matches); if (topLevelMatch != null && Guid.TryParse(topLevelMatch.Groups["id"].Value, out Guid topLevelId) && topLevelId != Guid.Empty) @@ -1196,14 +1231,22 @@ private void RebuildRecoveredElementIds() string relativePath = NormalizeRelativePath( Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)); _recoveredElementIds[relativePath] = child.Id; + var occurrences = new Dictionary(); foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { + Guid originalId = descendantRemaps.TryGetValue( + descendant, + out (Guid OriginalId, Guid AssignedId) remap) + ? remap.OriginalId + : descendant.Id; + int occurrence = occurrences.GetValueOrDefault(originalId); + occurrences[originalId] = occurrence + 1; if (descendantRemaps.TryGetValue( descendant, - out (Guid OriginalId, Guid AssignedId) remap) + out remap) && descendant.Id == remap.AssignedId) { - string remapKey = CreateRecoveredDescendantKey(relativePath, remap.OriginalId); + string remapKey = CreateRecoveredDescendantKey(relativePath, remap.OriginalId, occurrence); _recoveredDescendantIds[remapKey] = remap.AssignedId; _recoveredDescendantRemaps[descendant] = remap; } @@ -1211,9 +1254,9 @@ private void RebuildRecoveredElementIds() } } - private static string CreateRecoveredDescendantKey(string relativePath, Guid originalId) + private static string CreateRecoveredDescendantKey(string relativePath, Guid originalId, int occurrence) { - return $"{relativePath}!{originalId:D}"; + return $"{relativePath}!{originalId:D}#{occurrence}"; } private static string NormalizeRelativePath(string path) @@ -1221,7 +1264,7 @@ private static string NormalizeRelativePath(string path) return path.Replace('\\', '/'); } - private static Match? FindTopLevelIdMatch(string rawText, MatchCollection matches) + private static Match? FindTopLevelMatch(string rawText, MatchCollection matches) { int matchIndex = 0; int objectDepth = 0; diff --git a/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs index af21e3ad7c..4c83f3f30b 100644 --- a/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs @@ -124,7 +124,6 @@ public void SetJsonString(string? str) { AudioEffect? previous = Value.Value; SetValue(previous, FallbackHelper.DeserializeInstance(str)); - ResumeElementPersistenceAfterFallbackReplacement(previous); } public override void Accept(IPropertyEditorContextVisitor visitor) diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index d78d4db5ce..5dd7d96ad4 100644 --- a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs @@ -187,9 +187,14 @@ protected BaseEditorViewModel(IPropertyAdapter property) protected void ResumeElementPersistenceAfterFallbackReplacement(object? previous) { - if (previous is IFallback && _element is not null) + if (previous is IFallback + && _element is not null + && Scene.TryResumeElementPersistence(_element) is { } suppression) { - Scene.TryResumeElementPersistence(_element); + Element element = _element; + this.GetRequiredService().Record( + () => element.SuppressedStorageSource = null, + () => element.SuppressedStorageSource = suppression); } } @@ -564,6 +569,7 @@ public void SetValue(T? oldValue, T? newValue) prop.SetValue(newValue); } + ResumeElementPersistenceAfterFallbackReplacement(oldValue); Commit(); } } diff --git a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs index 653fb304c1..3699f5b7fc 100644 --- a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs @@ -153,7 +153,6 @@ public void SetJsonString(string? str) { Brush? previous = Value.Value; SetValue(previous, FallbackHelper.DeserializeInstance(str)); - ResumeElementPersistenceAfterFallbackReplacement(previous); } public void UpdateBrushPreview() @@ -174,6 +173,7 @@ public void SetValue(Brush? oldValue, Brush? newValue) if (!EqualityComparer.Default.Equals(oldValue, newValue)) { PropertyAdapter.SetValue(newValue); + ResumeElementPersistenceAfterFallbackReplacement(oldValue); Commit(); } } diff --git a/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs index 66bc76a8fb..6c072a746b 100644 --- a/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs @@ -154,7 +154,6 @@ public void SetJsonString(string? str) { T? previous = Value.Value; SetValue(previous, FallbackHelper.DeserializeInstance(str)); - ResumeElementPersistenceAfterFallbackReplacement(previous); } public void SetNull() diff --git a/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs index 914438b917..7a9bf365a6 100644 --- a/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs @@ -282,7 +282,6 @@ public void SetJsonString(string? str) { FilterEffect? previous = Value.Value; SetValue(previous, FallbackHelper.DeserializeInstance(str)); - ResumeElementPersistenceAfterFallbackReplacement(previous); } protected override void Dispose(bool disposing) diff --git a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs index dc00aa8e5c..002f92a025 100644 --- a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs @@ -103,7 +103,6 @@ public void SetJsonString(string? str) { Geometry? previous = Value.Value; SetValue(previous, FallbackHelper.DeserializeInstance(str)); - ResumeElementPersistenceAfterFallbackReplacement(previous); } public override void Accept(IPropertyEditorContextVisitor visitor) diff --git a/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs index 960ea2b2f2..e3613040da 100644 --- a/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs @@ -214,7 +214,6 @@ public void SetJsonString(string? str) { Transform? previous = Value.Value; SetValue(previous, FallbackHelper.DeserializeInstance(str)); - ResumeElementPersistenceAfterFallbackReplacement(previous); } public override void Accept(IPropertyEditorContextVisitor visitor) diff --git a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs index cf0a1dfde7..8d6b59877e 100644 --- a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs @@ -140,6 +140,63 @@ public void SetProjectPath_keeps_element_sidecar_subpaths_starting_with_two_dots Is.EqualTo(relativePath)); } + [Test] + public void SaveAs_uniquifies_escaping_same_named_recovered_sidecars() + { + string root = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string projectPath = Path.Combine(root, "demo.bep"); + using var source = new FileSessionSource(); + FileEditingSession session = source.CreateProject(new ProjectCreateOptions( + projectPath, 640, 360, 30, TimeSpan.FromSeconds(2), Name: "demo")); + Scene scene = session.Project.Items.OfType().Single(); + string sceneDirectory = Path.GetDirectoryName(scene.Uri!.LocalPath)!; + string firstPath = Path.Combine(sceneDirectory, "first", "clip.belm"); + string secondPath = Path.Combine(sceneDirectory, "second", "clip.belm"); + scene.Children.Add(new Element + { + Name = "First", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(firstPath), + }); + scene.Children.Add(new Element + { + Name = "Second", + Start = TimeSpan.FromSeconds(1), + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(secondPath), + }); + session.Save(skipConflictCheck: true); + + byte[] firstBytes = "{\"$type\":\"[Missing]Example:First\",\"Id\":\"11111111-1111-1111-1111-111111111111\"}"u8.ToArray(); + byte[] secondBytes = "{\"$type\":\"[Missing]Example:Second\",\"Id\":\"22222222-2222-2222-2222-222222222222\"}"u8.ToArray(); + File.WriteAllBytes(firstPath, firstBytes); + File.WriteAllBytes(secondPath, secondBytes); + + FileEditingSession recovered = source.OpenProject(projectPath); + Scene recoveredScene = recovered.Project.Items.OfType().Single(); + Element first = recoveredScene.Children.Single(element => element.Uri!.LocalPath == firstPath); + Element second = recoveredScene.Children.Single(element => element.Uri!.LocalPath == secondPath); + string firstOutsidePath = Path.Combine(root, "outside-first", "clip.belm"); + string secondOutsidePath = Path.Combine(root, "outside-second", "clip.belm"); + first.Uri = new Uri(firstOutsidePath); + second.Uri = new Uri(secondOutsidePath); + + recovered.SaveAs(Path.Combine(root, "copy.bep"), skipConflictCheck: true); + + Assert.Multiple(() => + { + Assert.That(first.Uri!.LocalPath, Is.Not.EqualTo(second.Uri!.LocalPath)); + Assert.That( + new[] { Path.GetFileName(first.Uri.LocalPath), Path.GetFileName(second.Uri.LocalPath) }, + Is.EquivalentTo(new[] { "clip.belm", "clip-2.belm" })); + Assert.That(File.Exists(first.Uri.LocalPath), Is.True); + Assert.That(File.Exists(second.Uri.LocalPath), Is.True); + Assert.That(File.ReadAllBytes(first.Uri.LocalPath), Is.EqualTo(firstBytes)); + Assert.That(File.ReadAllBytes(second.Uri.LocalPath), Is.EqualTo(secondBytes)); + }); + } + [Test] public void Failed_plain_save_restores_the_original_uri_state() { diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index 1e726708e3..73605d3069 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -302,6 +302,67 @@ public async Task Open_project_warning_paths_distinguish_same_named_sidecars_in_ }); } + [Test] + public async Task Open_project_incidents_distinguish_same_named_sidecars_across_scenes_and_keep_top_level_type() + { + const string MissingType = "[Missing.Assembly]Missing.Namespace:MissingElement"; + string root = CreateWorkspace(); + string projectPath = Path.Combine(root, "duplicate-sidecars-across-scenes.bep"); + Project project = ProjectOperations.CreateProject(new ProjectCreateOptions( + projectPath, + 64, + 64, + 30, + TimeSpan.FromSeconds(1), + Name: "First scene")); + Scene firstScene = project.Items.OfType().Single(); + Scene secondScene = ProjectOperations.AddScene(project, new SceneCreateOptions( + 64, + 64, + TimeSpan.Zero, + TimeSpan.FromSeconds(1), + Name: "Second scene")); + Scene[] scenes = [firstScene, secondScene]; + foreach (Scene scene in scenes) + { + scene.Children.Add(new Element + { + Name = "Clip", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(Path.GetDirectoryName(scene.Uri!.LocalPath)!, "clip.belm")), + }); + } + + ProjectOperations.Save(project); + foreach (Scene scene in scenes) + { + Element element = scene.Children.Single(); + File.WriteAllText( + element.Uri!.LocalPath, + $$"""{"$type":"{{MissingType}}","Id":"{{element.Id}}","Name":"Clip"}"""); + } + + var manager = new AgentSessionManager(); + using var source = new FileSessionSource(); + SessionTools sessionTools = CreateSessionTools(source, manager, root); + + ToolResult opened = await sessionTools.OpenProject(projectPath); + + Assert.Multiple(() => + { + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); + Assert.That(opened.Value!.RecoveryIncidents, Has.Count.EqualTo(2)); + Assert.That(opened.Value.RecoveryIncidents.Select(static incident => incident.ElementFile), + Is.All.EqualTo("clip.belm")); + Assert.That(opened.Value.RecoveryIncidents.Select(static incident => incident.SceneId), + Is.EquivalentTo(scenes.Select(static scene => scene.Id.ToString()))); + Assert.That(opened.Value.RecoveryIncidents.Select(static incident => incident.SceneName), + Is.EquivalentTo(new[] { "First scene", "Second scene" })); + Assert.That(opened.Value.RecoveryIncidents.Select(static incident => incident.TypeName), + Is.All.EqualTo(MissingType)); + }); + } + [Test] public async Task Apply_edit_can_rename_healthy_element_while_malformed_element_is_recovered() { diff --git a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs index d5fa3de970..47007ab310 100644 --- a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs +++ b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs @@ -217,6 +217,31 @@ public void PasteOver_LastFallback_ClearsPersistenceSuppression() }); } + [Test] + public void PasteOver_LastFallback_UndoRestoresPersistenceSuppressionAndPreservesSidecar() + { + var fallback = new FallbackEngineObject(); + _service.Add(_element, fallback); + byte[] originalBytes = "{ preserved fallback bytes"u8.ToArray(); + File.WriteAllBytes(_element.Uri!.LocalPath, originalBytes); + var suppression = new SuppressedStorageSource(originalBytes, _element.Uri); + _element.SuppressedStorageSource = suppression; + string json = CoreSerializer.SerializeToJsonString(new TestEngineObject()); + + ObjectPasteOutcome outcome = _service.PasteOver(_element, 0, json); + bool undone = _history.Undo(); + CoreSerializer.StoreToUri(_element, _element.Uri); + + Assert.Multiple(() => + { + Assert.That(outcome, Is.EqualTo(ObjectPasteOutcome.Pasted)); + Assert.That(undone, Is.True); + Assert.That(_element.Objects.Single(), Is.SameAs(fallback)); + Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); + Assert.That(File.ReadAllBytes(_element.Uri.LocalPath), Is.EqualTo(originalBytes)); + }); + } + [Test] public void PasteOver_WhenAnotherFallbackRemains_KeepsPersistenceSuppression() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 864be6008f..69d1fad7ce 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -677,13 +677,60 @@ public void Restore_PersistedRecoveredDescendantRemapSurvivesClaimantRemoval() { Assert.That(remappedId, Is.Not.EqualTo(claimantId)); Assert.That( - persistedIds[$"recovered.belm!{claimantId:D}"]!.GetValue(), + persistedIds[$"recovered.belm!{claimantId:D}#0"]!.GetValue(), Is.EqualTo(remappedId.ToString())); Assert.That(reloaded.Children, Has.Count.EqualTo(1)); Assert.That(reloadedId, Is.EqualTo(remappedId)); }); } + [Test] + public void Restore_RecoveredDescendantsSharingSerializedIdKeepOccurrenceStableRemaps() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("healthy.belm", "recovered.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element healthySource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + Guid claimantId = healthySource.Objects.Single().Id; + Element recoveredSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + recoveredSource.AddObject(new RectShape()); + CoreSerializer.StoreToUri(recoveredSource, recoveredSource.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[1]))!.AsObject(); + JsonArray objects = json[nameof(Element.Objects)]!.AsArray(); + foreach (JsonObject obj in objects.OfType()) + { + obj[nameof(CoreObject.Id)] = claimantId.ToString(); + } + + objects[1]!.AsObject()["$type"] = "[Beutl.Engine]Beutl.Graphics.Shapes:DoesNotExist"; + File.WriteAllText(elementPaths[1], json.ToJsonString()); + + Scene firstLoad = CoreSerializer.RestoreFromUri(sceneUri); + Element firstRecovered = firstLoad.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + Guid[] firstAssignedIds = firstRecovered.Objects.Select(static obj => obj.Id).ToArray(); + Guid[] firstGraphIds = EnumerateElementGraphs(firstLoad).Select(static obj => obj.Id).ToArray(); + CoreSerializer.StoreToUri(firstLoad, sceneUri); + + JsonObject persistedScene = JsonNode.Parse(File.ReadAllText(sceneUri.LocalPath))!.AsObject(); + JsonObject persistedIds = persistedScene["RecoveredDescendantIds"]!.AsObject(); + Scene secondLoad = CoreSerializer.RestoreFromUri(sceneUri); + Element secondRecovered = secondLoad.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + Guid[] secondAssignedIds = secondRecovered.Objects.Select(static obj => obj.Id).ToArray(); + Guid[] secondGraphIds = EnumerateElementGraphs(secondLoad).Select(static obj => obj.Id).ToArray(); + + Assert.Multiple(() => + { + Assert.That(firstGraphIds, Is.Unique); + Assert.That(secondGraphIds, Is.Unique); + Assert.That(firstAssignedIds, Has.Length.EqualTo(2)); + Assert.That(firstAssignedIds, Does.Not.Contain(claimantId)); + Assert.That(secondAssignedIds, Is.EqualTo(firstAssignedIds)); + Assert.That(persistedIds.ContainsKey($"recovered.belm!{claimantId:D}#0"), Is.True); + Assert.That(persistedIds.ContainsKey($"recovered.belm!{claimantId:D}#1"), Is.True); + }); + } + [Test] public void Save_RebuildsRecoveredElementIdMapAfterRehome() { From ef21083e8356d78e26710d7bbc1a57b26ecb4deb Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 21:41:52 +0900 Subject: [PATCH 14/35] fix(review): keep lossy elements frozen, surface incident-only recoveries, and migrate references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SuppressedStorageSource tracks non-fallback incidents (a lossy value substitution such as an unresolvable easing); repairing the last fallback no longer unfreezes such elements — only an external file repair can. - open_project reports elements frozen solely by the incident tally (no fallback object) as structured incidents and warnings. - Placeholder-id migrations now also rewrite IReference-typed property values, not just Groups, so healthy references follow a repaired element's new id. - Descendant remap occurrences are anchored at load time and reused verbatim when rebuilding the persisted map, so in-editor mutations cannot shift which key maps to which frozen-file descendant. --- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 15 +++ src/Beutl.Core/Properties/AssemblyInfo.cs | 1 + .../Serialization/SuppressedStorageSource.cs | 5 +- .../ProjectSystem/Scene.cs | 92 ++++++++++++++----- .../Tools/SessionToolsTests.cs | 65 +++++++++++++ .../Services/ElementObjectServiceTests.cs | 21 +++++ .../MalformedElementRecoveryTests.cs | 80 +++++++++++++++- 7 files changed, 250 insertions(+), 29 deletions(-) diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 8f54b54b30..d9a4900bf6 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -143,6 +143,21 @@ private static DeserializationWarningCollection CollectDeserializationWarnings(P warnings.Add( $"Element file '{elementFile}' contains content that could not be deserialized: {error}"); } + + if (fallbacks.Count == 0 + && element.SuppressedStorageSource is { HasNonFallbackIncidents: true }) + { + const string message + = "A value was replaced during load, and the original element file is preserved."; + incidents.Add(new RecoveryIncident( + scene.Id.ToString(), + scene.Name, + elementFile, + nameof(FallbackReason.DeserializationFailed), + null, + message)); + warnings.Add($"Element file '{elementFile}' could not be loaded without replacement: {message}"); + } } } diff --git a/src/Beutl.Core/Properties/AssemblyInfo.cs b/src/Beutl.Core/Properties/AssemblyInfo.cs index ee38697b06..f3581d18a0 100644 --- a/src/Beutl.Core/Properties/AssemblyInfo.cs +++ b/src/Beutl.Core/Properties/AssemblyInfo.cs @@ -13,3 +13,4 @@ [assembly: InternalsVisibleTo("Beutl.FFmpegWorker")] [assembly: InternalsVisibleTo("Beutl.FFmpegWorker.Tests")] [assembly: InternalsVisibleTo("Beutl.AgentToolkit.Mcp")] +[assembly: InternalsVisibleTo("Beutl.AgentToolkit")] diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs index e03f66d1fc..2efe55c4b6 100644 --- a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs +++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs @@ -5,4 +5,7 @@ /// location those bytes came from. The source location is never rewritten; any other location /// receives a verbatim copy. /// -internal sealed record SuppressedStorageSource(byte[] RawBytes, Uri SourceUri); +internal sealed record SuppressedStorageSource( + byte[] RawBytes, + Uri SourceUri, + bool HasNonFallbackIncidents = false); diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 0046087cae..55f4b1f414 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -73,7 +73,7 @@ public class Scene : ProjectItem, INotifyEdited private readonly HierarchicalList _layers; private readonly HierarchicalList _markers; private readonly Dictionary _recoveredDescendantIds = new(StringComparer.Ordinal); - private readonly Dictionary _recoveredDescendantRemaps + private readonly Dictionary _recoveredDescendantRemaps = new(ReferenceEqualityComparer.Instance); private readonly Dictionary _recoveredElementIds = new(StringComparer.Ordinal); private readonly Dictionary _pendingRecoveredElementIdMigrations = []; @@ -749,6 +749,7 @@ private void SyncronizeFiles(IEnumerable pathToElement) Children.AddRange(urisAdd.AsParallel().Select(RestoreElementOrFallback)); ReassignDuplicateRecoveredIds(); + MigrateRecoveredElementReferences(); activity?.SetTag("addCount", urisAdd.Length); activity?.SetTag("removeCount", elementsRemove.Length); @@ -793,7 +794,7 @@ private void ReassignDuplicateRecoveredIds() _recoveredDescendantIds.Clear(); _recoveredDescendantRemaps.Clear(); var pendingDescendantRemaps - = new Dictionary( + = new Dictionary( ReferenceEqualityComparer.Instance); foreach ((Element child, string relativePath) in recoveredChildren) { @@ -814,15 +815,20 @@ var pendingDescendantRemaps if (claimedIds.Add(persistedId)) { descendant.Id = persistedId; - RecordRecoveredDescendantRemap(descendant, remapKey, originalId, persistedId); + RecordRecoveredDescendantRemap( + descendant, + remapKey, + originalId, + persistedId, + occurrence); continue; } - pendingDescendantRemaps[descendant] = (remapKey, originalId); + pendingDescendantRemaps[descendant] = (remapKey, originalId, occurrence); } else if (!claimedIds.Add(originalId)) { - pendingDescendantRemaps[descendant] = (remapKey, originalId); + pendingDescendantRemaps[descendant] = (remapKey, originalId, occurrence); } } } @@ -908,7 +914,7 @@ var pendingDescendantRemaps { if (!pendingDescendantRemaps.Remove( descendant, - out (string RemapKey, Guid OriginalId) remap)) + out (string RemapKey, Guid OriginalId, int Occurrence) remap)) { continue; } @@ -927,7 +933,8 @@ var pendingDescendantRemaps descendant, remap.RemapKey, remap.OriginalId, - candidate); + candidate, + remap.Occurrence); assigned = true; break; } @@ -951,10 +958,11 @@ private void RecordRecoveredDescendantRemap( CoreObject descendant, string remapKey, Guid originalId, - Guid assignedId) + Guid assignedId, + int occurrence) { _recoveredDescendantIds[remapKey] = assignedId; - _recoveredDescendantRemaps[descendant] = (originalId, assignedId); + _recoveredDescendantRemaps[descendant] = (originalId, assignedId, occurrence); if (descendant is IFallback fallback) { EnsureFallbackProjection(fallback); @@ -968,15 +976,20 @@ private Element RestoreElementOrFallback(Uri uri) { Element element = CoreSerializer.RestoreFromUri(uri); IFallback[] fallbacks = EnumerateSerializedGraphFallbacks(element).ToArray(); + int incidentCount = DeserializationIncidents.FallbackCount - fallbackCountBefore; - if (fallbacks.Length > 0 || DeserializationIncidents.FallbackCount != fallbackCountBefore) + if (fallbacks.Length > 0 || incidentCount > 0) { foreach (IFallback fallback in fallbacks) { EnsureFallbackProjection(fallback); } - MarkRecoveredElement(element, File.ReadAllBytes(uri.LocalPath), uri); + MarkRecoveredElement( + element, + File.ReadAllBytes(uri.LocalPath), + uri, + incidentCount > fallbacks.Length); } return element; @@ -1011,14 +1024,22 @@ private Element RestoreElementOrFallback(Uri uri) } } - private static void MarkRecoveredElement(Element element, byte[] rawBytes, Uri uri) + private static void MarkRecoveredElement( + Element element, + byte[] rawBytes, + Uri uri, + bool hasNonFallbackIncidents = false) { - element.SuppressedStorageSource = new SuppressedStorageSource(rawBytes, uri); + element.SuppressedStorageSource = new SuppressedStorageSource( + rawBytes, + uri, + hasNonFallbackIncidents); } internal static SuppressedStorageSource? TryResumeElementPersistence(Element element) { if (element.SuppressedStorageSource is not { } source + || source.HasNonFallbackIncidents || EnumerateSerializedGraphFallbacks(element).Any()) { return null; @@ -1073,6 +1094,34 @@ private static IEnumerable EnumerateSerializedGraphDescendants(Eleme .Where(value => !ReferenceEquals(value, element)); } + private void MigrateRecoveredElementReferences() + { + if (_pendingRecoveredElementIdMigrations.Count == 0) + { + return; + } + + foreach (Element element in Children) + { + foreach (EngineObject engineObject in EnumerateSerializedGraphObjects(element).OfType()) + { + foreach (IProperty property in engineObject.Properties) + { + if (property.CurrentValue is not IReference reference + || !_pendingRecoveredElementIdMigrations.TryGetValue(reference.Id, out Guid migratedId)) + { + continue; + } + + Element? target = Children.FirstOrDefault(child => child.Id == migratedId); + property.CurrentValue = target is not null && reference.ObjectType.IsInstanceOfType(target) + ? reference.Resolved(target) + : Activator.CreateInstance(reference.GetType(), migratedId)!; + } + } + } + } + private static IEnumerable EnumerateSerializedGraphObjects(Element element) { var objects = new List(); @@ -1220,7 +1269,7 @@ private void RebuildRecoveredElementIds() } string sceneDirectory = Path.GetDirectoryName(Uri.LocalPath)!; - var descendantRemaps = new Dictionary( + var descendantRemaps = new Dictionary( _recoveredDescendantRemaps, ReferenceEqualityComparer.Instance); _recoveredDescendantIds.Clear(); @@ -1231,22 +1280,17 @@ private void RebuildRecoveredElementIds() string relativePath = NormalizeRelativePath( Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)); _recoveredElementIds[relativePath] = child.Id; - var occurrences = new Dictionary(); foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { - Guid originalId = descendantRemaps.TryGetValue( - descendant, - out (Guid OriginalId, Guid AssignedId) remap) - ? remap.OriginalId - : descendant.Id; - int occurrence = occurrences.GetValueOrDefault(originalId); - occurrences[originalId] = occurrence + 1; if (descendantRemaps.TryGetValue( descendant, - out remap) + out (Guid OriginalId, Guid AssignedId, int Occurrence) remap) && descendant.Id == remap.AssignedId) { - string remapKey = CreateRecoveredDescendantKey(relativePath, remap.OriginalId, occurrence); + string remapKey = CreateRecoveredDescendantKey( + relativePath, + remap.OriginalId, + remap.Occurrence); _recoveredDescendantIds[remapKey] = remap.AssignedId; _recoveredDescendantRemaps[descendant] = remap; } diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index 73605d3069..832af9accf 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -162,6 +162,71 @@ public async Task Open_project_warns_about_fallback_in_animation_keyframe_value( }); } + [Test] + public async Task Open_project_warns_about_unresolvable_keyframe_easing_without_fallback() + { + string root = CreateWorkspace(); + string projectPath = Path.Combine(root, "easing-replacement.bep"); + Project project = ProjectOperations.CreateProject(new ProjectCreateOptions( + projectPath, + 64, + 64, + 30, + TimeSpan.FromSeconds(1))); + Scene scene = project.Items.OfType().Single(); + var shape = new RectShape(); + var animation = new KeyFrameAnimation(); + animation.KeyFrames.Add(new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = 32, + }); + shape.Width.Animation = animation; + var element = new Element + { + Name = "Easing replacement", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine( + Path.GetDirectoryName(scene.Uri!.LocalPath)!, + "easing-replacement.belm")), + }; + element.AddObject(shape); + scene.Children.Add(element); + ProjectOperations.Save(project); + + string elementPath = element.Uri!.LocalPath; + string elementRelativePath = Path.GetRelativePath( + Path.GetDirectoryName(scene.Uri!.LocalPath)!, + elementPath).Replace('\\', '/'); + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject objectJson = elementJson[nameof(Element.Objects)]!.AsArray()[0]!.AsObject(); + JsonObject animationJson = objectJson["Animations"]![nameof(RectShape.Width)]!.AsObject(); + JsonObject keyFrameJson = animationJson[nameof(KeyFrameAnimation.KeyFrames)]!.AsArray()[0]!.AsObject(); + keyFrameJson[nameof(KeyFrame.Easing)] = "[Missing.Assembly]Missing.Namespace:MissingEasing"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + + var manager = new AgentSessionManager(); + using var source = new FileSessionSource(); + SessionTools sessionTools = CreateSessionTools(source, manager, root); + + ToolResult opened = await sessionTools.OpenProject(projectPath); + + Assert.Multiple(() => + { + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); + Assert.That( + opened.Value!.Warnings, + Has.Some.Contains(elementRelativePath).And.Some.Contains("replaced during load")); + Assert.That(opened.Value.RecoveryIncidents, Has.Count.EqualTo(1)); + Assert.That(opened.Value.RecoveryIncidents[0].ElementFile, Is.EqualTo(elementRelativePath)); + Assert.That(opened.Value.RecoveryIncidents[0].Reason, + Is.EqualTo(nameof(FallbackReason.DeserializationFailed))); + Assert.That(opened.Value.RecoveryIncidents[0].TypeName, Is.Null); + Assert.That(opened.Value.RecoveryIncidents[0].Message, + Does.Contain("value was replaced during load").And.Contain("original element file is preserved")); + }); + } + [Test] public async Task Open_project_warns_about_malformed_element_json_and_keeps_healthy_elements() { diff --git a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs index 47007ab310..503660876b 100644 --- a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs +++ b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs @@ -242,6 +242,27 @@ public void PasteOver_LastFallback_UndoRestoresPersistenceSuppressionAndPreserve }); } + [Test] + public void PasteOver_LastFallbackWithNonFallbackIncident_KeepsPersistenceSuppressionAndPreservesSidecar() + { + _service.Add(_element, new FallbackEngineObject()); + byte[] originalBytes = "{ preserved lossy bytes"u8.ToArray(); + File.WriteAllBytes(_element.Uri!.LocalPath, originalBytes); + var suppression = new SuppressedStorageSource(originalBytes, _element.Uri, true); + _element.SuppressedStorageSource = suppression; + string json = CoreSerializer.SerializeToJsonString(new TestEngineObject()); + + ObjectPasteOutcome outcome = _service.PasteOver(_element, 0, json); + CoreSerializer.StoreToUri(_element, _element.Uri); + + Assert.Multiple(() => + { + Assert.That(outcome, Is.EqualTo(ObjectPasteOutcome.Pasted)); + Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); + Assert.That(File.ReadAllBytes(_element.Uri.LocalPath), Is.EqualTo(originalBytes)); + }); + } + [Test] public void PasteOver_WhenAnotherFallbackRemains_KeepsPersistenceSuppression() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 69d1fad7ce..436905f228 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -24,6 +24,16 @@ public IOExceptionElement() } } + public sealed class ElementReferenceHolder : EngineObject + { + public ElementReferenceHolder() + { + ScanProperties(); + } + + public IProperty> Target { get; } = Property.Create>(); + } + [SetUp] public void SetUp() { @@ -103,6 +113,51 @@ public void Save_UnresolvableKeyFrameEasing_PreservesSidecarBytes() }); } + [Test] + public void Save_RepairedFallbackWithUnresolvableKeyFrameEasing_PreservesSidecarBytes() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Element element = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var shape = (RectShape)element.Objects.Single(); + var animation = new KeyFrameAnimation(); + animation.KeyFrames.Add(new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = 32, + }); + shape.Width.Animation = animation; + element.AddObject(new RectShape()); + CoreSerializer.StoreToUri(element, element.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject keyFrameJson = FindObjectWithProperty(json, nameof(KeyFrame.Easing))!; + keyFrameJson[nameof(KeyFrame.Easing)] = "[Missing.Assembly]Missing.Namespace:MissingEasing"; + json[nameof(Element.Objects)]!.AsArray()[1]!.AsObject()["$type"] + = "[Beutl.Engine]Beutl.Graphics.Shapes:MissingShape"; + File.WriteAllText(elementPath, json.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recovered.Children.Single(); + var recoveredShape = (RectShape)recoveredElement.Objects[0]; + var recoveredAnimation = (KeyFrameAnimation)recoveredShape.Width.Animation!; + Assert.That(recoveredElement.Objects[1], Is.InstanceOf()); + + recoveredElement.Objects[1] = new RectShape(); + SuppressedStorageSource? resumed = Scene.TryResumeElementPersistence(recoveredElement); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + Assert.That(recoveredAnimation.KeyFrames.Single().Easing, Is.InstanceOf()); + Assert.That(resumed, Is.Null); + Assert.That(recoveredElement.SuppressedStorageSource, Is.Not.Null); + Assert.That(recoveredElement.SuppressedStorageSource!.HasNonFallbackIncidents, Is.True); + Assert.That(recoveredElement.Objects.OfType(), Is.Empty); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + [Test] public void Restore_MalformedElementWithoutReadableId_UsesStableId() { @@ -616,6 +671,9 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() Element healthy = recoveredScene.Children.Single( child => child.Uri!.LocalPath == elementPaths[1]); recoveredScene.Groups.Add(ImmutableHashSet.Create(placeholder.Id, healthy.Id)); + var referenceHolder = new ElementReferenceHolder(); + referenceHolder.Target.CurrentValue = new Reference(placeholder.Id); + healthy.AddObject(referenceHolder); CoreSerializer.StoreToUri(recoveredScene, sceneUri); Guid repairedId = Guid.NewGuid(); @@ -631,14 +689,23 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() CoreSerializer.StoreToUri(repaired, repaired.Uri!); Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + Element reloadedRepaired = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Element reloadedHealthy = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[1]); + Reference migratedReference = reloadedHealthy.Objects + .OfType() + .Single() + .Target.CurrentValue; Assert.Multiple(() => { - Assert.That(reloaded.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]).Id, - Is.EqualTo(repairedId)); + Assert.That(reloadedRepaired.Id, Is.EqualTo(repairedId)); Assert.That(reloaded.Groups, Has.Count.EqualTo(1)); Assert.That(reloaded.Groups.Single(), Is.EqualTo(ImmutableHashSet.Create(repairedId, healthy.Id))); + Assert.That(migratedReference.Id, Is.EqualTo(repairedId)); + Assert.That(migratedReference.Value, Is.SameAs(reloadedRepaired)); }); } @@ -710,6 +777,7 @@ public void Restore_RecoveredDescendantsSharingSerializedIdKeepOccurrenceStableR Element firstRecovered = firstLoad.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); Guid[] firstAssignedIds = firstRecovered.Objects.Select(static obj => obj.Id).ToArray(); Guid[] firstGraphIds = EnumerateElementGraphs(firstLoad).Select(static obj => obj.Id).ToArray(); + firstRecovered.Objects.Move(0, 1); CoreSerializer.StoreToUri(firstLoad, sceneUri); JsonObject persistedScene = JsonNode.Parse(File.ReadAllText(sceneUri.LocalPath))!.AsObject(); @@ -726,8 +794,12 @@ public void Restore_RecoveredDescendantsSharingSerializedIdKeepOccurrenceStableR Assert.That(firstAssignedIds, Has.Length.EqualTo(2)); Assert.That(firstAssignedIds, Does.Not.Contain(claimantId)); Assert.That(secondAssignedIds, Is.EqualTo(firstAssignedIds)); - Assert.That(persistedIds.ContainsKey($"recovered.belm!{claimantId:D}#0"), Is.True); - Assert.That(persistedIds.ContainsKey($"recovered.belm!{claimantId:D}#1"), Is.True); + Assert.That( + persistedIds[$"recovered.belm!{claimantId:D}#0"]!.GetValue(), + Is.EqualTo(firstAssignedIds[0].ToString())); + Assert.That( + persistedIds[$"recovered.belm!{claimantId:D}#1"]!.GetValue(), + Is.EqualTo(firstAssignedIds[1].ToString())); }); } From 6e14fe96605f55fbf481b5c1b3fb17fdc02a815a Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 31 Jul 2026 22:33:02 +0900 Subject: [PATCH 15/35] fix(review): resume persistence on every repair path and harden claim order and scanning - Every editor value-replacement path (SetValue overloads, ApplyTemplate, TryPasteJson, drawable-brush inner replacement) runs the fallback resume hook with the clear recorded in the same history transaction. - Recovered ELEMENT ids claim before any recovered descendant, so a descendant sharing its element's id is the side that remaps and groups keep resolving. - The top-level scan requires an object root and stops at its close, so trailing JSON after the first root can no longer donate an Id. - Storing a suppressed object to a non-file URI throws like the ordinary path instead of reporting silent success. --- .../Serialization/CoreSerializer.cs | 7 +- .../ProjectSystem/Scene.cs | 108 +++++++++------- .../ViewModels/Editors/BaseEditorViewModel.cs | 11 +- .../Editors/BrushEditorViewModel.cs | 19 ++- .../Editors/CoreObjectEditorViewModel.cs | 11 +- .../Editors/GeometryEditorViewModel.cs | 8 +- .../ViewModels/Editors/GroupedEditorHelper.cs | 6 +- .../FallbackEditorPersistenceTests.cs | 115 ++++++++++++++++++ .../MalformedElementRecoveryTests.cs | 82 +++++++++++++ 9 files changed, 306 insertions(+), 61 deletions(-) create mode 100644 tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index fc706c3ed1..e88b464f51 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -251,11 +251,16 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n { if (obj is CoreObject { SuppressedStorageSource: { } suppressed } suppressedObj) { - if (uri == suppressed.SourceUri || uri.Scheme != "file") + if (uri == suppressed.SourceUri) { return; } + if (uri.Scheme != "file") + { + throw new JsonException(); + } + // Rehomed (save-as): the retained bytes move verbatim so the new project copy keeps the // element. The suppression record is never mutated — the source location stays // skip-protected even if a failed multi-file save rolls Uri back afterwards. diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 55f4b1f414..18d8899ce2 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -788,51 +788,6 @@ private void ReassignDuplicateRecoveredIds() } } - var persistedDescendantIds = new Dictionary( - _recoveredDescendantIds, - StringComparer.Ordinal); - _recoveredDescendantIds.Clear(); - _recoveredDescendantRemaps.Clear(); - var pendingDescendantRemaps - = new Dictionary( - ReferenceEqualityComparer.Instance); - foreach ((Element child, string relativePath) in recoveredChildren) - { - var occurrences = new Dictionary(); - foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) - { - if (!seenDescendants.Add(descendant)) - { - continue; - } - - Guid originalId = descendant.Id; - int occurrence = occurrences.GetValueOrDefault(originalId); - occurrences[originalId] = occurrence + 1; - string remapKey = CreateRecoveredDescendantKey(relativePath, originalId, occurrence); - if (persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId)) - { - if (claimedIds.Add(persistedId)) - { - descendant.Id = persistedId; - RecordRecoveredDescendantRemap( - descendant, - remapKey, - originalId, - persistedId, - occurrence); - continue; - } - - pendingDescendantRemaps[descendant] = (remapKey, originalId, occurrence); - } - else if (!claimedIds.Add(originalId)) - { - pendingDescendantRemaps[descendant] = (remapKey, originalId, occurrence); - } - } - } - foreach ((Element child, string relativePath) in healthyChildren) { if (_recoveredElementIds.Remove(relativePath, out Guid placeholderId) @@ -908,6 +863,51 @@ var pendingDescendantRemaps _recoveredElementIds[relativePath] = child.Id; } + var persistedDescendantIds = new Dictionary( + _recoveredDescendantIds, + StringComparer.Ordinal); + _recoveredDescendantIds.Clear(); + _recoveredDescendantRemaps.Clear(); + var pendingDescendantRemaps + = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach ((Element child, string relativePath) in recoveredChildren) + { + var occurrences = new Dictionary(); + foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + { + if (!seenDescendants.Add(descendant)) + { + continue; + } + + Guid originalId = descendant.Id; + int occurrence = occurrences.GetValueOrDefault(originalId); + occurrences[originalId] = occurrence + 1; + string remapKey = CreateRecoveredDescendantKey(relativePath, originalId, occurrence); + if (persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId)) + { + if (claimedIds.Add(persistedId)) + { + descendant.Id = persistedId; + RecordRecoveredDescendantRemap( + descendant, + remapKey, + originalId, + persistedId, + occurrence); + continue; + } + + pendingDescendantRemaps[descendant] = (remapKey, originalId, occurrence); + } + else if (!claimedIds.Add(originalId)) + { + pendingDescendantRemaps[descendant] = (remapKey, originalId, occurrence); + } + } + } + foreach ((Element child, string relativePath) in recoveredChildren) { foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) @@ -1310,13 +1310,25 @@ private static string NormalizeRelativePath(string path) private static Match? FindTopLevelMatch(string rawText, MatchCollection matches) { + int rootStart = 0; + while (rootStart < rawText.Length + && (char.IsWhiteSpace(rawText[rootStart]) || rawText[rootStart] == '\uFEFF')) + { + rootStart++; + } + + if (rootStart >= rawText.Length || rawText[rootStart] != '{') + { + return null; + } + int matchIndex = 0; int objectDepth = 0; int arrayDepth = 0; bool inString = false; bool escaped = false; - for (int i = 0; i < rawText.Length && matchIndex < matches.Count; i++) + for (int i = rootStart; i < rawText.Length && matchIndex < matches.Count; i++) { Match match = matches[matchIndex]; if (i == match.Index) @@ -1356,6 +1368,10 @@ private static string NormalizeRelativePath(string path) else if (current == '}' && objectDepth > 0) { objectDepth--; + if (objectDepth == 0) + { + return null; + } } else if (current == '[') { diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index 5dd7d96ad4..a2b822725b 100644 --- a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs @@ -556,6 +556,11 @@ public sealed override void Reset() } public void SetValue(T? oldValue, T? newValue) + { + SetValue(oldValue, newValue, null); + } + + internal void SetValue(T? oldValue, T? newValue, string? commandName) { if (!EqualityComparer.Default.Equals(oldValue, newValue)) { @@ -570,22 +575,26 @@ public void SetValue(T? oldValue, T? newValue) } ResumeElementPersistenceAfterFallbackReplacement(oldValue); - Commit(); + Commit(commandName); } } public void SetValue(T? newValue) { + T? oldValue; if (EditingKeyFrame.Value is { } kf) { + oldValue = kf.Value; kf.Value = newValue!; } else { IPropertyAdapter prop = PropertyAdapter; + oldValue = prop.GetValue(); prop.SetValue(newValue); } + ResumeElementPersistenceAfterFallbackReplacement(oldValue); Commit(); } diff --git a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs index 3699f5b7fc..9825522d18 100644 --- a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs @@ -169,12 +169,17 @@ public override void Reset() } public void SetValue(Brush? oldValue, Brush? newValue) + { + SetValue(oldValue, newValue, null); + } + + private void SetValue(Brush? oldValue, Brush? newValue, string? commandName) { if (!EqualityComparer.Default.Equals(oldValue, newValue)) { PropertyAdapter.SetValue(newValue); ResumeElementPersistenceAfterFallbackReplacement(oldValue); - Commit(); + Commit(commandName); } } @@ -187,8 +192,7 @@ public override bool ApplyTemplate(ObjectTemplateItem template) { if (template.CreateInstance() is not Brush instance) return false; IsExpanded.Value = true; - PropertyAdapter.SetValue(instance); - Commit(CommandNames.ApplyTemplate); + SetValue(Value.Value, instance, CommandNames.ApplyTemplate); return true; } @@ -197,8 +201,7 @@ public override bool TryPasteJson(string json) if (!CoreObjectClipboard.TryDeserializeJson(json, out var pasted)) return false; IsExpanded.Value = true; - PropertyAdapter.SetValue(pasted); - Commit(CommandNames.PasteObject); + SetValue(Value.Value, pasted, CommandNames.PasteObject); return true; } @@ -248,7 +251,9 @@ public void ChangeDrawableType(Type type) { if (Activator.CreateInstance(type) is Drawable instance) { + Drawable? previous = drawable.Drawable.CurrentValue; drawable.Drawable.CurrentValue = instance; + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } @@ -273,6 +278,8 @@ public void SetTarget(Brush? target) public void SetDrawableTarget(Drawable? target) { + Brush? previousBrush = Value.Value; + Drawable? previousDrawable = (previousBrush as DrawableBrush)?.Drawable.CurrentValue; if (Value.Value is not DrawableBrush drawableBrush) { drawableBrush = new DrawableBrush(); @@ -295,6 +302,8 @@ public void SetDrawableTarget(Drawable? target) presenter.Target.CurrentValue = null; } + ResumeElementPersistenceAfterFallbackReplacement(previousBrush); + ResumeElementPersistenceAfterFallbackReplacement(previousDrawable); Commit(); } diff --git a/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs index 6c072a746b..3352fa951f 100644 --- a/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs @@ -181,8 +181,10 @@ public void SetNewInstance(Type type) public override bool ApplyTemplate(ObjectTemplateItem template) { if (template.CreateInstance() is not T instance) return false; + T? previous = PropertyAdapter.GetValue(); IsExpanded.Value = true; PropertyAdapter.SetValue(instance); + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(CommandNames.ApplyTemplate); return true; } @@ -194,28 +196,30 @@ public override bool TryPasteJson(string json) IsExpanded.Value = true; if (EditingKeyFrame.Value is { } kf) { - kf.Value = pasted; + SetValue(kf.Value, pasted, CommandNames.PasteObject); } else if (PropertyAdapter is ListItemAccessorImpl listItemAccessor) { listItemAccessor.List.Insert(listItemAccessor.Index, pasted); + Commit(CommandNames.PasteObject); } else { - PropertyAdapter.SetValue(pasted); + SetValue(PropertyAdapter.GetValue(), pasted, CommandNames.PasteObject); } - Commit(CommandNames.PasteObject); return true; } public void SetTarget(CoreObject? target) { + T? previous = null; if (Value.Value is not IPresenter presenter) { Type? presenterType = PresenterTypeAttribute.GetPresenterType(PropertyAdapter.PropertyType); if (presenterType == null) return; if (Activator.CreateInstance(presenterType) is not IPresenter p) return; + previous = PropertyAdapter.GetValue(); presenter = p; PropertyAdapter.SetValue(presenter); } @@ -231,6 +235,7 @@ public void SetTarget(CoreObject? target) presenter.Target.CurrentValue = null; } + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } diff --git a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs index 002f92a025..3883682a72 100644 --- a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs @@ -132,8 +132,10 @@ public void ChangeGeometryType(Type type) public override bool ApplyTemplate(ObjectTemplateItem template) { if (template.CreateInstance() is not Geometry instance) return false; + Geometry? previous = PropertyAdapter.GetValue(); IsExpanded.Value = true; PropertyAdapter.SetValue(instance); + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(CommandNames.ApplyTemplate); return true; } @@ -145,18 +147,18 @@ public override bool TryPasteJson(string json) IsExpanded.Value = true; if (EditingKeyFrame.Value is { } kf) { - kf.Value = pasted; + SetValue(kf.Value, pasted, CommandNames.PasteObject); } else if (PropertyAdapter is ListItemAccessorImpl listItemAccessor) { listItemAccessor.List.Insert(listItemAccessor.Index, pasted); + Commit(CommandNames.PasteObject); } else { - PropertyAdapter.SetValue(pasted); + SetValue(PropertyAdapter.GetValue(), pasted, CommandNames.PasteObject); } - Commit(CommandNames.PasteObject); return true; } diff --git a/src/Beutl/ViewModels/Editors/GroupedEditorHelper.cs b/src/Beutl/ViewModels/Editors/GroupedEditorHelper.cs index 9af3398df4..2efe14cedc 100644 --- a/src/Beutl/ViewModels/Editors/GroupedEditorHelper.cs +++ b/src/Beutl/ViewModels/Editors/GroupedEditorHelper.cs @@ -23,7 +23,8 @@ public static bool TryPasteJson( } else if (vm.EditingKeyFrame.Value is { } kf) { - kf.Value = pasted; + vm.SetValue(kf.Value, pasted, CommandNames.PasteObject); + return true; } else if (vm.PropertyAdapter is ListItemAccessorImpl listItemAccessor) { @@ -31,7 +32,8 @@ public static bool TryPasteJson( } else { - vm.PropertyAdapter.SetValue(pasted); + vm.SetValue(vm.PropertyAdapter.GetValue(), pasted, CommandNames.PasteObject); + return true; } vm.Commit(CommandNames.PasteObject); diff --git a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs new file mode 100644 index 0000000000..da38df3390 --- /dev/null +++ b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs @@ -0,0 +1,115 @@ +using System.Text.Json.Nodes; +using Avalonia.Headless.NUnit; +using Beutl.Api.Services; +using Beutl.Editor; +using Beutl.Editor.Observers; +using Beutl.Engine; +using Beutl.Extensibility; +using Beutl.Graphics.Shapes; +using Beutl.Media; +using Beutl.ProjectSystem; +using Beutl.PropertyAdapters; +using Beutl.Serialization; +using Beutl.Testing.Headless; +using Beutl.ViewModels.Editors; + +namespace Beutl.HeadlessUITests; + +[TestFixture] +public sealed class FallbackEditorPersistenceTests +{ + [AvaloniaTest] + public void BrushTryPasteJson_LastFallbackResumesPersistenceInReplacementTransaction() + { + string root = Path.Combine( + BeutlHomeIsolation.CurrentHome!, + $"fallback-editor-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + + try + { + var sceneUri = new Uri(Path.Combine(root, "scene.scene")); + string elementPath = Path.Combine(root, "element.belm"); + var scene = new Scene(64, 64, "Scene") { Uri = sceneUri }; + var element = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(new RectShape + { + Fill = { CurrentValue = new SolidColorBrush(Colors.Red) }, + }); + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject brushJson = elementJson[nameof(Element.Objects)]!.AsArray()[0]! + [nameof(Shape.Fill)]!.AsObject(); + brushJson["$type"] = "[Beutl.Engine]Beutl.Media:DoesNotExist"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single(); + var recoveredShape = (RectShape)recoveredElement.Objects.Single(); + Assert.That(recoveredShape.Fill.CurrentValue, Is.InstanceOf()); + + var sequence = new OperationSequenceGenerator(); + using var history = new HistoryManager(recoveredElement, sequence); + using var observer = new CoreObjectOperationObserver(null, recoveredElement, sequence); + using IDisposable subscription = history.Subscribe(observer); + var adapter = new SimplePropertyAdapter( + (SimpleProperty)recoveredShape.Fill, + recoveredShape); + using var viewModel = new BrushEditorViewModel(adapter); + viewModel.Accept(new Visitor(recoveredElement, history)); + + bool pasted = viewModel.TryPasteJson( + CoreSerializer.SerializeToJsonString(new SolidColorBrush(Colors.Blue))); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = history.Undo(); + File.WriteAllBytes(elementPath, originalBytes); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + + Assert.Multiple(() => + { + Assert.That(pasted, Is.True); + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(recoveredShape.Fill.CurrentValue, Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, true); + } + } + } + + private sealed record Visitor(Element Element, HistoryManager History) + : IServiceProvider, IPropertyEditorContextVisitor + { + public object? GetService(Type serviceType) + { + if (serviceType == typeof(Element)) + return Element; + + if (serviceType == typeof(HistoryManager)) + return History; + + if (serviceType == typeof(ExtensionProvider)) + return TestShell.Extensions; + + return null; + } + + public void Visit(IPropertyEditorContext context) + { + } + } +} diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 436905f228..1ef40d9bff 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Collections.Immutable; +using System.Text.Json; using System.Text.Json.Nodes; using Beutl.Animation; using Beutl.Animation.Easings; @@ -210,6 +211,26 @@ public void Restore_MalformedElementWithOnlyNestedId_DoesNotAdoptIt() }); } + [Test] + public void Restore_TrailingRootId_DoesNotOverridePathDerivedId() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText(elementPath, "{ this is not valid JSON"); + Guid pathDerivedId = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + var trailingId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + File.WriteAllText(elementPath, $$"""{} {"Id":"{{trailingId}}"}"""); + + Guid first = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + Guid second = CoreSerializer.RestoreFromUri(sceneUri).Children.Single().Id; + + Assert.Multiple(() => + { + Assert.That(first, Is.EqualTo(pathDerivedId)); + Assert.That(first, Is.Not.EqualTo(trailingId)); + Assert.That(second, Is.EqualTo(first)); + }); + } + [Test] public void Restore_ResolvableNonElementDiscriminator_RecoversInsteadOfFailing() { @@ -390,6 +411,22 @@ public void StoreToUri_AfterRehome_KeepsTheOriginalSkipProtected() }); } + [Test] + public void StoreToUri_RecoveredElementNonFileDestinationMatchesNormalFailure() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText(elementPath, "{ this is not valid JSON"); + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + var destination = new Uri("https://example.com/element.belm"); + + JsonException normalException = Assert.Throws( + () => CoreSerializer.StoreToUri(new Element(), destination))!; + JsonException recoveredException = Assert.Throws( + () => CoreSerializer.StoreToUri(recovered, destination))!; + + Assert.That(recoveredException.GetType(), Is.EqualTo(normalException.GetType())); + } + [Test] public void Restore_NonStringDiscriminator_RecoversInsteadOfLoadingLegacyDefault() { @@ -501,6 +538,51 @@ public void Restore_DuplicateTopLevelId_YieldsToTheHealthyElement() }); } + [Test] + public void Restore_RecoveredElementIdWinsOwnDescendantAndPreservesGroup() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("recovered.belm", "healthy.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + Element healthySource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + Guid contestedId = recoveredSource.Id; + var recoveredShapeSource = (RectShape)recoveredSource.Objects.Single(); + recoveredShapeSource.Transform.CurrentValue = new RotationTransform { Id = contestedId }; + source.Groups.Add(ImmutableHashSet.Create(contestedId, healthySource.Id)); + CoreSerializer.StoreToUri(source, sceneUri); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[0]))!.AsObject(); + json[nameof(CoreObject.Id)] = contestedId.ToString(); + JsonObject transformJson = FindObjectByDiscriminator(json, nameof(RotationTransform))!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + transformJson[nameof(CoreObject.Id)] = contestedId.ToString(); + File.WriteAllText(elementPaths[0], json.ToJsonString()); + + Scene firstLoad = CoreSerializer.RestoreFromUri(sceneUri); + Element firstRecovered = firstLoad.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + Guid firstDescendantId = ((CoreObject)GetTransformFallback(firstLoad, elementPaths[0])).Id; + CoreSerializer.StoreToUri(firstLoad, sceneUri); + + Scene secondLoad = CoreSerializer.RestoreFromUri(sceneUri); + Element secondRecovered = secondLoad.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + Guid secondDescendantId = ((CoreObject)GetTransformFallback(secondLoad, elementPaths[0])).Id; + + Assert.Multiple(() => + { + Assert.That(firstRecovered.Id, Is.EqualTo(contestedId)); + Assert.That(secondRecovered.Id, Is.EqualTo(contestedId)); + Assert.That(firstDescendantId, Is.Not.EqualTo(contestedId)); + Assert.That(secondDescendantId, Is.EqualTo(firstDescendantId)); + Assert.That(firstLoad.Groups, Has.Count.EqualTo(1)); + Assert.That(firstLoad.Groups.Single(), + Is.EqualTo(ImmutableHashSet.Create(contestedId, healthySource.Id))); + Assert.That(secondLoad.Groups, Has.Count.EqualTo(1)); + Assert.That(secondLoad.Groups.Single(), + Is.EqualTo(ImmutableHashSet.Create(contestedId, healthySource.Id))); + }); + } + [Test] public void Restore_TopLevelIdMatchingSceneId_IsReassignedStably() { From cd491cc1d255c97601ded1e07c675303df92b484 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sat, 8 Aug 2026 17:19:49 +0900 Subject: [PATCH 16/35] fix(review): migrate reference expressions, resume persistence on every repair path, and harden easing recovery Addresses the remaining review feedback on malformed-element recovery: - Migrate ReferenceExpression ObjectIds alongside IReference values when a recovered element Id changes (Scene.MigrateRecoveredElementReferences). - Resume element persistence from every repair path: Reconciler.ApplyCore (undoable, in the same transaction) and TextureSourceEditorViewModel.SetDrawableType. HasNonFallbackIncidents no longer blocks resumption permanently: TryResumeElementPersistence re-checks for live fallbacks and lossy keyframe easings (KeyFrame.HasLossyEasing). - Keyframe-aware ApplyTemplate/SetValue in the CoreObject/Geometry/Brush editors so templates applied while editing a keyframe update the keyframe value. - Easing recovery: reject non-instantiable discriminators, catch activation failures, and record an incident for every unsupported easing shape (non-string primitives, null, malformed spline objects). - Report lossy non-fallback incidents even when fallback objects coexist in the same sidecar. - Capture the legacy "@type" discriminator in top-level type scanning. - Reserve TimelineLayer and SceneMarker Ids during recovered-Id assignment. - Classify unresolvable top-level discriminators as TypeNotFound. --- .../Reconciliation/Reconciler.cs | 36 +++ src/Beutl.AgentToolkit/Tools/SessionTools.cs | 3 +- src/Beutl.Engine/Animation/KeyFrame.cs | 80 ++++- .../ProjectSystem/Scene.cs | 59 +++- .../Properties/AssemblyInfo.cs | 1 + .../ViewModels/Editors/BaseEditorViewModel.cs | 3 +- .../Editors/BrushEditorViewModel.cs | 26 +- .../Editors/CoreObjectEditorViewModel.cs | 13 +- .../Editors/GeometryEditorViewModel.cs | 13 +- .../Editors/TextureSourceEditorViewModel.cs | 2 + .../ReconcilerIdIntegrityTests.cs | 47 +++ .../Tools/SessionToolsTests.cs | 29 +- .../FallbackEditorPersistenceTests.cs | 304 ++++++++++++++++++ .../Services/ElementObjectServiceTests.cs | 6 +- .../Engine/Animation/KeyFrameTests.cs | 102 ++++++ .../MalformedElementRecoveryTests.cs | 100 +++++- 16 files changed, 761 insertions(+), 63 deletions(-) diff --git a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs index f5ac5aee45..113668252d 100644 --- a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs +++ b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs @@ -330,6 +330,7 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I { JsonObject desiredDocument = PrepareDesired(session, desired); ReconcilePlan plan = PlanPrepared(session, desiredDocument, knownNewIds); + Element[] affectedSuppressedElements = GetAffectedSuppressedElements(session.Root, plan); session.History.ExecuteInTransaction( () => { @@ -338,6 +339,16 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I { ProjectOperations.NormalizeSidecarUrisWithinProject(scene); } + + foreach (Element element in affectedSuppressedElements) + { + if (Scene.TryResumeElementPersistence(element) is { } suppression) + { + session.History.Record( + () => element.SuppressedStorageSource = null, + () => element.SuppressedStorageSource = suppression); + } + } }, "Agent edit"); @@ -349,6 +360,31 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I return new ReconcileResult(plan, session.Documents.Read(session.Root)); } + private static Element[] GetAffectedSuppressedElements(CoreObject root, ReconcilePlan plan) + { + if (plan.Changes.Count == 0) + { + return []; + } + + if (root is Element { SuppressedStorageSource: not null } element) + { + return [element]; + } + + if (root is not Scene scene) + { + return []; + } + + return scene.Children + .Where(static child => child.SuppressedStorageSource is not null) + .Where(child => plan.Changes.Any(change => + change.Path.StartsWith($"$/Elements[Id={child.Id}]", StringComparison.Ordinal) + || string.Equals(change.TargetId, child.Id.ToString(), StringComparison.Ordinal))) + .ToArray(); + } + // Build the plan on the editor's dispatcher: PlanPrepared reads session.Documents/Root, so off // the MCP request thread it would race the live scene the editor mutates on the UI thread. public ReconcilePlan PlanFromCurrent( diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index d9a4900bf6..949630a7a6 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -144,8 +144,7 @@ private static DeserializationWarningCollection CollectDeserializationWarnings(P $"Element file '{elementFile}' contains content that could not be deserialized: {error}"); } - if (fallbacks.Count == 0 - && element.SuppressedStorageSource is { HasNonFallbackIncidents: true }) + if (element.SuppressedStorageSource is { HasNonFallbackIncidents: true }) { const string message = "A value was replaced during load, and the original element file is preserved."; diff --git a/src/Beutl.Engine/Animation/KeyFrame.cs b/src/Beutl.Engine/Animation/KeyFrame.cs index d1f904dc1a..f1d0bee380 100644 --- a/src/Beutl.Engine/Animation/KeyFrame.cs +++ b/src/Beutl.Engine/Animation/KeyFrame.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Nodes; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; using Beutl.Animation.Easings; using Beutl.Serialization; @@ -34,9 +36,16 @@ static KeyFrame() public Easing Easing { get => _easing; - set => SetAndRaise(EasingProperty, ref _easing, value); + set + { + HasLossyEasing = false; + SetAndRaise(EasingProperty, ref _easing, value); + } } + [NotAutoSerialized] + internal bool HasLossyEasing { get; private set; } + public TimeSpan KeyTime { get => _keyTime; @@ -49,23 +58,52 @@ public override void Deserialize(ICoreSerializationContext context) { base.Deserialize(context); - if (context.GetValue(nameof(Easing)) is { } easingNode) + JsonNode? easingNode = context.GetValue(nameof(Easing)); + if (easingNode is null) + { + if (context.Contains(nameof(Easing))) + { + UseFallbackEasing(); + } + } + else if (easingNode is JsonValue easingTypeValue + && easingTypeValue.TryGetValue(out string? easingType)) { - if (easingNode is JsonValue easingTypeValue - && easingTypeValue.TryGetValue(out string? easingType)) + Type? type = TypeFormat.ToType(easingType); + if (type is null + || !type.IsAssignableTo(typeof(Easing)) + || type.IsAbstract + || type.ContainsGenericParameters + || type.GetConstructor(Type.EmptyTypes) is null) + { + UseFallbackEasing(); + } + else { - Type? type = TypeFormat.ToType(easingType); - if (type is null || !type.IsAssignableTo(typeof(Easing))) + try { - DeserializationIncidents.RecordFallback(); - Easing = new LinearEasing(); + if (Activator.CreateInstance(type) is Easing easing) + { + Easing = easing; + } + else + { + UseFallbackEasing(); + } } - else if (Activator.CreateInstance(type) is Easing easing) + catch (Exception ex) when (ex is MissingMethodException + or MemberAccessException + or TargetInvocationException + or TypeInitializationException + or NotSupportedException) { - Easing = easing; + UseFallbackEasing(); } } - else if (easingNode is JsonObject easingObject) + } + else if (easingNode is JsonObject easingObject) + { + try { float x1 = (float?)easingObject["X1"] ?? 0; float y1 = (float?)easingObject["Y1"] ?? 0; @@ -74,9 +112,27 @@ public override void Deserialize(ICoreSerializationContext context) Easing = new SplineEasing(x1, y1, x2, y2); } + catch (Exception ex) when (ex is JsonException + or FormatException + or InvalidOperationException + or ArgumentException) + { + UseFallbackEasing(); + } + } + else + { + UseFallbackEasing(); } } + private void UseFallbackEasing() + { + DeserializationIncidents.RecordFallback(); + Easing = new LinearEasing(); + HasLossyEasing = true; + } + public override void Serialize(ICoreSerializationContext context) { base.Serialize(context); diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 18d8899ce2..72ae79a602 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -14,6 +14,7 @@ using Beutl.Collections; using Beutl.Configuration; using Beutl.Engine; +using Beutl.Engine.Expressions; using Beutl.Language; using Beutl.Media; using Beutl.Serialization; @@ -60,6 +61,9 @@ public class Scene : ProjectItem, INotifyEdited private static readonly Regex s_typePattern = new( "\"\\$type\"\\s*:\\s*(?\"(?:\\\\.|[^\"\\\\])*\")", RegexOptions.CultureInvariant); + private static readonly Regex s_legacyTypePattern = new( + "\"@type\"\\s*:\\s*(?\"(?:\\\\.|[^\"\\\\])*\")", + RegexOptions.CultureInvariant); public static readonly CoreProperty FrameSizeProperty; public static readonly CoreProperty ChildrenProperty; public static readonly CoreProperty StartProperty; @@ -769,6 +773,14 @@ private void ReassignDuplicateRecoveredIds() .ToArray(); var claimedIds = new HashSet { Guid.Empty, Id }; + foreach (CoreObject sceneObject in Layers.Cast().Concat(Markers)) + { + foreach (CoreObject graphObject in EnumerateSerializedGraphObjects(sceneObject).OfType()) + { + claimedIds.Add(graphObject.Id); + } + } + var seenDescendants = new HashSet(ReferenceEqualityComparer.Instance); var healthyChildren = Children .Where(static child => child.SuppressedStorageSource is null) @@ -1011,13 +1023,20 @@ private Element RestoreElementOrFallback(Uri uri) Uri = uri, IsEnabled = false, }; + string? topLevelTypeName = TryGetTopLevelTypeName(rawText); + FallbackReason fallbackReason = topLevelTypeName is not null + && TypeFormat.ToType(topLevelTypeName) is null + ? FallbackReason.TypeNotFound + : FallbackReason.DeserializationFailed; var fallback = new FallbackEngineObject { Name = "Unreadable element data", - Reason = FallbackReason.DeserializationFailed, - ErrorMessage = $"{ex.GetType().Name}: {ex.Message}", + Reason = fallbackReason, + ErrorMessage = fallbackReason == FallbackReason.DeserializationFailed + ? $"{ex.GetType().Name}: {ex.Message}" + : null, }; - fallback.Json = CreateFallbackProjection(fallback, TryGetTopLevelTypeName(rawText)); + fallback.Json = CreateFallbackProjection(fallback, topLevelTypeName); element.AddObject(fallback); MarkRecoveredElement(element, rawBytes, uri); return element; @@ -1039,8 +1058,8 @@ private static void MarkRecoveredElement( internal static SuppressedStorageSource? TryResumeElementPersistence(Element element) { if (element.SuppressedStorageSource is not { } source - || source.HasNonFallbackIncidents - || EnumerateSerializedGraphFallbacks(element).Any()) + || EnumerateSerializedGraphFallbacks(element).Any() + || EnumerateSerializedGraphObjects(element).OfType().Any(static keyFrame => keyFrame.HasLossyEasing)) { return null; } @@ -1107,26 +1126,35 @@ private void MigrateRecoveredElementReferences() { foreach (IProperty property in engineObject.Properties) { - if (property.CurrentValue is not IReference reference - || !_pendingRecoveredElementIdMigrations.TryGetValue(reference.Id, out Guid migratedId)) + if (property.CurrentValue is IReference reference + && _pendingRecoveredElementIdMigrations.TryGetValue(reference.Id, out Guid migratedId)) { - continue; + Element? target = Children.FirstOrDefault(child => child.Id == migratedId); + property.CurrentValue = target is not null && reference.ObjectType.IsInstanceOfType(target) + ? reference.Resolved(target) + : Activator.CreateInstance(reference.GetType(), migratedId)!; } - Element? target = Children.FirstOrDefault(child => child.Id == migratedId); - property.CurrentValue = target is not null && reference.ObjectType.IsInstanceOfType(target) - ? reference.Resolved(target) - : Activator.CreateInstance(reference.GetType(), migratedId)!; + if (property.Expression is IReferenceExpression referenceExpression + && _pendingRecoveredElementIdMigrations.TryGetValue( + referenceExpression.ObjectId, + out Guid migratedExpressionId)) + { + property.Expression = (IExpression)Activator.CreateInstance( + referenceExpression.GetType(), + migratedExpressionId, + referenceExpression.PropertyPath)!; + } } } } } - private static IEnumerable EnumerateSerializedGraphObjects(Element element) + private static IEnumerable EnumerateSerializedGraphObjects(object root) { var objects = new List(); var visited = new HashSet(ReferenceEqualityComparer.Instance); - CollectSerializedGraphObjects(element, visited, objects); + CollectSerializedGraphObjects(root, visited, objects); return objects; } @@ -1216,7 +1244,8 @@ private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback private static string? TryGetTopLevelTypeName(string rawText) { - Match? match = FindTopLevelMatch(rawText, s_typePattern.Matches(rawText)); + Match? match = FindTopLevelMatch(rawText, s_typePattern.Matches(rawText)) + ?? FindTopLevelMatch(rawText, s_legacyTypePattern.Matches(rawText)); if (match is null) { return null; diff --git a/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs b/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs index 90b2374a22..6366d8421d 100644 --- a/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs +++ b/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs @@ -5,3 +5,4 @@ [assembly: InternalsVisibleTo("Beutl.NodeGraph")] [assembly: InternalsVisibleTo("Beutl.Editor.Components")] [assembly: InternalsVisibleTo("Beutl.UnitTests")] +[assembly: InternalsVisibleTo("Beutl.AgentToolkit")] diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index a2b822725b..1cfa8e128e 100644 --- a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs @@ -187,8 +187,7 @@ protected BaseEditorViewModel(IPropertyAdapter property) protected void ResumeElementPersistenceAfterFallbackReplacement(object? previous) { - if (previous is IFallback - && _element is not null + if (_element is { SuppressedStorageSource: not null } && Scene.TryResumeElementPersistence(_element) is { } suppression) { Element element = _element; diff --git a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs index 9825522d18..f3315c0385 100644 --- a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using Avalonia.Input; +using Beutl.Animation; using Beutl.Composition; using Beutl.Editor.Components.Helpers; using Beutl.Engine; @@ -151,7 +152,7 @@ private void AcceptChildren(PropertiesEditorViewModel? obj) public void SetJsonString(string? str) { - Brush? previous = Value.Value; + Brush? previous = GetEditingValue(); SetValue(previous, FallbackHelper.DeserializeInstance(str)); } @@ -164,7 +165,7 @@ public override void Reset() { if (GetDefaultValue() is { } defaultValue) { - SetValue(Value.Value, (Brush?)defaultValue); + SetValue(GetEditingValue(), (Brush?)defaultValue); } } @@ -177,12 +178,27 @@ private void SetValue(Brush? oldValue, Brush? newValue, string? commandName) { if (!EqualityComparer.Default.Equals(oldValue, newValue)) { - PropertyAdapter.SetValue(newValue); + if (EditingKeyFrame.Value is KeyFrame keyFrame) + { + keyFrame.Value = newValue; + } + else + { + PropertyAdapter.SetValue(newValue); + } + ResumeElementPersistenceAfterFallbackReplacement(oldValue); Commit(commandName); } } + private Brush? GetEditingValue() + { + return EditingKeyFrame.Value is KeyFrame keyFrame + ? keyFrame.Value + : (Brush?)PropertyAdapter.GetValue(); + } + protected override ICoreSerializable? GetCopyTarget() => Value.Value is Brush brush and not FallbackBrush ? brush : null; @@ -192,7 +208,7 @@ public override bool ApplyTemplate(ObjectTemplateItem template) { if (template.CreateInstance() is not Brush instance) return false; IsExpanded.Value = true; - SetValue(Value.Value, instance, CommandNames.ApplyTemplate); + SetValue(GetEditingValue(), instance, CommandNames.ApplyTemplate); return true; } @@ -201,7 +217,7 @@ public override bool TryPasteJson(string json) if (!CoreObjectClipboard.TryDeserializeJson(json, out var pasted)) return false; IsExpanded.Value = true; - SetValue(Value.Value, pasted, CommandNames.PasteObject); + SetValue(GetEditingValue(), pasted, CommandNames.PasteObject); return true; } diff --git a/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs index 3352fa951f..10605045ab 100644 --- a/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs @@ -181,11 +181,16 @@ public void SetNewInstance(Type type) public override bool ApplyTemplate(ObjectTemplateItem template) { if (template.CreateInstance() is not T instance) return false; - T? previous = PropertyAdapter.GetValue(); IsExpanded.Value = true; - PropertyAdapter.SetValue(instance); - ResumeElementPersistenceAfterFallbackReplacement(previous); - Commit(CommandNames.ApplyTemplate); + if (EditingKeyFrame.Value is { } keyFrame) + { + SetValue(keyFrame.Value, instance, CommandNames.ApplyTemplate); + } + else + { + SetValue(PropertyAdapter.GetValue(), instance, CommandNames.ApplyTemplate); + } + return true; } diff --git a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs index 3883682a72..27338e744c 100644 --- a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs @@ -132,11 +132,16 @@ public void ChangeGeometryType(Type type) public override bool ApplyTemplate(ObjectTemplateItem template) { if (template.CreateInstance() is not Geometry instance) return false; - Geometry? previous = PropertyAdapter.GetValue(); IsExpanded.Value = true; - PropertyAdapter.SetValue(instance); - ResumeElementPersistenceAfterFallbackReplacement(previous); - Commit(CommandNames.ApplyTemplate); + if (EditingKeyFrame.Value is { } keyFrame) + { + SetValue(keyFrame.Value, instance, CommandNames.ApplyTemplate); + } + else + { + SetValue(PropertyAdapter.GetValue(), instance, CommandNames.ApplyTemplate); + } + return true; } diff --git a/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs b/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs index 30d2a4243b..9506df3a0a 100644 --- a/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs @@ -117,8 +117,10 @@ public void SetDrawableType(Type type) { if (Value.Value is DrawableTextureSource drawableSource) { + Drawable? previous = drawableSource.Drawable.CurrentValue; var drawable = (Drawable?)Activator.CreateInstance(type); drawableSource.Drawable.CurrentValue = drawable; + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } diff --git a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs index c9fb6cecc6..b41cf1025c 100644 --- a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs @@ -14,6 +14,53 @@ namespace Beutl.AgentToolkit.Tests.Reconciliation; public sealed class ReconcilerIdIntegrityTests { + [Test] + public void Apply_repair_of_last_fallback_resumes_persistence_in_same_transaction() + { + Scene source = CreateSceneWithElement(out Element sourceElement); + sourceElement.AddObject(new RectShape()); + CoreSerializer.StoreToUri(source, source.Uri!); + string elementPath = sourceElement.Uri!.LocalPath; + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + elementJson[nameof(Element.Objects)]!.AsArray()[0]!.AsObject()["$type"] + = "[Beutl.Engine]Beutl.Graphics.Shapes:MissingShape"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(source.Uri!); + Element recoveredElement = recovered.Children.Single(); + var fallback = (EngineObject)recoveredElement.Objects.Single(); + using var session = new AgentToolkitTestSession(recovered); + JsonObject desired = session.Documents.Read(recovered); + JsonObject repairedJson = CoreSerializer.SerializeToJsonObject(new RectShape + { + Name = "Repaired shape", + }); + // Omit the Id: the reconciler mints one for the inserted entity and treats the + // subtree as new, the sanctioned replacement for a fallback whose type cannot + // change in place. + repairedJson.Remove(nameof(CoreObject.Id)); + JsonObject desiredElement = desired["Elements"]!.AsArray()[0]!.AsObject(); + desiredElement[nameof(Element.Objects)] = new JsonArray(repairedJson); + + var reconciler = new Reconciler(); + ReconcileResult result = reconciler.Apply(session, desired); + CoreSerializer.StoreToUri(recovered, recovered.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = session.History.Undo(); + File.WriteAllBytes(elementPath, originalBytes); + CoreSerializer.StoreToUri(recovered, recovered.Uri!); + + Assert.Multiple(() => + { + Assert.That(result.Plan.Valid, Is.True); + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(recoveredElement.Objects.Single(), Is.SameAs(fallback)); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + [Test] public void Mint_missing_ids_avoids_reserved_collisions() { diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index 832af9accf..f17a847fd7 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -98,7 +98,7 @@ public async Task Open_project_warns_about_corrupt_element_and_render_still_rema } [Test] - public async Task Open_project_warns_about_fallback_in_animation_keyframe_value() + public async Task Open_project_reports_fallback_and_lossy_easing_incidents_together() { const string MissingType = "[Beutl.Engine]Beutl.Engine:MissingAnimatedValue"; string root = CreateWorkspace(); @@ -139,6 +139,7 @@ public async Task Open_project_warns_about_fallback_in_animation_keyframe_value( JsonObject animationJson = objectJson["Animations"]![nameof(AnimatedValueHolder.AnimatedValue)]!.AsObject(); JsonObject keyFrameJson = animationJson[nameof(KeyFrameAnimation.KeyFrames)]!.AsArray()[0]!.AsObject(); keyFrameJson[nameof(IKeyFrame.Value)]!.AsObject()["$type"] = MissingType; + keyFrameJson[nameof(KeyFrame.Easing)] = "[Missing.Assembly]Missing.Namespace:MissingEasing"; File.WriteAllText(elementPath, elementJson.ToJsonString()); var manager = new AgentSessionManager(); @@ -153,12 +154,22 @@ public async Task Open_project_warns_about_fallback_in_animation_keyframe_value( Assert.That( opened.Value!.Warnings, Has.Some.Contains(elementRelativePath).And.Some.Contains(nameof(FallbackReason.TypeNotFound))); - Assert.That(opened.Value.RecoveryIncidents, Has.Count.EqualTo(1)); - Assert.That(opened.Value.RecoveryIncidents[0].ElementFile, Is.EqualTo(elementRelativePath)); - Assert.That(opened.Value.RecoveryIncidents[0].Reason, - Is.EqualTo(nameof(FallbackReason.TypeNotFound))); - Assert.That(opened.Value.RecoveryIncidents[0].TypeName, Is.EqualTo(MissingType)); - Assert.That(opened.Value.RecoveryIncidents[0].Message, Is.Null); + Assert.That(opened.Value.Warnings, + Has.Some.Contains(elementRelativePath).And.Some.Contains("replaced during load")); + Assert.That(opened.Value.RecoveryIncidents, Has.Count.EqualTo(2)); + Assert.That(opened.Value.RecoveryIncidents.Select(static incident => incident.ElementFile), + Is.All.EqualTo(elementRelativePath)); + Assert.That(opened.Value.RecoveryIncidents, + Has.One.Matches(incident => + incident.Reason == nameof(FallbackReason.TypeNotFound) + && incident.TypeName == MissingType + && incident.Message is null)); + Assert.That(opened.Value.RecoveryIncidents, + Has.One.Matches(incident => + incident.Reason == nameof(FallbackReason.DeserializationFailed) + && incident.TypeName is null + && incident.Message != null + && incident.Message.Contains("value was replaced during load", StringComparison.Ordinal))); }); } @@ -425,6 +436,10 @@ public async Task Open_project_incidents_distinguish_same_named_sidecars_across_ Is.EquivalentTo(new[] { "First scene", "Second scene" })); Assert.That(opened.Value.RecoveryIncidents.Select(static incident => incident.TypeName), Is.All.EqualTo(MissingType)); + Assert.That(opened.Value.RecoveryIncidents.Select(static incident => incident.Reason), + Is.All.EqualTo(nameof(FallbackReason.TypeNotFound))); + Assert.That(opened.Value.RecoveryIncidents.Select(static incident => incident.Message), + Is.All.Null); }); } diff --git a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs index da38df3390..5b75d9edda 100644 --- a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs +++ b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs @@ -1,11 +1,16 @@ using System.Text.Json.Nodes; using Avalonia.Headless.NUnit; +using Beutl.Animation; +using Beutl.Animation.Easings; using Beutl.Api.Services; using Beutl.Editor; using Beutl.Editor.Observers; +using Beutl.Editor.Services; using Beutl.Engine; using Beutl.Extensibility; +using Beutl.Graphics; using Beutl.Graphics.Shapes; +using Beutl.Graphics3D.Textures; using Beutl.Media; using Beutl.ProjectSystem; using Beutl.PropertyAdapters; @@ -18,6 +23,23 @@ namespace Beutl.HeadlessUITests; [TestFixture] public sealed class FallbackEditorPersistenceTests { + [SuppressResourceClassGeneration] + public sealed class EditorValueHolder : EngineObject + { + public EditorValueHolder() + { + ScanProperties(); + } + + public IProperty CoreValue { get; } = Property.CreateAnimatable(); + + public IProperty GeometryValue { get; } = Property.CreateAnimatable(); + + public IProperty BrushValue { get; } = Property.CreateAnimatable(); + + public IProperty TextureValue { get; } = Property.Create(); + } + [AvaloniaTest] public void BrushTryPasteJson_LastFallbackResumesPersistenceInReplacementTransaction() { @@ -91,6 +113,288 @@ public void BrushTryPasteJson_LastFallbackResumesPersistenceInReplacementTransac } } + [AvaloniaTest] + public void EasingRepair_ResumesPersistenceAndWritesRepairedSidecar() + { + string root = CreateRoot(); + try + { + (Uri sceneUri, string elementPath) = CreateAnimatedScene(root); + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject keyFrameJson = FindObjectWithProperty(elementJson, nameof(KeyFrame.Easing))!; + keyFrameJson[nameof(KeyFrame.Easing)] = "[Missing.Assembly]Missing.Namespace:MissingEasing"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single(); + var shape = (RectShape)recoveredElement.Objects.Single(); + var animation = (KeyFrameAnimation)shape.Width.Animation!; + var keyFrame = (KeyFrame)animation.KeyFrames.Single(); + using var context = new EditorTestContext(recoveredElement); + var adapter = new CorePropertyAdapter(KeyFrame.EasingProperty, keyFrame); + using var viewModel = new ValueEditorViewModel(adapter); + viewModel.Accept(new Visitor(recoveredElement, context.History)); + + viewModel.SetValue(keyFrame.Easing, new SplineEasing()); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + + Assert.Multiple(() => + { + Assert.That(keyFrame.Easing, Is.InstanceOf()); + Assert.That(context.History.CanUndo, Is.True); + Assert.That(File.ReadAllBytes(elementPath), Is.Not.EqualTo(originalBytes)); + }); + } + finally + { + Directory.Delete(root, true); + } + } + + [AvaloniaTest] + public void CoreObjectApplyTemplate_UpdatesEditingKeyFrameOnly() + { + var holder = new EditorValueHolder(); + var property = (AnimatableProperty)holder.CoreValue; + var propertyValue = new RectShape(); + property.CurrentValue = propertyValue; + var keyFrame = new KeyFrame { Value = new RectShape() }; + property.Animation = CreateAnimation(keyFrame); + using var context = new EditorTestContext(holder); + var adapter = new AnimatablePropertyAdapter(property, holder); + using var viewModel = new CoreObjectEditorViewModel(adapter); + viewModel.Accept(new Visitor(context.Element, context.History)); + ((BaseEditorViewModel)viewModel).EditingKeyFrame.Value = keyFrame; + + bool applied = viewModel.ApplyTemplate( + ObjectTemplateItem.CreateFromInstance(new EllipseShape(), "Ellipse")); + + Assert.Multiple(() => + { + Assert.That(applied, Is.True); + Assert.That(property.CurrentValue, Is.SameAs(propertyValue)); + Assert.That(keyFrame.Value, Is.InstanceOf()); + }); + } + + [AvaloniaTest] + public void GeometryApplyTemplate_UpdatesEditingKeyFrameOnly() + { + var holder = new EditorValueHolder(); + var property = (AnimatableProperty)holder.GeometryValue; + var propertyValue = new RectGeometry(); + property.CurrentValue = propertyValue; + var keyFrame = new KeyFrame { Value = new RectGeometry() }; + property.Animation = CreateAnimation(keyFrame); + using var context = new EditorTestContext(holder); + var adapter = new AnimatablePropertyAdapter(property, holder); + using var viewModel = new GeometryEditorViewModel(adapter); + viewModel.Accept(new Visitor(context.Element, context.History)); + ((BaseEditorViewModel)viewModel).EditingKeyFrame.Value = keyFrame; + + bool applied = viewModel.ApplyTemplate( + ObjectTemplateItem.CreateFromInstance(new EllipseGeometry(), "Ellipse")); + + Assert.Multiple(() => + { + Assert.That(applied, Is.True); + Assert.That(property.CurrentValue, Is.SameAs(propertyValue)); + Assert.That(keyFrame.Value, Is.InstanceOf()); + }); + } + + [AvaloniaTest] + public void BrushApplyTemplate_UpdatesEditingKeyFrameOnly() + { + var holder = new EditorValueHolder(); + var property = (AnimatableProperty)holder.BrushValue; + var propertyValue = new SolidColorBrush(Colors.Red); + property.CurrentValue = propertyValue; + var keyFrame = new KeyFrame { Value = new SolidColorBrush(Colors.Blue) }; + property.Animation = CreateAnimation(keyFrame); + using var context = new EditorTestContext(holder); + var adapter = new AnimatablePropertyAdapter(property, holder); + using var viewModel = new BrushEditorViewModel(adapter); + viewModel.Accept(new Visitor(context.Element, context.History)); + viewModel.EditingKeyFrame.Value = keyFrame; + + bool applied = viewModel.ApplyTemplate( + ObjectTemplateItem.CreateFromInstance(new SolidColorBrush(Colors.Green), "Green")); + + Assert.Multiple(() => + { + Assert.That(applied, Is.True); + Assert.That(property.CurrentValue, Is.SameAs(propertyValue)); + Assert.That(keyFrame.Value, Is.InstanceOf()); + Assert.That(((SolidColorBrush)keyFrame.Value!).Color.CurrentValue, Is.EqualTo(Colors.Green)); + }); + } + + [AvaloniaTest] + public void TextureDrawableTypeRepair_ResumesPersistenceInReplacementTransaction() + { + string root = CreateRoot(); + try + { + var sceneUri = new Uri(Path.Combine(root, "scene.scene")); + string elementPath = Path.Combine(root, "element.belm"); + var scene = new Scene(64, 64, "Scene") { Uri = sceneUri }; + var holder = new EditorValueHolder(); + var texture = new DrawableTextureSource(); + texture.Drawable.CurrentValue = new RectShape(); + holder.TextureValue.CurrentValue = texture; + var element = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(holder); + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject drawableJson = FindObjectWithProperty(elementJson, nameof(DrawableTextureSource.Drawable))! + [nameof(DrawableTextureSource.Drawable)]!.AsObject(); + drawableJson["$type"] = "[Beutl.Engine]Beutl.Graphics:MissingDrawable"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single(); + var recoveredHolder = (EditorValueHolder)recoveredElement.Objects.Single(); + var recoveredTexture = (DrawableTextureSource)recoveredHolder.TextureValue.CurrentValue!; + Assert.That(recoveredTexture.Drawable.CurrentValue, Is.InstanceOf()); + using var context = new EditorTestContext(recoveredElement); + var adapter = new SimplePropertyAdapter( + (SimpleProperty)recoveredHolder.TextureValue, + recoveredHolder); + using var viewModel = new TextureSourceEditorViewModel(adapter); + viewModel.Accept(new Visitor(recoveredElement, context.History)); + + viewModel.SetDrawableType(typeof(RectShape)); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = context.History.Undo(); + File.WriteAllBytes(elementPath, originalBytes); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + + Assert.Multiple(() => + { + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(recoveredTexture.Drawable.CurrentValue, Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + finally + { + Directory.Delete(root, true); + } + } + + private static KeyFrameAnimation CreateAnimation(KeyFrame keyFrame) + { + var animation = new KeyFrameAnimation(); + animation.KeyFrames.Add(keyFrame); + return animation; + } + + private static string CreateRoot() + { + string root = Path.Combine( + BeutlHomeIsolation.CurrentHome!, + $"fallback-editor-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + return root; + } + + private static (Uri SceneUri, string ElementPath) CreateAnimatedScene(string root) + { + var sceneUri = new Uri(Path.Combine(root, "scene.scene")); + string elementPath = Path.Combine(root, "element.belm"); + var shape = new RectShape(); + var animation = new KeyFrameAnimation(); + animation.KeyFrames.Add(new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = 32, + }); + shape.Width.Animation = animation; + var element = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(shape); + var scene = new Scene(64, 64, "Scene") { Uri = sceneUri }; + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + return (sceneUri, elementPath); + } + + private static JsonObject? FindObjectWithProperty(JsonNode node, string propertyName) + { + if (node is JsonObject obj) + { + if (obj.ContainsKey(propertyName)) + { + return obj; + } + + foreach ((string _, JsonNode? child) in obj) + { + if (child is not null && FindObjectWithProperty(child, propertyName) is { } result) + { + return result; + } + } + } + else if (node is JsonArray array) + { + foreach (JsonNode? child in array) + { + if (child is not null && FindObjectWithProperty(child, propertyName) is { } result) + { + return result; + } + } + } + + return null; + } + + private sealed class EditorTestContext : IDisposable + { + private readonly CoreObjectOperationObserver _observer; + private readonly IDisposable _subscription; + + public EditorTestContext(Hierarchical obj) + { + Element = obj as Element ?? new Element { Uri = new Uri("file:///editor-test.belm") }; + if (obj is not Beutl.ProjectSystem.Element) + { + Element.AddObject((EngineObject)obj); + } + + var sequence = new OperationSequenceGenerator(); + History = new HistoryManager(Element, sequence); + _observer = new CoreObjectOperationObserver(null, Element, sequence); + _subscription = History.Subscribe(_observer); + } + + public Element Element { get; } + + public HistoryManager History { get; } + + public void Dispose() + { + _subscription.Dispose(); + _observer.Dispose(); + History.Dispose(); + } + } + private sealed record Visitor(Element Element, HistoryManager History) : IServiceProvider, IPropertyEditorContextVisitor { diff --git a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs index 503660876b..6b6d097218 100644 --- a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs +++ b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs @@ -243,7 +243,7 @@ public void PasteOver_LastFallback_UndoRestoresPersistenceSuppressionAndPreserve } [Test] - public void PasteOver_LastFallbackWithNonFallbackIncident_KeepsPersistenceSuppressionAndPreservesSidecar() + public void PasteOver_StaleNonFallbackIncidentFlagWithoutLiveMarker_ResumesPersistence() { _service.Add(_element, new FallbackEngineObject()); byte[] originalBytes = "{ preserved lossy bytes"u8.ToArray(); @@ -258,8 +258,8 @@ public void PasteOver_LastFallbackWithNonFallbackIncident_KeepsPersistenceSuppre Assert.Multiple(() => { Assert.That(outcome, Is.EqualTo(ObjectPasteOutcome.Pasted)); - Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); - Assert.That(File.ReadAllBytes(_element.Uri.LocalPath), Is.EqualTo(originalBytes)); + Assert.That(_element.SuppressedStorageSource, Is.Null); + Assert.That(File.ReadAllBytes(_element.Uri.LocalPath), Is.Not.EqualTo(originalBytes)); }); } diff --git a/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs b/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs index c994fb3b5f..06b5b35912 100644 --- a/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs +++ b/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs @@ -8,6 +8,29 @@ namespace Beutl.UnitTests.Engine.Animation; public class KeyFrameTests { + public abstract class AbstractTestEasing : Easing + { + } + + public sealed class PrivateConstructorTestEasing : Easing + { + private PrivateConstructorTestEasing() + { + } + + public override float Ease(float progress) => progress; + } + + public sealed class ThrowingConstructorTestEasing : Easing + { + public ThrowingConstructorTestEasing() + { + throw new InvalidOperationException("Constructor failure."); + } + + public override float Ease(float progress) => progress; + } + [Test] public void Serialize_ShouldCorrectlySerializeLinearEasing() { @@ -84,4 +107,83 @@ public void Deserialize_ShouldCorrectlyDeserializeSplineEasing() Assert.That(easing.X2, Is.EqualTo(0.3f)); Assert.That(easing.Y2, Is.EqualTo(0.4f)); } + + [TestCase(typeof(AbstractTestEasing))] + [TestCase(typeof(PrivateConstructorTestEasing))] + [TestCase(typeof(ThrowingConstructorTestEasing))] + public void Deserialize_NonInstantiableEasing_RecordsIncidentAndUsesLinearEasing(Type easingType) + { + int incidentsBefore = DeserializationIncidents.FallbackCount; + KeyFrame keyFrame = Deserialize(TypeFormat.ToString(easingType)); + + Assert.Multiple(() => + { + Assert.That(keyFrame.Easing, Is.InstanceOf()); + Assert.That(keyFrame.HasLossyEasing, Is.True); + Assert.That(DeserializationIncidents.FallbackCount, Is.EqualTo(incidentsBefore + 1)); + }); + } + + [TestCase(42)] + [TestCase(true)] + public void Deserialize_NonStringPrimitiveEasing_RecordsIncident(object value) + { + int incidentsBefore = DeserializationIncidents.FallbackCount; + KeyFrame keyFrame = Deserialize(JsonValue.Create(value)); + + Assert.Multiple(() => + { + Assert.That(keyFrame.Easing, Is.InstanceOf()); + Assert.That(keyFrame.HasLossyEasing, Is.True); + Assert.That(DeserializationIncidents.FallbackCount, Is.EqualTo(incidentsBefore + 1)); + }); + } + + [Test] + public void Deserialize_UnhandledEasingShape_RecordsIncident() + { + int incidentsBefore = DeserializationIncidents.FallbackCount; + KeyFrame keyFrame = Deserialize(new JsonArray()); + + Assert.Multiple(() => + { + Assert.That(keyFrame.Easing, Is.InstanceOf()); + Assert.That(keyFrame.HasLossyEasing, Is.True); + Assert.That(DeserializationIncidents.FallbackCount, Is.EqualTo(incidentsBefore + 1)); + }); + } + + [Test] + public void Deserialize_PresentNullEasing_RecordsIncident() + { + int incidentsBefore = DeserializationIncidents.FallbackCount; + KeyFrame keyFrame = Deserialize(null); + + Assert.Multiple(() => + { + Assert.That(keyFrame.Easing, Is.InstanceOf()); + Assert.That(keyFrame.HasLossyEasing, Is.True); + Assert.That(DeserializationIncidents.FallbackCount, Is.EqualTo(incidentsBefore + 1)); + }); + } + + [Test] + public void SettingEasingAfterLossyDeserialization_ClearsMarker() + { + KeyFrame keyFrame = Deserialize("[Missing.Assembly]Missing.Namespace:MissingEasing"); + + keyFrame.Easing = new SplineEasing(); + + Assert.That(keyFrame.HasLossyEasing, Is.False); + } + + private static KeyFrame Deserialize(JsonNode? easingNode) + { + var keyFrame = new KeyFrame(); + var context = new Mock(); + context.Setup(c => c.GetValue(nameof(KeyFrame.Easing))).Returns(easingNode); + context.Setup(c => c.Contains(nameof(KeyFrame.Easing))).Returns(true); + keyFrame.Deserialize(context.Object); + return keyFrame; + } } diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 1ef40d9bff..f6312d0a39 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -4,8 +4,10 @@ using System.Text.Json.Nodes; using Beutl.Animation; using Beutl.Animation.Easings; +using Beutl.Composition; using Beutl.Editor; using Beutl.Engine; +using Beutl.Engine.Expressions; using Beutl.Graphics.Shapes; using Beutl.Graphics.Transformation; using Beutl.ProjectSystem; @@ -25,6 +27,7 @@ public IOExceptionElement() } } + [SuppressResourceClassGeneration] public sealed class ElementReferenceHolder : EngineObject { public ElementReferenceHolder() @@ -33,6 +36,8 @@ public ElementReferenceHolder() } public IProperty> Target { get; } = Property.Create>(); + + public IProperty ExpressionTarget { get; } = Property.Create(); } [SetUp] @@ -115,7 +120,7 @@ public void Save_UnresolvableKeyFrameEasing_PreservesSidecarBytes() } [Test] - public void Save_RepairedFallbackWithUnresolvableKeyFrameEasing_PreservesSidecarBytes() + public void Save_RepairedFallbackAndKeyFrameEasing_ResumesPersistenceAfterBothRepairs() { (Uri sceneUri, string elementPath) = CreatePersistedScene(); Element element = CoreSerializer.RestoreFromUri(new Uri(elementPath)); @@ -145,20 +150,61 @@ public void Save_RepairedFallbackWithUnresolvableKeyFrameEasing_PreservesSidecar Assert.That(recoveredElement.Objects[1], Is.InstanceOf()); recoveredElement.Objects[1] = new RectShape(); + SuppressedStorageSource? blocked = Scene.TryResumeElementPersistence(recoveredElement); + recoveredAnimation.KeyFrames.Single().Easing = new SplineEasing(); SuppressedStorageSource? resumed = Scene.TryResumeElementPersistence(recoveredElement); CoreSerializer.StoreToUri(recovered, sceneUri); Assert.Multiple(() => { - Assert.That(recoveredAnimation.KeyFrames.Single().Easing, Is.InstanceOf()); - Assert.That(resumed, Is.Null); - Assert.That(recoveredElement.SuppressedStorageSource, Is.Not.Null); - Assert.That(recoveredElement.SuppressedStorageSource!.HasNonFallbackIncidents, Is.True); + Assert.That(blocked, Is.Null); + Assert.That(recoveredAnimation.KeyFrames.Single().Easing, Is.InstanceOf()); + Assert.That(resumed, Is.Not.Null); + Assert.That(resumed!.HasNonFallbackIncidents, Is.True); + Assert.That(recoveredElement.SuppressedStorageSource, Is.Null); Assert.That(recoveredElement.Objects.OfType(), Is.Empty); - Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + Assert.That(File.ReadAllBytes(elementPath), Is.Not.EqualTo(originalBytes)); }); } + [TestCase("$type")] + [TestCase("@type")] + public void Restore_UnresolvableTopLevelType_UsesTypeNotFoundReason(string discriminatorKey) + { + const string MissingType = "[Missing.Assembly]Missing.Namespace:MissingElement"; + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + $$"""{"{{discriminatorKey}}":"{{MissingType}}","Id":"{{Guid.NewGuid()}}"}"""); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + var fallback = (IFallback)recovered.Objects.Single(); + + Assert.Multiple(() => + { + Assert.That(fallback.Reason, Is.EqualTo(FallbackReason.TypeNotFound)); + Assert.That(fallback.ErrorMessage, Is.Null); + Assert.That(fallback.Json!["$type"]!.GetValue(), Is.EqualTo(MissingType)); + }); + } + + [Test] + public void Restore_TopLevelTypeScannerPrefersDollarType() + { + const string PreferredType = "[Missing.Assembly]Missing.Namespace:PreferredElement"; + const string LegacyType = "[Missing.Assembly]Missing.Namespace:LegacyElement"; + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + $$"""{"@type":"{{LegacyType}}","$type":"{{PreferredType}}","Id":"{{Guid.NewGuid()}}"}"""); + + var fallback = (IFallback)CoreSerializer.RestoreFromUri(sceneUri) + .Children.Single() + .Objects.Single(); + + Assert.That(fallback.Json!["$type"]!.GetValue(), Is.EqualTo(PreferredType)); + } + [Test] public void Restore_MalformedElementWithoutReadableId_UsesStableId() { @@ -601,6 +647,30 @@ public void Restore_TopLevelIdMatchingSceneId_IsReassignedStably() }); } + [Test] + public void Restore_TopLevelIdMatchingTimelineLayerId_IsReassignedStably() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + var layer = new TimelineLayer { Id = Guid.NewGuid(), ZIndex = 1 }; + source.Layers.Add(layer); + CoreSerializer.StoreToUri(source, sceneUri); + File.WriteAllText(elementPath, $$"""{"Id":"{{layer.Id}}","Objects":["""); + + Scene firstLoad = CoreSerializer.RestoreFromUri(sceneUri); + Scene secondLoad = CoreSerializer.RestoreFromUri(sceneUri); + Guid first = firstLoad.Children.Single().Id; + Guid second = secondLoad.Children.Single().Id; + + Assert.Multiple(() => + { + Assert.That(firstLoad.Layers.Single().Id, Is.EqualTo(layer.Id)); + Assert.That(first, Is.Not.EqualTo(layer.Id)); + Assert.That(first, Is.Not.EqualTo(Guid.Empty)); + Assert.That(second, Is.EqualTo(first)); + }); + } + [Test] public void Restore_TopLevelIdMatchingHealthyDescendantId_IsReassignedStably() { @@ -755,6 +825,7 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() recoveredScene.Groups.Add(ImmutableHashSet.Create(placeholder.Id, healthy.Id)); var referenceHolder = new ElementReferenceHolder(); referenceHolder.Target.CurrentValue = new Reference(placeholder.Id); + referenceHolder.ExpressionTarget.Expression = new ReferenceExpression(placeholder.Id); healthy.AddObject(referenceHolder); CoreSerializer.StoreToUri(recoveredScene, sceneUri); @@ -771,14 +842,21 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() CoreSerializer.StoreToUri(repaired, repaired.Uri!); Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + // Expression evaluation resolves through the hierarchical root; give the standalone + // scene one, as the editor and agent sessions do in production. + var application = new BeutlApplication(); + var project = new Project(); + application.Project = project; + project.Items.Add(reloaded); Element reloadedRepaired = reloaded.Children.Single( child => child.Uri!.LocalPath == elementPaths[0]); Element reloadedHealthy = reloaded.Children.Single( child => child.Uri!.LocalPath == elementPaths[1]); - Reference migratedReference = reloadedHealthy.Objects + ElementReferenceHolder reloadedHolder = reloadedHealthy.Objects .OfType() - .Single() - .Target.CurrentValue; + .Single(); + Reference migratedReference = reloadedHolder.Target.CurrentValue; + var migratedExpression = (IReferenceExpression)reloadedHolder.ExpressionTarget.Expression!; Assert.Multiple(() => { @@ -788,6 +866,10 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() Is.EqualTo(ImmutableHashSet.Create(repairedId, healthy.Id))); Assert.That(migratedReference.Id, Is.EqualTo(repairedId)); Assert.That(migratedReference.Value, Is.SameAs(reloadedRepaired)); + Assert.That(migratedExpression.ObjectId, Is.EqualTo(repairedId)); + Assert.That( + reloadedHolder.ExpressionTarget.GetValue(CompositionContext.Default), + Is.SameAs(reloadedRepaired)); }); } From 7ad8efc8862b67bb5f1bf7c0013afeabd9b6da29 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sat, 8 Aug 2026 23:07:06 +0900 Subject: [PATCH 17/35] fix(review): resume persistence on remove/target assignment, harden easing and descendant-id recovery --- .../Serialization/CoreSerializer.cs | 41 ++++++ .../Services/ElementObjectService.cs | 8 ++ src/Beutl.Engine/Animation/KeyFrame.cs | 45 ++++--- .../ProjectSystem/Scene.cs | 115 ++++++++++------- src/Beutl.Utilities/ExceptionHelpers.cs | 47 +++++++ .../Editors/TextureSourceEditorViewModel.cs | 2 + .../ReconcilerIdIntegrityTests.cs | 1 - .../FallbackEditorPersistenceTests.cs | 78 +++++++++++- .../Services/ElementObjectServiceTests.cs | 62 ++++++++++ .../Engine/Animation/KeyFrameTests.cs | 81 +++++++++++- .../MalformedElementRecoveryTests.cs | 117 ++++++++++++++++++ 11 files changed, 528 insertions(+), 69 deletions(-) create mode 100644 src/Beutl.Utilities/ExceptionHelpers.cs diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index e88b464f51..37202a21e6 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -253,6 +253,18 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n { if (uri == suppressed.SourceUri) { + // The source location is skip-protected only while the on-disk bytes still match + // the retained recovery bytes. A repair that was undone (or any other mutation + // that rewrote the sidecar) must not leave repaired bytes on disk for a still- + // suppressed object: restore the retained bytes verbatim so the next open sees the + // same recovery state the undo recorded. + string sourcePath = uri.LocalPath; + if (File.Exists(sourcePath) + && !File.ReadAllBytes(sourcePath).AsSpan().SequenceEqual(suppressed.RawBytes)) + { + WriteBytesAtomically(sourcePath, suppressed.RawBytes); + } + return; } @@ -367,4 +379,33 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n throw new JsonException(); } } + + private static void WriteBytesAtomically(string path, byte[] bytes) + { + string tempPath = $"{path}.{Guid.NewGuid():N}.tmp"; + try + { + using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + + File.Move(tempPath, path, overwrite: true); + } + finally + { + try + { + File.Delete(tempPath); + } + catch + { + } + } + } } diff --git a/src/Beutl.Editor/Services/ElementObjectService.cs b/src/Beutl.Editor/Services/ElementObjectService.cs index d03c4add65..3ae73701d1 100644 --- a/src/Beutl.Editor/Services/ElementObjectService.cs +++ b/src/Beutl.Editor/Services/ElementObjectService.cs @@ -45,6 +45,14 @@ public bool Remove(Element element, EngineObject obj) if (!element.Objects.Contains(obj)) return false; element.RemoveObject(obj); + if (obj is IFallback + && Scene.TryResumeElementPersistence(element) is { } suppression) + { + _historyManager.Record( + () => element.SuppressedStorageSource = null, + () => element.SuppressedStorageSource = suppression); + } + _historyManager.Commit(CommandNames.RemoveObject); return true; } diff --git a/src/Beutl.Engine/Animation/KeyFrame.cs b/src/Beutl.Engine/Animation/KeyFrame.cs index f1d0bee380..afaf1f167a 100644 --- a/src/Beutl.Engine/Animation/KeyFrame.cs +++ b/src/Beutl.Engine/Animation/KeyFrame.cs @@ -1,9 +1,11 @@ using System.Reflection; +using System.Runtime.ExceptionServices; using System.Text.Json; using System.Text.Json.Nodes; using Beutl.Animation.Easings; using Beutl.Serialization; +using Beutl.Utilities; using Beutl.Validation; namespace Beutl.Animation; @@ -13,6 +15,7 @@ public class KeyFrame : Hierarchical public static readonly CoreProperty EasingProperty; public static readonly CoreProperty KeyTimeProperty; private Easing _easing; + private Easing? _lossyFallbackEasing; private TimeSpan _keyTime; protected KeyFrame() @@ -36,15 +39,11 @@ static KeyFrame() public Easing Easing { get => _easing; - set - { - HasLossyEasing = false; - SetAndRaise(EasingProperty, ref _easing, value); - } + set => SetAndRaise(EasingProperty, ref _easing, value); } [NotAutoSerialized] - internal bool HasLossyEasing { get; private set; } + internal bool HasLossyEasing => ReferenceEquals(_easing, _lossyFallbackEasing); public TimeSpan KeyTime { @@ -97,25 +96,35 @@ or TargetInvocationException or TypeInitializationException or NotSupportedException) { + if (ExceptionHelpers.ContainsFileSystemFailure(ex)) + { + if (ex.InnerException is { } inner) + { + ExceptionDispatchInfo.Capture(inner).Throw(); + } + + throw; + } + UseFallbackEasing(); } } } else if (easingNode is JsonObject easingObject) { - try + if (easingObject.Count == 4 + && easingObject["X1"] is JsonValue x1Value + && easingObject["Y1"] is JsonValue y1Value + && easingObject["X2"] is JsonValue x2Value + && easingObject["Y2"] is JsonValue y2Value + && x1Value.TryGetValue(out float x1) + && y1Value.TryGetValue(out float y1) + && x2Value.TryGetValue(out float x2) + && y2Value.TryGetValue(out float y2)) { - float x1 = (float?)easingObject["X1"] ?? 0; - float y1 = (float?)easingObject["Y1"] ?? 0; - float x2 = (float?)easingObject["X2"] ?? 1; - float y2 = (float?)easingObject["Y2"] ?? 1; - Easing = new SplineEasing(x1, y1, x2, y2); } - catch (Exception ex) when (ex is JsonException - or FormatException - or InvalidOperationException - or ArgumentException) + else { UseFallbackEasing(); } @@ -129,8 +138,8 @@ or InvalidOperationException private void UseFallbackEasing() { DeserializationIncidents.RecordFallback(); - Easing = new LinearEasing(); - HasLossyEasing = true; + _lossyFallbackEasing = new LinearEasing(); + Easing = _lossyFallbackEasing; } public override void Serialize(ICoreSerializationContext context) diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 72ae79a602..fb6b57f493 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -81,6 +81,8 @@ public class Scene : ProjectItem, INotifyEdited = new(ReferenceEqualityComparer.Instance); private readonly Dictionary _recoveredElementIds = new(StringComparer.Ordinal); private readonly Dictionary _pendingRecoveredElementIdMigrations = []; + private readonly Dictionary _pendingRecoveredDescendantIdMigrations = []; + private readonly System.Collections.Concurrent.ConcurrentDictionary _idlessRecoveredDescendants = new(); private TimeSpan _start = TimeSpan.FromMinutes(0); private TimeSpan _duration = TimeSpan.FromMinutes(5); private PixelSize _frameSize; @@ -647,6 +649,8 @@ static void Process(Func add, JsonNode node, List list) } _pendingRecoveredElementIdMigrations.Clear(); + _pendingRecoveredDescendantIdMigrations.Clear(); + _idlessRecoveredDescendants.Clear(); _recoveredDescendantIds.Clear(); _recoveredDescendantRemaps.Clear(); _recoveredElementIds.Clear(); @@ -886,6 +890,7 @@ var pendingDescendantRemaps foreach ((Element child, string relativePath) in recoveredChildren) { var occurrences = new Dictionary(); + int idlessOccurrence = 0; foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { if (!seenDescendants.Add(descendant)) @@ -893,9 +898,16 @@ var pendingDescendantRemaps continue; } - Guid originalId = descendant.Id; - int occurrence = occurrences.GetValueOrDefault(originalId); - occurrences[originalId] = occurrence + 1; + bool idless = _idlessRecoveredDescendants.ContainsKey(descendant); + Guid originalId = idless ? Guid.Empty : descendant.Id; + int occurrence = idless + ? idlessOccurrence++ + : occurrences.GetValueOrDefault(originalId); + if (!idless) + { + occurrences[originalId] = occurrence + 1; + } + string remapKey = CreateRecoveredDescendantKey(relativePath, originalId, occurrence); if (persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId)) { @@ -975,6 +987,11 @@ private void RecordRecoveredDescendantRemap( { _recoveredDescendantIds[remapKey] = assignedId; _recoveredDescendantRemaps[descendant] = (originalId, assignedId, occurrence); + if (originalId != Guid.Empty) + { + _pendingRecoveredDescendantIdMigrations.TryAdd(originalId, assignedId); + } + if (descendant is IFallback fallback) { EnsureFallbackProjection(fallback); @@ -994,6 +1011,12 @@ private Element RestoreElementOrFallback(Uri uri) { foreach (IFallback fallback in fallbacks) { + if (fallback is CoreObject fallbackObject + && !HasSerializedId(fallback.Json)) + { + _idlessRecoveredDescendants.TryAdd(fallbackObject, 0); + } + EnsureFallbackProjection(fallback); } @@ -1009,7 +1032,7 @@ private Element RestoreElementOrFallback(Uri uri) // Any non-filesystem failure is a content problem the recovery path must absorb — value // converters throw freely (e.g. FormatException from Color.Parse); filesystem failures // still propagate so a genuinely unreadable project keeps failing loudly. - catch (Exception ex) when (!ContainsFileSystemFailure(ex)) + catch (Exception ex) when (!ExceptionHelpers.ContainsFileSystemFailure(ex)) { // Raw bytes, not text: the sidecar must survive rehoming byte-identically even when it // holds a BOM, another encoding, or undecodable bytes. The lossy decode is only scanned @@ -1038,11 +1061,22 @@ private Element RestoreElementOrFallback(Uri uri) }; fallback.Json = CreateFallbackProjection(fallback, topLevelTypeName); element.AddObject(fallback); + _idlessRecoveredDescendants.TryAdd(fallback, 0); MarkRecoveredElement(element, rawBytes, uri); return element; } } + private static bool HasSerializedId(JsonObject? json) + { + return json is not null + && json.TryGetPropertyValue(nameof(CoreObject.Id), out JsonNode? idNode) + && idNode is JsonValue idValue + && idValue.TryGetValue(out string? idText) + && Guid.TryParse(idText, out Guid id) + && id != Guid.Empty; + } + private static void MarkRecoveredElement( Element element, byte[] rawBytes, @@ -1068,39 +1102,6 @@ private static void MarkRecoveredElement( return source; } - private static bool ContainsFileSystemFailure(Exception exception) - { - var pending = new Stack(); - var visited = new HashSet(ReferenceEqualityComparer.Instance); - pending.Push(exception); - while (pending.TryPop(out Exception? current)) - { - if (!visited.Add(current)) - { - continue; - } - - if (current is IOException or UnauthorizedAccessException) - { - return true; - } - - if (current is AggregateException aggregate) - { - foreach (Exception inner in aggregate.InnerExceptions) - { - pending.Push(inner); - } - } - else if (current.InnerException is { } inner) - { - pending.Push(inner); - } - } - - return false; - } - private static IEnumerable EnumerateSerializedGraphFallbacks(Element element) { return EnumerateSerializedGraphObjects(element).OfType(); @@ -1115,7 +1116,8 @@ private static IEnumerable EnumerateSerializedGraphDescendants(Eleme private void MigrateRecoveredElementReferences() { - if (_pendingRecoveredElementIdMigrations.Count == 0) + if (_pendingRecoveredElementIdMigrations.Count == 0 + && _pendingRecoveredDescendantIdMigrations.Count == 0) { return; } @@ -1127,18 +1129,13 @@ private void MigrateRecoveredElementReferences() foreach (IProperty property in engineObject.Properties) { if (property.CurrentValue is IReference reference - && _pendingRecoveredElementIdMigrations.TryGetValue(reference.Id, out Guid migratedId)) + && (TryGetMigratedId(reference.Id, out Guid migratedId))) { - Element? target = Children.FirstOrDefault(child => child.Id == migratedId); - property.CurrentValue = target is not null && reference.ObjectType.IsInstanceOfType(target) - ? reference.Resolved(target) - : Activator.CreateInstance(reference.GetType(), migratedId)!; + property.CurrentValue = ResolveMigratedReference(reference, migratedId); } if (property.Expression is IReferenceExpression referenceExpression - && _pendingRecoveredElementIdMigrations.TryGetValue( - referenceExpression.ObjectId, - out Guid migratedExpressionId)) + && TryGetMigratedId(referenceExpression.ObjectId, out Guid migratedExpressionId)) { property.Expression = (IExpression)Activator.CreateInstance( referenceExpression.GetType(), @@ -1150,6 +1147,22 @@ private void MigrateRecoveredElementReferences() } } + private bool TryGetMigratedId(Guid originalId, out Guid migratedId) + { + return _pendingRecoveredElementIdMigrations.TryGetValue(originalId, out migratedId) + || _pendingRecoveredDescendantIdMigrations.TryGetValue(originalId, out migratedId); + } + + private object ResolveMigratedReference(IReference reference, Guid migratedId) + { + CoreObject? target = EnumerateSerializedGraphObjects(Children) + .OfType() + .FirstOrDefault(candidate => candidate.Id == migratedId); + return target is not null && reference.ObjectType.IsInstanceOfType(target) + ? reference.Resolved(target) + : Activator.CreateInstance(reference.GetType(), migratedId)!; + } + private static IEnumerable EnumerateSerializedGraphObjects(object root) { var objects = new List(); @@ -1191,13 +1204,21 @@ private static void CollectSerializedGraphObjects( { foreach (IKeyFrame keyFrame in animation.KeyFrames) { + CollectSerializedGraphObjects(keyFrame, visited, objects); CollectSerializedGraphObjects(keyFrame.Value, visited, objects); } } } } - if (value is IEnumerable enumerable) + if (value is System.Collections.IDictionary dictionary) + { + foreach (object? item in dictionary.Values) + { + CollectSerializedGraphObjects(item, visited, objects); + } + } + else if (value is IEnumerable enumerable) { foreach (object? item in enumerable) { diff --git a/src/Beutl.Utilities/ExceptionHelpers.cs b/src/Beutl.Utilities/ExceptionHelpers.cs new file mode 100644 index 0000000000..c0dce1d802 --- /dev/null +++ b/src/Beutl.Utilities/ExceptionHelpers.cs @@ -0,0 +1,47 @@ +namespace Beutl.Utilities; + +/// +/// Exception inspection helpers shared by recovery paths that must distinguish +/// environmental failures (I/O, access) from content damage. +/// +public static class ExceptionHelpers +{ + /// + /// Returns when the exception chain contains an + /// or , + /// including failures wrapped by reflection or aggregation. + /// + public static bool ContainsFileSystemFailure(Exception exception) + { + var pending = new Stack(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + pending.Push(exception); + + while (pending.TryPop(out Exception? current)) + { + if (!visited.Add(current)) + { + continue; + } + + if (current is IOException or UnauthorizedAccessException) + { + return true; + } + + if (current is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + { + pending.Push(inner); + } + } + else if (current.InnerException is { } inner) + { + pending.Push(inner); + } + } + + return false; + } +} diff --git a/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs b/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs index 9506df3a0a..bcc1b34fd4 100644 --- a/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs @@ -133,9 +133,11 @@ public void SetDrawableTarget(Drawable target) && presenterDrawable is IPresenter presenterInterface && Value.Value is DrawableTextureSource drawableSource) { + Drawable? previous = drawableSource.Drawable.CurrentValue; var expression = Expression.CreateReference(target.Id); presenterInterface.Target.Expression = expression; drawableSource.Drawable.CurrentValue = presenterDrawable; + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } diff --git a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs index b41cf1025c..63a5b43222 100644 --- a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs @@ -48,7 +48,6 @@ public void Apply_repair_of_last_fallback_resumes_persistence_in_same_transactio CoreSerializer.StoreToUri(recovered, recovered.Uri!); byte[] repairedBytes = File.ReadAllBytes(elementPath); bool undone = session.History.Undo(); - File.WriteAllBytes(elementPath, originalBytes); CoreSerializer.StoreToUri(recovered, recovered.Uri!); Assert.Multiple(() => diff --git a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs index 5b75d9edda..12896f3c76 100644 --- a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs +++ b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs @@ -43,6 +43,8 @@ public EditorValueHolder() [AvaloniaTest] public void BrushTryPasteJson_LastFallbackResumesPersistenceInReplacementTransaction() { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + string root = Path.Combine( BeutlHomeIsolation.CurrentHome!, $"fallback-editor-{Guid.NewGuid():N}"); @@ -92,7 +94,6 @@ public void BrushTryPasteJson_LastFallbackResumesPersistenceInReplacementTransac CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); byte[] repairedBytes = File.ReadAllBytes(elementPath); bool undone = history.Undo(); - File.WriteAllBytes(elementPath, originalBytes); CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); Assert.Multiple(() => @@ -116,6 +117,8 @@ public void BrushTryPasteJson_LastFallbackResumesPersistenceInReplacementTransac [AvaloniaTest] public void EasingRepair_ResumesPersistenceAndWritesRepairedSidecar() { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + string root = CreateRoot(); try { @@ -155,6 +158,8 @@ public void EasingRepair_ResumesPersistenceAndWritesRepairedSidecar() [AvaloniaTest] public void CoreObjectApplyTemplate_UpdatesEditingKeyFrameOnly() { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + var holder = new EditorValueHolder(); var property = (AnimatableProperty)holder.CoreValue; var propertyValue = new RectShape(); @@ -181,6 +186,8 @@ public void CoreObjectApplyTemplate_UpdatesEditingKeyFrameOnly() [AvaloniaTest] public void GeometryApplyTemplate_UpdatesEditingKeyFrameOnly() { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + var holder = new EditorValueHolder(); var property = (AnimatableProperty)holder.GeometryValue; var propertyValue = new RectGeometry(); @@ -207,6 +214,8 @@ public void GeometryApplyTemplate_UpdatesEditingKeyFrameOnly() [AvaloniaTest] public void BrushApplyTemplate_UpdatesEditingKeyFrameOnly() { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + var holder = new EditorValueHolder(); var property = (AnimatableProperty)holder.BrushValue; var propertyValue = new SolidColorBrush(Colors.Red); @@ -234,6 +243,8 @@ public void BrushApplyTemplate_UpdatesEditingKeyFrameOnly() [AvaloniaTest] public void TextureDrawableTypeRepair_ResumesPersistenceInReplacementTransaction() { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + string root = CreateRoot(); try { @@ -276,7 +287,70 @@ public void TextureDrawableTypeRepair_ResumesPersistenceInReplacementTransaction CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); byte[] repairedBytes = File.ReadAllBytes(elementPath); bool undone = context.History.Undo(); - File.WriteAllBytes(elementPath, originalBytes); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + + Assert.Multiple(() => + { + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(recoveredTexture.Drawable.CurrentValue, Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + finally + { + Directory.Delete(root, true); + } + } + + [AvaloniaTest] + public void TextureDrawableTargetRepair_ResumesPersistenceInReplacementTransaction() + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + string root = CreateRoot(); + try + { + var sceneUri = new Uri(Path.Combine(root, "scene.scene")); + string elementPath = Path.Combine(root, "element.belm"); + var scene = new Scene(64, 64, "Scene") { Uri = sceneUri }; + var holder = new EditorValueHolder(); + var texture = new DrawableTextureSource(); + texture.Drawable.CurrentValue = new RectShape(); + holder.TextureValue.CurrentValue = texture; + var element = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(holder); + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject drawableJson = FindObjectWithProperty(elementJson, nameof(DrawableTextureSource.Drawable))! + [nameof(DrawableTextureSource.Drawable)]!.AsObject(); + drawableJson["$type"] = "[Beutl.Engine]Beutl.Graphics:MissingDrawable"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single(); + var recoveredHolder = (EditorValueHolder)recoveredElement.Objects.Single(); + var recoveredTexture = (DrawableTextureSource)recoveredHolder.TextureValue.CurrentValue!; + Assert.That(recoveredTexture.Drawable.CurrentValue, Is.InstanceOf()); + using var context = new EditorTestContext(recoveredElement); + var adapter = new SimplePropertyAdapter( + (SimpleProperty)recoveredHolder.TextureValue, + recoveredHolder); + using var viewModel = new TextureSourceEditorViewModel(adapter); + viewModel.Accept(new Visitor(recoveredElement, context.History)); + + var target = new RectShape(); + viewModel.SetDrawableTarget(target); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = context.History.Undo(); CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); Assert.Multiple(() => diff --git a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs index 6b6d097218..ba3b019fcd 100644 --- a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs +++ b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs @@ -116,6 +116,68 @@ public void Remove_Present_RemovesAndCommits() }); } + [Test] + public void Remove_LastFallback_ClearsPersistenceSuppression() + { + var fallback = new FallbackEngineObject(); + _service.Add(_element, fallback); + var suppression = new SuppressedStorageSource([], _element.Uri!); + _element.SuppressedStorageSource = suppression; + + bool removed = _service.Remove(_element, fallback); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(_element.Objects, Is.Empty); + Assert.That(_element.SuppressedStorageSource, Is.Null); + }); + } + + [Test] + public void Remove_LastFallback_UndoRestoresPersistenceSuppressionAndPreservesSidecar() + { + var fallback = new FallbackEngineObject(); + _service.Add(_element, fallback); + byte[] originalBytes = "{ preserved fallback bytes"u8.ToArray(); + File.WriteAllBytes(_element.Uri!.LocalPath, originalBytes); + var suppression = new SuppressedStorageSource(originalBytes, _element.Uri); + _element.SuppressedStorageSource = suppression; + + bool removed = _service.Remove(_element, fallback); + bool undone = _history.Undo(); + CoreSerializer.StoreToUri(_element, _element.Uri); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(undone, Is.True); + Assert.That(_element.Objects.Single(), Is.SameAs(fallback)); + Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); + Assert.That(File.ReadAllBytes(_element.Uri.LocalPath), Is.EqualTo(originalBytes)); + }); + } + + [Test] + public void Remove_WhenAnotherFallbackRemains_KeepsPersistenceSuppression() + { + var first = new FallbackEngineObject(); + var second = new FallbackEngineObject(); + _service.Add(_element, first); + _service.Add(_element, second); + var suppression = new SuppressedStorageSource([], _element.Uri!); + _element.SuppressedStorageSource = suppression; + + bool removed = _service.Remove(_element, first); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(_element.Objects.Single(), Is.SameAs(second)); + Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); + }); + } + [Test] public void Move_SameIndex_NoOp() { diff --git a/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs b/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs index 06b5b35912..f3e106b431 100644 --- a/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs +++ b/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Nodes; +using System.IO; +using System.Text.Json.Nodes; using Beutl.Animation; using Beutl.Animation.Easings; using Beutl.Serialization; @@ -31,6 +32,16 @@ public ThrowingConstructorTestEasing() public override float Ease(float progress) => progress; } + public sealed class FilesystemThrowingConstructorTestEasing : Easing + { + public FilesystemThrowingConstructorTestEasing() + { + throw new IOException("Constructor could not access its storage."); + } + + public override float Ease(float progress) => progress; + } + [Test] public void Serialize_ShouldCorrectlySerializeLinearEasing() { @@ -177,6 +188,74 @@ public void SettingEasingAfterLossyDeserialization_ClearsMarker() Assert.That(keyFrame.HasLossyEasing, Is.False); } + [Test] + public void RestoringLossyFallbackEasingViaSetter_RestoresMarker() + { + KeyFrame keyFrame = Deserialize("[Missing.Assembly]Missing.Namespace:MissingEasing"); + Easing lossyFallback = keyFrame.Easing; + Assert.That(keyFrame.HasLossyEasing, Is.True); + + // Simulates the undo path: UpdatePropertyValueOperation routes the old value back + // through the Easing setter, which must restore the lossy marker by identity. + keyFrame.Easing = new SplineEasing(); + Assert.That(keyFrame.HasLossyEasing, Is.False); + + keyFrame.Easing = lossyFallback; + + Assert.That(keyFrame.HasLossyEasing, Is.True); + } + + [Test] + public void Deserialize_FilesystemFailureFromEasingConstructor_Propagates() + { + int incidentsBefore = DeserializationIncidents.FallbackCount; + + Assert.Throws( + () => Deserialize(TypeFormat.ToString(typeof(FilesystemThrowingConstructorTestEasing)))); + + Assert.That(DeserializationIncidents.FallbackCount, Is.EqualTo(incidentsBefore)); + } + + [Test] + public void Deserialize_UnknownTypedObjectEasing_RecordsIncident() + { + int incidentsBefore = DeserializationIncidents.FallbackCount; + var easingObject = new JsonObject + { + ["$type"] = "[Missing.Assembly]Missing.Namespace:MissingEasing", + ["Unrelated"] = 42, + }; + + KeyFrame keyFrame = Deserialize(easingObject); + + Assert.Multiple(() => + { + Assert.That(keyFrame.Easing, Is.InstanceOf()); + Assert.That(keyFrame.HasLossyEasing, Is.True); + Assert.That(DeserializationIncidents.FallbackCount, Is.EqualTo(incidentsBefore + 1)); + }); + } + + [Test] + public void Deserialize_ObjectEasingWithPartialSplineCoordinates_RecordsIncident() + { + int incidentsBefore = DeserializationIncidents.FallbackCount; + var easingObject = new JsonObject + { + ["X1"] = 0.1f, + ["Y1"] = 0.2f, + }; + + KeyFrame keyFrame = Deserialize(easingObject); + + Assert.Multiple(() => + { + Assert.That(keyFrame.Easing, Is.InstanceOf()); + Assert.That(keyFrame.HasLossyEasing, Is.True); + Assert.That(DeserializationIncidents.FallbackCount, Is.EqualTo(incidentsBefore + 1)); + }); + } + private static KeyFrame Deserialize(JsonNode? easingNode) { var keyFrame = new KeyFrame(); diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index f6312d0a39..c1ac0fb57a 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -40,6 +40,29 @@ public ElementReferenceHolder() public IProperty ExpressionTarget { get; } = Property.Create(); } + [SuppressResourceClassGeneration] + public sealed class DictionaryTransformHolder : EngineObject + { + public DictionaryTransformHolder() + { + ScanProperties(); + } + + public IProperty> Transforms { get; } + = Property.Create>(); + } + + [SuppressResourceClassGeneration] + public sealed class TransformReferenceHolder : EngineObject + { + public TransformReferenceHolder() + { + ScanProperties(); + } + + public IProperty> Target { get; } = Property.Create>(); + } + [SetUp] public void SetUp() { @@ -1105,6 +1128,100 @@ public void StoreToUri_RehomeTarget_NeverOverwritesAnExistingFile() Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(repairedBytes)); } + [Test] + public void Restore_IdlessRecoveredDescendant_IsAssignedStableOccurrenceId() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("recovered.belm"); + Element source = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + var sourceShape = (RectShape)source.Objects.Single(); + sourceShape.Transform.CurrentValue = new RotationTransform(); + CoreSerializer.StoreToUri(source, source.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[0]))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + transformJson.Remove(nameof(CoreObject.Id)); + File.WriteAllText(elementPaths[0], json.ToJsonString()); + + Scene firstLoad = CoreSerializer.RestoreFromUri(sceneUri); + Guid firstId = ((CoreObject)GetTransformFallback(firstLoad, elementPaths[0])).Id; + CoreSerializer.StoreToUri(firstLoad, sceneUri); + + Scene secondLoad = CoreSerializer.RestoreFromUri(sceneUri); + Guid secondId = ((CoreObject)GetTransformFallback(secondLoad, elementPaths[0])).Id; + + Assert.Multiple(() => + { + Assert.That(firstId, Is.Not.EqualTo(Guid.Empty)); + Assert.That(secondId, Is.EqualTo(firstId)); + }); + } + + [Test] + public void TryResumeElementPersistence_DictionaryValuedFallback_StaysBlocked() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Element element = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var holder = new DictionaryTransformHolder(); + holder.Transforms.CurrentValue = new Dictionary + { + ["rotation"] = new FallbackTransform(), + }; + element.AddObject(holder); + byte[] originalBytes = "{ preserved fallback bytes"u8.ToArray(); + File.WriteAllBytes(elementPath, originalBytes); + element.SuppressedStorageSource = new SuppressedStorageSource(originalBytes, element.Uri!); + + SuppressedStorageSource? suppression = Scene.TryResumeElementPersistence(element); + + Assert.That(suppression, Is.Null); + } + + [Test] + public void Restore_RemappedRecoveredDescendant_MigratesDirectReference() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("healthy.belm", "recovered.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element healthySource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + var healthyShapeSource = (RectShape)healthySource.Objects.Single(); + healthyShapeSource.Transform.CurrentValue = new RotationTransform(); + Guid claimantId = healthyShapeSource.Transform.CurrentValue!.Id; + Element recoveredSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + var recoveredShapeSource = (RectShape)recoveredSource.Objects.Single(); + recoveredShapeSource.Transform.CurrentValue = new RotationTransform { Id = claimantId }; + var referenceHolder = new TransformReferenceHolder(); + referenceHolder.Target.CurrentValue = new Reference(claimantId); + healthySource.AddObject(referenceHolder); + CoreSerializer.StoreToUri(recoveredSource, recoveredSource.Uri!); + CoreSerializer.StoreToUri(healthySource, healthySource.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[1]))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + transformJson[nameof(CoreObject.Id)] = claimantId.ToString(); + File.WriteAllText(elementPaths[1], json.ToJsonString()); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredHealthy = recovered.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + Element recoveredElement = recovered.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + Guid remappedId = ((CoreObject)GetTransformFallback(recovered, elementPaths[1])).Id; + var migratedReference = (Reference)recoveredHealthy.Objects + .OfType() + .Single() + .Target.CurrentValue; + + Assert.Multiple(() => + { + Assert.That(remappedId, Is.Not.EqualTo(claimantId)); + Assert.That(migratedReference.Id, Is.EqualTo(remappedId)); + Assert.That( + migratedReference.Value, + Is.SameAs(((RectShape)recoveredElement.Objects.Single()).Transform.CurrentValue)); + }); + } + [Test] public void SaveAs_CopiesRecoveredSidecarBytesToTheNewLocation() { From eb982cec885f580c50374ab99f9a91b7a38a9f61 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 10:00:26 +0900 Subject: [PATCH 18/35] fix(review): preserve recovered references and sidecars --- .../Reconciliation/Reconciler.cs | 6 +- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 9 ++- .../Serialization/CoreSerializer.cs | 16 +++-- .../Serialization/SuppressedStorageSource.cs | 11 +++- .../Services/ElementObjectService.cs | 19 +++--- .../Expressions/IReferenceExpression.cs | 29 ++++++++- .../Engine/Expressions/ReferenceExpression.cs | 5 ++ .../ProjectSystem/Scene.cs | 30 +++++++-- .../ViewModels/Editors/BaseEditorViewModel.cs | 6 +- .../Tools/SessionToolsTests.cs | 25 +++++++- .../Services/ElementObjectServiceTests.cs | 52 ++++++++++++++++ .../MalformedElementRecoveryTests.cs | 61 +++++++++++++++++-- 12 files changed, 240 insertions(+), 29 deletions(-) diff --git a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs index 113668252d..5a9256e55f 100644 --- a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs +++ b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs @@ -346,7 +346,11 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I { session.History.Record( () => element.SuppressedStorageSource = null, - () => element.SuppressedStorageSource = suppression); + () => + { + suppression.WasReinstated = true; + element.SuppressedStorageSource = suppression; + }); } } }, diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 949630a7a6..51a933669d 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -196,7 +196,14 @@ private static void CollectFallbacks( } } - if (value is IEnumerable enumerable) + if (value is System.Collections.IDictionary dictionary) + { + foreach (object? item in dictionary.Values) + { + CollectFallbacks(item, visited, fallbacks); + } + } + else if (value is IEnumerable enumerable) { foreach (object? item in enumerable) { diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 37202a21e6..a2fd096d5c 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -254,17 +254,23 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n if (uri == suppressed.SourceUri) { // The source location is skip-protected only while the on-disk bytes still match - // the retained recovery bytes. A repair that was undone (or any other mutation - // that rewrote the sidecar) must not leave repaired bytes on disk for a still- - // suppressed object: restore the retained bytes verbatim so the next open sees the - // same recovery state the undo recorded. + // the retained recovery bytes. A repair that was undone re-establishes the + // suppression record through history with WasReinstated set, and the retained + // bytes must be restored verbatim so the next open sees the same recovery state + // the undo recorded. A continuously held record treats a mismatch as an external + // repair of the sidecar and leaves the changed file alone — clobbering it would + // destroy the user's repair. string sourcePath = uri.LocalPath; if (File.Exists(sourcePath) - && !File.ReadAllBytes(sourcePath).AsSpan().SequenceEqual(suppressed.RawBytes)) + && !File.ReadAllBytes(sourcePath).AsSpan().SequenceEqual(suppressed.RawBytes) + && suppressed.WasReinstated) { WriteBytesAtomically(sourcePath, suppressed.RawBytes); } + // A reinstated record is consumed on first observation: once the disk state has + // been reconciled (restored or already matching), a later mismatch is external. + suppressed.WasReinstated = false; return; } diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs index 2efe55c4b6..ad40ca4978 100644 --- a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs +++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs @@ -8,4 +8,13 @@ internal sealed record SuppressedStorageSource( byte[] RawBytes, Uri SourceUri, - bool HasNonFallbackIncidents = false); + bool HasNonFallbackIncidents = false) +{ + /// + /// True when this suppression record was put back by undoing an in-process repair. Only a + /// reinstated record may restore the retained bytes over a mismatched sidecar; a continuously + /// held record treats a mismatch as an external repair and leaves the changed file alone. + /// Cleared once the retained bytes have been restored. + /// + public bool WasReinstated { get; set; } +} diff --git a/src/Beutl.Editor/Services/ElementObjectService.cs b/src/Beutl.Editor/Services/ElementObjectService.cs index 3ae73701d1..a4bd221ab4 100644 --- a/src/Beutl.Editor/Services/ElementObjectService.cs +++ b/src/Beutl.Editor/Services/ElementObjectService.cs @@ -45,12 +45,15 @@ public bool Remove(Element element, EngineObject obj) if (!element.Objects.Contains(obj)) return false; element.RemoveObject(obj); - if (obj is IFallback - && Scene.TryResumeElementPersistence(element) is { } suppression) + if (Scene.TryResumeElementPersistence(element) is { } suppression) { _historyManager.Record( () => element.SuppressedStorageSource = null, - () => element.SuppressedStorageSource = suppression); + () => + { + suppression.WasReinstated = true; + element.SuppressedStorageSource = suppression; + }); } _historyManager.Commit(CommandNames.RemoveObject); @@ -88,18 +91,20 @@ public ObjectPasteOutcome PasteOver(Element element, int index, string json) try { - EngineObject previous = element.Objects[index]; EngineObject? obj = Activator.CreateInstance(type) as EngineObject; if (obj is null) return ObjectPasteOutcome.MissingType; CoreSerializer.PopulateFromJsonObject(obj, type, newJson); element.Objects[index] = obj; - if (previous is IFallback - && Scene.TryResumeElementPersistence(element) is { } suppression) + if (Scene.TryResumeElementPersistence(element) is { } suppression) { _historyManager.Record( () => element.SuppressedStorageSource = null, - () => element.SuppressedStorageSource = suppression); + () => + { + suppression.WasReinstated = true; + element.SuppressedStorageSource = suppression; + }); } _historyManager.Commit(CommandNames.PasteObject); diff --git a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs index 0ef9839948..e215ddf2a2 100644 --- a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs +++ b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs @@ -1,4 +1,6 @@ -namespace Beutl.Engine.Expressions; +using System.Reflection; + +namespace Beutl.Engine.Expressions; // Non-generic view of a ReferenceExpression so a consumer that only has an IExpression (no static T) // can read the referenced object id and property path without evaluating the expression. @@ -9,4 +11,29 @@ public interface IReferenceExpression : IExpression string PropertyPath { get; } bool HasPropertyPath { get; } + + /// + /// Returns an equivalent expression targeting , preserving the + /// concrete implementation and its property path, or when the + /// implementation cannot be rebuilt (the original expression is then left in place). The + /// default tries a public (Guid, string) constructor; implementations that cannot be + /// rebuilt that way must override this method. + /// + IReferenceExpression? Rebind(Guid objectId) + { + try + { + return (IReferenceExpression?)Activator.CreateInstance( + GetType(), + objectId, + PropertyPath); + } + catch (Exception ex) when (ex is MissingMethodException + or TargetInvocationException + or ArgumentException + or InvalidCastException) + { + return null; + } + } } diff --git a/src/Beutl.Engine/Engine/Expressions/ReferenceExpression.cs b/src/Beutl.Engine/Engine/Expressions/ReferenceExpression.cs index f06af10b4d..d6df0ea970 100644 --- a/src/Beutl.Engine/Engine/Expressions/ReferenceExpression.cs +++ b/src/Beutl.Engine/Engine/Expressions/ReferenceExpression.cs @@ -24,6 +24,11 @@ public ReferenceExpression(Guid objectId, string? propertyPath) public bool HasPropertyPath => !string.IsNullOrEmpty(PropertyPath); + public IReferenceExpression? Rebind(Guid objectId) + { + return new ReferenceExpression(objectId, PropertyPath); + } + public string ExpressionString => HasPropertyPath ? $"{ObjectId}.{PropertyPath}" : ObjectId.ToString(); diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index fb6b57f493..09ad202086 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -976,6 +976,28 @@ var pendingDescendantRemaps } } } + + // A global OriginalId -> AssignedId migration would redirect every reference that still + // targets a surviving object. Only migrate when the original ID was abandoned entirely; + // otherwise the references keep pointing at the object that retained it. + var retainedIds = new HashSet { Guid.Empty, Id }; + foreach (CoreObject graphObject in EnumerateSerializedGraphObjects(Children).OfType()) + { + retainedIds.Add(graphObject.Id); + } + + foreach (CoreObject sceneObject in Layers.Cast().Concat(Markers)) + { + retainedIds.Add(sceneObject.Id); + } + + foreach (Guid originalId in _pendingRecoveredDescendantIdMigrations.Keys.ToArray()) + { + if (retainedIds.Contains(originalId)) + { + _pendingRecoveredDescendantIdMigrations.Remove(originalId); + } + } } private void RecordRecoveredDescendantRemap( @@ -1135,12 +1157,10 @@ private void MigrateRecoveredElementReferences() } if (property.Expression is IReferenceExpression referenceExpression - && TryGetMigratedId(referenceExpression.ObjectId, out Guid migratedExpressionId)) + && TryGetMigratedId(referenceExpression.ObjectId, out Guid migratedExpressionId) + && referenceExpression.Rebind(migratedExpressionId) is { } reboundExpression) { - property.Expression = (IExpression)Activator.CreateInstance( - referenceExpression.GetType(), - migratedExpressionId, - referenceExpression.PropertyPath)!; + property.Expression = (IExpression)reboundExpression; } } } diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index 1cfa8e128e..360132cf52 100644 --- a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs @@ -193,7 +193,11 @@ protected void ResumeElementPersistenceAfterFallbackReplacement(object? previous Element element = _element; this.GetRequiredService().Record( () => element.SuppressedStorageSource = null, - () => element.SuppressedStorageSource = suppression); + () => + { + suppression.WasReinstated = true; + element.SuppressedStorageSource = suppression; + }); } } diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index f17a847fd7..127c2f4e55 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Reflection; +using System.Text.Json; using System.Text.Json.Nodes; using Beutl.AgentToolkit.Common; using Beutl.AgentToolkit.Documents; @@ -13,6 +14,7 @@ using Beutl.Engine; using Beutl.Graphics; using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; using Beutl.Media; using Beutl.ProjectSystem; using Beutl.Serialization; @@ -331,6 +333,27 @@ public async Task Open_project_warns_about_malformed_element_json_and_keeps_heal }); } + [Test] + public void CollectFallbacks_TraversesDictionaryValues() + { + var fallback = new FallbackTransform(); + var fallbacks = new List(); + MethodInfo method = typeof(SessionTools).GetMethod( + "CollectFallbacks", + BindingFlags.NonPublic | BindingFlags.Static)!; + + method.Invoke( + null, + new object?[] + { + new Dictionary { ["broken"] = fallback }, + new HashSet(), + fallbacks, + }); + + Assert.That(fallbacks, Has.One.SameAs(fallback)); + } + [Test] public async Task Open_project_warning_paths_distinguish_same_named_sidecars_in_different_directories() { diff --git a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs index ba3b019fcd..0d0f058146 100644 --- a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs +++ b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs @@ -44,6 +44,17 @@ public void TearDown() [SuppressResourceClassGeneration] private sealed class TestEngineObject : EngineObject; + [SuppressResourceClassGeneration] + private sealed class FallbackContainer : EngineObject + { + public FallbackContainer() + { + ScanProperties(); + } + + public IProperty Child { get; } = Property.Create(); + } + [Test] public void Constructor_NullHistoryManager_Throws() { @@ -158,6 +169,26 @@ public void Remove_LastFallback_UndoRestoresPersistenceSuppressionAndPreservesSi }); } + [Test] + public void Remove_ContainerWithLastNestedFallback_ClearsPersistenceSuppression() + { + var container = new FallbackContainer + { + Child = { CurrentValue = new FallbackEngineObject() }, + }; + _service.Add(_element, container); + var suppression = new SuppressedStorageSource([], _element.Uri!); + _element.SuppressedStorageSource = suppression; + + bool removed = _service.Remove(_element, container); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(_element.SuppressedStorageSource, Is.Null); + }); + } + [Test] public void Remove_WhenAnotherFallbackRemains_KeepsPersistenceSuppression() { @@ -304,6 +335,27 @@ public void PasteOver_LastFallback_UndoRestoresPersistenceSuppressionAndPreserve }); } + [Test] + public void PasteOver_ContainerWithLastNestedFallback_ClearsPersistenceSuppression() + { + var container = new FallbackContainer + { + Child = { CurrentValue = new FallbackEngineObject() }, + }; + _service.Add(_element, container); + var suppression = new SuppressedStorageSource([], _element.Uri!); + _element.SuppressedStorageSource = suppression; + string json = CoreSerializer.SerializeToJsonString(new TestEngineObject()); + + ObjectPasteOutcome outcome = _service.PasteOver(_element, 0, json); + + Assert.Multiple(() => + { + Assert.That(outcome, Is.EqualTo(ObjectPasteOutcome.Pasted)); + Assert.That(_element.SuppressedStorageSource, Is.Null); + }); + } + [Test] public void PasteOver_StaleNonFallbackIncidentFlagWithoutLiveMarker_ResumesPersistence() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index c1ac0fb57a..bc9845c1a2 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -63,6 +63,30 @@ public TransformReferenceHolder() public IProperty> Target { get; } = Property.Create>(); } + private sealed class CustomReferenceExpression : IReferenceExpression + { + public CustomReferenceExpression(Guid objectId) + { + ObjectId = objectId; + } + + public Guid ObjectId { get; } + + public string PropertyPath => string.Empty; + + public bool HasPropertyPath => false; + + public string ExpressionString => ObjectId.ToString(); + + public Type ResultType => typeof(Element); + + public bool Validate(out string? error) + { + error = null; + return true; + } + } + [SetUp] public void SetUp() { @@ -480,6 +504,30 @@ public void StoreToUri_AfterRehome_KeepsTheOriginalSkipProtected() }); } + [Test] + public void StoreToUri_LeavesExternallyRepairedSourceSidecarUntouched() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Objects\":["u8.ToArray(); + File.WriteAllBytes(elementPath, corruptBytes); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + byte[] repairedBytes = "{\"Objects\":[]}"u8.ToArray(); + File.WriteAllBytes(elementPath, repairedBytes); + + CoreSerializer.StoreToUri(recovered, recovered.Uri!); + + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(repairedBytes)); + } + + [Test] + public void ReferenceExpression_Rebind_ReturnsNullForUnsupportedCustomImplementation() + { + var expression = new CustomReferenceExpression(Guid.NewGuid()); + + Assert.That(((IReferenceExpression)expression).Rebind(Guid.NewGuid()), Is.Null); + } + [Test] public void StoreToUri_RecoveredElementNonFileDestinationMatchesNormalFailure() { @@ -1179,7 +1227,7 @@ public void TryResumeElementPersistence_DictionaryValuedFallback_StaysBlocked() } [Test] - public void Restore_RemappedRecoveredDescendant_MigratesDirectReference() + public void Restore_CollisionRemappedRecoveredDescendant_KeepsReferenceOnSurvivingClaimant() { (Uri sceneUri, string[] elementPaths) = CreatePersistedSceneWithElements("healthy.belm", "recovered.belm"); @@ -1205,9 +1253,8 @@ public void Restore_RemappedRecoveredDescendant_MigratesDirectReference() Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); Element recoveredHealthy = recovered.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); - Element recoveredElement = recovered.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); Guid remappedId = ((CoreObject)GetTransformFallback(recovered, elementPaths[1])).Id; - var migratedReference = (Reference)recoveredHealthy.Objects + var reference = (Reference)recoveredHealthy.Objects .OfType() .Single() .Target.CurrentValue; @@ -1215,10 +1262,12 @@ public void Restore_RemappedRecoveredDescendant_MigratesDirectReference() Assert.Multiple(() => { Assert.That(remappedId, Is.Not.EqualTo(claimantId)); - Assert.That(migratedReference.Id, Is.EqualTo(remappedId)); + // The healthy claimant still owns the original ID, so the reference must keep + // targeting it instead of being redirected to the remapped recovered fallback. + Assert.That(reference.Id, Is.EqualTo(claimantId)); Assert.That( - migratedReference.Value, - Is.SameAs(((RectShape)recoveredElement.Objects.Single()).Transform.CurrentValue)); + recoveredHealthy.Objects.OfType().Single().Transform.CurrentValue!.Id, + Is.EqualTo(claimantId)); }); } From cf86c1d42571dea6f59c37d1ce67e729872416fa Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 12:44:57 +0900 Subject: [PATCH 19/35] fix(review): close remaining recovery persistence gaps --- .../Reconciliation/Reconciler.cs | 19 +++++- .../Serialization/CoreSerializer.cs | 39 +++++++---- .../Editors/TextureSourceEditorViewModel.cs | 1 + .../Reconciliation/ApplyEditTests.cs | 54 +++++++++++++++- .../FallbackEditorPersistenceTests.cs | 64 +++++++++++++++++++ .../Core/JsonSerializationTest.cs | 19 ++++++ .../Services/ElementObjectServiceTests.cs | 50 ++++++++++++++- 7 files changed, 229 insertions(+), 17 deletions(-) diff --git a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs index 5a9256e55f..46222eb4ec 100644 --- a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs +++ b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs @@ -850,7 +850,24 @@ private static bool TraverseSerializedGraph( return false; } - if (value is IEnumerable enumerable) + if (value is IDictionary dictionary) + { + int index = 0; + foreach (object? item in dictionary.Values) + { + if (TraverseSerializedGraph( + item, + $"{path}[{index}]", + visited, + visitCoreObject)) + { + return true; + } + + index++; + } + } + else if (value is IEnumerable enumerable) { int index = 0; foreach (object? item in enumerable) diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index a2fd096d5c..94ab793afc 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -183,7 +183,10 @@ public static object RestoreFromUri(Uri uri, Type type) } } - Type? actualType = type.IsSealed ? type : jsonObject.GetDiscriminator(type); + bool hasDiscriminator = jsonObject.ContainsKey("$type") || jsonObject.ContainsKey("@type"); + Type? actualType = hasDiscriminator + ? jsonObject.GetDiscriminator() + : type.IsSealed ? type : jsonObject.GetDiscriminator(type); if (actualType == null) { throw new InvalidOperationException("Discriminator not found in JSON object."); @@ -261,16 +264,7 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n // repair of the sidecar and leaves the changed file alone — clobbering it would // destroy the user's repair. string sourcePath = uri.LocalPath; - if (File.Exists(sourcePath) - && !File.ReadAllBytes(sourcePath).AsSpan().SequenceEqual(suppressed.RawBytes) - && suppressed.WasReinstated) - { - WriteBytesAtomically(sourcePath, suppressed.RawBytes); - } - - // A reinstated record is consumed on first observation: once the disk state has - // been reconciled (restored or already matching), a later mismatch is external. - suppressed.WasReinstated = false; + RestoreReinstatedBytes(suppressed, sourcePath); return; } @@ -280,11 +274,12 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n } // Rehomed (save-as): the retained bytes move verbatim so the new project copy keeps the - // element. The suppression record is never mutated — the source location stays - // skip-protected even if a failed multi-file save rolls Uri back afterwards. + // element. SourceUri stays unchanged so the source location remains skip-protected if a + // failed multi-file save rolls Uri back afterwards. string rehomedPath = uri.LocalPath; if (File.Exists(rehomedPath)) { + RestoreReinstatedBytes(suppressed, rehomedPath); suppressedObj.Uri = uri; return; } @@ -314,6 +309,7 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n } catch (IOException) when (File.Exists(rehomedPath)) { + RestoreReinstatedBytes(suppressed, rehomedPath); suppressedObj.Uri = uri; return; } @@ -329,6 +325,7 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n } } + suppressed.WasReinstated = false; suppressedObj.Uri = uri; return; } @@ -386,6 +383,22 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n } } + private static void RestoreReinstatedBytes(SuppressedStorageSource suppressed, string path) + { + if (!suppressed.WasReinstated) + { + return; + } + + if (File.Exists(path) + && !File.ReadAllBytes(path).AsSpan().SequenceEqual(suppressed.RawBytes)) + { + WriteBytesAtomically(path, suppressed.RawBytes); + } + + suppressed.WasReinstated = false; + } + private static void WriteBytesAtomically(string path, byte[] bytes) { string tempPath = $"{path}.{Guid.NewGuid():N}.tmp"; diff --git a/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs b/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs index bcc1b34fd4..b3f3d5d93a 100644 --- a/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs @@ -89,6 +89,7 @@ public void SetValue(TextureSource? oldValue, TextureSource? newValue) if (!EqualityComparer.Default.Equals(oldValue, newValue)) { PropertyAdapter.SetValue(newValue); + ResumeElementPersistenceAfterFallbackReplacement(oldValue); Commit(); } } diff --git a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs index 4a14ddb84e..29ab7ac447 100644 --- a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs @@ -1,13 +1,16 @@ -using System.Text.Json.Nodes; +using System.Reflection; +using System.Text.Json.Nodes; using Beutl.AgentToolkit.Common; using Beutl.AgentToolkit.Reconciliation; using Beutl.AgentToolkit.Schema; using Beutl.AgentToolkit.Sessions; using Beutl.AgentToolkit.Tests.Helpers; using Beutl.AgentToolkit.Tools; +using Beutl.Engine; using Beutl.Graphics; using Beutl.Graphics.Effects; using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; using Beutl.Media; using Beutl.ProjectSystem; using Beutl.Serialization; @@ -16,6 +19,18 @@ namespace Beutl.AgentToolkit.Tests.Reconciliation; public sealed class ApplyEditTests { + [SuppressResourceClassGeneration] + public sealed class DictionaryTransformHolder : EngineObject + { + public DictionaryTransformHolder() + { + ScanProperties(); + } + + public IProperty> Transforms { get; } + = Property.Create>(); + } + [Test] public void Apply_edit_applies_patch_directly() { @@ -135,6 +150,43 @@ public void Apply_edit_rejects_payloads_that_deserialize_to_fallback_objects_wit }); } + [Test] + public void Validate_no_new_fallback_objects_rejects_dictionary_values() + { + Scene current = CreateSceneWithElement(out Element currentElement); + var currentHolder = new DictionaryTransformHolder(); + var currentTransform = new TranslateTransform(10, 20); + currentHolder.Transforms.CurrentValue = new Dictionary + { + ["move"] = currentTransform, + }; + currentElement.AddObject(currentHolder); + using var session = new AgentToolkitTestSession(current); + + Scene sandbox = CreateSceneWithElement(out Element sandboxElement); + var sandboxHolder = new DictionaryTransformHolder(); + sandboxHolder.Transforms.CurrentValue = new Dictionary + { + ["move"] = new FallbackTransform(), + }; + sandboxElement.AddObject(sandboxHolder); + MethodInfo method = typeof(Reconciler).GetMethod( + "ValidateNoNewFallbackObjects", + BindingFlags.NonPublic | BindingFlags.Static)!; + + TargetInvocationException exception = Assert.Throws(() => + method.Invoke(null, new object[] { session, sandbox }))!; + var error = (ReconcileException)exception.InnerException!; + + Assert.Multiple(() => + { + Assert.That(error.Error.Code, Is.EqualTo(ErrorCode.ValidationRejected)); + Assert.That(error.Error.Message, Does.Contain("fallback object")); + Assert.That(error.Error.Target, Does.Contain(nameof(DictionaryTransformHolder.Transforms))); + Assert.That(currentHolder.Transforms.CurrentValue!["move"], Is.SameAs(currentTransform)); + }); + } + [Test] public void Apply_edit_returns_compact_response_and_optional_document() { diff --git a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs index 12896f3c76..83cb9cd495 100644 --- a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs +++ b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs @@ -303,6 +303,70 @@ public void TextureDrawableTypeRepair_ResumesPersistenceInReplacementTransaction } } + [AvaloniaTest] + public void TextureSourceReplacement_ResumesPersistenceInReplacementTransaction() + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + string root = CreateRoot(); + try + { + var sceneUri = new Uri(Path.Combine(root, "scene.scene")); + string elementPath = Path.Combine(root, "element.belm"); + var scene = new Scene(64, 64, "Scene") { Uri = sceneUri }; + var holder = new EditorValueHolder(); + var texture = new DrawableTextureSource(); + texture.Drawable.CurrentValue = new RectShape(); + holder.TextureValue.CurrentValue = texture; + var element = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(holder); + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject drawableJson = FindObjectWithProperty(elementJson, nameof(DrawableTextureSource.Drawable))! + [nameof(DrawableTextureSource.Drawable)]!.AsObject(); + drawableJson["$type"] = "[Beutl.Engine]Beutl.Graphics:MissingDrawable"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single(); + var recoveredHolder = (EditorValueHolder)recoveredElement.Objects.Single(); + var recoveredTexture = (DrawableTextureSource)recoveredHolder.TextureValue.CurrentValue!; + Assert.That(recoveredTexture.Drawable.CurrentValue, Is.InstanceOf()); + using var context = new EditorTestContext(recoveredElement); + var adapter = new SimplePropertyAdapter( + (SimpleProperty)recoveredHolder.TextureValue, + recoveredHolder); + using var viewModel = new TextureSourceEditorViewModel(adapter); + viewModel.Accept(new Visitor(recoveredElement, context.History)); + + viewModel.ChangeToImageTextureSource(); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = context.History.Undo(); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + + Assert.Multiple(() => + { + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(recoveredHolder.TextureValue.CurrentValue, Is.SameAs(recoveredTexture)); + Assert.That(recoveredTexture.Drawable.CurrentValue, Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + finally + { + Directory.Delete(root, true); + } + } + [AvaloniaTest] public void TextureDrawableTargetRepair_ResumesPersistenceInReplacementTransaction() { diff --git a/tests/Beutl.UnitTests/Core/JsonSerializationTest.cs b/tests/Beutl.UnitTests/Core/JsonSerializationTest.cs index 22b74fe41c..69c1a0ad44 100644 --- a/tests/Beutl.UnitTests/Core/JsonSerializationTest.cs +++ b/tests/Beutl.UnitTests/Core/JsonSerializationTest.cs @@ -234,6 +234,25 @@ public void RestoreFromUri_FillsMissingDiscriminatorForLegacyFiles() Assert.That(element, Is.InstanceOf()); } + [Test] + public void RestoreFromUri_RejectsWrongDiscriminatorForSealedProject() + { + string path = Path.Combine(Path.GetTempPath(), $"beutl-project-discriminator-{Guid.NewGuid():N}.bep"); + try + { + JsonObject json = CoreSerializer.SerializeToJsonObject(new Project()); + json["$type"] = TypeFormat.ToString(typeof(Scene)); + File.WriteAllText(path, json.ToJsonString()); + + Assert.Throws(() => + CoreSerializer.RestoreFromUri(UriHelper.CreateFromPath(path))); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + [Test] public void Resolve() { diff --git a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs index 0d0f058146..ece57b9bf5 100644 --- a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs +++ b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs @@ -170,16 +170,25 @@ public void Remove_LastFallback_UndoRestoresPersistenceSuppressionAndPreservesSi } [Test] - public void Remove_ContainerWithLastNestedFallback_ClearsPersistenceSuppression() + public void Remove_ContainerWithLastNestedFallback_KeepsSuppressionUntilContainerRemoved() { var container = new FallbackContainer { Child = { CurrentValue = new FallbackEngineObject() }, }; + var other = new TestEngineObject(); _service.Add(_element, container); + _service.Add(_element, other); var suppression = new SuppressedStorageSource([], _element.Uri!); _element.SuppressedStorageSource = suppression; + bool removedOther = _service.Remove(_element, other); + Assert.Multiple(() => + { + Assert.That(removedOther, Is.True); + Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); + }); + bool removed = _service.Remove(_element, container); Assert.Multiple(() => @@ -189,6 +198,35 @@ public void Remove_ContainerWithLastNestedFallback_ClearsPersistenceSuppression( }); } + [Test] + public void Remove_LastFallbackAfterRehome_UndoRestoresRehomedSidecar() + { + var fallback = new FallbackEngineObject(); + _service.Add(_element, fallback); + byte[] originalBytes = "{ preserved fallback bytes"u8.ToArray(); + File.WriteAllBytes(_element.Uri!.LocalPath, originalBytes); + var suppression = new SuppressedStorageSource(originalBytes, _element.Uri); + _element.SuppressedStorageSource = suppression; + var rehomedUri = new Uri(Path.Combine(_basePath, "save-as", "element.belm")); + CoreSerializer.StoreToUri(_element, rehomedUri); + + bool removed = _service.Remove(_element, fallback); + CoreSerializer.StoreToUri(_element, _element.Uri!); + byte[] repairedBytes = File.ReadAllBytes(rehomedUri.LocalPath); + bool undone = _history.Undo(); + CoreSerializer.StoreToUri(_element, _element.Uri!); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(_element.Objects.Single(), Is.SameAs(fallback)); + Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); + Assert.That(File.ReadAllBytes(rehomedUri.LocalPath), Is.EqualTo(originalBytes)); + }); + } + [Test] public void Remove_WhenAnotherFallbackRemains_KeepsPersistenceSuppression() { @@ -336,17 +374,25 @@ public void PasteOver_LastFallback_UndoRestoresPersistenceSuppressionAndPreserve } [Test] - public void PasteOver_ContainerWithLastNestedFallback_ClearsPersistenceSuppression() + public void PasteOver_ContainerWithLastNestedFallback_KeepsSuppressionUntilContainerReplaced() { var container = new FallbackContainer { Child = { CurrentValue = new FallbackEngineObject() }, }; _service.Add(_element, container); + _service.Add(_element, new TestEngineObject()); var suppression = new SuppressedStorageSource([], _element.Uri!); _element.SuppressedStorageSource = suppression; string json = CoreSerializer.SerializeToJsonString(new TestEngineObject()); + ObjectPasteOutcome intermediate = _service.PasteOver(_element, 1, json); + Assert.Multiple(() => + { + Assert.That(intermediate, Is.EqualTo(ObjectPasteOutcome.Pasted)); + Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); + }); + ObjectPasteOutcome outcome = _service.PasteOver(_element, 0, json); Assert.Multiple(() => From b7c30296522d2533ecd11a9aa04c38897473e70e Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 15:39:06 +0900 Subject: [PATCH 20/35] fix(review): close nested recovery gaps --- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 2 +- .../Serialization/CoreSerializer.cs | 10 +- .../ProjectSystem/Scene.cs | 102 ++++++++++++--- .../ViewModels/Editors/ListEditorViewModel.cs | 6 + .../Tools/SessionToolsTests.cs | 6 +- .../FallbackEditorPersistenceTests.cs | 102 +++++++++++++++ .../Services/ElementObjectServiceTests.cs | 26 ++++ .../MalformedElementRecoveryTests.cs | 119 ++++++++++++++++++ 8 files changed, 348 insertions(+), 25 deletions(-) diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 51a933669d..91e1e1d085 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -154,7 +154,7 @@ const string message elementFile, nameof(FallbackReason.DeserializationFailed), null, - message)); + null)); warnings.Add($"Element file '{elementFile}' could not be loaded without replacement: {message}"); } } diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 94ab793afc..8ab0888ccd 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -390,9 +390,15 @@ private static void RestoreReinstatedBytes(SuppressedStorageSource suppressed, s return; } - if (File.Exists(path) - && !File.ReadAllBytes(path).AsSpan().SequenceEqual(suppressed.RawBytes)) + if (!File.Exists(path) + || !File.ReadAllBytes(path).AsSpan().SequenceEqual(suppressed.RawBytes)) { + string? directory = Path.GetDirectoryName(path); + if (directory != null) + { + Directory.CreateDirectory(directory); + } + WriteBytesAtomically(path, suppressed.RawBytes); } diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 09ad202086..f04781fee9 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -680,6 +680,13 @@ static void Process(Func add, JsonNode node, List list) } } + Markers.Clear(); + if (context.Contains(nameof(Markers)) + && context.GetValue(nameof(Markers)) is { } markers) + { + Markers.AddRange(markers); + } + if (context.GetValue(nameof(Elements)) is { } elementsJson) { if (elementsJson is JsonObject elementsObject) @@ -731,12 +738,6 @@ static void Process(Func add, JsonNode node, List list) } } - Markers.Clear(); - if (context.Contains(nameof(Markers)) - && context.GetValue(nameof(Markers)) is { } markers) - { - Markers.AddRange(markers); - } } private void SyncronizeFiles(IEnumerable pathToElement) @@ -1033,10 +1034,16 @@ private Element RestoreElementOrFallback(Uri uri) { foreach (IFallback fallback in fallbacks) { - if (fallback is CoreObject fallbackObject - && !HasSerializedId(fallback.Json)) + if (fallback is CoreObject fallbackObject) { - _idlessRecoveredDescendants.TryAdd(fallbackObject, 0); + if (TryGetSerializedId(fallback.Json, out Guid serializedId)) + { + fallbackObject.Id = serializedId; + } + else + { + _idlessRecoveredDescendants.TryAdd(fallbackObject, 0); + } } EnsureFallbackProjection(fallback); @@ -1089,14 +1096,15 @@ private Element RestoreElementOrFallback(Uri uri) } } - private static bool HasSerializedId(JsonObject? json) + private static bool TryGetSerializedId(JsonObject? json, out Guid id) { + id = Guid.Empty; return json is not null - && json.TryGetPropertyValue(nameof(CoreObject.Id), out JsonNode? idNode) - && idNode is JsonValue idValue - && idValue.TryGetValue(out string? idText) - && Guid.TryParse(idText, out Guid id) - && id != Guid.Empty; + && json.TryGetPropertyValue(nameof(CoreObject.Id), out JsonNode? idNode) + && idNode is JsonValue idValue + && idValue.TryGetValue(out string? idText) + && Guid.TryParse(idText, out id) + && id != Guid.Empty; } private static void MarkRecoveredElement( @@ -1146,14 +1154,16 @@ private void MigrateRecoveredElementReferences() foreach (Element element in Children) { + var visited = new HashSet(ReferenceEqualityComparer.Instance); foreach (EngineObject engineObject in EnumerateSerializedGraphObjects(element).OfType()) { foreach (IProperty property in engineObject.Properties) { - if (property.CurrentValue is IReference reference - && (TryGetMigratedId(reference.Id, out Guid migratedId))) + object? currentValue = property.CurrentValue; + object? migratedValue = MigrateRecoveredReferenceValue(currentValue, visited); + if (!Equals(currentValue, migratedValue)) { - property.CurrentValue = ResolveMigratedReference(reference, migratedId); + property.CurrentValue = migratedValue; } if (property.Expression is IReferenceExpression referenceExpression @@ -1162,6 +1172,19 @@ private void MigrateRecoveredElementReferences() { property.Expression = (IExpression)reboundExpression; } + + if (property.Animation is IKeyFrameAnimation animation) + { + foreach (IKeyFrame keyFrame in animation.KeyFrames) + { + object? keyFrameValue = keyFrame.Value; + object? migratedKeyFrameValue = MigrateRecoveredReferenceValue(keyFrameValue, visited); + if (!Equals(keyFrameValue, migratedKeyFrameValue)) + { + keyFrame.Value = migratedKeyFrameValue; + } + } + } } } } @@ -1183,6 +1206,49 @@ private object ResolveMigratedReference(IReference reference, Guid migratedId) : Activator.CreateInstance(reference.GetType(), migratedId)!; } + private object? MigrateRecoveredReferenceValue(object? value, ISet visited) + { + if (value is IReference reference) + { + return TryGetMigratedId(reference.Id, out Guid migratedId) + ? ResolveMigratedReference(reference, migratedId) + : value; + } + + if (value is null or string + || (!value.GetType().IsValueType && !visited.Add(value))) + { + return value; + } + + if (value is IDictionary dictionary) + { + foreach (object key in dictionary.Keys.Cast().ToArray()) + { + object? item = dictionary[key]; + object? migratedItem = MigrateRecoveredReferenceValue(item, visited); + if (!Equals(item, migratedItem)) + { + dictionary[key] = migratedItem; + } + } + } + else if (value is IList list) + { + for (int i = 0; i < list.Count; i++) + { + object? item = list[i]; + object? migratedItem = MigrateRecoveredReferenceValue(item, visited); + if (!Equals(item, migratedItem)) + { + list[i] = migratedItem; + } + } + } + + return value; + } + private static IEnumerable EnumerateSerializedGraphObjects(object root) { var objects = new List(); diff --git a/src/Beutl/ViewModels/Editors/ListEditorViewModel.cs b/src/Beutl/ViewModels/Editors/ListEditorViewModel.cs index 40addd677f..24e197097e 100644 --- a/src/Beutl/ViewModels/Editors/ListEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/ListEditorViewModel.cs @@ -282,6 +282,7 @@ void UpdateIndex(int start) public void Initialize() { + IList? previous = List.Value; if (List.Value == null) { Type listType = PropertyAdapter.PropertyType; @@ -302,6 +303,7 @@ public void Initialize() List.Value.Clear(); } + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } @@ -312,7 +314,9 @@ public void Delete() if (PropertyAdapter.IsReadOnly) throw new InvalidOperationException("読み取り専用です。"); + IList previous = List.Value; PropertyAdapter.SetValue(null); + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } @@ -344,7 +348,9 @@ public void AddItem(TItem? item) public void RemoveItem(int index) { + TItem? previous = List.Value![index]; List.Value!.RemoveAt(index); + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index 127c2f4e55..fcee4f7e45 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -170,8 +170,7 @@ public async Task Open_project_reports_fallback_and_lossy_easing_incidents_toget Has.One.Matches(incident => incident.Reason == nameof(FallbackReason.DeserializationFailed) && incident.TypeName is null - && incident.Message != null - && incident.Message.Contains("value was replaced during load", StringComparison.Ordinal))); + && incident.Message is null)); }); } @@ -235,8 +234,7 @@ public async Task Open_project_warns_about_unresolvable_keyframe_easing_without_ Assert.That(opened.Value.RecoveryIncidents[0].Reason, Is.EqualTo(nameof(FallbackReason.DeserializationFailed))); Assert.That(opened.Value.RecoveryIncidents[0].TypeName, Is.Null); - Assert.That(opened.Value.RecoveryIncidents[0].Message, - Does.Contain("value was replaced during load").And.Contain("original element file is preserved")); + Assert.That(opened.Value.RecoveryIncidents[0].Message, Is.Null); }); } diff --git a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs index 83cb9cd495..36c9d6c395 100644 --- a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs +++ b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs @@ -3,12 +3,14 @@ using Beutl.Animation; using Beutl.Animation.Easings; using Beutl.Api.Services; +using Beutl.Collections; using Beutl.Editor; using Beutl.Editor.Observers; using Beutl.Editor.Services; using Beutl.Engine; using Beutl.Extensibility; using Beutl.Graphics; +using Beutl.Graphics.Effects; using Beutl.Graphics.Shapes; using Beutl.Graphics3D.Textures; using Beutl.Media; @@ -431,6 +433,73 @@ public void TextureDrawableTargetRepair_ResumesPersistenceInReplacementTransacti } } + [AvaloniaTest] + public void ListRemoveItem_LastFallbackResumesPersistenceInReplacementTransaction() + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + string root = CreateRoot(); + try + { + var sceneUri = new Uri(Path.Combine(root, "scene.scene")); + string elementPath = Path.Combine(root, "element.belm"); + var group = new FilterEffectGroup(); + group.Children.Add(new Blur()); + var shape = new RectShape + { + FilterEffect = { CurrentValue = group }, + }; + var element = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(shape); + var scene = new Scene(64, 64, "Scene") { Uri = sceneUri }; + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject groupJson = FindObjectWithNonEmptyArrayProperty( + elementJson, + nameof(FilterEffectGroup.Children))!; + groupJson[nameof(FilterEffectGroup.Children)]!.AsArray()[0]!.AsObject()["$type"] + = "[Beutl.Engine]Beutl.Graphics.Effects:MissingFilterEffect"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single(); + var recoveredShape = (RectShape)recoveredElement.Objects.Single(); + var recoveredGroup = (FilterEffectGroup)recoveredShape.FilterEffect.CurrentValue!; + Assert.That(recoveredGroup.Children.Single(), Is.InstanceOf()); + using var context = new EditorTestContext(recoveredElement); + var adapter = new EnginePropertyAdapter>( + recoveredGroup.Children, + recoveredGroup); + using var viewModel = new ListEditorViewModel(adapter); + viewModel.Accept(new Visitor(recoveredElement, context.History)); + + viewModel.RemoveItem(0); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = context.History.Undo(); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + + Assert.Multiple(() => + { + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(recoveredGroup.Children.Single(), Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + finally + { + Directory.Delete(root, true); + } + } + private static KeyFrameAnimation CreateAnimation(KeyFrame keyFrame) { var animation = new KeyFrameAnimation(); @@ -502,6 +571,39 @@ private static (Uri SceneUri, string ElementPath) CreateAnimatedScene(string roo return null; } + private static JsonObject? FindObjectWithNonEmptyArrayProperty(JsonNode node, string propertyName) + { + if (node is JsonObject obj) + { + if (obj[propertyName] is JsonArray { Count: > 0 }) + { + return obj; + } + + foreach ((string _, JsonNode? child) in obj) + { + if (child is not null + && FindObjectWithNonEmptyArrayProperty(child, propertyName) is { } result) + { + return result; + } + } + } + else if (node is JsonArray array) + { + foreach (JsonNode? child in array) + { + if (child is not null + && FindObjectWithNonEmptyArrayProperty(child, propertyName) is { } result) + { + return result; + } + } + } + + return null; + } + private sealed class EditorTestContext : IDisposable { private readonly CoreObjectOperationObserver _observer; diff --git a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs index ece57b9bf5..e08b2ac939 100644 --- a/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs +++ b/tests/Beutl.UnitTests/Editor/Services/ElementObjectServiceTests.cs @@ -227,6 +227,32 @@ public void Remove_LastFallbackAfterRehome_UndoRestoresRehomedSidecar() }); } + [Test] + public void Remove_LastFallback_UndoRecreatesDeletedSidecarAndDirectory() + { + var fallback = new FallbackEngineObject(); + _service.Add(_element, fallback); + byte[] originalBytes = "{ preserved fallback bytes"u8.ToArray(); + File.WriteAllBytes(_element.Uri!.LocalPath, originalBytes); + var suppression = new SuppressedStorageSource(originalBytes, _element.Uri); + _element.SuppressedStorageSource = suppression; + + bool removed = _service.Remove(_element, fallback); + CoreSerializer.StoreToUri(_element, _element.Uri); + Directory.Delete(_basePath, recursive: true); + bool undone = _history.Undo(); + CoreSerializer.StoreToUri(_element, _element.Uri); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(undone, Is.True); + Assert.That(_element.Objects.Single(), Is.SameAs(fallback)); + Assert.That(_element.SuppressedStorageSource, Is.SameAs(suppression)); + Assert.That(File.ReadAllBytes(_element.Uri.LocalPath), Is.EqualTo(originalBytes)); + }); + } + [Test] public void Remove_WhenAnotherFallbackRemains_KeepsPersistenceSuppression() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index bc9845c1a2..2688562b06 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Collections.Immutable; +using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using Beutl.Animation; @@ -40,6 +41,24 @@ public ElementReferenceHolder() public IProperty ExpressionTarget { get; } = Property.Create(); } + [SuppressResourceClassGeneration] + public sealed class NestedReferenceHolder : EngineObject + { + public NestedReferenceHolder() + { + ScanProperties(); + } + + public IProperty>> ListTargets { get; } + = Property.Create>>(); + + public IProperty>> DictionaryTargets { get; } + = Property.Create>>(); + + public IProperty> AnimatedTarget { get; } + = Property.CreateAnimatable>(); + } + [SuppressResourceClassGeneration] public sealed class DictionaryTransformHolder : EngineObject { @@ -944,6 +963,106 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() }); } + [Test] + public void MigrateRecoveredElementReferences_TraversesContainersAndKeyFrames() + { + Guid originalId = Guid.NewGuid(); + var migrated = new Element + { + Id = Guid.NewGuid(), + Uri = new Uri(Path.Combine(_root, "migrated.belm")), + }; + var holder = new NestedReferenceHolder(); + holder.ListTargets.CurrentValue = [new Reference(originalId)]; + holder.DictionaryTargets.CurrentValue = new Dictionary> + { + ["migrated"] = new Reference(originalId), + }; + var animation = new KeyFrameAnimation>(); + animation.KeyFrames.Add(new KeyFrame> + { + KeyTime = TimeSpan.Zero, + Value = new Reference(originalId), + }); + holder.AnimatedTarget.Animation = animation; + var owner = new Element { Uri = new Uri(Path.Combine(_root, "owner.belm")) }; + owner.AddObject(holder); + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "migration.scene")) }; + scene.Children.Add(migrated); + scene.Children.Add(owner); + var migrations = (Dictionary)typeof(Scene) + .GetField("_pendingRecoveredElementIdMigrations", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(scene)!; + migrations[originalId] = migrated.Id; + MethodInfo method = typeof(Scene).GetMethod( + "MigrateRecoveredElementReferences", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + method.Invoke(scene, null); + + Reference migratedReference + = ((KeyFrame>)animation.KeyFrames.Single()).Value; + Reference migratedListReference = holder.ListTargets.CurrentValue!.Single(); + Reference migratedDictionaryReference + = holder.DictionaryTargets.CurrentValue!["migrated"]; + Assert.Multiple(() => + { + Assert.That(migratedListReference.Id, Is.EqualTo(migrated.Id)); + Assert.That(migratedListReference.Value, Is.SameAs(migrated)); + Assert.That(migratedDictionaryReference.Id, Is.EqualTo(migrated.Id)); + Assert.That(migratedDictionaryReference.Value, Is.SameAs(migrated)); + Assert.That(migratedReference.Id, Is.EqualTo(migrated.Id)); + Assert.That(migratedReference.Value, Is.SameAs(migrated)); + }); + } + + [Test] + public void Restore_MalformedElementIdAvoidsSerializedMarkerCollision() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + var marker = new SceneMarker(TimeSpan.Zero, "Marker") { Id = Guid.NewGuid() }; + source.Markers.Add(marker); + CoreSerializer.StoreToUri(source, sceneUri); + File.WriteAllText(elementPath, $$"""{"Id":"{{marker.Id}}","Objects":["""); + + Scene first = CoreSerializer.RestoreFromUri(sceneUri); + Guid recoveredId = first.Children.Single().Id; + CoreSerializer.StoreToUri(first, sceneUri); + Scene second = CoreSerializer.RestoreFromUri(sceneUri); + + Assert.Multiple(() => + { + Assert.That(first.Markers.Single().Id, Is.EqualTo(marker.Id)); + Assert.That(recoveredId, Is.Not.EqualTo(marker.Id)); + Assert.That(second.Markers.Single().Id, Is.EqualTo(marker.Id)); + Assert.That(second.Children.Single().Id, Is.EqualTo(recoveredId)); + }); + } + + [Test] + public void Restore_KnownTypeDeserializationFallbackAdoptsSerializedId() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject objectJson = json[nameof(Element.Objects)]!.AsArray()[0]!.AsObject(); + Guid serializedId = Guid.Parse(objectJson[nameof(CoreObject.Id)]!.GetValue()); + objectJson[nameof(RectShape.Width)] = "invalid-width"; + File.WriteAllText(elementPath, json.ToJsonString()); + + Scene first = CoreSerializer.RestoreFromUri(sceneUri); + Scene second = CoreSerializer.RestoreFromUri(sceneUri); + var firstFallback = (CoreObject)first.Children.Single().Objects.Single(); + var secondFallback = (CoreObject)second.Children.Single().Objects.Single(); + + Assert.Multiple(() => + { + Assert.That(firstFallback, Is.InstanceOf()); + Assert.That(firstFallback.Id, Is.EqualTo(serializedId)); + Assert.That(secondFallback.Id, Is.EqualTo(serializedId)); + }); + } + [Test] public void Restore_PersistedRecoveredDescendantRemapSurvivesClaimantRemoval() { From c559defdf3b9dd71321141e864d0333e88ab6b36 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 16:07:50 +0900 Subject: [PATCH 21/35] fix(review): preserve escaped recovered element ids --- src/Beutl.ProjectSystem/ProjectSystem/Scene.cs | 12 ++++++++++++ .../ProjectSystem/MalformedElementRecoveryTests.cs | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index f04781fee9..59777bf6d3 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -1370,6 +1370,18 @@ private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback private Guid ResolveRecoveredElementId(string rawText, Uri uri) { + try + { + if (JsonNode.Parse(rawText) is JsonObject root + && TryGetSerializedId(root, out Guid parsedId)) + { + return parsedId; + } + } + catch (JsonException) + { + } + // Only a top-level Id may name the element: a nested object's or quoted Id would collide // with live objects, so anything else falls through to the deterministic filename Guid. MatchCollection matches = s_idPattern.Matches(rawText); diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 2688562b06..c9020262ad 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -287,6 +287,20 @@ public void Restore_MalformedElementWithoutReadableId_UsesStableId() }); } + [Test] + public void Restore_SyntacticallyValidElementAdoptsEscapedTopLevelId() + { + var expectedId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + """{"\u0049d":"\u0061aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","$type":"[Missing.Assembly]Missing.Namespace:Element"}"""); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + + Assert.That(recovered.Id, Is.EqualTo(expectedId)); + } + [Test] public void Restore_MalformedElementWithEmptyTopLevelId_UsesStableNonEmptyId() { From 920a76703570fabfd9abfc8749c946b340dbe024 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 16:42:09 +0900 Subject: [PATCH 22/35] fix(review): cover extended recovery graphs --- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 13 +++-- .../ProjectSystem/Scene.cs | 42 ++++++++++----- .../Tools/SessionToolsTests.cs | 31 ++++++++++- .../MalformedElementRecoveryTests.cs | 53 +++++++++++++++++++ 4 files changed, 119 insertions(+), 20 deletions(-) diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 91e1e1d085..0ed0196310 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -108,10 +108,7 @@ private static DeserializationWarningCollection CollectDeserializationWarnings(P { var fallbacks = new List(); var visited = new HashSet(ReferenceEqualityComparer.Instance); - foreach (EngineObject obj in element.Objects) - { - CollectFallbacks(obj, visited, fallbacks); - } + CollectFallbacks(element, visited, fallbacks); string elementFile = element.Uri is { IsFile: true } uri && scene.Uri is { IsFile: true } sceneUri @@ -181,6 +178,14 @@ private static void CollectFallbacks( return; } + if (value is IHierarchical hierarchical) + { + foreach (IHierarchical child in hierarchical.HierarchicalChildren) + { + CollectFallbacks(child, visited, fallbacks); + } + } + if (value is EngineObject engineObject) { foreach (IProperty property in engineObject.Properties) diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 59777bf6d3..15e179cada 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -989,7 +989,10 @@ var pendingDescendantRemaps foreach (CoreObject sceneObject in Layers.Cast().Concat(Markers)) { - retainedIds.Add(sceneObject.Id); + foreach (CoreObject graphObject in EnumerateSerializedGraphObjects(sceneObject).OfType()) + { + retainedIds.Add(graphObject.Id); + } } foreach (Guid originalId in _pendingRecoveredDescendantIdMigrations.Keys.ToArray()) @@ -1068,14 +1071,15 @@ private Element RestoreElementOrFallback(Uri uri) // for top-level recovery metadata. byte[] rawBytes = File.ReadAllBytes(uri.LocalPath); string rawText = Encoding.UTF8.GetString(rawBytes); + JsonObject? root = TryParseTopLevelObject(rawText); var element = new Element { - Id = ResolveRecoveredElementId(rawText, uri), + Id = ResolveRecoveredElementId(rawText, root, uri), Name = Path.GetFileNameWithoutExtension(uri.LocalPath), Uri = uri, IsEnabled = false, }; - string? topLevelTypeName = TryGetTopLevelTypeName(rawText); + string? topLevelTypeName = TryGetTopLevelTypeName(rawText, root); FallbackReason fallbackReason = topLevelTypeName is not null && TypeFormat.ToType(topLevelTypeName) is null ? FallbackReason.TypeNotFound @@ -1349,8 +1353,25 @@ private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback return json; } - private static string? TryGetTopLevelTypeName(string rawText) + private static JsonObject? TryParseTopLevelObject(string rawText) + { + try + { + return JsonNode.Parse(rawText) as JsonObject; + } + catch (JsonException) + { + return null; + } + } + + private static string? TryGetTopLevelTypeName(string rawText, JsonObject? root) { + if (root?.TryGetDiscriminator(out string? parsedTypeName) == true) + { + return parsedTypeName; + } + Match? match = FindTopLevelMatch(rawText, s_typePattern.Matches(rawText)) ?? FindTopLevelMatch(rawText, s_legacyTypePattern.Matches(rawText)); if (match is null) @@ -1368,18 +1389,11 @@ private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback } } - private Guid ResolveRecoveredElementId(string rawText, Uri uri) + private Guid ResolveRecoveredElementId(string rawText, JsonObject? root, Uri uri) { - try - { - if (JsonNode.Parse(rawText) is JsonObject root - && TryGetSerializedId(root, out Guid parsedId)) - { - return parsedId; - } - } - catch (JsonException) + if (TryGetSerializedId(root, out Guid parsedId)) { + return parsedId; } // Only a top-level Id may name the element: a nested object's or quoted Id would collide diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index fcee4f7e45..9e0d5f7af6 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -352,6 +352,33 @@ public void CollectFallbacks_TraversesDictionaryValues() Assert.That(fallbacks, Has.One.SameAs(fallback)); } + [Test] + public void CollectFallbacks_TraversesElementHierarchyOutsideObjects() + { + var element = new Element(); + var fallback = new FallbackTransform(); + ((IModifiableHierarchical)element).AddChild(fallback); + var fallbacks = new List(); + MethodInfo method = typeof(SessionTools).GetMethod( + "CollectFallbacks", + BindingFlags.NonPublic | BindingFlags.Static)!; + + method.Invoke( + null, + new object?[] + { + element, + new HashSet(), + fallbacks, + }); + + Assert.Multiple(() => + { + Assert.That(element.Objects, Is.Empty); + Assert.That(fallbacks, Has.One.SameAs(fallback)); + }); + } + [Test] public async Task Open_project_warning_paths_distinguish_same_named_sidecars_in_different_directories() { @@ -400,7 +427,7 @@ public async Task Open_project_warning_paths_distinguish_same_named_sidecars_in_ } [Test] - public async Task Open_project_incidents_distinguish_same_named_sidecars_across_scenes_and_keep_top_level_type() + public async Task Open_project_incidents_distinguish_same_named_sidecars_and_decode_escaped_top_level_type() { const string MissingType = "[Missing.Assembly]Missing.Namespace:MissingElement"; string root = CreateWorkspace(); @@ -436,7 +463,7 @@ public async Task Open_project_incidents_distinguish_same_named_sidecars_across_ Element element = scene.Children.Single(); File.WriteAllText( element.Uri!.LocalPath, - $$"""{"$type":"{{MissingType}}","Id":"{{element.Id}}","Name":"Clip"}"""); + $$"""{"\u0024type":"{{MissingType}}","Id":"{{element.Id}}","Name":"Clip"}"""); } var manager = new AgentSessionManager(); diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index c9020262ad..0c817ade1b 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1404,6 +1404,59 @@ public void Restore_CollisionRemappedRecoveredDescendant_KeepsReferenceOnSurvivi }); } + [Test] + public void ReassignDuplicateRecoveredIds_RetainsIdsFromLayerAndMarkerGraphs() + { + Guid layerClaimantId = Guid.NewGuid(); + Guid markerClaimantId = Guid.NewGuid(); + var layerClaimant = new RotationTransform { Id = layerClaimantId }; + var markerClaimant = new RotationTransform { Id = markerClaimantId }; + var layer = new TimelineLayer(); + var marker = new SceneMarker(); + ((IModifiableHierarchical)layer).AddChild(layerClaimant); + ((IModifiableHierarchical)marker).AddChild(markerClaimant); + + var layerReference = new TransformReferenceHolder(); + layerReference.Target.CurrentValue = new Reference(layerClaimantId); + var markerReference = new TransformReferenceHolder(); + markerReference.Target.CurrentValue = new Reference(markerClaimantId); + var healthy = new Element { Uri = new Uri(Path.Combine(_root, "healthy.belm")) }; + healthy.AddObject(layerReference); + healthy.AddObject(markerReference); + + var layerFallback = new FallbackTransform { Id = layerClaimantId }; + var markerFallback = new FallbackTransform { Id = markerClaimantId }; + var recovered = new Element { Uri = new Uri(Path.Combine(_root, "recovered.belm")) }; + recovered.AddObject(layerFallback); + recovered.AddObject(markerFallback); + recovered.SuppressedStorageSource = new SuppressedStorageSource([], recovered.Uri); + + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "hierarchy.scene")) }; + scene.Layers.Add(layer); + scene.Markers.Add(marker); + scene.Children.Add(healthy); + scene.Children.Add(recovered); + MethodInfo reassign = typeof(Scene).GetMethod( + "ReassignDuplicateRecoveredIds", + BindingFlags.Instance | BindingFlags.NonPublic)!; + MethodInfo migrate = typeof(Scene).GetMethod( + "MigrateRecoveredElementReferences", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + reassign.Invoke(scene, null); + migrate.Invoke(scene, null); + + Assert.Multiple(() => + { + Assert.That(layerFallback.Id, Is.Not.EqualTo(layerClaimantId)); + Assert.That(markerFallback.Id, Is.Not.EqualTo(markerClaimantId)); + Assert.That(layerReference.Target.CurrentValue.Id, Is.EqualTo(layerClaimantId)); + Assert.That(markerReference.Target.CurrentValue.Id, Is.EqualTo(markerClaimantId)); + Assert.That(layerClaimant.Id, Is.EqualTo(layerClaimantId)); + Assert.That(markerClaimant.Id, Is.EqualTo(markerClaimantId)); + }); + } + [Test] public void SaveAs_CopiesRecoveredSidecarBytesToTheNewLocation() { From 7085180019d04d1fbe57c2bf82afef88959a146b Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 17:21:07 +0900 Subject: [PATCH 23/35] fix(review): close remaining recovery gaps --- .../Reconciliation/Reconciler.cs | 18 ++++ .../ProjectSystem/Scene.cs | 83 +++++++++++----- .../Reconciliation/ApplyEditTests.cs | 24 +++++ .../MalformedElementRecoveryTests.cs | 97 +++++++++++++++++++ 4 files changed, 196 insertions(+), 26 deletions(-) diff --git a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs index 46222eb4ec..994f5e75f4 100644 --- a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs +++ b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs @@ -847,6 +847,24 @@ private static bool TraverseSerializedGraph( break; } + if (coreObject is IHierarchical hierarchical) + { + int index = 0; + foreach (IHierarchical child in hierarchical.HierarchicalChildren) + { + if (TraverseSerializedGraph( + child, + $"{path}/HierarchicalChildren[{index}]", + visited, + visitCoreObject)) + { + return true; + } + + index++; + } + } + return false; } diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 15e179cada..320cd4c479 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -795,9 +795,9 @@ private void ReassignDuplicateRecoveredIds() Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)))) .OrderBy(static item => item.RelativePath, StringComparer.Ordinal) .ToArray(); - foreach ((Element child, string _) in healthyChildren) + + void ClaimHealthyDescendants(Element child) { - claimedIds.Add(child.Id); foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { seenDescendants.Add(descendant); @@ -805,10 +805,30 @@ private void ReassignDuplicateRecoveredIds() } } + foreach ((Element child, string relativePath) in healthyChildren + .Where(item => !_recoveredElementIds.ContainsKey(item.RelativePath))) + { + claimedIds.Add(child.Id); + ClaimHealthyDescendants(child); + } + + foreach ((Element child, string relativePath) in healthyChildren + .Where(item => _recoveredElementIds.ContainsKey(item.RelativePath))) + { + if (!claimedIds.Add(child.Id)) + { + Guid placeholderId = _recoveredElementIds[relativePath]; + child.Id = claimedIds.Add(placeholderId) + ? placeholderId + : ClaimRecoveredElementId(relativePath, claimedIds); + } + + ClaimHealthyDescendants(child); + } + foreach ((Element child, string relativePath) in healthyChildren) { - if (_recoveredElementIds.Remove(relativePath, out Guid placeholderId) - && placeholderId != child.Id) + if (_recoveredElementIds.Remove(relativePath, out Guid placeholderId)) { _pendingRecoveredElementIdMigrations.TryAdd(placeholderId, child.Id); } @@ -853,26 +873,7 @@ private void ReassignDuplicateRecoveredIds() continue; } - bool assigned = false; - for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++) - { - string candidateName = attempt == 0 - ? relativePath - : $"{relativePath}#{attempt}"; - Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName); - if (claimedIds.Add(candidate)) - { - child.Id = candidate; - assigned = true; - break; - } - } - - if (!assigned) - { - throw new InvalidOperationException( - $"Could not assign a unique recovered element Id for '{relativePath}'."); - } + child.Id = ClaimRecoveredElementId(relativePath, claimedIds); } foreach ((Element child, string relativePath) in recoveredChildren) @@ -995,6 +996,15 @@ var pendingDescendantRemaps } } + foreach (Guid originalId in _pendingRecoveredElementIdMigrations.Keys.ToArray()) + { + if (_pendingRecoveredElementIdMigrations[originalId] != originalId + && retainedIds.Contains(originalId)) + { + _pendingRecoveredElementIdMigrations.Remove(originalId); + } + } + foreach (Guid originalId in _pendingRecoveredDescendantIdMigrations.Keys.ToArray()) { if (retainedIds.Contains(originalId)) @@ -1004,6 +1014,24 @@ var pendingDescendantRemaps } } + private static Guid ClaimRecoveredElementId(string relativePath, ISet claimedIds) + { + for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++) + { + string candidateName = attempt == 0 + ? relativePath + : $"{relativePath}#{attempt}"; + Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName); + if (claimedIds.Add(candidate)) + { + return candidate; + } + } + + throw new InvalidOperationException( + $"Could not assign a unique recovered element Id for '{relativePath}'."); + } + private void RecordRecoveredDescendantRemap( CoreObject descendant, string remapKey, @@ -1156,10 +1184,13 @@ private void MigrateRecoveredElementReferences() return; } - foreach (Element element in Children) + IEnumerable ownerRoots = Children.Cast() + .Concat(Layers) + .Concat(Markers); + foreach (CoreObject ownerRoot in ownerRoots) { var visited = new HashSet(ReferenceEqualityComparer.Instance); - foreach (EngineObject engineObject in EnumerateSerializedGraphObjects(element).OfType()) + foreach (EngineObject engineObject in EnumerateSerializedGraphObjects(ownerRoot).OfType()) { foreach (IProperty property in engineObject.Properties) { diff --git a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs index 29ab7ac447..93d8d13861 100644 --- a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs @@ -187,6 +187,30 @@ public void Validate_no_new_fallback_objects_rejects_dictionary_values() }); } + [Test] + public void Validate_no_new_fallback_objects_rejects_element_hierarchy_children_outside_objects() + { + Scene current = CreateSceneWithElement(out _); + using var session = new AgentToolkitTestSession(current); + Scene sandbox = CreateSceneWithElement(out Element sandboxElement); + ((IModifiableHierarchical)sandboxElement).AddChild(new FallbackTransform()); + MethodInfo method = typeof(Reconciler).GetMethod( + "ValidateNoNewFallbackObjects", + BindingFlags.NonPublic | BindingFlags.Static)!; + + TargetInvocationException exception = Assert.Throws(() => + method.Invoke(null, new object[] { session, sandbox }))!; + var error = (ReconcileException)exception.InnerException!; + + Assert.Multiple(() => + { + Assert.That(sandboxElement.Objects, Is.Empty); + Assert.That(error.Error.Code, Is.EqualTo(ErrorCode.ValidationRejected)); + Assert.That(error.Error.Message, Does.Contain("fallback object")); + Assert.That(error.Error.Target, Does.Contain("HierarchicalChildren")); + }); + } + [Test] public void Apply_edit_returns_compact_response_and_optional_document() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 0c817ade1b..d315ce4bf6 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -977,6 +977,63 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() }); } + [Test] + public void Restore_RepairedElementIdCollisionPreservesPlaceholderIdentity() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("claimant.belm", "repaired.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element claimant = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + File.WriteAllText(elementPaths[1], "{ this is not valid JSON"); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element placeholder = recoveredScene.Children.Single( + child => child.Uri!.LocalPath == elementPaths[1]); + Element recoveredClaimant = recoveredScene.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Guid placeholderId = placeholder.Id; + recoveredScene.Groups.Add(ImmutableHashSet.Create(claimant.Id, placeholderId)); + var referenceHolder = new ElementReferenceHolder(); + referenceHolder.Target.CurrentValue = new Reference(placeholderId); + recoveredClaimant.AddObject(referenceHolder); + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + var repaired = new Element + { + Id = claimant.Id, + Name = "Repaired", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPaths[1]), + }; + repaired.AddObject(new RectShape()); + CoreSerializer.StoreToUri(repaired, repaired.Uri!); + + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + var application = new BeutlApplication(); + var project = new Project(); + application.Project = project; + project.Items.Add(reloaded); + Element reloadedClaimant = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Element reloadedRepaired = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[1]); + Reference reloadedReference = reloadedClaimant.Objects + .OfType() + .Single() + .Target.CurrentValue; + + Assert.Multiple(() => + { + Assert.That(reloaded.Children.Select(static child => child.Id), Is.Unique); + Assert.That(reloadedClaimant.Id, Is.EqualTo(claimant.Id)); + Assert.That(reloadedRepaired.Id, Is.EqualTo(placeholderId)); + Assert.That(reloaded.Groups.Single(), + Is.EqualTo(ImmutableHashSet.Create(claimant.Id, placeholderId))); + Assert.That(reloadedReference.Id, Is.EqualTo(placeholderId)); + Assert.That(reloadedReference.Value, Is.SameAs(reloadedRepaired)); + }); + } + [Test] public void MigrateRecoveredElementReferences_TraversesContainersAndKeyFrames() { @@ -1030,6 +1087,46 @@ Reference migratedDictionaryReference }); } + [Test] + public void MigrateRecoveredElementReferences_TraversesLayerAndMarkerGraphs() + { + Guid originalId = Guid.NewGuid(); + var migrated = new Element + { + Id = Guid.NewGuid(), + Uri = new Uri(Path.Combine(_root, "migrated.belm")), + }; + var layerHolder = new ElementReferenceHolder(); + layerHolder.Target.CurrentValue = new Reference(originalId); + var markerHolder = new ElementReferenceHolder(); + markerHolder.Target.CurrentValue = new Reference(originalId); + var layer = new TimelineLayer(); + var marker = new SceneMarker(); + ((IModifiableHierarchical)layer).AddChild(layerHolder); + ((IModifiableHierarchical)marker).AddChild(markerHolder); + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "migration.scene")) }; + scene.Children.Add(migrated); + scene.Layers.Add(layer); + scene.Markers.Add(marker); + var migrations = (Dictionary)typeof(Scene) + .GetField("_pendingRecoveredElementIdMigrations", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(scene)!; + migrations[originalId] = migrated.Id; + MethodInfo method = typeof(Scene).GetMethod( + "MigrateRecoveredElementReferences", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + method.Invoke(scene, null); + + Assert.Multiple(() => + { + Assert.That(layerHolder.Target.CurrentValue.Id, Is.EqualTo(migrated.Id)); + Assert.That(layerHolder.Target.CurrentValue.Value, Is.SameAs(migrated)); + Assert.That(markerHolder.Target.CurrentValue.Id, Is.EqualTo(migrated.Id)); + Assert.That(markerHolder.Target.CurrentValue.Value, Is.SameAs(migrated)); + }); + } + [Test] public void Restore_MalformedElementIdAvoidsSerializedMarkerCollision() { From 714efed90784a1847fc1ddd9d06648019b7d199c Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 18:16:10 +0900 Subject: [PATCH 24/35] fix(review): preserve recovery integrity --- .../Serialization/CoreSerializer.cs | 4 +- .../Serialization/DeserializationIncidents.cs | 58 ++++++- .../FallbackDeserializationHelper.cs | 2 +- .../JsonSerializationContext.Deserialize.cs | 2 +- .../Serialization/SuppressedStorageSource.cs | 7 +- .../ProjectSystem/Scene.cs | 155 ++++++++++++++---- .../MalformedElementRecoveryTests.cs | 125 ++++++++++++++ 7 files changed, 310 insertions(+), 43 deletions(-) diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 8ab0888ccd..c3fc80fd83 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -92,7 +92,7 @@ public static object DeserializeFromJsonObject(JsonObject json, Type baseType, C if (obj is IFallback fallbackObj) { fallbackObj.Reason = FallbackReason.TypeNotFound; - DeserializationIncidents.RecordFallback(); + DeserializationIncidents.RecordFallback(fallbackObj); } return obj; @@ -216,7 +216,7 @@ public static object RestoreFromUri(Uri uri, Type type) if (obj is IFallback fallbackObj) { fallbackObj.Reason = FallbackReason.TypeNotFound; - DeserializationIncidents.RecordFallback(); + DeserializationIncidents.RecordFallback(fallbackObj); } return obj; diff --git a/src/Beutl.Core/Serialization/DeserializationIncidents.cs b/src/Beutl.Core/Serialization/DeserializationIncidents.cs index 1ce0a90e28..6a01b3121e 100644 --- a/src/Beutl.Core/Serialization/DeserializationIncidents.cs +++ b/src/Beutl.Core/Serialization/DeserializationIncidents.cs @@ -10,7 +10,63 @@ internal static class DeserializationIncidents [ThreadStatic] private static int t_fallbackCount; + [ThreadStatic] + private static Capture? t_capture; + internal static int FallbackCount => t_fallbackCount; - internal static void RecordFallback() => t_fallbackCount++; + internal static Capture BeginCapture() => new(t_capture); + + internal static void RecordFallback(IFallback? fallback = null) + { + t_fallbackCount++; + for (Capture? capture = t_capture; capture != null; capture = capture.Parent) + { + capture.Record(fallback); + } + } + + internal sealed class Capture : IDisposable + { + private readonly int _initialCount; + private List? _fallbacks; + private bool _disposed; + + internal Capture(Capture? parent) + { + Parent = parent; + _initialCount = t_fallbackCount; + t_capture = this; + } + + internal Capture? Parent { get; } + + internal int Count => t_fallbackCount - _initialCount; + + internal IReadOnlyList Fallbacks => _fallbacks ?? []; + + internal void Record(IFallback? fallback) + { + if (fallback != null) + { + (_fallbacks ??= []).Add(fallback); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + if (!ReferenceEquals(t_capture, this)) + { + throw new InvalidOperationException("Deserialization incident captures must be disposed in stack order."); + } + + t_capture = Parent; + _disposed = true; + } + } } diff --git a/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs b/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs index 4caa70e740..485bec0e11 100644 --- a/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs +++ b/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs @@ -29,7 +29,7 @@ internal static class FallbackDeserializationHelper fallback.Reason = FallbackReason.DeserializationFailed; fallback.ErrorMessage = exception?.Message; - DeserializationIncidents.RecordFallback(); + DeserializationIncidents.RecordFallback(fallback); return fallback; } } diff --git a/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs b/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs index dd0865e656..54a1eeede8 100644 --- a/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs +++ b/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs @@ -116,7 +116,7 @@ private static bool TryDeserializeCoreSerializable( if (instance is IFallback fallbackObj) { fallbackObj.Reason = FallbackReason.TypeNotFound; - DeserializationIncidents.RecordFallback(); + DeserializationIncidents.RecordFallback(fallbackObj); } result = instance; diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs index ad40ca4978..adbf1b9ce2 100644 --- a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs +++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs @@ -1,4 +1,6 @@ -namespace Beutl; +using System.Text.Json.Nodes; + +namespace Beutl; /// /// The retained on-disk bytes of an object the serializer must not regenerate, together with the @@ -8,7 +10,8 @@ internal sealed record SuppressedStorageSource( byte[] RawBytes, Uri SourceUri, - bool HasNonFallbackIncidents = false) + bool HasNonFallbackIncidents = false, + JsonObject[]? UntraversedFallbacks = null) { /// /// True when this suppression record was put back by undoing an in-process repair. Only a diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 320cd4c479..c870a4b248 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -787,6 +787,9 @@ private void ReassignDuplicateRecoveredIds() } var seenDescendants = new HashSet(ReferenceEqualityComparer.Instance); + var persistedDescendantIds = new Dictionary( + _recoveredDescendantIds, + StringComparer.Ordinal); var healthyChildren = Children .Where(static child => child.SuppressedStorageSource is null) .Select(child => ( @@ -805,6 +808,42 @@ void ClaimHealthyDescendants(Element child) } } + void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePath) + { + var occurrences = new Dictionary(); + foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + { + if (!seenDescendants.Add(descendant)) + { + continue; + } + + Guid originalId = descendant.Id; + int occurrence = occurrences.GetValueOrDefault(originalId); + occurrences[originalId] = occurrence + 1; + string remapKey = CreateRecoveredDescendantKey(relativePath, originalId, occurrence); + bool hasPersistedId = persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId); + + if (claimedIds.Add(originalId)) + { + if (hasPersistedId) + { + _pendingRecoveredDescendantIdMigrations.TryAdd(persistedId, originalId); + } + + continue; + } + + Guid assignedId = hasPersistedId && claimedIds.Add(persistedId) + ? persistedId + : ClaimRecoveredDescendantId(relativePath, remapKey, claimedIds); + descendant.Id = assignedId; + _pendingRecoveredDescendantIdMigrations.TryAdd( + hasPersistedId ? persistedId : assignedId, + assignedId); + } + } + foreach ((Element child, string relativePath) in healthyChildren .Where(item => !_recoveredElementIds.ContainsKey(item.RelativePath))) { @@ -823,7 +862,7 @@ void ClaimHealthyDescendants(Element child) : ClaimRecoveredElementId(relativePath, claimedIds); } - ClaimHealthyDescendants(child); + ClaimPreviouslyRecoveredHealthyDescendants(child, relativePath); } foreach ((Element child, string relativePath) in healthyChildren) @@ -881,9 +920,6 @@ void ClaimHealthyDescendants(Element child) _recoveredElementIds[relativePath] = child.Id; } - var persistedDescendantIds = new Dictionary( - _recoveredDescendantIds, - StringComparer.Ordinal); _recoveredDescendantIds.Clear(); _recoveredDescendantRemaps.Clear(); var pendingDescendantRemaps @@ -945,32 +981,14 @@ var pendingDescendantRemaps continue; } - bool assigned = false; - for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++) - { - string candidateName = attempt == 0 - ? remap.RemapKey - : $"{remap.RemapKey}#{attempt}"; - Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName); - if (claimedIds.Add(candidate)) - { - descendant.Id = candidate; - RecordRecoveredDescendantRemap( - descendant, - remap.RemapKey, - remap.OriginalId, - candidate, - remap.Occurrence); - assigned = true; - break; - } - } - - if (!assigned) - { - throw new InvalidOperationException( - $"Could not assign a unique recovered descendant Id for '{relativePath}'."); - } + Guid candidate = ClaimRecoveredDescendantId(relativePath, remap.RemapKey, claimedIds); + descendant.Id = candidate; + RecordRecoveredDescendantRemap( + descendant, + remap.RemapKey, + remap.OriginalId, + candidate, + remap.Occurrence); if (descendant is IFallback fallback) { @@ -1007,7 +1025,8 @@ var pendingDescendantRemaps foreach (Guid originalId in _pendingRecoveredDescendantIdMigrations.Keys.ToArray()) { - if (retainedIds.Contains(originalId)) + if (_pendingRecoveredDescendantIdMigrations[originalId] != originalId + && retainedIds.Contains(originalId)) { _pendingRecoveredDescendantIdMigrations.Remove(originalId); } @@ -1032,6 +1051,27 @@ private static Guid ClaimRecoveredElementId(string relativePath, ISet clai $"Could not assign a unique recovered element Id for '{relativePath}'."); } + private static Guid ClaimRecoveredDescendantId( + string relativePath, + string remapKey, + ISet claimedIds) + { + for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++) + { + string candidateName = attempt == 0 + ? remapKey + : $"{remapKey}#{attempt}"; + Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName); + if (claimedIds.Add(candidate)) + { + return candidate; + } + } + + throw new InvalidOperationException( + $"Could not assign a unique recovered descendant Id for '{relativePath}'."); + } + private void RecordRecoveredDescendantRemap( CoreObject descendant, string remapKey, @@ -1054,15 +1094,22 @@ private void RecordRecoveredDescendantRemap( private Element RestoreElementOrFallback(Uri uri) { - int fallbackCountBefore = DeserializationIncidents.FallbackCount; + using DeserializationIncidents.Capture incidentCapture = DeserializationIncidents.BeginCapture(); try { Element element = CoreSerializer.RestoreFromUri(uri); IFallback[] fallbacks = EnumerateSerializedGraphFallbacks(element).ToArray(); - int incidentCount = DeserializationIncidents.FallbackCount - fallbackCountBefore; + int incidentCount = incidentCapture.Count; if (fallbacks.Length > 0 || incidentCount > 0) { + var traversedFallbacks = new HashSet( + fallbacks, + ReferenceEqualityComparer.Instance); + JsonObject[] untraversedFallbacks = incidentCapture.Fallbacks + .Where(fallback => !traversedFallbacks.Contains(fallback) && fallback.Json != null) + .Select(fallback => fallback.Json!.DeepClone().AsObject()) + .ToArray(); foreach (IFallback fallback in fallbacks) { if (fallback is CoreObject fallbackObject) @@ -1084,7 +1131,8 @@ private Element RestoreElementOrFallback(Uri uri) element, File.ReadAllBytes(uri.LocalPath), uri, - incidentCount > fallbacks.Length); + incidentCount > fallbacks.Length, + untraversedFallbacks); } return element; @@ -1143,18 +1191,21 @@ private static void MarkRecoveredElement( Element element, byte[] rawBytes, Uri uri, - bool hasNonFallbackIncidents = false) + bool hasNonFallbackIncidents = false, + JsonObject[]? untraversedFallbacks = null) { element.SuppressedStorageSource = new SuppressedStorageSource( rawBytes, uri, - hasNonFallbackIncidents); + hasNonFallbackIncidents, + untraversedFallbacks); } internal static SuppressedStorageSource? TryResumeElementPersistence(Element element) { if (element.SuppressedStorageSource is not { } source || EnumerateSerializedGraphFallbacks(element).Any() + || HasUnresolvedUntraversedFallback(element, source) || EnumerateSerializedGraphObjects(element).OfType().Any(static keyFrame => keyFrame.HasLossyEasing)) { return null; @@ -1164,6 +1215,38 @@ private static void MarkRecoveredElement( return source; } + private static bool HasUnresolvedUntraversedFallback( + Element element, + SuppressedStorageSource source) + { + if (source.UntraversedFallbacks is not { Length: > 0 } snapshots) + { + return false; + } + + JsonObject current = CoreSerializer.SerializeToJsonObject( + element, + new CoreSerializerOptions { BaseUri = element.Uri }); + return snapshots.Any(snapshot => ContainsEquivalentJsonNode(current, snapshot)); + } + + private static bool ContainsEquivalentJsonNode(JsonNode? current, JsonNode snapshot) + { + if (JsonNode.DeepEquals(current, snapshot)) + { + return true; + } + + return current switch + { + JsonObject obj => obj.Any(item => + item.Value != null && ContainsEquivalentJsonNode(item.Value, snapshot)), + JsonArray array => array.Any(item => + item != null && ContainsEquivalentJsonNode(item, snapshot)), + _ => false, + }; + } + private static IEnumerable EnumerateSerializedGraphFallbacks(Element element) { return EnumerateSerializedGraphObjects(element).OfType(); diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index d315ce4bf6..d05bd6e9b1 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -71,6 +71,24 @@ public DictionaryTransformHolder() = Property.Create>(); } + [SuppressResourceClassGeneration] + public sealed class ManuallySerializedTransformHolder : EngineObject + { + public Transform? HiddenTransform { get; set; } + + public override void Serialize(ICoreSerializationContext context) + { + base.Serialize(context); + context.SetValue(nameof(HiddenTransform), HiddenTransform); + } + + public override void Deserialize(ICoreSerializationContext context) + { + base.Deserialize(context); + HiddenTransform = context.GetValue(nameof(HiddenTransform)); + } + } + [SuppressResourceClassGeneration] public sealed class TransformReferenceHolder : EngineObject { @@ -1034,6 +1052,78 @@ public void Restore_RepairedElementIdCollisionPreservesPlaceholderIdentity() }); } + [Test] + public void Restore_RepairedDescendantIdCollisionPreservesPlaceholderIdentity() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("claimant.belm", "repaired.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element claimant = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + Element repairedSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + var claimantShape = (RectShape)claimant.Objects.Single(); + var repairedShape = (RectShape)repairedSource.Objects.Single(); + var claimantTransform = new RotationTransform(); + claimantShape.Transform.CurrentValue = claimantTransform; + repairedShape.Transform.CurrentValue = new RotationTransform { Id = claimantTransform.Id }; + CoreSerializer.StoreToUri(claimant, claimant.Uri!); + CoreSerializer.StoreToUri(repairedSource, repairedSource.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[1]))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + File.WriteAllText(elementPaths[1], json.ToJsonString()); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredClaimant = recoveredScene.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Guid placeholderId = ((CoreObject)GetTransformFallback(recoveredScene, elementPaths[1])).Id; + var referenceHolder = new TransformReferenceHolder(); + referenceHolder.Target.CurrentValue = new Reference(placeholderId); + recoveredClaimant.AddObject(referenceHolder); + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + var repaired = new Element + { + Id = repairedSource.Id, + Name = "Repaired", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPaths[1]), + }; + var healthyShape = new RectShape(); + healthyShape.Transform.CurrentValue = new RotationTransform { Id = claimantTransform.Id }; + repaired.AddObject(healthyShape); + CoreSerializer.StoreToUri(repaired, repaired.Uri!); + + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + var application = new BeutlApplication(); + var project = new Project(); + application.Project = project; + project.Items.Add(reloaded); + Element reloadedClaimant = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Element reloadedRepaired = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[1]); + Transform reloadedClaimantTransform = ((RectShape)reloadedClaimant.Objects + .OfType() + .Single()).Transform.CurrentValue!; + Transform reloadedRepairedTransform = ((RectShape)reloadedRepaired.Objects + .OfType() + .Single()).Transform.CurrentValue!; + Reference reloadedReference = reloadedClaimant.Objects + .OfType() + .Single() + .Target.CurrentValue; + + Assert.Multiple(() => + { + Assert.That(reloadedClaimantTransform.Id, Is.EqualTo(claimantTransform.Id)); + Assert.That(reloadedRepairedTransform.Id, Is.EqualTo(placeholderId)); + Assert.That(reloadedClaimantTransform.Id, Is.Not.EqualTo(reloadedRepairedTransform.Id)); + Assert.That(reloadedReference.Id, Is.EqualTo(placeholderId)); + Assert.That(reloadedReference.Value, Is.SameAs(reloadedRepairedTransform)); + }); + } + [Test] public void MigrateRecoveredElementReferences_TraversesContainersAndKeyFrames() { @@ -1456,6 +1546,41 @@ public void TryResumeElementPersistence_DictionaryValuedFallback_StaysBlocked() Assert.That(suppression, Is.Null); } + [Test] + public void TryResumeElementPersistence_ManuallySerializedFallbackStaysBlockedUntilRepair() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Element source = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var holder = new ManuallySerializedTransformHolder + { + HiddenTransform = new RotationTransform(), + }; + source.AddObject(holder); + CoreSerializer.StoreToUri(source, source.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + File.WriteAllText(elementPath, json.ToJsonString()); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + var recoveredHolder = recovered.Objects.OfType().Single(); + Assert.That(recoveredHolder.HiddenTransform, Is.InstanceOf()); + recovered.Name = "Unrelated edit"; + + SuppressedStorageSource? blocked = Scene.TryResumeElementPersistence(recovered); + recoveredHolder.HiddenTransform = new RotationTransform(); + SuppressedStorageSource? resumed = Scene.TryResumeElementPersistence(recovered); + + Assert.Multiple(() => + { + Assert.That(recoveredHolder.HiddenTransform, Is.Not.InstanceOf()); + Assert.That(blocked, Is.Null); + Assert.That(resumed, Is.Not.Null); + Assert.That(recovered.SuppressedStorageSource, Is.Null); + }); + } + [Test] public void Restore_CollisionRemappedRecoveredDescendant_KeepsReferenceOnSurvivingClaimant() { From ccf5e4dab8be524e07eee31e42f97191616f62f2 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 19:03:19 +0900 Subject: [PATCH 25/35] fix(review): close recovery gaps --- src/Beutl.Core/OptionalJsonConverter.cs | 4 +- .../Serialization/CoreSerializer.cs | 13 +++ .../ProjectSystem/Scene.cs | 64 +++++++++++- .../ViewModels/Editors/PenEditorViewModel.cs | 5 + .../FallbackEditorPersistenceTests.cs | 86 ++++++++++++++++ .../MalformedElementRecoveryTests.cs | 99 +++++++++++++++++++ 6 files changed, 265 insertions(+), 6 deletions(-) diff --git a/src/Beutl.Core/OptionalJsonConverter.cs b/src/Beutl.Core/OptionalJsonConverter.cs index 725dd974a5..2a4e884940 100644 --- a/src/Beutl.Core/OptionalJsonConverter.cs +++ b/src/Beutl.Core/OptionalJsonConverter.cs @@ -33,7 +33,9 @@ public override bool CanConvert(Type typeToConvert) goto Return; } - instance = JsonSerializer.Deserialize(jsonNode, valueType, options); + instance = typeof(ICoreSerializable).IsAssignableFrom(valueType) + ? CoreSerializer.DeserializeFromJsonNode(jsonNode, valueType) + : JsonSerializer.Deserialize(jsonNode, valueType, options); Return: var o = (IOptional?)Activator.CreateInstance(typeToConvert, instance); diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index c3fc80fd83..b368472151 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -187,6 +187,19 @@ public static object RestoreFromUri(Uri uri, Type type) Type? actualType = hasDiscriminator ? jsonObject.GetDiscriminator() : type.IsSealed ? type : jsonObject.GetDiscriminator(type); + if (hasDiscriminator + && actualType == null + && FallbackDeserializationHelper.TryCreateFallback(type, null, jsonObject) is { } unknownTypeFallback) + { + ((IFallback)unknownTypeFallback).Reason = FallbackReason.TypeNotFound; + if (unknownTypeFallback is CoreObject coreObject) + { + coreObject.Uri = uri; + } + + return unknownTypeFallback; + } + if (actualType == null) { throw new InvalidOperationException("Discriminator not found in JSON object."); diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index c870a4b248..5ec6c30159 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -53,6 +53,7 @@ public class Scene : ProjectItem, INotifyEdited { private const int MaxRecoveredIdCollisionAttempts = 1024; private const string RecoveredDescendantIdsKey = "RecoveredDescendantIds"; + private const string RecoveredDescendantIdentitiesKey = "RecoveredDescendantIdentities"; private const string RecoveredElementIdsKey = "RecoveredElementIds"; private static readonly Guid s_recoveredElementNamespace = new("dfad2f76-1d04-5593-ae3b-f371fb1f42ee"); private static readonly Regex s_idPattern = new( @@ -77,6 +78,7 @@ public class Scene : ProjectItem, INotifyEdited private readonly HierarchicalList _layers; private readonly HierarchicalList _markers; private readonly Dictionary _recoveredDescendantIds = new(StringComparer.Ordinal); + private readonly Dictionary _recoveredDescendantIdentities = new(StringComparer.Ordinal); private readonly Dictionary _recoveredDescendantRemaps = new(ReferenceEqualityComparer.Instance); private readonly Dictionary _recoveredElementIds = new(StringComparer.Ordinal); @@ -592,6 +594,19 @@ static void Process(JsonObject jobject, string jsonName, List list) context.SetValue(RecoveredDescendantIdsKey, recoveredDescendantIds); } + if (_recoveredDescendantIdentities.Count > 0) + { + var recoveredDescendantIdentities = new JsonObject(); + foreach ((string key, Guid id) in _recoveredDescendantIdentities.OrderBy( + static item => item.Key, + StringComparer.Ordinal)) + { + recoveredDescendantIdentities[key] = id.ToString(); + } + + context.SetValue(RecoveredDescendantIdentitiesKey, recoveredDescendantIdentities); + } + if (context.Mode.HasFlag(CoreSerializationMode.SaveReferencedObjects)) { foreach (Element item in Children) @@ -652,6 +667,7 @@ static void Process(Func add, JsonNode node, List list) _pendingRecoveredDescendantIdMigrations.Clear(); _idlessRecoveredDescendants.Clear(); _recoveredDescendantIds.Clear(); + _recoveredDescendantIdentities.Clear(); _recoveredDescendantRemaps.Clear(); _recoveredElementIds.Clear(); if (context.GetValue(RecoveredElementIdsKey) is JsonObject recoveredElementIds) @@ -680,6 +696,19 @@ static void Process(Func add, JsonNode node, List list) } } + if (context.GetValue(RecoveredDescendantIdentitiesKey) is JsonObject recoveredDescendantIdentities) + { + foreach ((string key, JsonNode? idNode) in recoveredDescendantIdentities) + { + if (idNode is JsonValue idValue + && idValue.TryGetValue(out string? idText) + && Guid.TryParse(idText, out Guid id)) + { + _recoveredDescendantIdentities[NormalizeRelativePath(key)] = id; + } + } + } + Markers.Clear(); if (context.Contains(nameof(Markers)) && context.GetValue(nameof(Markers)) is { } markers) @@ -790,6 +819,9 @@ private void ReassignDuplicateRecoveredIds() var persistedDescendantIds = new Dictionary( _recoveredDescendantIds, StringComparer.Ordinal); + var persistedDescendantIdentities = new Dictionary( + _recoveredDescendantIdentities, + StringComparer.Ordinal); var healthyChildren = Children .Where(static child => child.SuppressedStorageSource is null) .Select(child => ( @@ -811,8 +843,10 @@ void ClaimHealthyDescendants(Element child) void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePath) { var occurrences = new Dictionary(); + int descendantIndex = 0; foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { + string identityKey = CreateRecoveredDescendantIdentityKey(relativePath, descendantIndex++); if (!seenDescendants.Add(descendant)) { continue; @@ -823,23 +857,28 @@ void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePa occurrences[originalId] = occurrence + 1; string remapKey = CreateRecoveredDescendantKey(relativePath, originalId, occurrence); bool hasPersistedId = persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId); + bool hasPersistedIdentity = persistedDescendantIdentities.TryGetValue( + identityKey, + out Guid persistedIdentityId); + bool hasPreviousAssignedId = hasPersistedId || hasPersistedIdentity; + Guid previousAssignedId = hasPersistedId ? persistedId : persistedIdentityId; if (claimedIds.Add(originalId)) { - if (hasPersistedId) + if (hasPreviousAssignedId) { - _pendingRecoveredDescendantIdMigrations.TryAdd(persistedId, originalId); + _pendingRecoveredDescendantIdMigrations.TryAdd(previousAssignedId, originalId); } continue; } - Guid assignedId = hasPersistedId && claimedIds.Add(persistedId) - ? persistedId + Guid assignedId = hasPreviousAssignedId && claimedIds.Add(previousAssignedId) + ? previousAssignedId : ClaimRecoveredDescendantId(relativePath, remapKey, claimedIds); descendant.Id = assignedId; _pendingRecoveredDescendantIdMigrations.TryAdd( - hasPersistedId ? persistedId : assignedId, + hasPreviousAssignedId ? previousAssignedId : assignedId, assignedId); } } @@ -1539,6 +1578,7 @@ private void RebuildRecoveredElementIds() if (recoveredChildren.Length == 0 && _recoveredElementIds.Count == 0 && _recoveredDescendantIds.Count == 0 + && _recoveredDescendantIdentities.Count == 0 && _recoveredDescendantRemaps.Count == 0) { return; @@ -1549,6 +1589,7 @@ private void RebuildRecoveredElementIds() _recoveredDescendantRemaps, ReferenceEqualityComparer.Instance); _recoveredDescendantIds.Clear(); + _recoveredDescendantIdentities.Clear(); _recoveredDescendantRemaps.Clear(); _recoveredElementIds.Clear(); foreach (Element child in recoveredChildren) @@ -1556,8 +1597,16 @@ private void RebuildRecoveredElementIds() string relativePath = NormalizeRelativePath( Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)); _recoveredElementIds[relativePath] = child.Id; + int descendantIndex = 0; foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { + if (descendant is IFallback) + { + string identityKey = CreateRecoveredDescendantIdentityKey(relativePath, descendantIndex); + _recoveredDescendantIdentities[identityKey] = descendant.Id; + } + + descendantIndex++; if (descendantRemaps.TryGetValue( descendant, out (Guid OriginalId, Guid AssignedId, int Occurrence) remap) @@ -1579,6 +1628,11 @@ private static string CreateRecoveredDescendantKey(string relativePath, Guid ori return $"{relativePath}!{originalId:D}#{occurrence}"; } + private static string CreateRecoveredDescendantIdentityKey(string relativePath, int index) + { + return $"{relativePath}!@{index}"; + } + private static string NormalizeRelativePath(string path) { return path.Replace('\\', '/'); diff --git a/src/Beutl/ViewModels/Editors/PenEditorViewModel.cs b/src/Beutl/ViewModels/Editors/PenEditorViewModel.cs index b48346b451..5eb955988e 100644 --- a/src/Beutl/ViewModels/Editors/PenEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/PenEditorViewModel.cs @@ -131,6 +131,7 @@ public void SetValue(Pen? oldValue, Pen? newValue) if (!EqualityComparer.Default.Equals(oldValue, newValue)) { PropertyAdapter.SetValue(newValue); + ResumeElementPersistenceAfterFallbackReplacement(oldValue); Commit(); } } @@ -143,7 +144,9 @@ public override bool ApplyTemplate(ObjectTemplateItem template) { if (template.CreateInstance() is not Pen instance) return false; IsExpanded.Value = true; + Pen? previous = Value.Value; PropertyAdapter.SetValue(instance); + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(CommandNames.ApplyTemplate); return true; } @@ -153,7 +156,9 @@ public override bool TryPasteJson(string json) if (!CoreObjectClipboard.TryDeserializeJson(json, out var pasted)) return false; IsExpanded.Value = true; + Pen? previous = Value.Value; PropertyAdapter.SetValue(pasted); + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(CommandNames.PasteObject); return true; } diff --git a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs index 36c9d6c395..a530ec2b27 100644 --- a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs +++ b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs @@ -39,6 +39,8 @@ public EditorValueHolder() public IProperty BrushValue { get; } = Property.CreateAnimatable(); + public IProperty PenValue { get; } = Property.Create(); + public IProperty TextureValue { get; } = Property.Create(); } @@ -116,6 +118,69 @@ public void BrushTryPasteJson_LastFallbackResumesPersistenceInReplacementTransac } } + [AvaloniaTest] + public void PenWholeValueReplacements_ResumePersistenceInReplacementTransaction() + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + string root = CreateRoot(); + try + { + var sceneUri = new Uri(Path.Combine(root, "scene.scene")); + string elementPath = Path.Combine(root, "element.belm"); + var holder = new EditorValueHolder(); + var pen = new Pen(); + pen.Brush.CurrentValue = new SolidColorBrush(Colors.Red); + holder.PenValue.CurrentValue = pen; + var element = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(holder); + var scene = new Scene(64, 64, "Scene") { Uri = sceneUri }; + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject brushJson = FindObjectWithProperty(elementJson, nameof(Pen.Brush))! + [nameof(Pen.Brush)]!.AsObject(); + brushJson["$type"] = "[Beutl.Engine]Beutl.Media:MissingBrush"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single(); + var recoveredHolder = (EditorValueHolder)recoveredElement.Objects.Single(); + var recoveredPen = recoveredHolder.PenValue.CurrentValue!; + Assert.That(recoveredPen.Brush.CurrentValue, Is.InstanceOf()); + using var context = new EditorTestContext(recoveredElement); + var adapter = new SimplePropertyAdapter( + (SimpleProperty)recoveredHolder.PenValue, + recoveredHolder); + using var viewModel = new PenEditorViewModel(adapter); + viewModel.Accept(new Visitor(recoveredElement, context.History)); + + var directPen = new Pen(); + directPen.Brush.CurrentValue = new SolidColorBrush(Colors.Blue); + viewModel.SetValue(recoveredPen, directPen); + AssertPenRepairAndUndo(recoveredElement, recoveredHolder, context.History, elementPath, originalBytes); + + bool applied = viewModel.ApplyTemplate( + ObjectTemplateItem.CreateFromInstance(new Pen(), "Template")); + Assert.That(applied, Is.True); + AssertPenRepairAndUndo(recoveredElement, recoveredHolder, context.History, elementPath, originalBytes); + + bool pasted = viewModel.TryPasteJson(CoreSerializer.SerializeToJsonString(new Pen())); + Assert.That(pasted, Is.True); + AssertPenRepairAndUndo(recoveredElement, recoveredHolder, context.History, elementPath, originalBytes); + } + finally + { + Directory.Delete(root, true); + } + } + [AvaloniaTest] public void EasingRepair_ResumesPersistenceAndWritesRepairedSidecar() { @@ -507,6 +572,27 @@ private static KeyFrameAnimation CreateAnimation(KeyFrame keyFrame) return animation; } + private static void AssertPenRepairAndUndo( + Element recoveredElement, + EditorValueHolder recoveredHolder, + HistoryManager history, + string elementPath, + byte[] originalBytes) + { + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = history.Undo(); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + + Assert.Multiple(() => + { + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(recoveredHolder.PenValue.CurrentValue!.Brush.CurrentValue, Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + private static string CreateRoot() { string root = Path.Combine( diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index d05bd6e9b1..591cd912cb 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1124,6 +1124,68 @@ public void Restore_RepairedDescendantIdCollisionPreservesPlaceholderIdentity() }); } + [Test] + public void Restore_RepairedDescendantWithNewIdMigratesPlaceholderReference() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("repaired.belm", "holder.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element repairedSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + var repairedShape = (RectShape)repairedSource.Objects.Single(); + var originalTransform = new RotationTransform(); + repairedShape.Transform.CurrentValue = originalTransform; + CoreSerializer.StoreToUri(repairedSource, repairedSource.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[0]))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + File.WriteAllText(elementPaths[0], json.ToJsonString()); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Guid placeholderId = ((CoreObject)GetTransformFallback(recoveredScene, elementPaths[0])).Id; + Element holder = recoveredScene.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + var referenceHolder = new TransformReferenceHolder(); + referenceHolder.Target.CurrentValue = new Reference(placeholderId); + holder.AddObject(referenceHolder); + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + Guid repairedId = Guid.NewGuid(); + var repaired = new Element + { + Id = repairedSource.Id, + Name = "Repaired", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPaths[0]), + }; + var healthyShape = new RectShape(); + healthyShape.Transform.CurrentValue = new RotationTransform { Id = repairedId }; + repaired.AddObject(healthyShape); + CoreSerializer.StoreToUri(repaired, repaired.Uri!); + + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + var application = new BeutlApplication(); + var project = new Project(); + application.Project = project; + project.Items.Add(reloaded); + Element reloadedRepaired = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Element reloadedHolder = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[1]); + Transform reloadedTransform = ((RectShape)reloadedRepaired.Objects.Single()) + .Transform.CurrentValue!; + Reference migratedReference = reloadedHolder.Objects + .OfType() + .Single() + .Target.CurrentValue; + + Assert.Multiple(() => + { + Assert.That(reloadedTransform.Id, Is.EqualTo(repairedId)); + Assert.That(migratedReference.Id, Is.EqualTo(repairedId)); + Assert.That(migratedReference.Value, Is.SameAs(reloadedTransform)); + }); + } + [Test] public void MigrateRecoveredElementReferences_TraversesContainersAndKeyFrames() { @@ -1264,6 +1326,43 @@ public void Restore_KnownTypeDeserializationFallbackAdoptsSerializedId() }); } + [Test] + public void Restore_UnknownExternalObjectTypeUsesPropertyFallback() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + string transformPath = Path.Combine(_root, "external-transform.json"); + Element source = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var sourceShape = (RectShape)source.Objects.Single(); + sourceShape.Transform.CurrentValue = new RotationTransform + { + Uri = new Uri(transformPath), + }; + CoreSerializer.StoreToUri(source, source.Uri!); + + JsonObject transformJson = JsonNode.Parse(File.ReadAllText(transformPath))!.AsObject(); + transformJson["$type"] = "[Missing.Plugin]Missing.Namespace:MissingTransform"; + File.WriteAllText(transformPath, transformJson.ToJsonString()); + + Transform restoredTransform = CoreSerializer.RestoreFromUri(new Uri(transformPath)); + Element restoredElement = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recovered.Children.Single(); + RectShape? recoveredShape = recoveredElement.Objects.OfType().SingleOrDefault(); + string? recoveryError = recoveredElement.Objects.OfType().FirstOrDefault()?.ErrorMessage; + + Assert.Multiple(() => + { + Assert.That(restoredTransform, Is.InstanceOf()); + Assert.That(restoredTransform.Uri, Is.EqualTo(new Uri(transformPath))); + Assert.That(restoredElement.Objects.OfType(), Has.Exactly(1).Items); + Assert.That(recoveredElement.IsEnabled, Is.True); + Assert.That(recoveredElement.Objects, Has.Count.EqualTo(1)); + Assert.That(recoveredShape, Is.Not.Null, recoveryError); + Assert.That(recoveredShape?.Transform.CurrentValue, Is.InstanceOf()); + Assert.That(recoveredShape?.Transform.CurrentValue?.Uri, Is.EqualTo(new Uri(transformPath))); + }); + } + [Test] public void Restore_PersistedRecoveredDescendantRemapSurvivesClaimantRemoval() { From 4402ffbab2fa2dec8686306a0cebbb5ca7217c8d Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 19:45:49 +0900 Subject: [PATCH 26/35] fix(review): harden recovered sidecar state --- .../Sessions/FileEditingSession.cs | 26 +- .../Serialization/CoreSerializer.cs | 2 +- .../ProjectSystem/Scene.cs | 282 +++++++++++++++++- .../Sessions/FileEditingSessionTests.cs | 63 +++- .../MalformedElementRecoveryTests.cs | 92 +++++- 5 files changed, 448 insertions(+), 17 deletions(-) diff --git a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs index ec31427d1e..add0144911 100644 --- a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs +++ b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs @@ -238,16 +238,38 @@ internal void SaveAs(string projectPath, bool skipConflictCheck) private UriState CaptureUriState() { - return new UriState(ProjectOperations.CaptureUriState(Project), _projectLastWriteUtc); + var suppressions = Project.Items.OfType() + .SelectMany(static scene => scene.Children) + .Where(static element => element.SuppressedStorageSource is not null) + .Select(static element => ( + Element: element, + Source: element.SuppressedStorageSource!, + element.SuppressedStorageSource!.WasReinstated)) + .ToArray(); + return new UriState( + ProjectOperations.CaptureUriState(Project), + suppressions, + _projectLastWriteUtc); } private void RestoreUriState(UriState state) { ProjectOperations.RestoreUriState(Project, state.Uris); + foreach ((Element element, SuppressedStorageSource source, bool wasReinstated) in state.Suppressions) + { + if (ReferenceEquals(element.SuppressedStorageSource, source)) + { + source.WasReinstated = wasReinstated; + } + } + _projectLastWriteUtc = state.Stamp; } - private sealed record UriState(ProjectUriState Uris, DateTime Stamp); + private sealed record UriState( + ProjectUriState Uris, + IReadOnlyList<(Element Element, SuppressedStorageSource Source, bool WasReinstated)> Suppressions, + DateTime Stamp); public void MarkDirty() { diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index b368472151..506cf65c7d 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -398,7 +398,7 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n private static void RestoreReinstatedBytes(SuppressedStorageSource suppressed, string path) { - if (!suppressed.WasReinstated) + if (!suppressed.WasReinstated && File.Exists(path)) { return; } diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 5ec6c30159..abee91ce29 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -843,15 +843,22 @@ void ClaimHealthyDescendants(Element child) void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePath) { var occurrences = new Dictionary(); - int descendantIndex = 0; + var legacyIndices = new Dictionary(ReferenceEqualityComparer.Instance); + int legacyIndex = 0; foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) { - string identityKey = CreateRecoveredDescendantIdentityKey(relativePath, descendantIndex++); + legacyIndices.TryAdd(descendant, legacyIndex++); + } + + foreach ((CoreObject descendant, SerializedGraphPath graphPath) in + EnumerateSerializedGraphDescendantPaths(child)) + { if (!seenDescendants.Add(descendant)) { continue; } + string identityKey = CreateRecoveredDescendantIdentityKey(relativePath, graphPath.Stable); Guid originalId = descendant.Id; int occurrence = occurrences.GetValueOrDefault(originalId); occurrences[originalId] = occurrence + 1; @@ -860,6 +867,27 @@ void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePa bool hasPersistedIdentity = persistedDescendantIdentities.TryGetValue( identityKey, out Guid persistedIdentityId); + if (!hasPersistedIdentity && graphPath.Positional != graphPath.Stable) + { + string positionalIdentityKey = CreateRecoveredDescendantIdentityKey( + relativePath, + graphPath.Positional); + hasPersistedIdentity = persistedDescendantIdentities.TryGetValue( + positionalIdentityKey, + out persistedIdentityId); + } + + if (!hasPersistedIdentity + && legacyIndices.TryGetValue(descendant, out int persistedIndex)) + { + string legacyIdentityKey = CreateLegacyRecoveredDescendantIdentityKey( + relativePath, + persistedIndex); + hasPersistedIdentity = persistedDescendantIdentities.TryGetValue( + legacyIdentityKey, + out persistedIdentityId); + } + bool hasPreviousAssignedId = hasPersistedId || hasPersistedIdentity; Guid previousAssignedId = hasPersistedId ? persistedId : persistedIdentityId; @@ -1189,12 +1217,12 @@ private Element RestoreElementOrFallback(Uri uri) JsonObject? root = TryParseTopLevelObject(rawText); var element = new Element { - Id = ResolveRecoveredElementId(rawText, root, uri), + Id = ResolveRecoveredElementId(rawBytes, rawText, root, uri), Name = Path.GetFileNameWithoutExtension(uri.LocalPath), Uri = uri, IsEnabled = false, }; - string? topLevelTypeName = TryGetTopLevelTypeName(rawText, root); + string? topLevelTypeName = TryGetTopLevelTypeName(rawBytes, rawText, root); FallbackReason fallbackReason = topLevelTypeName is not null && TypeFormat.ToType(topLevelTypeName) is null ? FallbackReason.TypeNotFound @@ -1470,6 +1498,157 @@ private static void CollectSerializedGraphObjects( } } + private static IEnumerable<(CoreObject Object, SerializedGraphPath Path)> EnumerateSerializedGraphDescendantPaths( + Element element) + { + var objects = new List<(CoreObject Object, SerializedGraphPath Path)>(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + CollectSerializedGraphObjectPaths(element, new SerializedGraphPath("$", "$"), visited, objects); + return objects.Where(item => !ReferenceEquals(item.Object, element)); + } + + private static void CollectSerializedGraphObjectPaths( + object? value, + SerializedGraphPath path, + ISet visited, + ICollection<(CoreObject Object, SerializedGraphPath Path)> objects) + { + if (value is null or string + || (!value.GetType().IsValueType && !visited.Add(value))) + { + return; + } + + if (value is CoreObject coreObject) + { + objects.Add((coreObject, path)); + } + + if (value is Element element) + { + CollectSerializedGraphPathItems( + element.Objects, + AppendSerializedGraphPath(path, "property", nameof(Element.Objects)), + visited, + objects); + } + + if (value is EngineObject engineObject) + { + foreach (IProperty property in engineObject.Properties) + { + SerializedGraphPath propertyPath = AppendSerializedGraphPath(path, "property", property.Name); + CollectSerializedGraphObjectPaths(property.CurrentValue, propertyPath, visited, objects); + if (property.Animation is IKeyFrameAnimation animation) + { + SerializedGraphPath keyFramesPath = AppendSerializedGraphPath( + path, + "animation", + property.Name); + var occurrences = new Dictionary(); + int index = 0; + foreach (IKeyFrame keyFrame in animation.KeyFrames) + { + SerializedGraphPath keyFramePath = CreateSerializedGraphCollectionItemPath( + keyFramesPath, + keyFrame, + index++, + occurrences); + CollectSerializedGraphObjectPaths(keyFrame, keyFramePath, visited, objects); + CollectSerializedGraphObjectPaths( + keyFrame.Value, + AppendSerializedGraphPath(keyFramePath, "property", nameof(IKeyFrame.Value)), + visited, + objects); + } + } + } + } + + if (value is IHierarchical hierarchical) + { + CollectSerializedGraphPathItems( + hierarchical.HierarchicalChildren, + AppendSerializedGraphPath(path, "collection", "HierarchicalChildren"), + visited, + objects); + } + + if (value is System.Collections.IDictionary dictionary) + { + foreach (DictionaryEntry entry in dictionary) + { + CollectSerializedGraphObjectPaths( + entry.Value, + AppendSerializedGraphPath(path, "key", entry.Key?.ToString() ?? "null"), + visited, + objects); + } + } + else if (value is IEnumerable enumerable) + { + CollectSerializedGraphPathItems(enumerable, path, visited, objects); + } + } + + private static void CollectSerializedGraphPathItems( + IEnumerable items, + SerializedGraphPath path, + ISet visited, + ICollection<(CoreObject Object, SerializedGraphPath Path)> objects) + { + var occurrences = new Dictionary(); + int index = 0; + foreach (object? item in items) + { + SerializedGraphPath itemPath = CreateSerializedGraphCollectionItemPath( + path, + item, + index++, + occurrences); + CollectSerializedGraphObjectPaths(item, itemPath, visited, objects); + } + } + + private static SerializedGraphPath CreateSerializedGraphCollectionItemPath( + SerializedGraphPath path, + object? item, + int index, + IDictionary occurrences) + { + string indexText = index.ToString(System.Globalization.CultureInfo.InvariantCulture); + string positional = AppendSerializedGraphPath(path.Positional, "index", indexText); + if (item is CoreObject { Id: var id } && id != Guid.Empty) + { + int occurrence = occurrences.TryGetValue(id, out int value) ? value : 0; + occurrences[id] = occurrence + 1; + string stable = AppendSerializedGraphPath(path.Stable, "id", $"{id:D}#{occurrence}"); + return new SerializedGraphPath(stable, positional); + } + + return new SerializedGraphPath( + AppendSerializedGraphPath(path.Stable, "index", indexText), + positional); + } + + private static SerializedGraphPath AppendSerializedGraphPath( + SerializedGraphPath path, + string kind, + string value) + { + return new SerializedGraphPath( + AppendSerializedGraphPath(path.Stable, kind, value), + AppendSerializedGraphPath(path.Positional, kind, value)); + } + + private static string AppendSerializedGraphPath(string path, string kind, string value) + { + string escaped = value.Replace("~", "~0").Replace("/", "~1"); + return $"{path}/{kind}:{escaped}"; + } + + private readonly record struct SerializedGraphPath(string Stable, string Positional); + private static void EnsureFallbackProjection(IFallback fallback) { if (fallback is not CoreObject coreObject) @@ -1518,13 +1697,26 @@ private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback } } - private static string? TryGetTopLevelTypeName(string rawText, JsonObject? root) + private static string? TryGetTopLevelTypeName( + ReadOnlySpan rawBytes, + string rawText, + JsonObject? root) { if (root?.TryGetDiscriminator(out string? parsedTypeName) == true) { return parsedTypeName; } + if (TryGetTopLevelStringProperty(rawBytes, "$type", out string? scannedTypeName)) + { + return scannedTypeName; + } + + if (TryGetTopLevelStringProperty(rawBytes, "@type", out string? scannedLegacyTypeName)) + { + return scannedLegacyTypeName; + } + Match? match = FindTopLevelMatch(rawText, s_typePattern.Matches(rawText)) ?? FindTopLevelMatch(rawText, s_legacyTypePattern.Matches(rawText)); if (match is null) @@ -1542,13 +1734,24 @@ private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback } } - private Guid ResolveRecoveredElementId(string rawText, JsonObject? root, Uri uri) + private Guid ResolveRecoveredElementId( + ReadOnlySpan rawBytes, + string rawText, + JsonObject? root, + Uri uri) { if (TryGetSerializedId(root, out Guid parsedId)) { return parsedId; } + if (TryGetTopLevelStringProperty(rawBytes, nameof(CoreObject.Id), out string? scannedId) + && Guid.TryParse(scannedId, out Guid scannedGuid) + && scannedGuid != Guid.Empty) + { + return scannedGuid; + } + // Only a top-level Id may name the element: a nested object's or quoted Id would collide // with live objects, so anything else falls through to the deterministic filename Guid. MatchCollection matches = s_idPattern.Matches(rawText); @@ -1565,6 +1768,52 @@ private Guid ResolveRecoveredElementId(string rawText, JsonObject? root, Uri uri return CreateVersion5Guid(s_recoveredElementNamespace, relativePath); } + private static bool TryGetTopLevelStringProperty( + ReadOnlySpan rawBytes, + string propertyName, + out string? value) + { + value = null; + if (rawBytes.Length >= 3 + && rawBytes[0] == 0xef + && rawBytes[1] == 0xbb + && rawBytes[2] == 0xbf) + { + rawBytes = rawBytes[3..]; + } + + var reader = new Utf8JsonReader(rawBytes, isFinalBlock: false, state: default); + try + { + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + { + return false; + } + + int propertyDepth = reader.CurrentDepth + 1; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName + && reader.CurrentDepth == propertyDepth + && reader.ValueTextEquals(propertyName)) + { + if (reader.Read() && reader.TokenType == JsonTokenType.String) + { + value = reader.GetString(); + return value is not null; + } + + return false; + } + } + } + catch (JsonException) + { + } + + return false; + } + private void RebuildRecoveredElementIds() { if (Uri is null) @@ -1597,16 +1846,22 @@ private void RebuildRecoveredElementIds() string relativePath = NormalizeRelativePath( Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)); _recoveredElementIds[relativePath] = child.Id; - int descendantIndex = 0; - foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + foreach ((CoreObject descendant, SerializedGraphPath graphPath) in + EnumerateSerializedGraphDescendantPaths(child)) { if (descendant is IFallback) { - string identityKey = CreateRecoveredDescendantIdentityKey(relativePath, descendantIndex); + string identityKey = CreateRecoveredDescendantIdentityKey(relativePath, graphPath.Stable); _recoveredDescendantIdentities[identityKey] = descendant.Id; + if (graphPath.Positional != graphPath.Stable) + { + string positionalIdentityKey = CreateRecoveredDescendantIdentityKey( + relativePath, + graphPath.Positional); + _recoveredDescendantIdentities[positionalIdentityKey] = descendant.Id; + } } - descendantIndex++; if (descendantRemaps.TryGetValue( descendant, out (Guid OriginalId, Guid AssignedId, int Occurrence) remap) @@ -1628,7 +1883,12 @@ private static string CreateRecoveredDescendantKey(string relativePath, Guid ori return $"{relativePath}!{originalId:D}#{occurrence}"; } - private static string CreateRecoveredDescendantIdentityKey(string relativePath, int index) + private static string CreateRecoveredDescendantIdentityKey(string relativePath, string graphPath) + { + return $"{relativePath}!path:{graphPath}"; + } + + private static string CreateLegacyRecoveredDescendantIdentityKey(string relativePath, int index) { return $"{relativePath}!@{index}"; } diff --git a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs index 8d6b59877e..e29446a80c 100644 --- a/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs @@ -1,5 +1,9 @@ -using Beutl.AgentToolkit.Sessions; +using System.Text.Json.Nodes; +using Beutl.AgentToolkit.Reconciliation; +using Beutl.AgentToolkit.Sessions; +using Beutl.Graphics.Shapes; using Beutl.ProjectSystem; +using Beutl.Serialization; namespace Beutl.AgentToolkit.Tests.Sessions; @@ -224,6 +228,63 @@ public void Failed_plain_save_restores_the_original_uri_state() }); } + [Test] + public void Failed_save_as_restores_reinstated_suppression_state() + { + string root = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string projectPath = Path.Combine(root, "demo.bep"); + using var source = new FileSessionSource(); + FileEditingSession session = source.CreateProject(new ProjectCreateOptions( + projectPath, 640, 360, 30, TimeSpan.FromSeconds(2), Name: "demo")); + Scene scene = session.Project.Items.OfType().Single(); + string elementPath = Path.Combine(Path.GetDirectoryName(scene.Uri!.LocalPath)!, "clip.belm"); + scene.Children.Add(new Element + { + Name = "clip", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + Objects = { new RectShape() }, + }); + session.Save(skipConflictCheck: true); + + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + elementJson[nameof(Element.Objects)]!.AsArray()[0]!.AsObject()["$type"] + = "[Beutl.Engine]Beutl.Graphics.Shapes:MissingShape"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] malformedBytes = File.ReadAllBytes(elementPath); + FileEditingSession recovered = source.OpenProject(projectPath); + Element recoveredElement = recovered.Scene.Children.Single(); + JsonObject desired = recovered.Documents.Read(recovered.Scene); + JsonObject repairedJson = CoreSerializer.SerializeToJsonObject(new RectShape + { + Name = "Repaired shape", + }); + repairedJson.Remove(nameof(CoreObject.Id)); + desired["Elements"]!.AsArray()[0]!.AsObject()[nameof(Element.Objects)] + = new JsonArray(repairedJson); + new Reconciler().Apply(recovered, desired); + CoreSerializer.StoreToUri(recovered.Scene, recovered.Scene.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = recovered.History.Undo(); + + string failedTarget = Path.Combine(root, "copy.bep"); + Directory.CreateDirectory(failedTarget); + Assert.Catch(() => recovered.SaveAs(failedTarget, skipConflictCheck: true)); + + Assert.Multiple(() => + { + Assert.That(recovered.Project.Uri!.LocalPath, Is.EqualTo(projectPath)); + Assert.That(recoveredElement.Uri!.LocalPath, Is.EqualTo(elementPath)); + Assert.That(undone, Is.True); + Assert.That(repairedBytes, Is.Not.EqualTo(malformedBytes)); + }); + + recovered.Save(skipConflictCheck: true); + + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(malformedBytes)); + } + [Test] public void Save_and_save_as_on_a_disposed_session_throw_session_unavailable() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 591cd912cb..dd7a9ee21e 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -319,6 +319,20 @@ public void Restore_SyntacticallyValidElementAdoptsEscapedTopLevelId() Assert.That(recovered.Id, Is.EqualTo(expectedId)); } + [Test] + public void Restore_MalformedElementAdoptsEscapedTopLevelId() + { + var expectedId = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText( + elementPath, + """{"\u0049d":"\u0061aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","Objects":["""); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + + Assert.That(recovered.Id, Is.EqualTo(expectedId)); + } + [Test] public void Restore_MalformedElementWithEmptyTopLevelId_UsesStableNonEmptyId() { @@ -533,7 +547,7 @@ public void SaveAs_PreservesBomAndNonUtf8SidecarBytes() } [Test] - public void StoreToUri_AfterRehome_KeepsTheOriginalSkipProtected() + public void StoreToUri_AfterRehome_RecreatesMissingProtectedSource() { (Uri sceneUri, string elementPath) = CreatePersistedScene(); byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); @@ -550,7 +564,8 @@ public void StoreToUri_AfterRehome_KeepsTheOriginalSkipProtected() Assert.Multiple(() => { - Assert.That(File.Exists(elementPath), Is.False); + Assert.That(File.Exists(elementPath), Is.True); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(corruptBytes)); }); } @@ -1186,6 +1201,79 @@ public void Restore_RepairedDescendantWithNewIdMigratesPlaceholderReference() }); } + [Test] + public void Restore_RepairedDescendantPathSurvivesEarlierObjectRemoval() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("repaired.belm", "holder.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element repairedSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + var firstShape = (RectShape)repairedSource.Objects.Single(); + firstShape.Transform.CurrentValue = new RotationTransform(); + var secondShape = new RectShape(); + secondShape.Transform.CurrentValue = new RotationTransform(); + repairedSource.AddObject(secondShape); + Guid secondShapeId = secondShape.Id; + CoreSerializer.StoreToUri(repairedSource, repairedSource.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[0]))!.AsObject(); + foreach (JsonNode? shapeNode in json[nameof(Element.Objects)]!.AsArray()) + { + JsonObject transformJson = FindObjectByDiscriminator(shapeNode!, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + } + + File.WriteAllText(elementPaths[0], json.ToJsonString()); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Guid[] placeholderIds = recoveredElement.Objects + .OfType() + .Select(shape => ((CoreObject)shape.Transform.CurrentValue!).Id) + .ToArray(); + Element holder = recoveredScene.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + var referenceHolder = new TransformReferenceHolder(); + referenceHolder.Target.CurrentValue = new Reference(placeholderIds[1]); + holder.AddObject(referenceHolder); + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + Guid repairedId = Guid.NewGuid(); + var repaired = new Element + { + Id = repairedSource.Id, + Name = "Repaired", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPaths[0]), + }; + var repairedSecondShape = new RectShape { Id = secondShapeId }; + repairedSecondShape.Transform.CurrentValue = new RotationTransform { Id = repairedId }; + repaired.AddObject(repairedSecondShape); + CoreSerializer.StoreToUri(repaired, repaired.Uri!); + + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + var application = new BeutlApplication(); + var project = new Project(); + application.Project = project; + project.Items.Add(reloaded); + Transform reloadedTransform = ((RectShape)reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]).Objects.Single()) + .Transform.CurrentValue!; + Reference migratedReference = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[1]).Objects + .OfType() + .Single() + .Target.CurrentValue; + + Assert.Multiple(() => + { + Assert.That(reloadedTransform.Id, Is.EqualTo(repairedId)); + Assert.That(migratedReference.Id, Is.EqualTo(repairedId)); + Assert.That(migratedReference.Id, Is.Not.EqualTo(placeholderIds[0])); + Assert.That(migratedReference.Value, Is.SameAs(reloadedTransform)); + }); + } + [Test] public void MigrateRecoveredElementReferences_TraversesContainersAndKeyFrames() { From c532e3786dd0b616d60cb066038eab0ea5d04045 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 20:16:34 +0900 Subject: [PATCH 27/35] fix(review): resume persistence for cleared presenter targets --- .../Editors/BrushEditorViewModel.cs | 2 + .../Editors/FilterEffectEditorViewModel.cs | 2 + .../Editors/TransformEditorViewModel.cs | 2 + .../FallbackEditorPersistenceTests.cs | 158 ++++++++++++++++++ 4 files changed, 164 insertions(+) diff --git a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs index f3315c0385..99c902849e 100644 --- a/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs @@ -279,6 +279,7 @@ public void SetTarget(Brush? target) { if (Value.Value is IPresenter presenter) { + Brush? previous = presenter.Target.CurrentValue; if (target != null) { presenter.Target.Expression = Expression.CreateReference(target.Id); @@ -288,6 +289,7 @@ public void SetTarget(Brush? target) presenter.Target.Expression = null; presenter.Target.CurrentValue = null; } + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } diff --git a/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs index 7a9bf365a6..38f57208c9 100644 --- a/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs @@ -244,6 +244,7 @@ public void SetTarget(FilterEffect? target) { if (Value.Value is IPresenter presenter) { + FilterEffect? previous = presenter.Target.CurrentValue; if (target != null) { var expression = Expression.CreateReference(target.Id); @@ -255,6 +256,7 @@ public void SetTarget(FilterEffect? target) presenter.Target.CurrentValue = null; } + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } diff --git a/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs index e3613040da..65ec4de10f 100644 --- a/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs @@ -295,6 +295,7 @@ public void SetTarget(Transform? target) { if (Value.Value is IPresenter presenter) { + Transform? previous = presenter.Target.CurrentValue; if (target != null) { var expression = Expression.CreateReference(target.Id); @@ -305,6 +306,7 @@ public void SetTarget(Transform? target) presenter.Target.Expression = null; presenter.Target.CurrentValue = null; } + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } diff --git a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs index a530ec2b27..184029b5a5 100644 --- a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs +++ b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs @@ -12,6 +12,7 @@ using Beutl.Graphics; using Beutl.Graphics.Effects; using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; using Beutl.Graphics3D.Textures; using Beutl.Media; using Beutl.ProjectSystem; @@ -118,6 +119,24 @@ public void BrushTryPasteJson_LastFallbackResumesPersistenceInReplacementTransac } } + [AvaloniaTest] + public void BrushPresenterClearTarget_LastFallbackResumesPersistenceInReplacementTransaction() + { + AssertPresenterTargetClearResumesPersistence(PresenterKind.Brush); + } + + [AvaloniaTest] + public void TransformPresenterClearTarget_LastFallbackResumesPersistenceInReplacementTransaction() + { + AssertPresenterTargetClearResumesPersistence(PresenterKind.Transform); + } + + [AvaloniaTest] + public void FilterEffectPresenterClearTarget_LastFallbackResumesPersistenceInReplacementTransaction() + { + AssertPresenterTargetClearResumesPersistence(PresenterKind.FilterEffect); + } + [AvaloniaTest] public void PenWholeValueReplacements_ResumePersistenceInReplacementTransaction() { @@ -572,6 +591,138 @@ private static KeyFrameAnimation CreateAnimation(KeyFrame keyFrame) return animation; } + private static void AssertPresenterTargetClearResumesPersistence(PresenterKind kind) + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + string root = CreateRoot(); + try + { + var sceneUri = new Uri(Path.Combine(root, "scene.scene")); + string elementPath = Path.Combine(root, "element.belm"); + var shape = new RectShape(); + SetPresenter(shape, kind); + var element = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(shape); + var scene = new Scene(64, 64, "Scene") { Uri = sceneUri }; + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject targetJson = FindObjectWithProperty(elementJson, nameof(BrushPresenter.Target))! + [nameof(BrushPresenter.Target)]!.AsObject(); + targetJson["$type"] = kind switch + { + PresenterKind.Brush => "[Beutl.Engine]Beutl.Media:MissingBrush", + PresenterKind.Transform => "[Beutl.Engine]Beutl.Graphics.Transformation:MissingTransform", + PresenterKind.FilterEffect => "[Beutl.Engine]Beutl.Graphics.Effects:MissingFilterEffect", + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single(); + var recoveredShape = (RectShape)recoveredElement.Objects.Single(); + Assert.That(GetPresenterTarget(recoveredShape, kind), Is.InstanceOf()); + using var context = new EditorTestContext(recoveredElement); + using BaseEditorViewModel viewModel = CreatePresenterEditor(recoveredShape, kind); + viewModel.Accept(new Visitor(recoveredElement, context.History)); + + ClearPresenterTarget(viewModel, kind); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = context.History.Undo(); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + + Assert.Multiple(() => + { + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(GetPresenterTarget(recoveredShape, kind), Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + finally + { + Directory.Delete(root, true); + } + } + + private static void SetPresenter(RectShape shape, PresenterKind kind) + { + switch (kind) + { + case PresenterKind.Brush: + shape.Fill.CurrentValue = new BrushPresenter + { + Target = { CurrentValue = new SolidColorBrush(Colors.Red) }, + }; + break; + case PresenterKind.Transform: + shape.Transform.CurrentValue = new TransformPresenter + { + Target = { CurrentValue = new TranslateTransform() }, + }; + break; + case PresenterKind.FilterEffect: + shape.FilterEffect.CurrentValue = new FilterEffectPresenter + { + Target = { CurrentValue = new Blur() }, + }; + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind)); + } + } + + private static BaseEditorViewModel CreatePresenterEditor(RectShape shape, PresenterKind kind) + { + return kind switch + { + PresenterKind.Brush => new BrushEditorViewModel( + new SimplePropertyAdapter((SimpleProperty)shape.Fill, shape)), + PresenterKind.Transform => new TransformEditorViewModel( + new SimplePropertyAdapter((SimpleProperty)shape.Transform, shape)), + PresenterKind.FilterEffect => new FilterEffectEditorViewModel( + new SimplePropertyAdapter((SimpleProperty)shape.FilterEffect, shape)), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + } + + private static object? GetPresenterTarget(RectShape shape, PresenterKind kind) + { + return kind switch + { + PresenterKind.Brush => ((BrushPresenter)shape.Fill.CurrentValue!).Target.CurrentValue, + PresenterKind.Transform => ((TransformPresenter)shape.Transform.CurrentValue!).Target.CurrentValue, + PresenterKind.FilterEffect => ((FilterEffectPresenter)shape.FilterEffect.CurrentValue!).Target.CurrentValue, + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + } + + private static void ClearPresenterTarget(BaseEditorViewModel viewModel, PresenterKind kind) + { + switch (kind) + { + case PresenterKind.Brush: + ((BrushEditorViewModel)viewModel).SetTarget(null); + break; + case PresenterKind.Transform: + ((TransformEditorViewModel)viewModel).SetTarget(null); + break; + case PresenterKind.FilterEffect: + ((FilterEffectEditorViewModel)viewModel).SetTarget(null); + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind)); + } + } + private static void AssertPenRepairAndUndo( Element recoveredElement, EditorValueHolder recoveredHolder, @@ -742,4 +893,11 @@ public void Visit(IPropertyEditorContext context) { } } + + private enum PresenterKind + { + Brush, + Transform, + FilterEffect, + } } From 1a0b98df3dffbca442b5b4b51a0b94fa49ccf042 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 21:00:51 +0900 Subject: [PATCH 28/35] fix(review): harden recovery migrations and edits --- .../ProjectSystem/Scene.cs | 106 ++++++++++++- .../ViewModels/Editors/BaseEditorViewModel.cs | 5 + .../FallbackEditorPersistenceTests.cs | 86 +++++++++++ .../MalformedElementRecoveryTests.cs | 139 ++++++++++++++++++ 4 files changed, 330 insertions(+), 6 deletions(-) diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index abee91ce29..f4bda14daf 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -867,6 +867,7 @@ void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePa bool hasPersistedIdentity = persistedDescendantIdentities.TryGetValue( identityKey, out Guid persistedIdentityId); + bool ambiguousPositionalIdentity = false; if (!hasPersistedIdentity && graphPath.Positional != graphPath.Stable) { string positionalIdentityKey = CreateRecoveredDescendantIdentityKey( @@ -875,9 +876,20 @@ void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePa hasPersistedIdentity = persistedDescendantIdentities.TryGetValue( positionalIdentityKey, out persistedIdentityId); + if (hasPersistedIdentity + && !IsRecoveredDescendantPositionalIdentityUnambiguous( + persistedDescendantIdentities, + relativePath, + graphPath.Positional, + persistedIdentityId)) + { + hasPersistedIdentity = false; + ambiguousPositionalIdentity = true; + } } if (!hasPersistedIdentity + && !ambiguousPositionalIdentity && legacyIndices.TryGetValue(descendant, out int persistedIndex)) { string legacyIdentityKey = CreateLegacyRecoveredDescendantIdentityKey( @@ -1213,16 +1225,17 @@ private Element RestoreElementOrFallback(Uri uri) // holds a BOM, another encoding, or undecodable bytes. The lossy decode is only scanned // for top-level recovery metadata. byte[] rawBytes = File.ReadAllBytes(uri.LocalPath); - string rawText = Encoding.UTF8.GetString(rawBytes); + string rawText = DecodeRecoveryMetadata(rawBytes); + byte[] metadataBytes = Encoding.UTF8.GetBytes(rawText); JsonObject? root = TryParseTopLevelObject(rawText); var element = new Element { - Id = ResolveRecoveredElementId(rawBytes, rawText, root, uri), + Id = ResolveRecoveredElementId(metadataBytes, rawText, root, uri), Name = Path.GetFileNameWithoutExtension(uri.LocalPath), Uri = uri, IsEnabled = false, }; - string? topLevelTypeName = TryGetTopLevelTypeName(rawBytes, rawText, root); + string? topLevelTypeName = TryGetTopLevelTypeName(metadataBytes, rawText, root); FallbackReason fallbackReason = topLevelTypeName is not null && TypeFormat.ToType(topLevelTypeName) is null ? FallbackReason.TypeNotFound @@ -1386,9 +1399,19 @@ private object ResolveMigratedReference(IReference reference, Guid migratedId) CoreObject? target = EnumerateSerializedGraphObjects(Children) .OfType() .FirstOrDefault(candidate => candidate.Id == migratedId); - return target is not null && reference.ObjectType.IsInstanceOfType(target) - ? reference.Resolved(target) - : Activator.CreateInstance(reference.GetType(), migratedId)!; + if (target is not null && reference.ObjectType.IsInstanceOfType(target)) + { + return reference.Resolved(target); + } + + try + { + return Activator.CreateInstance(reference.GetType(), migratedId) ?? reference; + } + catch (MissingMethodException) + { + return reference; + } } private object? MigrateRecoveredReferenceValue(object? value, ISet visited) @@ -1641,6 +1664,41 @@ private static SerializedGraphPath AppendSerializedGraphPath( AppendSerializedGraphPath(path.Positional, kind, value)); } + private static bool IsRecoveredDescendantPositionalIdentityUnambiguous( + IReadOnlyDictionary identities, + string relativePath, + string positionalPath, + Guid expectedId) + { + string keyPrefix = $"{relativePath}!path:"; + string normalizedPath = NormalizeSerializedGraphPositionalPath(positionalPath); + foreach ((string key, Guid id) in identities) + { + if (id != expectedId + && key.StartsWith(keyPrefix, StringComparison.Ordinal) + && NormalizeSerializedGraphPositionalPath(key[keyPrefix.Length..]) == normalizedPath) + { + return false; + } + } + + return true; + } + + private static string NormalizeSerializedGraphPositionalPath(string path) + { + string[] segments = path.Split('/'); + for (int i = 0; i < segments.Length; i++) + { + if (segments[i].StartsWith("index:", StringComparison.Ordinal)) + { + segments[i] = "index:*"; + } + } + + return string.Join('/', segments); + } + private static string AppendSerializedGraphPath(string path, string kind, string value) { string escaped = value.Replace("~", "~0").Replace("/", "~1"); @@ -1697,6 +1755,42 @@ private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback } } + private static string DecodeRecoveryMetadata(byte[] rawBytes) + { + ReadOnlySpan bytes = rawBytes; + if (bytes.Length >= 4 + && bytes[0] == 0xff && bytes[1] == 0xfe + && bytes[2] == 0x00 && bytes[3] == 0x00) + { + return Encoding.UTF32.GetString(bytes[4..]); + } + + if (bytes.Length >= 4 + && bytes[0] == 0x00 && bytes[1] == 0x00 + && bytes[2] == 0xfe && bytes[3] == 0xff) + { + return new UTF32Encoding(bigEndian: true, byteOrderMark: true).GetString(bytes[4..]); + } + + if (bytes.Length >= 3 + && bytes[0] == 0xef && bytes[1] == 0xbb && bytes[2] == 0xbf) + { + return Encoding.UTF8.GetString(bytes[3..]); + } + + if (bytes.Length >= 2 && bytes[0] == 0xff && bytes[1] == 0xfe) + { + return Encoding.Unicode.GetString(bytes[2..]); + } + + if (bytes.Length >= 2 && bytes[0] == 0xfe && bytes[1] == 0xff) + { + return Encoding.BigEndianUnicode.GetString(bytes[2..]); + } + + return Encoding.UTF8.GetString(bytes); + } + private static string? TryGetTopLevelTypeName( ReadOnlySpan rawBytes, string rawText, diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index 360132cf52..2e6ddd9560 100644 --- a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs @@ -645,6 +645,7 @@ public override void RemoveKeyFrame(TimeSpan keyTime) animation: kfAnimation, keyTime: keyTime, logger: Logger); + ResumeElementPersistenceAfterFallbackReplacement(kfAnimation); Commit(); } @@ -676,7 +677,9 @@ public override void RemoveAnimation() { if (PropertyAdapter is IAnimatablePropertyAdapter animatableProperty) { + IAnimation? previous = animatableProperty.Animation; animatableProperty.Animation = null; + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } @@ -693,7 +696,9 @@ public override bool SetExpression(string expressionString, [NotNullWhen(false)] expressionProperty.Expression = newExpression; if (PropertyAdapter is IAnimatablePropertyAdapter ap) { + IAnimation? previous = ap.Animation; ap.Animation = null; + ResumeElementPersistenceAfterFallbackReplacement(previous); } Commit(); diff --git a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs index 184029b5a5..578674391c 100644 --- a/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs +++ b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs @@ -241,6 +241,24 @@ public void EasingRepair_ResumesPersistenceAndWritesRepairedSidecar() } } + [AvaloniaTest] + public void RemoveKeyFrame_LastLossyEasingResumesPersistenceInReplacementTransaction() + { + AssertAnimationDiscardResumesPersistence(AnimationDiscardKind.RemoveKeyFrame); + } + + [AvaloniaTest] + public void RemoveAnimation_LastLossyEasingResumesPersistenceInReplacementTransaction() + { + AssertAnimationDiscardResumesPersistence(AnimationDiscardKind.RemoveAnimation); + } + + [AvaloniaTest] + public void SetExpression_LastLossyEasingResumesPersistenceInReplacementTransaction() + { + AssertAnimationDiscardResumesPersistence(AnimationDiscardKind.SetExpression); + } + [AvaloniaTest] public void CoreObjectApplyTemplate_UpdatesEditingKeyFrameOnly() { @@ -653,6 +671,67 @@ private static void AssertPresenterTargetClearResumesPersistence(PresenterKind k } } + private static void AssertAnimationDiscardResumesPersistence(AnimationDiscardKind kind) + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + string root = CreateRoot(); + try + { + (Uri sceneUri, string elementPath) = CreateAnimatedScene(root); + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject keyFrameJson = FindObjectWithProperty(elementJson, nameof(KeyFrame.Easing))!; + keyFrameJson[nameof(KeyFrame.Easing)] = "[Missing.Assembly]Missing.Namespace:MissingEasing"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single(); + var shape = (RectShape)recoveredElement.Objects.Single(); + var animation = (KeyFrameAnimation)shape.Width.Animation!; + Assert.That(animation.KeyFrames, Has.Count.EqualTo(1)); + using var context = new EditorTestContext(recoveredElement); + var adapter = new AnimatablePropertyAdapter( + (AnimatableProperty)shape.Width, + shape); + using var viewModel = new ValueEditorViewModel(adapter); + viewModel.Accept(new Visitor(recoveredElement, context.History)); + + switch (kind) + { + case AnimationDiscardKind.RemoveKeyFrame: + viewModel.RemoveKeyFrame(TimeSpan.Zero); + break; + case AnimationDiscardKind.RemoveAnimation: + viewModel.RemoveAnimation(); + break; + case AnimationDiscardKind.SetExpression: + Assert.That(viewModel.SetExpression("1 + 2", out string? error), Is.True, error); + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + byte[] repairedBytes = File.ReadAllBytes(elementPath); + bool undone = context.History.Undo(); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + var restoredAnimation = (KeyFrameAnimation)shape.Width.Animation!; + + Assert.Multiple(() => + { + Assert.That(repairedBytes, Is.Not.EqualTo(originalBytes)); + Assert.That(undone, Is.True); + Assert.That(restoredAnimation.KeyFrames, Has.Count.EqualTo(1)); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + finally + { + Directory.Delete(root, true); + } + } + private static void SetPresenter(RectShape shape, PresenterKind kind) { switch (kind) @@ -900,4 +979,11 @@ private enum PresenterKind Transform, FilterEffect, } + + private enum AnimationDiscardKind + { + RemoveKeyFrame, + RemoveAnimation, + SetExpression, + } } diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index dd7a9ee21e..4afa8e77af 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Collections.Immutable; using System.Reflection; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Beutl.Animation; @@ -124,6 +125,24 @@ public bool Validate(out string? error) } } + private sealed class ConstructorlessReference(Guid id, Type objectType, string marker) : IReference + { + public Guid Id { get; } = id; + + public CoreObject? Value => null; + + public bool IsNull => Id == Guid.Empty; + + public Type ObjectType { get; } = objectType; + + public string Marker { get; } = marker; + + public IReference Resolved(CoreObject obj) + { + return new ConstructorlessReference(obj.Id, ObjectType, Marker); + } + } + [SetUp] public void SetUp() { @@ -333,6 +352,33 @@ public void Restore_MalformedElementAdoptsEscapedTopLevelId() Assert.That(recovered.Id, Is.EqualTo(expectedId)); } + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void Restore_MalformedBomEncodedElementAdoptsTopLevelIdAndPreservesBytes( + bool utf32, + bool bigEndian) + { + Guid expectedId = Guid.NewGuid(); + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Encoding encoding = utf32 + ? new UTF32Encoding(bigEndian, byteOrderMark: true, throwOnInvalidCharacters: true) + : new UnicodeEncoding(bigEndian, byteOrderMark: true, throwOnInvalidBytes: true); + byte[] encodedText = encoding.GetBytes($$"""{"Id":"{{expectedId}}","Objects":["""); + byte[] rawBytes = encoding.GetPreamble().Concat(encodedText).ToArray(); + File.WriteAllBytes(elementPath, rawBytes); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + CoreSerializer.StoreToUri(recovered, recovered.Uri!); + + Assert.Multiple(() => + { + Assert.That(recovered.Id, Is.EqualTo(expectedId)); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(rawBytes)); + }); + } + [Test] public void Restore_MalformedElementWithEmptyTopLevelId_UsesStableNonEmptyId() { @@ -1274,6 +1320,99 @@ public void Restore_RepairedDescendantPathSurvivesEarlierObjectRemoval() }); } + [Test] + public void Restore_AmbiguousRepairedDescendantAfterEarlierRemovalDoesNotMigratePlaceholders() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("repaired.belm", "holder.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element repairedSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + var firstShape = (RectShape)repairedSource.Objects.Single(); + firstShape.Transform.CurrentValue = new RotationTransform(); + var secondShape = new RectShape(); + secondShape.Transform.CurrentValue = new RotationTransform(); + repairedSource.AddObject(secondShape); + CoreSerializer.StoreToUri(repairedSource, repairedSource.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[0]))!.AsObject(); + foreach (JsonNode? shapeNode in json[nameof(Element.Objects)]!.AsArray()) + { + JsonObject transformJson = FindObjectByDiscriminator(shapeNode!, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + } + + File.WriteAllText(elementPaths[0], json.ToJsonString()); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Guid[] placeholderIds = recoveredElement.Objects + .OfType() + .Select(shape => ((CoreObject)shape.Transform.CurrentValue!).Id) + .ToArray(); + Element holder = recoveredScene.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + foreach (Guid placeholderId in placeholderIds) + { + var referenceHolder = new TransformReferenceHolder(); + referenceHolder.Target.CurrentValue = new Reference(placeholderId); + holder.AddObject(referenceHolder); + } + + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + Guid repairedId = Guid.NewGuid(); + var repaired = new Element + { + Id = repairedSource.Id, + Name = "Repaired", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPaths[0]), + }; + var repairedShape = new RectShape { Id = Guid.NewGuid() }; + repairedShape.Transform.CurrentValue = new RotationTransform { Id = repairedId }; + repaired.AddObject(repairedShape); + CoreSerializer.StoreToUri(repaired, repaired.Uri!); + + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + var application = new BeutlApplication(); + var project = new Project(); + application.Project = project; + project.Items.Add(reloaded); + Reference[] references = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[1]).Objects + .OfType() + .Select(static item => item.Target.CurrentValue) + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(references.Select(static item => item.Id), Is.EqualTo(placeholderIds)); + Assert.That(references.Select(static item => item.Value), Is.All.Null); + Assert.That(references.Select(static item => item.Id), Does.Not.Contain(repairedId)); + }); + } + + [Test] + public void ResolveMigratedReference_CustomReferenceWithoutGuidConstructorIsRetained() + { + Guid migratedId = Guid.NewGuid(); + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "scene.scene")) }; + scene.Children.Add(new Element + { + Id = migratedId, + Uri = new Uri(Path.Combine(_root, "target.belm")), + }); + var reference = new ConstructorlessReference(Guid.NewGuid(), typeof(RectShape), "custom"); + MethodInfo method = typeof(Scene).GetMethod( + "ResolveMigratedReference", + BindingFlags.Instance | BindingFlags.NonPublic)!; + object? result = null; + + Assert.DoesNotThrow(() => result = method.Invoke(scene, [reference, migratedId])); + + Assert.That(result, Is.SameAs(reference)); + } + [Test] public void MigrateRecoveredElementReferences_TraversesContainersAndKeyFrames() { From b01b8b973a21b1367a3ba11f00554d7105e39200 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Sun, 9 Aug 2026 22:00:55 +0900 Subject: [PATCH 29/35] fix(review): close recovery traversal gaps --- .../Documents/DeclarativeDocumentApplier.cs | 21 ++- .../Reconciliation/Reconciler.cs | 79 ++++++--- .../Serialization/CoreSerializer.cs | 69 ++++++++ .../Serialization/SuppressedStorageSource.cs | 7 +- .../ProjectSystem/Scene.cs | 124 +++++++++++++- .../ApplierReviewFollowupTests.cs | 61 +++++++ .../Reconciliation/ApplyEditTests.cs | 160 ++++++++++++++++++ .../MalformedElementRecoveryTests.cs | 80 +++++++++ 8 files changed, 576 insertions(+), 25 deletions(-) diff --git a/src/Beutl.AgentToolkit/Documents/DeclarativeDocumentApplier.cs b/src/Beutl.AgentToolkit/Documents/DeclarativeDocumentApplier.cs index 487ad7f528..a8ee7a6758 100644 --- a/src/Beutl.AgentToolkit/Documents/DeclarativeDocumentApplier.cs +++ b/src/Beutl.AgentToolkit/Documents/DeclarativeDocumentApplier.cs @@ -220,7 +220,11 @@ private void ApplyKeyFrame(IKeyFrame keyFrame, JsonObject desired) if (desired.TryGetPropertyValue(nameof(KeyFrame.Easing), out JsonNode? easingNode) && easingNode is not null) { - keyFrame.Easing = DeserializeEasing(easingNode); + Easing desiredEasing = DeserializeEasing(easingNode); + if (!AreEquivalentEasings(keyFrame.Easing, desiredEasing)) + { + keyFrame.Easing = desiredEasing; + } } if (desired.TryGetPropertyValue(nameof(KeyFrame.KeyTime), out JsonNode? keyTimeNode) && keyTimeNode is not null) @@ -823,6 +827,21 @@ private static Easing DeserializeEasing(JsonNode node) "Easing must be a type string or spline object.")); } + private static bool AreEquivalentEasings(Easing current, Easing desired) + { + if (current.GetType() != desired.GetType()) + { + return false; + } + + return current is not SplineEasing currentSpline + || desired is SplineEasing desiredSpline + && currentSpline.X1 == desiredSpline.X1 + && currentSpline.Y1 == desiredSpline.Y1 + && currentSpline.X2 == desiredSpline.X2 + && currentSpline.Y2 == desiredSpline.Y2; + } + internal static string? ValidateEasingNode(JsonNode? node) { if (node is JsonValue value && value.TryGetValue(out string? typeName)) diff --git a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs index 994f5e75f4..31559fb2ec 100644 --- a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs +++ b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs @@ -683,8 +683,8 @@ private static void ValidateSceneInvariants(CoreObject sandboxRoot, List existingFallbackIds = CollectFallbackIds(session.Root); - if (FindFirstNewFallback(sandboxRoot, "$", existingFallbackIds) is { } occurrence) + Dictionary existingFallbacks = CollectFallbackIdentities(session.Root); + if (FindFirstNewFallback(sandboxRoot, "$", existingFallbacks) is { } occurrence) { string typeDetail = string.IsNullOrWhiteSpace(occurrence.FallbackTypeName) ? "unknown serialized type" @@ -718,41 +718,48 @@ private static CoreObject CloneCurrentRoot(IEditingSession session, JsonObject c return clone; } - private static HashSet CollectFallbackIds(CoreObject root) + private static Dictionary CollectFallbackIdentities(CoreObject root) { - var ids = new HashSet(); + var identities = new Dictionary(); var visited = new HashSet(ReferenceEqualityComparer.Instance); TraverseSerializedGraph(root, "$", visited, (node, _) => { - if (node is IFallback) + if (node is IFallback fallback) { - ids.Add(node.Id); + FallbackIdentity identity = CreateFallbackIdentity(fallback); + identities[identity] = identities.GetValueOrDefault(identity) + 1; } return false; }); - return ids; + return identities; } private static FallbackOccurrence? FindFirstNewFallback( CoreObject root, string path, - HashSet existingFallbackIds) + Dictionary existingFallbacks) { FallbackOccurrence? result = null; var visited = new HashSet(ReferenceEqualityComparer.Instance); TraverseSerializedGraph(root, path, visited, (node, nodePath) => { - if (node is not IFallback fallback || existingFallbackIds.Contains(node.Id)) + if (node is not IFallback fallback) { return false; } + FallbackIdentity identity = CreateFallbackIdentity(fallback); + if (existingFallbacks.TryGetValue(identity, out int remaining) && remaining > 0) + { + existingFallbacks[identity] = remaining - 1; + return false; + } + fallback.TryGetTypeName(out string? fallbackTypeName); result = new FallbackOccurrence( nodePath, - node.Id, fallbackTypeName, fallback.Reason.ToString(), fallback.ErrorMessage); @@ -761,11 +768,20 @@ private static HashSet CollectFallbackIds(CoreObject root) return result; } + private static FallbackIdentity CreateFallbackIdentity(IFallback fallback) + { + return fallback is CoreObject { Id: var id } && id != Guid.Empty + ? new FallbackIdentity(id, null) + : new FallbackIdentity( + null, + $"{fallback.GetType().AssemblyQualifiedName}|{fallback.Json?.ToJsonString()}"); + } + private static bool TraverseSerializedGraph( object? value, string path, HashSet visited, - Func visitCoreObject) + Func visitObject) { if (value is null or string) { @@ -777,13 +793,16 @@ private static bool TraverseSerializedGraph( return false; } - if (value is CoreObject coreObject) + if (value is CoreObject or IFallback) { - if (visitCoreObject(coreObject, path)) + if (visitObject(value, path)) { return true; } + } + if (value is CoreObject coreObject) + { switch (coreObject) { case Scene scene: @@ -793,7 +812,7 @@ private static bool TraverseSerializedGraph( scene.Children[i], $"{path}/Elements[{i}]", visited, - visitCoreObject)) + visitObject)) { return true; } @@ -807,7 +826,7 @@ private static bool TraverseSerializedGraph( element.Objects[i], $"{path}/Objects[{i}]", visited, - visitCoreObject)) + visitObject)) { return true; } @@ -821,7 +840,7 @@ private static bool TraverseSerializedGraph( property.CurrentValue, $"{path}/{property.Name}", visited, - visitCoreObject)) + visitObject)) { return true; } @@ -835,7 +854,7 @@ private static bool TraverseSerializedGraph( keyFrame.Value, $"{path}/Animations/{property.Name}/KeyFrames[{index}]/Value", visited, - visitCoreObject)) + visitObject)) { return true; } @@ -847,6 +866,23 @@ private static bool TraverseSerializedGraph( break; } + foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) + { + if (!property.GetMetadata(coreObject.GetType()).ShouldSerialize) + { + continue; + } + + if (TraverseSerializedGraph( + coreObject.GetValue(property), + $"{path}/{property.Name}", + visited, + visitObject)) + { + return true; + } + } + if (coreObject is IHierarchical hierarchical) { int index = 0; @@ -856,7 +892,7 @@ private static bool TraverseSerializedGraph( child, $"{path}/HierarchicalChildren[{index}]", visited, - visitCoreObject)) + visitObject)) { return true; } @@ -877,7 +913,7 @@ private static bool TraverseSerializedGraph( item, $"{path}[{index}]", visited, - visitCoreObject)) + visitObject)) { return true; } @@ -894,7 +930,7 @@ private static bool TraverseSerializedGraph( item, $"{path}[{index}]", visited, - visitCoreObject)) + visitObject)) { return true; } @@ -1335,9 +1371,10 @@ private static bool JsonEquals(JsonNode? left, JsonNode? right) return left.ToJsonString() == right.ToJsonString(); } + private readonly record struct FallbackIdentity(Guid? Id, string? Signature); + private sealed record FallbackOccurrence( string Path, - Guid Id, string? FallbackTypeName, string Reason, string? Message); diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 506cf65c7d..7b897fde95 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -286,6 +286,8 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n throw new JsonException(); } + CopyReferencedStorageSources(suppressed, uri); + // Rehomed (save-as): the retained bytes move verbatim so the new project copy keeps the // element. SourceUri stays unchanged so the source location remains skip-protected if a // failed multi-file save rolls Uri back afterwards. @@ -396,6 +398,73 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n } } + private static void CopyReferencedStorageSources( + SuppressedStorageSource suppressed, + Uri rehomedUri) + { + if (suppressed.ReferencedStorageSources is not { Length: > 0 } referencedSources) + { + return; + } + + foreach (SuppressedReferencedStorageSource source in referencedSources) + { + if (!Uri.TryCreate(rehomedUri, source.RelativeUri, out Uri? destination) + || !destination.IsFile) + { + throw new JsonException($"Invalid retained sidecar URI: {source.RelativeUri}"); + } + + WriteBytesAtomicallyIfMissing(destination.LocalPath, source.RawBytes); + } + } + + private static void WriteBytesAtomicallyIfMissing(string path, byte[] bytes) + { + if (File.Exists(path)) + { + return; + } + + string? directory = Path.GetDirectoryName(path); + if (directory != null) + { + Directory.CreateDirectory(directory); + } + + string tempPath = $"{path}.{Guid.NewGuid():N}.tmp"; + try + { + using (var stream = new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + + try + { + File.Move(tempPath, path, overwrite: false); + } + catch (IOException) when (File.Exists(path)) + { + } + } + finally + { + try + { + File.Delete(tempPath); + } + catch + { + } + } + } + private static void RestoreReinstatedBytes(SuppressedStorageSource suppressed, string path) { if (!suppressed.WasReinstated && File.Exists(path)) diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs index adbf1b9ce2..4aea75cce3 100644 --- a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs +++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs @@ -11,7 +11,8 @@ internal sealed record SuppressedStorageSource( byte[] RawBytes, Uri SourceUri, bool HasNonFallbackIncidents = false, - JsonObject[]? UntraversedFallbacks = null) + JsonObject[]? UntraversedFallbacks = null, + SuppressedReferencedStorageSource[]? ReferencedStorageSources = null) { /// /// True when this suppression record was put back by undoing an in-process repair. Only a @@ -21,3 +22,7 @@ internal sealed record SuppressedStorageSource( /// public bool WasReinstated { get; set; } } + +internal sealed record SuppressedReferencedStorageSource( + byte[] RawBytes, + string RelativeUri); diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index f4bda14daf..e4ed15fb3c 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; +using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -704,7 +705,7 @@ static void Process(Func add, JsonNode node, List list) && idValue.TryGetValue(out string? idText) && Guid.TryParse(idText, out Guid id)) { - _recoveredDescendantIdentities[NormalizeRelativePath(key)] = id; + _recoveredDescendantIdentities[key] = id; } } } @@ -1278,7 +1279,104 @@ private static void MarkRecoveredElement( rawBytes, uri, hasNonFallbackIncidents, - untraversedFallbacks); + untraversedFallbacks, + CollectReferencedFallbackStorageSources(element, rawBytes, uri)); + } + + private static SuppressedReferencedStorageSource[]? CollectReferencedFallbackStorageSources( + Element element, + byte[] rawBytes, + Uri elementUri) + { + var fallbackFiles = EnumerateSerializedGraphFallbacks(element) + .OfType() + .Where(static fallback => fallback.Uri is { IsFile: true }) + .Select(static fallback => fallback.Uri!) + .Where(uri => uri != elementUri && File.Exists(uri.LocalPath)) + .Distinct() + .ToArray(); + if (fallbackFiles.Length == 0) + { + return null; + } + + JsonNode? root; + try + { + root = JsonNode.Parse(rawBytes); + } + catch (JsonException) + { + return null; + } + + var result = new List(); + var seenRelativeUris = new HashSet(StringComparer.Ordinal); + foreach (string serializedValue in EnumerateJsonStringValues(root)) + { + string relativeUri; + try + { + relativeUri = Uri.UnescapeDataString(serializedValue); + } + catch (UriFormatException) + { + continue; + } + + if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out Uri? parsed) + || parsed.IsAbsoluteUri + || !Uri.TryCreate(elementUri, relativeUri, out Uri? resolved) + || !resolved.IsFile + || !seenRelativeUris.Add(relativeUri)) + { + continue; + } + + Uri? source = fallbackFiles.FirstOrDefault(candidate => + string.Equals( + Path.GetFullPath(candidate.LocalPath), + Path.GetFullPath(resolved.LocalPath), + OperatingSystem.IsLinux() + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase)); + if (source is not null) + { + result.Add(new SuppressedReferencedStorageSource( + File.ReadAllBytes(source.LocalPath), + relativeUri)); + } + } + + return result.Count > 0 ? result.ToArray() : null; + } + + private static IEnumerable EnumerateJsonStringValues(JsonNode? node) + { + switch (node) + { + case JsonValue value when value.TryGetValue(out string? text): + yield return text; + break; + case JsonObject obj: + foreach (JsonNode? child in obj.Select(static item => item.Value)) + { + foreach (string text in EnumerateJsonStringValues(child)) + { + yield return text; + } + } + break; + case JsonArray array: + foreach (JsonNode? child in array) + { + foreach (string text in EnumerateJsonStringValues(child)) + { + yield return text; + } + } + break; + } } internal static SuppressedStorageSource? TryResumeElementPersistence(Element element) @@ -1423,6 +1521,28 @@ private object ResolveMigratedReference(IReference reference, Guid migratedId) : value; } + if (value is IOptional { HasValue: true } optional) + { + object? item = optional.ToObject().Value; + object? migratedItem = MigrateRecoveredReferenceValue(item, visited); + if (!Equals(item, migratedItem)) + { + try + { + ConstructorInfo? constructor = value.GetType().GetConstructor([optional.GetValueType()]); + return constructor?.Invoke([migratedItem]) ?? value; + } + catch (Exception ex) when (ex is TargetInvocationException + or ArgumentException + or MemberAccessException) + { + return value; + } + } + + return value; + } + if (value is null or string || (!value.GetType().IsValueType && !visited.Add(value))) { diff --git a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplierReviewFollowupTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplierReviewFollowupTests.cs index f11f889b6d..dcdb613a57 100644 --- a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplierReviewFollowupTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplierReviewFollowupTests.cs @@ -60,6 +60,67 @@ public void KeyTime_edit_that_crosses_a_neighbour_re_sorts_the_keyframes() }); } + [Test] + public void Unrelated_edit_preserves_lossy_easing_storage_suppression() + { + string dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var sceneUri = new Uri(Path.Combine(dir, "Scene.scene")); + string elementPath = Path.Combine(dir, "element.belm"); + var scene = new Scene(1920, 1080, "Scene") { Uri = sceneUri }; + var element = new Element + { + Name = "Recovered", + Length = TimeSpan.FromSeconds(2), + Uri = new Uri(elementPath), + }; + var text = new TextBlock { Text = { CurrentValue = "Title" } }; + var animation = new KeyFrameAnimation(); + var keyFrame = new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = 100, + Easing = new LinearEasing(), + }; + animation.KeyFrames.Add(keyFrame, out _); + text.Opacity.Animation = animation; + element.AddObject(text); + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, sceneUri); + + JsonObject elementJson = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + FindById(elementJson, keyFrame.Id)![nameof(KeyFrame.Easing)] + = "[Missing.Plugin]Missing.Namespace:MissingEasing"; + File.WriteAllText(elementPath, elementJson.ToJsonString()); + byte[] originalBytes = File.ReadAllBytes(elementPath); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recovered.Children.Single(); + using var session = new AgentToolkitTestSession(recovered); + EditTools tools = CreateTools(session); + JsonObject desired = session.Documents.Read(session.Root); + FindById(desired, recoveredElement.Id)![nameof(CoreObject.Name)] = "Changed"; + + ToolResult apply = tools.ApplyEdit( + desired: desired, + schemaVersion: SchemaVersion.Current); + CoreSerializer.StoreToUri(recoveredElement, recoveredElement.Uri!); + + Assert.Multiple(() => + { + Assert.That(apply.IsSuccess, Is.True, apply.Error?.Message); + Assert.That(recoveredElement.Name, Is.EqualTo("Changed")); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + finally + { + Directory.Delete(dir, true); + } + } + [Test] public void Scene_groups_are_reconciled_after_element_mutations() { diff --git a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs index 93d8d13861..07e544ffa1 100644 --- a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs @@ -19,6 +19,24 @@ namespace Beutl.AgentToolkit.Tests.Reconciliation; public sealed class ApplyEditTests { + private sealed class RegisteredTransformElement : Element + { + public static readonly CoreProperty PluginTransformProperty; + + static RegisteredTransformElement() + { + PluginTransformProperty = ConfigureProperty( + nameof(PluginTransform)) + .Register(); + } + + public Transform? PluginTransform + { + get => GetValue(PluginTransformProperty); + set => SetValue(PluginTransformProperty, value); + } + } + [SuppressResourceClassGeneration] public sealed class DictionaryTransformHolder : EngineObject { @@ -31,6 +49,45 @@ public DictionaryTransformHolder() = Property.Create>(); } + [SuppressResourceClassGeneration] + public sealed class ArbitraryValueHolder : EngineObject + { + public ArbitraryValueHolder() + { + ScanProperties(); + } + + public IProperty Value { get; } = Property.Create(); + } + + private sealed class PlainFallback : IFallback + { + public JsonObject? Json { get; set; } = new() + { + ["$type"] = "[Missing.Plugin]Missing.Namespace:MissingValue", + }; + + public FallbackReason Reason { get; set; } = FallbackReason.TypeNotFound; + + public string? ErrorMessage { get; set; } + + public void Serialize(ICoreSerializationContext context) + { + (context as IJsonSerializationContext)?.SetJsonObject(Json!); + } + + public void Deserialize(ICoreSerializationContext context) + { + Json = (context as IJsonSerializationContext)?.GetJsonObject(); + } + + public bool TryGetTypeName([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? result) + { + result = "[Missing.Plugin]Missing.Namespace:MissingValue"; + return true; + } + } + [Test] public void Apply_edit_applies_patch_directly() { @@ -211,6 +268,109 @@ public void Validate_no_new_fallback_objects_rejects_element_hierarchy_children_ }); } + [Test] + public void Validate_no_new_fallback_objects_rejects_registered_properties_on_non_engine_objects() + { + Scene current = CreateScene(); + current.Children.Add(new RegisteredTransformElement + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(Path.GetDirectoryName(current.Uri!.LocalPath)!, "element.belm")), + PluginTransform = new RotationTransform(), + }); + using var session = new AgentToolkitTestSession(current); + Scene sandbox = CreateScene(); + sandbox.Children.Add(new RegisteredTransformElement + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(Path.Combine(Path.GetDirectoryName(sandbox.Uri!.LocalPath)!, "element.belm")), + PluginTransform = new FallbackTransform(), + }); + MethodInfo method = typeof(Reconciler).GetMethod( + "ValidateNoNewFallbackObjects", + BindingFlags.NonPublic | BindingFlags.Static)!; + + TargetInvocationException exception = Assert.Throws(() => + method.Invoke(null, new object[] { session, sandbox }))!; + var error = (ReconcileException)exception.InnerException!; + + Assert.Multiple(() => + { + Assert.That(error.Error.Code, Is.EqualTo(ErrorCode.ValidationRejected)); + Assert.That(error.Error.Target, Does.Contain(nameof(RegisteredTransformElement.PluginTransform))); + }); + } + + [Test] + public void Validate_no_new_fallback_objects_rejects_non_core_fallbacks() + { + Scene current = CreateSceneWithElement(out _); + using var session = new AgentToolkitTestSession(current); + Scene sandbox = CreateSceneWithElement(out Element sandboxElement); + var holder = new ArbitraryValueHolder(); + holder.Value.CurrentValue = new PlainFallback(); + sandboxElement.AddObject(holder); + MethodInfo method = typeof(Reconciler).GetMethod( + "ValidateNoNewFallbackObjects", + BindingFlags.NonPublic | BindingFlags.Static)!; + + TargetInvocationException exception = Assert.Throws(() => + method.Invoke(null, new object[] { session, sandbox }))!; + var error = (ReconcileException)exception.InnerException!; + + Assert.Multiple(() => + { + Assert.That(error.Error.Code, Is.EqualTo(ErrorCode.ValidationRejected)); + Assert.That(error.Error.Target, Does.Contain(nameof(ArbitraryValueHolder.Value))); + }); + } + + [Test] + public void Validate_no_new_fallback_objects_allows_existing_non_core_fallbacks() + { + Scene current = CreateSceneWithElement(out Element currentElement); + var currentHolder = new ArbitraryValueHolder(); + currentHolder.Value.CurrentValue = new PlainFallback(); + currentElement.AddObject(currentHolder); + using var session = new AgentToolkitTestSession(current); + Scene sandbox = CreateSceneWithElement(out Element sandboxElement); + var sandboxHolder = new ArbitraryValueHolder(); + sandboxHolder.Value.CurrentValue = new PlainFallback(); + sandboxElement.AddObject(sandboxHolder); + MethodInfo method = typeof(Reconciler).GetMethod( + "ValidateNoNewFallbackObjects", + BindingFlags.NonPublic | BindingFlags.Static)!; + + Assert.DoesNotThrow(() => method.Invoke(null, new object[] { session, sandbox })); + } + + [Test] + public void Validate_no_new_fallback_objects_rejects_additional_matching_non_core_fallbacks() + { + Scene current = CreateSceneWithElement(out Element currentElement); + var currentHolder = new ArbitraryValueHolder(); + currentHolder.Value.CurrentValue = new PlainFallback(); + currentElement.AddObject(currentHolder); + using var session = new AgentToolkitTestSession(current); + Scene sandbox = CreateSceneWithElement(out Element sandboxElement); + for (int i = 0; i < 2; i++) + { + var holder = new ArbitraryValueHolder(); + holder.Value.CurrentValue = new PlainFallback(); + sandboxElement.AddObject(holder); + } + + MethodInfo method = typeof(Reconciler).GetMethod( + "ValidateNoNewFallbackObjects", + BindingFlags.NonPublic | BindingFlags.Static)!; + + TargetInvocationException exception = Assert.Throws(() => + method.Invoke(null, new object[] { session, sandbox }))!; + var error = (ReconcileException)exception.InnerException!; + + Assert.That(error.Error.Code, Is.EqualTo(ErrorCode.ValidationRejected)); + } + [Test] public void Apply_edit_returns_compact_response_and_optional_document() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 4afa8e77af..c8bb2dcab3 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -42,6 +42,18 @@ public ElementReferenceHolder() public IProperty ExpressionTarget { get; } = Property.Create(); } + [SuppressResourceClassGeneration] + public sealed class OptionalReferenceHolder : EngineObject + { + public OptionalReferenceHolder() + { + ScanProperties(); + } + + public IProperty>> Target { get; } + = Property.Create>>(); + } + [SuppressResourceClassGeneration] public sealed class NestedReferenceHolder : EngineObject { @@ -1008,8 +1020,12 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() recoveredScene.Groups.Add(ImmutableHashSet.Create(placeholder.Id, healthy.Id)); var referenceHolder = new ElementReferenceHolder(); referenceHolder.Target.CurrentValue = new Reference(placeholder.Id); + var optionalReferenceHolder = new OptionalReferenceHolder(); + optionalReferenceHolder.Target.CurrentValue = new Optional>( + new Reference(placeholder.Id)); referenceHolder.ExpressionTarget.Expression = new ReferenceExpression(placeholder.Id); healthy.AddObject(referenceHolder); + healthy.AddObject(optionalReferenceHolder); CoreSerializer.StoreToUri(recoveredScene, sceneUri); Guid repairedId = Guid.NewGuid(); @@ -1039,6 +1055,10 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() .OfType() .Single(); Reference migratedReference = reloadedHolder.Target.CurrentValue; + Reference migratedOptionalReference = reloadedHealthy.Objects + .OfType() + .Single() + .Target.CurrentValue.Value; var migratedExpression = (IReferenceExpression)reloadedHolder.ExpressionTarget.Expression!; Assert.Multiple(() => @@ -1049,6 +1069,8 @@ public void Restore_RepairedElementIdMigratesPersistedGroup() Is.EqualTo(ImmutableHashSet.Create(repairedId, healthy.Id))); Assert.That(migratedReference.Id, Is.EqualTo(repairedId)); Assert.That(migratedReference.Value, Is.SameAs(reloadedRepaired)); + Assert.That(migratedOptionalReference.Id, Is.EqualTo(repairedId)); + Assert.That(migratedOptionalReference.Value, Is.SameAs(reloadedRepaired)); Assert.That(migratedExpression.ObjectId, Is.EqualTo(repairedId)); Assert.That( reloadedHolder.ExpressionTarget.GetValue(CompositionContext.Default), @@ -1392,6 +1414,31 @@ public void Restore_AmbiguousRepairedDescendantAfterEarlierRemovalDoesNotMigrate }); } + [Test] + public void Deserialize_PreservesBackslashesInRecoveredDescendantIdentityGraphPath() + { + const string IdentityKey = "element.belm!path:$/property:Objects/key:folder\\turn"; + Guid identityId = Guid.NewGuid(); + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "scene.scene")) }; + JsonObject json = CoreSerializer.SerializeToJsonObject(scene); + json["RecoveredDescendantIdentities"] = new JsonObject + { + [IdentityKey] = identityId.ToString(), + }; + var restored = new Scene { Uri = scene.Uri }; + + CoreSerializer.PopulateFromJsonObject( + restored, + typeof(Scene), + json, + new CoreSerializerOptions { BaseUri = scene.Uri, Mode = CoreSerializationMode.Read }); + + var identities = (Dictionary)typeof(Scene) + .GetField("_recoveredDescendantIdentities", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(restored)!; + Assert.That(identities, Contains.Key(IdentityKey).WithValue(identityId)); + } + [Test] public void ResolveMigratedReference_CustomReferenceWithoutGuidConstructorIsRetained() { @@ -2028,6 +2075,39 @@ public void SaveAs_CopiesRecoveredSidecarBytesToTheNewLocation() }); } + [Test] + public void SaveAs_CopiesRelativeFallbackSidecarBytesToTheNewLocation() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + string referencedPath = Path.Combine(_root, "nested", "transform.json"); + Element source = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var shape = (RectShape)source.Objects.Single(); + shape.Transform.CurrentValue = new RotationTransform { Uri = new Uri(referencedPath) }; + CoreSerializer.StoreToUri(source, source.Uri!); + + JsonObject transformJson = JsonNode.Parse(File.ReadAllText(referencedPath))!.AsObject(); + transformJson["$type"] = "[Missing.Plugin]Missing.Namespace:MissingTransform"; + File.WriteAllText(referencedPath, transformJson.ToJsonString()); + byte[] elementBytes = File.ReadAllBytes(elementPath); + byte[] referencedBytes = File.ReadAllBytes(referencedPath); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + string rehomedPath = Path.Combine(_root, "copy", Path.GetFileName(elementPath)); + string rehomedReferencedPath = Path.Combine(_root, "copy", "nested", "transform.json"); + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + Element reopened = CoreSerializer.RestoreFromUri(new Uri(rehomedPath)); + var reopenedShape = (RectShape)reopened.Objects.Single(); + + Assert.Multiple(() => + { + Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(elementBytes)); + Assert.That(File.ReadAllBytes(rehomedReferencedPath), Is.EqualTo(referencedBytes)); + Assert.That(reopenedShape.Transform.CurrentValue, Is.InstanceOf()); + Assert.That(reopenedShape.Transform.CurrentValue?.Uri, + Is.EqualTo(new Uri(rehomedReferencedPath))); + }); + } + [Test] public void Save_PreservesDeserializationFallbackSidecarBytes() { From 400f7a96a7581e08224e680f654414db5d850e5b Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 10 Aug 2026 02:34:18 +0900 Subject: [PATCH 30/35] fix(review): complete recovery graph handling --- .../Reconciliation/Reconciler.cs | 10 + .../Serialization/CoreSerializer.cs | 71 +++++- .../Serialization/SuppressedStorageSource.cs | 6 +- .../ProjectSystem/Scene.cs | 171 ++++++------- .../Reconciliation/ApplyEditTests.cs | 40 ++++ .../MalformedElementRecoveryTests.cs | 224 ++++++++++++++++++ 6 files changed, 434 insertions(+), 88 deletions(-) diff --git a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs index 31559fb2ec..14f0af1c00 100644 --- a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs +++ b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs @@ -788,6 +788,16 @@ private static bool TraverseSerializedGraph( return false; } + if (value is IOptional optional) + { + return optional.HasValue + && TraverseSerializedGraph( + optional.ToObject().Value, + path, + visited, + visitObject); + } + if (!value.GetType().IsValueType && !visited.Add(value)) { return false; diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 7b897fde95..ac597cb16e 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -264,6 +264,26 @@ public static void PopulateFromUri(ICoreSerializable obj, Type type, Uri uri) public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = null) where T : ICoreSerializable + { + StoreToUriCore(obj, uri, mode, authorizedRootPath: null); + } + + internal static void StoreToUri( + T obj, + Uri uri, + string authorizedRootPath, + CoreSerializationMode? mode = null) + where T : ICoreSerializable + { + StoreToUriCore(obj, uri, mode, authorizedRootPath); + } + + private static void StoreToUriCore( + T obj, + Uri uri, + CoreSerializationMode? mode, + string? authorizedRootPath) + where T : ICoreSerializable { if (obj is CoreObject { SuppressedStorageSource: { } suppressed } suppressedObj) { @@ -286,7 +306,7 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n throw new JsonException(); } - CopyReferencedStorageSources(suppressed, uri); + CopyReferencedStorageSources(suppressed, uri, authorizedRootPath); // Rehomed (save-as): the retained bytes move verbatim so the new project copy keeps the // element. SourceUri stays unchanged so the source location remains skip-protected if a @@ -400,25 +420,64 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n private static void CopyReferencedStorageSources( SuppressedStorageSource suppressed, - Uri rehomedUri) + Uri rehomedUri, + string? authorizedRootPath) { if (suppressed.ReferencedStorageSources is not { Length: > 0 } referencedSources) { return; } + if (suppressed.SourceRootPath is null) + { + throw new JsonException("Retained sidecars have no authorized source root."); + } + + string destinationRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath( + authorizedRootPath + ?? Path.GetDirectoryName(rehomedUri.LocalPath) + ?? throw new JsonException("Rehomed element has no destination directory."))); + StringComparison comparison = OperatingSystem.IsLinux() + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + var copies = new List<(SuppressedReferencedStorageSource Source, string Destination)>(); foreach (SuppressedReferencedStorageSource source in referencedSources) { - if (!Uri.TryCreate(rehomedUri, source.RelativeUri, out Uri? destination) - || !destination.IsFile) + string relativePath = authorizedRootPath is null + ? source.ElementRelativePath + : source.RelativePath; + if (Path.IsPathRooted(relativePath)) { - throw new JsonException($"Invalid retained sidecar URI: {source.RelativeUri}"); + throw new JsonException($"Invalid retained sidecar path: {relativePath}"); } - WriteBytesAtomicallyIfMissing(destination.LocalPath, source.RawBytes); + string destination = Path.GetFullPath(Path.Combine(destinationRoot, relativePath)); + if (!IsPathInsideRoot(destinationRoot, destination, comparison)) + { + throw new JsonException($"Retained sidecar escapes the Save As root: {relativePath}"); + } + + copies.Add((source, destination)); + } + + foreach ((SuppressedReferencedStorageSource source, string destination) in copies) + { + WriteBytesAtomicallyIfMissing(destination, source.RawBytes); } } + private static bool IsPathInsideRoot( + string root, + string candidate, + StringComparison comparison) + { + string prefix = Path.EndsInDirectorySeparator(root) + ? root + : root + Path.DirectorySeparatorChar; + return string.Equals(candidate, root, comparison) + || candidate.StartsWith(prefix, comparison); + } + private static void WriteBytesAtomicallyIfMissing(string path, byte[] bytes) { if (File.Exists(path)) diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs index 4aea75cce3..7a38f262f6 100644 --- a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs +++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs @@ -12,7 +12,8 @@ internal sealed record SuppressedStorageSource( Uri SourceUri, bool HasNonFallbackIncidents = false, JsonObject[]? UntraversedFallbacks = null, - SuppressedReferencedStorageSource[]? ReferencedStorageSources = null) + SuppressedReferencedStorageSource[]? ReferencedStorageSources = null, + string? SourceRootPath = null) { /// /// True when this suppression record was put back by undoing an in-process repair. Only a @@ -25,4 +26,5 @@ internal sealed record SuppressedStorageSource( internal sealed record SuppressedReferencedStorageSource( byte[] RawBytes, - string RelativeUri); + string RelativePath, + string ElementRelativePath); diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index e4ed15fb3c..f65e439caa 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -610,9 +610,11 @@ static void Process(JsonObject jobject, string jsonName, List list) if (context.Mode.HasFlag(CoreSerializationMode.SaveReferencedObjects)) { + string sidecarRoot = Path.GetDirectoryName(Uri!.LocalPath) + ?? throw new JsonException("Scene has no sidecar directory."); foreach (Element item in Children) { - CoreSerializer.StoreToUri(item, item.Uri!); + CoreSerializer.StoreToUri(item, item.Uri!, sidecarRoot); } } @@ -1268,115 +1270,75 @@ private static bool TryGetSerializedId(JsonObject? json, out Guid id) && id != Guid.Empty; } - private static void MarkRecoveredElement( + private void MarkRecoveredElement( Element element, byte[] rawBytes, Uri uri, bool hasNonFallbackIncidents = false, JsonObject[]? untraversedFallbacks = null) { + string sourceRootPath = Path.GetDirectoryName(Uri?.LocalPath ?? uri.LocalPath) + ?? throw new JsonException("Recovered element has no source directory."); element.SuppressedStorageSource = new SuppressedStorageSource( rawBytes, uri, hasNonFallbackIncidents, untraversedFallbacks, - CollectReferencedFallbackStorageSources(element, rawBytes, uri)); + CollectReferencedStorageSources(element, uri, sourceRootPath), + sourceRootPath); } - private static SuppressedReferencedStorageSource[]? CollectReferencedFallbackStorageSources( + private static SuppressedReferencedStorageSource[]? CollectReferencedStorageSources( Element element, - byte[] rawBytes, - Uri elementUri) + Uri elementUri, + string sourceRootPath) { - var fallbackFiles = EnumerateSerializedGraphFallbacks(element) - .OfType() - .Where(static fallback => fallback.Uri is { IsFile: true }) - .Select(static fallback => fallback.Uri!) - .Where(uri => uri != elementUri && File.Exists(uri.LocalPath)) - .Distinct() - .ToArray(); - if (fallbackFiles.Length == 0) - { - return null; - } - - JsonNode? root; - try - { - root = JsonNode.Parse(rawBytes); - } - catch (JsonException) + string sourceRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceRootPath)); + string elementPath = Path.GetFullPath(elementUri.LocalPath); + StringComparison comparison = OperatingSystem.IsLinux() + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + if (!IsPathInsideRoot(sourceRoot, elementPath, comparison)) { return null; } + string elementDirectory = Path.GetDirectoryName(elementPath) + ?? throw new JsonException("Recovered element has no source directory."); + var seenPaths = new HashSet(StringComparer.FromComparison(comparison)); var result = new List(); - var seenRelativeUris = new HashSet(StringComparer.Ordinal); - foreach (string serializedValue in EnumerateJsonStringValues(root)) + foreach (string sourcePath in EnumerateSerializedGraphObjects(element) + .OfType() + .Where(static coreObject => coreObject.Uri is { IsFile: true }) + .Select(static coreObject => Path.GetFullPath(coreObject.Uri!.LocalPath))) { - string relativeUri; - try - { - relativeUri = Uri.UnescapeDataString(serializedValue); - } - catch (UriFormatException) - { - continue; - } - - if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out Uri? parsed) - || parsed.IsAbsoluteUri - || !Uri.TryCreate(elementUri, relativeUri, out Uri? resolved) - || !resolved.IsFile - || !seenRelativeUris.Add(relativeUri)) + if (string.Equals(sourcePath, elementPath, comparison) + || !IsPathInsideRoot(sourceRoot, sourcePath, comparison) + || !File.Exists(sourcePath) + || !seenPaths.Add(sourcePath)) { continue; } - Uri? source = fallbackFiles.FirstOrDefault(candidate => - string.Equals( - Path.GetFullPath(candidate.LocalPath), - Path.GetFullPath(resolved.LocalPath), - OperatingSystem.IsLinux() - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase)); - if (source is not null) - { - result.Add(new SuppressedReferencedStorageSource( - File.ReadAllBytes(source.LocalPath), - relativeUri)); - } + result.Add(new SuppressedReferencedStorageSource( + File.ReadAllBytes(sourcePath), + Path.GetRelativePath(sourceRoot, sourcePath), + Path.GetRelativePath(elementDirectory, sourcePath))); } return result.Count > 0 ? result.ToArray() : null; } - private static IEnumerable EnumerateJsonStringValues(JsonNode? node) + private static bool IsPathInsideRoot( + string root, + string candidate, + StringComparison comparison) { - switch (node) - { - case JsonValue value when value.TryGetValue(out string? text): - yield return text; - break; - case JsonObject obj: - foreach (JsonNode? child in obj.Select(static item => item.Value)) - { - foreach (string text in EnumerateJsonStringValues(child)) - { - yield return text; - } - } - break; - case JsonArray array: - foreach (JsonNode? child in array) - { - foreach (string text in EnumerateJsonStringValues(child)) - { - yield return text; - } - } - break; - } + string prefix = Path.EndsInDirectorySeparator(root) + ? root + : root + Path.DirectorySeparatorChar; + return string.Equals(candidate, root, comparison) + || candidate.StartsWith(prefix, comparison); } internal static SuppressedStorageSource? TryResumeElementPersistence(Element element) @@ -1451,8 +1413,29 @@ private void MigrateRecoveredElementReferences() foreach (CoreObject ownerRoot in ownerRoots) { var visited = new HashSet(ReferenceEqualityComparer.Instance); - foreach (EngineObject engineObject in EnumerateSerializedGraphObjects(ownerRoot).OfType()) + foreach (CoreObject coreObject in EnumerateSerializedGraphObjects(ownerRoot).OfType()) { + foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) + { + if (!property.GetMetadata(coreObject.GetType()).ShouldSerialize) + { + continue; + } + + object? currentValue = coreObject.GetValue(property); + object? migratedValue = MigrateRecoveredReferenceValue(currentValue, visited); + if (!Equals(currentValue, migratedValue) + && property is not IStaticProperty { CanWrite: false }) + { + coreObject.SetValue(property, migratedValue); + } + } + + if (coreObject is not EngineObject engineObject) + { + continue; + } + foreach (IProperty property in engineObject.Properties) { object? currentValue = property.CurrentValue; @@ -1625,6 +1608,17 @@ private static void CollectSerializedGraphObjects( } } + if (value is CoreObject coreObject) + { + foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) + { + if (property.GetMetadata(coreObject.GetType()).ShouldSerialize) + { + CollectSerializedGraphObjects(coreObject.GetValue(property), visited, objects); + } + } + } + if (value is System.Collections.IDictionary dictionary) { foreach (object? item in dictionary.Values) @@ -1717,6 +1711,23 @@ private static void CollectSerializedGraphObjectPaths( objects); } + if (value is CoreObject registeredObject) + { + foreach (CoreProperty property in PropertyRegistry.GetRegistered(registeredObject.GetType())) + { + if (!property.GetMetadata(registeredObject.GetType()).ShouldSerialize) + { + continue; + } + + CollectSerializedGraphObjectPaths( + registeredObject.GetValue(property), + AppendSerializedGraphPath(path, "property", property.Name), + visited, + objects); + } + } + if (value is System.Collections.IDictionary dictionary) { foreach (DictionaryEntry entry in dictionary) diff --git a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs index 07e544ffa1..847175edc7 100644 --- a/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ApplyEditTests.cs @@ -60,6 +60,18 @@ public ArbitraryValueHolder() public IProperty Value { get; } = Property.Create(); } + [SuppressResourceClassGeneration] + public sealed class OptionalTransformHolder : EngineObject + { + public OptionalTransformHolder() + { + ScanProperties(); + } + + public IProperty> Transform { get; } + = Property.Create>(); + } + private sealed class PlainFallback : IFallback { public JsonObject? Json { get; set; } = new() @@ -244,6 +256,34 @@ public void Validate_no_new_fallback_objects_rejects_dictionary_values() }); } + [Test] + public void Validate_no_new_fallback_objects_rejects_optional_values() + { + Scene current = CreateSceneWithElement(out Element currentElement); + var currentHolder = new OptionalTransformHolder(); + currentHolder.Transform.CurrentValue = new Optional(new RotationTransform()); + currentElement.AddObject(currentHolder); + using var session = new AgentToolkitTestSession(current); + + Scene sandbox = CreateSceneWithElement(out Element sandboxElement); + var sandboxHolder = new OptionalTransformHolder(); + sandboxHolder.Transform.CurrentValue = new Optional(new FallbackTransform()); + sandboxElement.AddObject(sandboxHolder); + MethodInfo method = typeof(Reconciler).GetMethod( + "ValidateNoNewFallbackObjects", + BindingFlags.NonPublic | BindingFlags.Static)!; + + TargetInvocationException exception = Assert.Throws(() => + method.Invoke(null, new object[] { session, sandbox }))!; + var error = (ReconcileException)exception.InnerException!; + + Assert.Multiple(() => + { + Assert.That(error.Error.Code, Is.EqualTo(ErrorCode.ValidationRejected)); + Assert.That(error.Error.Target, Does.Contain(nameof(OptionalTransformHolder.Transform))); + }); + } + [Test] public void Validate_no_new_fallback_objects_rejects_element_hierarchy_children_outside_objects() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index c8bb2dcab3..47a748e9b7 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -113,6 +113,34 @@ public TransformReferenceHolder() public IProperty> Target { get; } = Property.Create>(); } + public sealed class RegisteredRecoveryElement : Element + { + public static readonly CoreProperty PluginTransformProperty; + public static readonly CoreProperty> PluginTargetProperty; + + static RegisteredRecoveryElement() + { + PluginTransformProperty = ConfigureProperty( + nameof(PluginTransform)) + .Register(); + PluginTargetProperty = ConfigureProperty, RegisteredRecoveryElement>( + nameof(PluginTarget)) + .Register(); + } + + public Transform? PluginTransform + { + get => GetValue(PluginTransformProperty); + set => SetValue(PluginTransformProperty, value); + } + + public Reference PluginTarget + { + get => GetValue(PluginTargetProperty); + set => SetValue(PluginTargetProperty, value); + } + } + private sealed class CustomReferenceExpression : IReferenceExpression { public CustomReferenceExpression(Guid objectId) @@ -1553,6 +1581,40 @@ public void MigrateRecoveredElementReferences_TraversesLayerAndMarkerGraphs() }); } + [Test] + public void MigrateRecoveredElementReferences_TraversesRegisteredCoreProperties() + { + Guid originalId = Guid.NewGuid(); + var migrated = new Element + { + Id = Guid.NewGuid(), + Uri = new Uri(Path.Combine(_root, "migrated.belm")), + }; + var holder = new RegisteredRecoveryElement + { + Uri = new Uri(Path.Combine(_root, "holder.belm")), + PluginTarget = new Reference(originalId), + }; + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "migration.scene")) }; + scene.Children.Add(migrated); + scene.Children.Add(holder); + var migrations = (Dictionary)typeof(Scene) + .GetField("_pendingRecoveredElementIdMigrations", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(scene)!; + migrations[originalId] = migrated.Id; + MethodInfo method = typeof(Scene).GetMethod( + "MigrateRecoveredElementReferences", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + method.Invoke(scene, null); + + Assert.Multiple(() => + { + Assert.That(holder.PluginTarget.Id, Is.EqualTo(migrated.Id)); + Assert.That(holder.PluginTarget.Value, Is.SameAs(migrated)); + }); + } + [Test] public void Restore_MalformedElementIdAvoidsSerializedMarkerCollision() { @@ -1600,6 +1662,88 @@ public void Restore_KnownTypeDeserializationFallbackAdoptsSerializedId() }); } + [Test] + public void Restore_RegisteredCorePropertyFallbackAdoptsSerializedId() + { + string scenePath = Path.Combine(_root, "registered.scene"); + string elementPath = Path.Combine(_root, "registered.belm"); + var element = new RegisteredRecoveryElement + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + PluginTransform = new RotationTransform(), + }; + var scene = new Scene(64, 64, "Scene") { Uri = new Uri(scenePath) }; + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, scene.Uri); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, nameof(RotationTransform))!; + Guid serializedId = Guid.Parse(transformJson[nameof(CoreObject.Id)]!.GetValue()); + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + File.WriteAllText(elementPath, json.ToJsonString()); + + Scene first = CoreSerializer.RestoreFromUri(scene.Uri); + Scene second = CoreSerializer.RestoreFromUri(scene.Uri); + var firstElement = (RegisteredRecoveryElement)first.Children.Single(); + var secondElement = (RegisteredRecoveryElement)second.Children.Single(); + var firstFallback = (CoreObject)firstElement.PluginTransform!; + var secondFallback = (CoreObject)secondElement.PluginTransform!; + + Assert.Multiple(() => + { + Assert.That(firstFallback, Is.InstanceOf()); + Assert.That(firstFallback.Id, Is.EqualTo(serializedId)); + Assert.That(secondFallback.Id, Is.EqualTo(serializedId)); + }); + } + + [Test] + public void Restore_RegisteredCorePropertyFallbackCollisionIsRemappedStably() + { + string scenePath = Path.Combine(_root, "registered-collision.scene"); + string healthyPath = Path.Combine(_root, "healthy.belm"); + string recoveredPath = Path.Combine(_root, "registered.belm"); + Guid claimantId = Guid.NewGuid(); + var healthy = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(healthyPath), + }; + healthy.AddObject(new RectShape { Id = claimantId }); + var recovered = new RegisteredRecoveryElement + { + Start = TimeSpan.FromSeconds(1), + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(recoveredPath), + PluginTransform = new RotationTransform { Id = claimantId }, + }; + var scene = new Scene(64, 64, "Scene") { Uri = new Uri(scenePath) }; + scene.Children.Add(healthy); + scene.Children.Add(recovered); + CoreSerializer.StoreToUri(scene, scene.Uri); + + JsonObject json = JsonNode.Parse(File.ReadAllText(recoveredPath))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, nameof(RotationTransform))!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + File.WriteAllText(recoveredPath, json.ToJsonString()); + + Scene first = CoreSerializer.RestoreFromUri(scene.Uri); + var firstRecovered = (RegisteredRecoveryElement)first.Children.Single( + element => element.Uri!.LocalPath == recoveredPath); + Guid reassignedId = firstRecovered.PluginTransform!.Id; + CoreSerializer.StoreToUri(first, scene.Uri); + Scene second = CoreSerializer.RestoreFromUri(scene.Uri); + var secondRecovered = (RegisteredRecoveryElement)second.Children.Single( + element => element.Uri!.LocalPath == recoveredPath); + + Assert.Multiple(() => + { + Assert.That(reassignedId, Is.Not.EqualTo(claimantId)); + Assert.That(secondRecovered.PluginTransform!.Id, Is.EqualTo(reassignedId)); + }); + } + [Test] public void Restore_UnknownExternalObjectTypeUsesPropertyFallback() { @@ -2108,6 +2252,86 @@ public void SaveAs_CopiesRelativeFallbackSidecarBytesToTheNewLocation() }); } + [Test] + public void SaveAs_CopiesTransitiveFallbackSidecarBytesToTheNewLocation() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + string outerPath = Path.Combine(_root, "nested", "group.json"); + string innerPath = Path.Combine(_root, "nested", "transform.json"); + Element source = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var shape = (RectShape)source.Objects.Single(); + var group = new TransformGroup { Uri = new Uri(outerPath) }; + group.Children.Add(new RotationTransform { Uri = new Uri(innerPath) }); + shape.Transform.CurrentValue = group; + CoreSerializer.StoreToUri(source, source.Uri!); + + JsonObject transformJson = JsonNode.Parse(File.ReadAllText(innerPath))!.AsObject(); + transformJson["$type"] = "[Missing.Plugin]Missing.Namespace:MissingTransform"; + File.WriteAllText(innerPath, transformJson.ToJsonString()); + byte[] elementBytes = File.ReadAllBytes(elementPath); + byte[] outerBytes = File.ReadAllBytes(outerPath); + byte[] innerBytes = File.ReadAllBytes(innerPath); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + string rehomedPath = Path.Combine(_root, "copy", Path.GetFileName(elementPath)); + string rehomedOuterPath = Path.Combine(_root, "copy", "nested", "group.json"); + string rehomedInnerPath = Path.Combine(_root, "copy", "nested", "transform.json"); + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + Element reopened = CoreSerializer.RestoreFromUri(new Uri(rehomedPath)); + var reopenedShape = (RectShape)reopened.Objects.Single(); + var reopenedGroup = (TransformGroup)reopenedShape.Transform.CurrentValue!; + + Assert.Multiple(() => + { + Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(elementBytes)); + Assert.That(File.ReadAllBytes(rehomedOuterPath), Is.EqualTo(outerBytes)); + Assert.That(File.ReadAllBytes(rehomedInnerPath), Is.EqualTo(innerBytes)); + Assert.That(reopenedGroup.Children.Single(), Is.InstanceOf()); + }); + } + + [Test] + public void SaveAs_DoesNotCopyRetainedSidecarsOutsideTheDestinationRoot() + { + string sourceDirectory = Path.Combine(_root, "source"); + string outsideDirectory = Path.Combine(_root, "outside"); + string destinationRoot = Path.Combine(_root, "destination", "project"); + Directory.CreateDirectory(sourceDirectory); + string scenePath = Path.Combine(sourceDirectory, "scene.scene"); + string elementPath = Path.Combine(sourceDirectory, "element.belm"); + string outsideTransformPath = Path.Combine(outsideDirectory, "transform.json"); + var element = new Element + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPath), + }; + element.AddObject(new RectShape + { + Transform = + { + CurrentValue = new RotationTransform { Uri = new Uri(outsideTransformPath) }, + }, + }); + var scene = new Scene(64, 64, "Scene") { Uri = new Uri(scenePath) }; + scene.Children.Add(element); + CoreSerializer.StoreToUri(scene, scene.Uri); + + JsonObject transformJson = JsonNode.Parse(File.ReadAllText(outsideTransformPath))!.AsObject(); + transformJson["$type"] = "[Missing.Plugin]Missing.Namespace:MissingTransform"; + File.WriteAllText(outsideTransformPath, transformJson.ToJsonString()); + Element recovered = CoreSerializer.RestoreFromUri(scene.Uri).Children.Single(); + string rehomedPath = Path.Combine(destinationRoot, "element.belm"); + string escapedDestination = Path.Combine(_root, "destination", "outside", "transform.json"); + + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + + Assert.Multiple(() => + { + Assert.That(File.Exists(rehomedPath), Is.True); + Assert.That(File.Exists(escapedDestination), Is.False); + }); + } + [Test] public void Save_PreservesDeserializationFallbackSidecarBytes() { From d0db5276f62a9df4c8b7a40c11c0a188fa0035fc Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 10 Aug 2026 03:31:08 +0900 Subject: [PATCH 31/35] fix(review): harden recovery boundaries --- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 26 +++- .../Serialization/CoreSerializer.cs | 29 +++- .../Serialization/FilePathBoundary.cs | 127 +++++++++++++++ .../ProjectSystem/Scene.cs | 32 +++- .../Tools/SessionToolsTests.cs | 43 ++++++ .../MalformedElementRecoveryTests.cs | 144 ++++++++++++++++++ 6 files changed, 390 insertions(+), 11 deletions(-) create mode 100644 src/Beutl.Core/Serialization/FilePathBoundary.cs diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index 0ed0196310..d265afead0 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -169,7 +169,20 @@ private static void CollectFallbacks( ISet visited, ICollection fallbacks) { - if (value is null or string || !visited.Add(value)) + if (value is null or string) + return; + + if (value is IOptional optional) + { + if (optional.HasValue) + { + CollectFallbacks(optional.ToObject().Value, visited, fallbacks); + } + + return; + } + + if (!visited.Add(value)) return; if (value is IFallback fallback) @@ -186,6 +199,17 @@ private static void CollectFallbacks( } } + if (value is CoreObject coreObject) + { + foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) + { + if (property.GetMetadata(coreObject.GetType()).ShouldSerialize) + { + CollectFallbacks(coreObject.GetValue(property), visited, fallbacks); + } + } + } + if (value is EngineObject engineObject) { foreach (IProperty property in engineObject.Properties) diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index ac597cb16e..7c29169b59 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -209,8 +209,23 @@ public static object RestoreFromUri(Uri uri, Type type) { // Reject before instantiating: deserializing the declared type first would run its own // load side effects (e.g. a Scene declared in a .belm globs and reopens element files). - throw new InvalidCastException( + var exception = new InvalidCastException( $"Discriminator type '{actualType}' is not assignable to the expected type '{type}'."); + if (FallbackDeserializationHelper.TryCreateFallback( + type, + actualType, + jsonObject, + exception) is { } incompatibleTypeFallback) + { + if (incompatibleTypeFallback is CoreObject coreObject) + { + coreObject.Uri = uri; + } + + return incompatibleTypeFallback; + } + + throw exception; } try @@ -433,10 +448,11 @@ private static void CopyReferencedStorageSources( throw new JsonException("Retained sidecars have no authorized source root."); } - string destinationRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath( - authorizedRootPath - ?? Path.GetDirectoryName(rehomedUri.LocalPath) - ?? throw new JsonException("Rehomed element has no destination directory."))); + string destinationRoot = Path.TrimEndingDirectorySeparator( + FilePathBoundary.ResolveDeepestExistingTarget( + authorizedRootPath + ?? Path.GetDirectoryName(rehomedUri.LocalPath) + ?? throw new JsonException("Rehomed element has no destination directory."))); StringComparison comparison = OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; @@ -452,7 +468,8 @@ private static void CopyReferencedStorageSources( } string destination = Path.GetFullPath(Path.Combine(destinationRoot, relativePath)); - if (!IsPathInsideRoot(destinationRoot, destination, comparison)) + string resolvedDestination = FilePathBoundary.ResolveDeepestExistingTarget(destination); + if (!IsPathInsideRoot(destinationRoot, resolvedDestination, comparison)) { throw new JsonException($"Retained sidecar escapes the Save As root: {relativePath}"); } diff --git a/src/Beutl.Core/Serialization/FilePathBoundary.cs b/src/Beutl.Core/Serialization/FilePathBoundary.cs new file mode 100644 index 0000000000..6ed21ac89f --- /dev/null +++ b/src/Beutl.Core/Serialization/FilePathBoundary.cs @@ -0,0 +1,127 @@ +namespace Beutl.Serialization; + +internal static class FilePathBoundary +{ + private static readonly StringComparison s_comparison = OperatingSystem.IsLinux() + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + + public static string ResolveDeepestExistingTarget(string path) + { + string absolute = Path.GetFullPath(path); + string? current = absolute; + var remainder = new Stack(); + while (!string.IsNullOrEmpty(current) && !PathEntryExists(current)) + { + string? name = Path.GetFileName(current); + if (!string.IsNullOrEmpty(name)) + { + remainder.Push(name); + } + + current = Path.GetDirectoryName(current); + } + + if (string.IsNullOrEmpty(current)) + { + return absolute; + } + + string resolved = ResolveExistingPath(current); + while (remainder.Count > 0) + { + resolved = Path.Combine(resolved, remainder.Pop()); + } + + return Path.GetFullPath(resolved); + } + + private static string ResolveExistingPath(string path) + { + string absolute = Path.GetFullPath(path); + string root = Path.GetPathRoot(absolute) ?? absolute; + var components = new Stack(); + string? current = absolute; + while (current is not null + && current.Length >= root.Length + && !string.Equals(current, root, s_comparison)) + { + string name = Path.GetFileName(current); + if (!string.IsNullOrEmpty(name)) + { + components.Push(name); + } + + current = Path.GetDirectoryName(current); + } + + string resolved = root; + while (components.Count > 0) + { + string candidate = Path.Combine(resolved, components.Pop()); + if (TryResolveLinkTarget(candidate, out string? target)) + { + resolved = Path.IsPathRooted(target) + ? Path.GetFullPath(target) + : Path.GetFullPath(Path.Combine(resolved, target)); + } + else + { + resolved = Path.GetFullPath(candidate); + } + } + + return resolved; + } + + private static bool PathEntryExists(string path) + => Path.Exists(path) || new FileInfo(path).LinkTarget is not null; + + private static FileSystemInfo? TryResolveLinkTarget(FileSystemInfo info) + { + try + { + return info.ResolveLinkTarget(returnFinalTarget: true); + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException) + { + try + { + string? immediateTarget = info.LinkTarget; + string? baseDirectory = Path.GetDirectoryName(info.FullName); + if (immediateTarget is null || baseDirectory is null) + { + return null; + } + + string absoluteTarget = Path.IsPathRooted(immediateTarget) + ? Path.GetFullPath(immediateTarget) + : Path.GetFullPath(Path.Combine(baseDirectory, immediateTarget)); + return Directory.Exists(absoluteTarget) + ? new DirectoryInfo(absoluteTarget) + : new FileInfo(absoluteTarget); + } + catch (Exception fallbackEx) when (fallbackEx is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + return null; + } + } + } + + private static bool TryResolveLinkTarget( + string path, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? target) + { + FileSystemInfo info = Directory.Exists(path) + ? new DirectoryInfo(path) + : new FileInfo(path); + FileSystemInfo? resolved = TryResolveLinkTarget(info); + target = resolved?.FullName; + return target is not null; + } +} diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index f65e439caa..03f0b84cb8 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -1294,11 +1294,14 @@ private void MarkRecoveredElement( string sourceRootPath) { string sourceRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceRootPath)); + string resolvedSourceRoot = Path.TrimEndingDirectorySeparator( + FilePathBoundary.ResolveDeepestExistingTarget(sourceRoot)); string elementPath = Path.GetFullPath(elementUri.LocalPath); + string resolvedElementPath = FilePathBoundary.ResolveDeepestExistingTarget(elementPath); StringComparison comparison = OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - if (!IsPathInsideRoot(sourceRoot, elementPath, comparison)) + if (!IsPathInsideRoot(resolvedSourceRoot, resolvedElementPath, comparison)) { return null; } @@ -1312,10 +1315,11 @@ private void MarkRecoveredElement( .Where(static coreObject => coreObject.Uri is { IsFile: true }) .Select(static coreObject => Path.GetFullPath(coreObject.Uri!.LocalPath))) { - if (string.Equals(sourcePath, elementPath, comparison) - || !IsPathInsideRoot(sourceRoot, sourcePath, comparison) + string resolvedSourcePath = FilePathBoundary.ResolveDeepestExistingTarget(sourcePath); + if (string.Equals(resolvedSourcePath, resolvedElementPath, comparison) + || !IsPathInsideRoot(resolvedSourceRoot, resolvedSourcePath, comparison) || !File.Exists(sourcePath) - || !seenPaths.Add(sourcePath)) + || !seenPaths.Add(resolvedSourcePath)) { continue; } @@ -1579,6 +1583,16 @@ private static void CollectSerializedGraphObjects( return; } + if (value is IOptional optional) + { + if (optional.HasValue) + { + CollectSerializedGraphObjects(optional.ToObject().Value, visited, objects); + } + + return; + } + if (value is CoreObject or IFallback) { objects.Add(value); @@ -1656,6 +1670,16 @@ private static void CollectSerializedGraphObjectPaths( return; } + if (value is IOptional optional) + { + if (optional.HasValue) + { + CollectSerializedGraphObjectPaths(optional.ToObject().Value, path, visited, objects); + } + + return; + } + if (value is CoreObject coreObject) { objects.Add((coreObject, path)); diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index 9e0d5f7af6..fe2b350719 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -23,6 +23,24 @@ namespace Beutl.AgentToolkit.Tests.Tools; public sealed class SessionToolsTests { + private sealed class RegisteredOptionalTransformElement : Element + { + public static readonly CoreProperty> PluginTransformProperty; + + static RegisteredOptionalTransformElement() + { + PluginTransformProperty = ConfigureProperty, RegisteredOptionalTransformElement>( + nameof(PluginTransform)) + .Register(); + } + + public Optional PluginTransform + { + get => GetValue(PluginTransformProperty); + set => SetValue(PluginTransformProperty, value); + } + } + [Test] public async Task Open_project_warns_about_corrupt_element_and_render_still_remains_available() { @@ -379,6 +397,31 @@ public void CollectFallbacks_TraversesElementHierarchyOutsideObjects() }); } + [Test] + public void CollectFallbacks_TraversesRegisteredCorePropertiesAndOptionalValues() + { + var fallback = new FallbackTransform(); + var element = new RegisteredOptionalTransformElement + { + PluginTransform = new Optional(fallback), + }; + var fallbacks = new List(); + MethodInfo method = typeof(SessionTools).GetMethod( + "CollectFallbacks", + BindingFlags.NonPublic | BindingFlags.Static)!; + + method.Invoke( + null, + new object?[] + { + element, + new HashSet(), + fallbacks, + }); + + Assert.That(fallbacks, Has.One.SameAs(fallback)); + } + [Test] public async Task Open_project_warning_paths_distinguish_same_named_sidecars_in_different_directories() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 47a748e9b7..e091eca18d 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -141,6 +141,24 @@ public Reference PluginTarget } } + public sealed class RegisteredOptionalRecoveryElement : Element + { + public static readonly CoreProperty> PluginTransformProperty; + + static RegisteredOptionalRecoveryElement() + { + PluginTransformProperty = ConfigureProperty, RegisteredOptionalRecoveryElement>( + nameof(PluginTransform)) + .Register(); + } + + public Optional PluginTransform + { + get => GetValue(PluginTransformProperty); + set => SetValue(PluginTransformProperty, value); + } + } + private sealed class CustomReferenceExpression : IReferenceExpression { public CustomReferenceExpression(Guid objectId) @@ -1744,6 +1762,67 @@ public void Restore_RegisteredCorePropertyFallbackCollisionIsRemappedStably() }); } + [Test] + public void Restore_RegisteredOptionalFallbackRepairMigratesReferences() + { + string scenePath = Path.Combine(_root, "registered-optional.scene"); + string recoveredPath = Path.Combine(_root, "registered-optional.belm"); + string holderPath = Path.Combine(_root, "holder.belm"); + Guid originalId = Guid.NewGuid(); + var recovered = new RegisteredOptionalRecoveryElement + { + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(recoveredPath), + PluginTransform = new Optional( + new RotationTransform { Id = originalId }), + }; + var referenceHolder = new TransformReferenceHolder(); + referenceHolder.Target.CurrentValue = new Reference(originalId); + var holder = new Element + { + Start = TimeSpan.FromSeconds(1), + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(holderPath), + }; + holder.AddObject(referenceHolder); + var scene = new Scene(64, 64, "Scene") { Uri = new Uri(scenePath) }; + scene.Children.Add(recovered); + scene.Children.Add(holder); + CoreSerializer.StoreToUri(scene, scene.Uri); + + JsonObject json = JsonNode.Parse(File.ReadAllText(recoveredPath))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, nameof(RotationTransform))!; + string transformType = transformJson["$type"]!.GetValue(); + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + File.WriteAllText(recoveredPath, json.ToJsonString()); + + Scene first = CoreSerializer.RestoreFromUri(scene.Uri); + CoreSerializer.StoreToUri(first, scene.Uri); + Guid repairedId = Guid.NewGuid(); + json = JsonNode.Parse(File.ReadAllText(recoveredPath))!.AsObject(); + transformJson = FindObjectByDiscriminator(json, "DoesNotExist")!; + transformJson["$type"] = transformType; + transformJson[nameof(CoreObject.Id)] = repairedId.ToString(); + File.WriteAllText(recoveredPath, json.ToJsonString()); + + Scene second = CoreSerializer.RestoreFromUri(scene.Uri); + var repairedElement = (RegisteredOptionalRecoveryElement)second.Children.Single( + element => element.Uri!.LocalPath == recoveredPath); + Transform repairedTransform = repairedElement.PluginTransform.Value; + Reference migratedReference = second.Children.Single( + element => element.Uri!.LocalPath == holderPath) + .Objects.OfType() + .Single() + .Target.CurrentValue; + + Assert.Multiple(() => + { + Assert.That(repairedTransform.Id, Is.EqualTo(repairedId)); + Assert.That(migratedReference.Id, Is.EqualTo(repairedId)); + Assert.That(migratedReference.Value, Is.SameAs(repairedTransform)); + }); + } + [Test] public void Restore_UnknownExternalObjectTypeUsesPropertyFallback() { @@ -1781,6 +1860,34 @@ public void Restore_UnknownExternalObjectTypeUsesPropertyFallback() }); } + [Test] + public void Restore_IncompatibleExternalObjectTypeUsesPropertyFallback() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + string transformPath = Path.Combine(_root, "external-transform.json"); + Element source = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var sourceShape = (RectShape)source.Objects.Single(); + sourceShape.Transform.CurrentValue = new RotationTransform + { + Uri = new Uri(transformPath), + }; + CoreSerializer.StoreToUri(source, source.Uri!); + var incompatible = new Scene(64, 64, "Incompatible") { Uri = new Uri(transformPath) }; + CoreSerializer.StoreToUri(incompatible, incompatible.Uri); + + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recovered.Children.Single(); + RectShape? recoveredShape = recoveredElement.Objects.OfType().SingleOrDefault(); + + Assert.Multiple(() => + { + Assert.That(recoveredElement.IsEnabled, Is.True); + Assert.That(recoveredShape, Is.Not.Null); + Assert.That(recoveredShape?.Transform.CurrentValue, Is.InstanceOf()); + Assert.That(recoveredShape?.Transform.CurrentValue?.Uri, Is.EqualTo(new Uri(transformPath))); + }); + } + [Test] public void Restore_PersistedRecoveredDescendantRemapSurvivesClaimantRemoval() { @@ -2332,6 +2439,43 @@ public void SaveAs_DoesNotCopyRetainedSidecarsOutsideTheDestinationRoot() }); } + [Test] + public void SaveAs_DoesNotCopyRetainedSidecarsThroughDestinationSymlinks() + { + if (OperatingSystem.IsWindows()) + { + Assert.Ignore("Creating directory symlinks requires additional privileges on Windows."); + } + + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + string referencedPath = Path.Combine(_root, "nested", "transform.json"); + Element source = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var shape = (RectShape)source.Objects.Single(); + shape.Transform.CurrentValue = new RotationTransform { Uri = new Uri(referencedPath) }; + CoreSerializer.StoreToUri(source, source.Uri!); + JsonObject transformJson = JsonNode.Parse(File.ReadAllText(referencedPath))!.AsObject(); + transformJson["$type"] = "[Missing.Plugin]Missing.Namespace:MissingTransform"; + File.WriteAllText(referencedPath, transformJson.ToJsonString()); + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + + string destinationRoot = Path.Combine(_root, "destination"); + string outsideRoot = Path.Combine(_root, "outside-symlink-target"); + Directory.CreateDirectory(destinationRoot); + Directory.CreateDirectory(outsideRoot); + Directory.CreateSymbolicLink(Path.Combine(destinationRoot, "nested"), outsideRoot); + string rehomedPath = Path.Combine(destinationRoot, Path.GetFileName(elementPath)); + string escapedDestination = Path.Combine(outsideRoot, "transform.json"); + + Assert.Throws(() => + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath))); + + Assert.Multiple(() => + { + Assert.That(File.Exists(rehomedPath), Is.False); + Assert.That(File.Exists(escapedDestination), Is.False); + }); + } + [Test] public void Save_PreservesDeserializationFallbackSidecarBytes() { From cbb2493af74f17a04dc4559942e0d275371e8645 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 10 Aug 2026 04:36:42 +0900 Subject: [PATCH 32/35] fix(review): preserve recovered reference state --- src/Beutl.Core/CoreObject.cs | 47 ++- src/Beutl.Core/CoreProperty.cs | 14 + src/Beutl.Core/ReferenceRewriting.cs | 29 ++ .../Serialization/CoreSerializer.cs | 17 +- .../Serialization/FilePathBoundary.cs | 21 +- src/Beutl.Engine/Animation/IKeyFrame.cs | 5 + src/Beutl.Engine/Animation/KeyFrame{T}.cs | 38 +- src/Beutl.Engine/Engine/AnimatableProperty.cs | 63 ++- .../Expressions/IReferenceExpression.cs | 27 +- src/Beutl.Engine/Engine/IProperty.cs | 12 + src/Beutl.Engine/Engine/SimpleProperty.cs | 66 ++-- .../ProjectSystem/Scene.cs | 286 +++++++++----- .../Core/FilePathBoundaryTests.cs | 20 + .../Engine/AnimatablePropertyTests.cs | 61 +++ .../Engine/SimplePropertyTests.cs | 61 +++ .../MalformedElementRecoveryTests.cs | 361 ++++++++++++++++++ 16 files changed, 925 insertions(+), 203 deletions(-) create mode 100644 src/Beutl.Core/ReferenceRewriting.cs create mode 100644 tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs diff --git a/src/Beutl.Core/CoreObject.cs b/src/Beutl.Core/CoreObject.cs index b4b79fc1fa..0c706ff704 100644 --- a/src/Beutl.Core/CoreObject.cs +++ b/src/Beutl.Core/CoreObject.cs @@ -188,6 +188,26 @@ private void ValidateProperty( } public void SetValue(CoreProperty property, TValue? value) + { + SetValueCore(property, value, forceReferenceReplacement: false); + } + + internal void ReplaceValue(CoreProperty property, TValue? value) + { + SetValueCore(property, value, forceReferenceReplacement: true); + } + + internal void ReplaceValue(CoreProperty property, object? value) + { + ArgumentNullException.ThrowIfNull(property); + + property.RouteReplaceValue(this, value); + } + + private void SetValueCore( + CoreProperty property, + TValue? value, + bool forceReferenceReplacement) { if (value != null && !value.GetType().IsAssignableTo(property.PropertyType)) { @@ -215,7 +235,7 @@ public void SetValue(CoreProperty property, TValue? value) oldEntry is Entry entryT) { TValue? oldValue = entryT.Value; - if (!EqualityComparer.Default.Equals(oldValue, value)) + if (HasValueReplacement(oldValue, value, forceReferenceReplacement)) { entryT.Value = value; RaisePropertyChanged(property, metadata, value, oldValue); @@ -223,7 +243,7 @@ public void SetValue(CoreProperty property, TValue? value) } else { - if (!EqualityComparer.Default.Equals(metadata.DefaultValue, value)) + if (HasValueReplacement(metadata.DefaultValue, value, forceReferenceReplacement)) { entryT = new Entry { Value = value, }; Values[property.Id] = entryT; @@ -232,6 +252,16 @@ public void SetValue(CoreProperty property, TValue? value) } } + private static bool HasValueReplacement( + TValue? current, + TValue? replacement, + bool forceReferenceReplacement) + { + return forceReferenceReplacement && !typeof(TValue).IsValueType + ? !ReferenceEquals(current, replacement) + : !EqualityComparer.Default.Equals(current, replacement); + } + public void SetValue(CoreProperty property, object? value) { ArgumentNullException.ThrowIfNull(property); @@ -274,11 +304,22 @@ protected virtual void OnPropertyChanged(PropertyChangedEventArgs args) } protected bool SetAndRaise(CoreProperty property, ref T field, T value) + { + return SetAndRaise(property, ref field, value, forceReferenceReplacement: false); + } + + protected bool SetAndRaise( + CoreProperty property, + ref T field, + T value, + bool forceReferenceReplacement) { CorePropertyMetadata? metadata = property.GetMetadata>(GetType()); ValidateProperty(metadata, property, ref value!); - bool result = !EqualityComparer.Default.Equals(field, value); + bool result = forceReferenceReplacement && !typeof(T).IsValueType + ? !ReferenceEquals(field, value) + : !EqualityComparer.Default.Equals(field, value); if (result) { T old = field; diff --git a/src/Beutl.Core/CoreProperty.cs b/src/Beutl.Core/CoreProperty.cs index ea6eca54a4..4eb9d69246 100644 --- a/src/Beutl.Core/CoreProperty.cs +++ b/src/Beutl.Core/CoreProperty.cs @@ -70,6 +70,8 @@ protected CoreProperty( internal abstract void RouteSetValue(ICoreObject o, object? value); + internal abstract void RouteReplaceValue(CoreObject o, object? value); + internal abstract object? RouteGetValue(ICoreObject o); internal abstract void NotifyChanged(CorePropertyChangedEventArgs e); @@ -252,6 +254,18 @@ internal override void RouteSetValue(ICoreObject o, object? value) } } + internal override void RouteReplaceValue(CoreObject o, object? value) + { + if (value is T typed) + { + o.ReplaceValue(this, typed); + } + else + { + o.ReplaceValue(this, default); + } + } + internal override object? RouteGetValue(ICoreObject o) { return o.GetValue(this); diff --git a/src/Beutl.Core/ReferenceRewriting.cs b/src/Beutl.Core/ReferenceRewriting.cs new file mode 100644 index 0000000000..1da1fefcea --- /dev/null +++ b/src/Beutl.Core/ReferenceRewriting.cs @@ -0,0 +1,29 @@ +namespace Beutl; + +/// +/// Provides recursive reference rewriting for values owned by an +/// implementation. +/// +public interface IReferenceRewriteContext +{ + /// + /// Rewrites references contained in while preserving its declared type. + /// + T Rewrite(T value); +} + +/// +/// Represents a value that can rebuild itself after its contained references are rewritten. +/// +/// +/// Implementations are responsible for preserving all non-reference state. The returned value must +/// have the same runtime type as the original value; otherwise the rewrite is ignored. +/// +public interface IReferenceRewritable +{ + /// + /// Returns an equivalent value whose contained references were processed by + /// . + /// + object RewriteReferences(IReferenceRewriteContext context); +} diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 7c29169b59..8f3a994105 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -453,9 +453,6 @@ private static void CopyReferencedStorageSources( authorizedRootPath ?? Path.GetDirectoryName(rehomedUri.LocalPath) ?? throw new JsonException("Rehomed element has no destination directory."))); - StringComparison comparison = OperatingSystem.IsLinux() - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; var copies = new List<(SuppressedReferencedStorageSource Source, string Destination)>(); foreach (SuppressedReferencedStorageSource source in referencedSources) { @@ -469,7 +466,7 @@ private static void CopyReferencedStorageSources( string destination = Path.GetFullPath(Path.Combine(destinationRoot, relativePath)); string resolvedDestination = FilePathBoundary.ResolveDeepestExistingTarget(destination); - if (!IsPathInsideRoot(destinationRoot, resolvedDestination, comparison)) + if (!FilePathBoundary.IsPathInsideRoot(destinationRoot, resolvedDestination)) { throw new JsonException($"Retained sidecar escapes the Save As root: {relativePath}"); } @@ -483,18 +480,6 @@ private static void CopyReferencedStorageSources( } } - private static bool IsPathInsideRoot( - string root, - string candidate, - StringComparison comparison) - { - string prefix = Path.EndsInDirectorySeparator(root) - ? root - : root + Path.DirectorySeparatorChar; - return string.Equals(candidate, root, comparison) - || candidate.StartsWith(prefix, comparison); - } - private static void WriteBytesAtomicallyIfMissing(string path, byte[] bytes) { if (File.Exists(path)) diff --git a/src/Beutl.Core/Serialization/FilePathBoundary.cs b/src/Beutl.Core/Serialization/FilePathBoundary.cs index 6ed21ac89f..6a54deed0d 100644 --- a/src/Beutl.Core/Serialization/FilePathBoundary.cs +++ b/src/Beutl.Core/Serialization/FilePathBoundary.cs @@ -2,9 +2,24 @@ internal static class FilePathBoundary { - private static readonly StringComparison s_comparison = OperatingSystem.IsLinux() - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; + private static readonly StringComparison s_comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + public static StringComparison Comparison => s_comparison; + + public static StringComparer Comparer { get; } = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + public static bool IsPathInsideRoot(string root, string candidate) + { + string prefix = Path.EndsInDirectorySeparator(root) + ? root + : root + Path.DirectorySeparatorChar; + return string.Equals(candidate, root, s_comparison) + || candidate.StartsWith(prefix, s_comparison); + } public static string ResolveDeepestExistingTarget(string path) { diff --git a/src/Beutl.Engine/Animation/IKeyFrame.cs b/src/Beutl.Engine/Animation/IKeyFrame.cs index f2cb8bb502..d182a5f572 100644 --- a/src/Beutl.Engine/Animation/IKeyFrame.cs +++ b/src/Beutl.Engine/Animation/IKeyFrame.cs @@ -21,3 +21,8 @@ public interface IKeyFrame : ICoreObject, INotifyEdited, IHierarchical //void SetDuration(TimeSpan timeSpan); } + +internal interface IKeyFrameValueReplacer +{ + void ReplaceValue(object? value); +} diff --git a/src/Beutl.Engine/Animation/KeyFrame{T}.cs b/src/Beutl.Engine/Animation/KeyFrame{T}.cs index a17f5ab04b..907000cc16 100644 --- a/src/Beutl.Engine/Animation/KeyFrame{T}.cs +++ b/src/Beutl.Engine/Animation/KeyFrame{T}.cs @@ -5,7 +5,7 @@ namespace Beutl.Animation; -public sealed class KeyFrame : KeyFrame, IKeyFrame +public sealed class KeyFrame : KeyFrame, IKeyFrame, IKeyFrameValueReplacer { public static readonly CoreProperty ValueProperty; internal static readonly Animator s_animator; @@ -28,19 +28,21 @@ static KeyFrame() public T? Value { get => _value; - set + set => SetValue(value, replaceEquivalent: false); + } + + private void SetValue(T? value, bool replaceEquivalent) + { + if (Validator != null) { - if (Validator != null) + T? coerced = value; + if (Validator.TryCoerce(default, ref coerced)) { - T? coerced = value; - if (Validator.TryCoerce(default, ref coerced)) - { - value = coerced!; - } + value = coerced!; } - - SetAndRaise(ValueProperty, ref _value, value); } + + SetAndRaise(ValueProperty, ref _value, value, replaceEquivalent); } object? IKeyFrame.Value @@ -53,6 +55,22 @@ public T? Value } } + void IKeyFrameValueReplacer.ReplaceValue(object? value) + { + if (value is T typed) + { + SetValue(typed, replaceEquivalent: true); + } + else if (value is null && !typeof(T).IsValueType) + { + SetValue(default, replaceEquivalent: true); + } + else + { + throw new InvalidCastException(); + } + } + public new IValidator? Validator { get => base.Validator as IValidator; diff --git a/src/Beutl.Engine/Engine/AnimatableProperty.cs b/src/Beutl.Engine/Engine/AnimatableProperty.cs index 67864a26e7..3a8d512e29 100644 --- a/src/Beutl.Engine/Engine/AnimatableProperty.cs +++ b/src/Beutl.Engine/Engine/AnimatableProperty.cs @@ -9,7 +9,7 @@ namespace Beutl.Engine; -public class AnimatableProperty : IProperty +public class AnimatableProperty : IProperty, IPropertyValueReplacer { private T _currentValue; private IAnimation? _animation; @@ -42,31 +42,52 @@ public AnimatableProperty(T defaultValue, IValidator? validator = null) public T CurrentValue { get => _currentValue; - set + set => SetCurrentValue(value, replaceEquivalent: false); + } + + private void SetCurrentValue(T value, bool replaceEquivalent) + { + var validatedValue = ValidateAndCoerce(value); + bool hasReplacement = replaceEquivalent && !typeof(T).IsValueType + ? !ReferenceEquals(_currentValue, validatedValue) + : !EqualityComparer.Default.Equals(_currentValue, validatedValue); + if (hasReplacement) { - var validatedValue = ValidateAndCoerce(value); - if (!EqualityComparer.Default.Equals(_currentValue, validatedValue)) + var oldValue = _currentValue; + _currentValue = validatedValue; + HasLocalValue = true; + + ValueChanged?.Invoke(this, new PropertyValueChangedEventArgs(this, oldValue, validatedValue)); + Edited?.Invoke(this, EventArgs.Empty); + if (_owner is IModifiableHierarchical ownerHierarchical) { - var oldValue = _currentValue; - _currentValue = validatedValue; - HasLocalValue = true; + if (oldValue is IHierarchical oldHierarchical) + ownerHierarchical.RemoveChild(oldHierarchical); - ValueChanged?.Invoke(this, new PropertyValueChangedEventArgs(this, oldValue, validatedValue)); - Edited?.Invoke(this, EventArgs.Empty); - if (_owner is IModifiableHierarchical ownerHierarchical) - { - if (oldValue is IHierarchical oldHierarchical) - ownerHierarchical.RemoveChild(oldHierarchical); + if (validatedValue is IHierarchical newHierarchical) + ownerHierarchical.AddChild(newHierarchical); + } - if (validatedValue is IHierarchical newHierarchical) - ownerHierarchical.AddChild(newHierarchical); - } + if (oldValue is INotifyEdited oldEdited) + oldEdited.Edited -= OnChildEdited; + if (validatedValue is INotifyEdited newEdited) + newEdited.Edited += OnChildEdited; + } + } - if (oldValue is INotifyEdited oldEdited) - oldEdited.Edited -= OnChildEdited; - if (validatedValue is INotifyEdited newEdited) - newEdited.Edited += OnChildEdited; - } + void IPropertyValueReplacer.ReplaceCurrentValue(object? value) + { + if (value is T typed) + { + SetCurrentValue(typed, replaceEquivalent: true); + } + else if (value is null && !typeof(T).IsValueType) + { + SetCurrentValue(default!, replaceEquivalent: true); + } + else + { + throw new InvalidCastException(); } } diff --git a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs index e215ddf2a2..8bea60cddb 100644 --- a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs +++ b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs @@ -1,6 +1,4 @@ -using System.Reflection; - -namespace Beutl.Engine.Expressions; +namespace Beutl.Engine.Expressions; // Non-generic view of a ReferenceExpression so a consumer that only has an IExpression (no static T) // can read the referenced object id and property path without evaluating the expression. @@ -15,25 +13,8 @@ public interface IReferenceExpression : IExpression /// /// Returns an equivalent expression targeting , preserving the /// concrete implementation and its property path, or when the - /// implementation cannot be rebuilt (the original expression is then left in place). The - /// default tries a public (Guid, string) constructor; implementations that cannot be - /// rebuilt that way must override this method. + /// implementation cannot preserve all of its state while rebinding. Implementations that + /// support rebinding must override this method explicitly. /// - IReferenceExpression? Rebind(Guid objectId) - { - try - { - return (IReferenceExpression?)Activator.CreateInstance( - GetType(), - objectId, - PropertyPath); - } - catch (Exception ex) when (ex is MissingMethodException - or TargetInvocationException - or ArgumentException - or InvalidCastException) - { - return null; - } - } + IReferenceExpression? Rebind(Guid objectId) => null; } diff --git a/src/Beutl.Engine/Engine/IProperty.cs b/src/Beutl.Engine/Engine/IProperty.cs index 626fc6bf4b..f2edd1694e 100644 --- a/src/Beutl.Engine/Engine/IProperty.cs +++ b/src/Beutl.Engine/Engine/IProperty.cs @@ -61,6 +61,18 @@ public interface IProperty : INotifyEdited JsonNode? SerializeExpression(); } +/// +/// Replaces a property's current value when a distinct reference compares equal to the current one. +/// +public interface IPropertyValueReplacer +{ + /// + /// Validates and installs using reference identity for reference types + /// and normal equality for value types, while preserving the property's notification semantics. + /// + void ReplaceCurrentValue(object? value); +} + public interface IProperty : IProperty { new T DefaultValue { get; } diff --git a/src/Beutl.Engine/Engine/SimpleProperty.cs b/src/Beutl.Engine/Engine/SimpleProperty.cs index 20b83d9c83..cd2d6756df 100644 --- a/src/Beutl.Engine/Engine/SimpleProperty.cs +++ b/src/Beutl.Engine/Engine/SimpleProperty.cs @@ -9,7 +9,8 @@ namespace Beutl.Engine; -public class SimpleProperty(T defaultValue, IValidator? validator = null) : IProperty +public class SimpleProperty(T defaultValue, IValidator? validator = null) + : IProperty, IPropertyValueReplacer { private IValidator? _validator = validator; private T _currentValue = defaultValue; @@ -32,31 +33,52 @@ public class SimpleProperty(T defaultValue, IValidator? validator = null) public T CurrentValue { get => _currentValue; - set + set => SetCurrentValue(value, replaceEquivalent: false); + } + + private void SetCurrentValue(T value, bool replaceEquivalent) + { + var validatedValue = ValidateAndCoerce(value); + bool hasReplacement = replaceEquivalent && !typeof(T).IsValueType + ? !ReferenceEquals(_currentValue, validatedValue) + : !EqualityComparer.Default.Equals(_currentValue, validatedValue); + if (hasReplacement) { - var validatedValue = ValidateAndCoerce(value); - if (!EqualityComparer.Default.Equals(_currentValue, validatedValue)) + var oldValue = _currentValue; + _currentValue = validatedValue; + HasLocalValue = true; + + ValueChanged?.Invoke(this, new PropertyValueChangedEventArgs(this, oldValue, validatedValue)); + Edited?.Invoke(this, EventArgs.Empty); + if (_owner is IModifiableHierarchical ownerHierarchical) { - var oldValue = _currentValue; - _currentValue = validatedValue; - HasLocalValue = true; + if (oldValue is IHierarchical oldHierarchical) + ownerHierarchical.RemoveChild(oldHierarchical); - ValueChanged?.Invoke(this, new PropertyValueChangedEventArgs(this, oldValue, validatedValue)); - Edited?.Invoke(this, EventArgs.Empty); - if (_owner is IModifiableHierarchical ownerHierarchical) - { - if (oldValue is IHierarchical oldHierarchical) - ownerHierarchical.RemoveChild(oldHierarchical); - - if (validatedValue is IHierarchical newHierarchical) - ownerHierarchical.AddChild(newHierarchical); - } - - if (oldValue is INotifyEdited oldEdited) - oldEdited.Edited -= OnChildEdited; - if (validatedValue is INotifyEdited newEdited) - newEdited.Edited += OnChildEdited; + if (validatedValue is IHierarchical newHierarchical) + ownerHierarchical.AddChild(newHierarchical); } + + if (oldValue is INotifyEdited oldEdited) + oldEdited.Edited -= OnChildEdited; + if (validatedValue is INotifyEdited newEdited) + newEdited.Edited += OnChildEdited; + } + } + + void IPropertyValueReplacer.ReplaceCurrentValue(object? value) + { + if (value is T typed) + { + SetCurrentValue(typed, replaceEquivalent: true); + } + else if (value is null && !typeof(T).IsValueType) + { + SetCurrentValue(default!, replaceEquivalent: true); + } + else + { + throw new InvalidCastException(); } } diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 03f0b84cb8..7999cc2b4f 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -873,22 +873,12 @@ void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePa bool ambiguousPositionalIdentity = false; if (!hasPersistedIdentity && graphPath.Positional != graphPath.Stable) { - string positionalIdentityKey = CreateRecoveredDescendantIdentityKey( + hasPersistedIdentity = TryGetRecoveredDescendantPositionalIdentity( + persistedDescendantIdentities, relativePath, - graphPath.Positional); - hasPersistedIdentity = persistedDescendantIdentities.TryGetValue( - positionalIdentityKey, - out persistedIdentityId); - if (hasPersistedIdentity - && !IsRecoveredDescendantPositionalIdentityUnambiguous( - persistedDescendantIdentities, - relativePath, - graphPath.Positional, - persistedIdentityId)) - { - hasPersistedIdentity = false; - ambiguousPositionalIdentity = true; - } + graphPath.Positional, + out persistedIdentityId, + out ambiguousPositionalIdentity); } if (!hasPersistedIdentity @@ -1298,17 +1288,14 @@ private void MarkRecoveredElement( FilePathBoundary.ResolveDeepestExistingTarget(sourceRoot)); string elementPath = Path.GetFullPath(elementUri.LocalPath); string resolvedElementPath = FilePathBoundary.ResolveDeepestExistingTarget(elementPath); - StringComparison comparison = OperatingSystem.IsLinux() - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - if (!IsPathInsideRoot(resolvedSourceRoot, resolvedElementPath, comparison)) + if (!FilePathBoundary.IsPathInsideRoot(resolvedSourceRoot, resolvedElementPath)) { return null; } string elementDirectory = Path.GetDirectoryName(elementPath) ?? throw new JsonException("Recovered element has no source directory."); - var seenPaths = new HashSet(StringComparer.FromComparison(comparison)); + var seenPaths = new HashSet(FilePathBoundary.Comparer); var result = new List(); foreach (string sourcePath in EnumerateSerializedGraphObjects(element) .OfType() @@ -1316,8 +1303,11 @@ private void MarkRecoveredElement( .Select(static coreObject => Path.GetFullPath(coreObject.Uri!.LocalPath))) { string resolvedSourcePath = FilePathBoundary.ResolveDeepestExistingTarget(sourcePath); - if (string.Equals(resolvedSourcePath, resolvedElementPath, comparison) - || !IsPathInsideRoot(resolvedSourceRoot, resolvedSourcePath, comparison) + if (string.Equals( + resolvedSourcePath, + resolvedElementPath, + FilePathBoundary.Comparison) + || !FilePathBoundary.IsPathInsideRoot(resolvedSourceRoot, resolvedSourcePath) || !File.Exists(sourcePath) || !seenPaths.Add(resolvedSourcePath)) { @@ -1333,18 +1323,6 @@ private void MarkRecoveredElement( return result.Count > 0 ? result.ToArray() : null; } - private static bool IsPathInsideRoot( - string root, - string candidate, - StringComparison comparison) - { - string prefix = Path.EndsInDirectorySeparator(root) - ? root - : root + Path.DirectorySeparatorChar; - return string.Equals(candidate, root, comparison) - || candidate.StartsWith(prefix, comparison); - } - internal static SuppressedStorageSource? TryResumeElementPersistence(Element element) { if (element.SuppressedStorageSource is not { } source @@ -1411,58 +1389,68 @@ private void MigrateRecoveredElementReferences() return; } - IEnumerable ownerRoots = Children.Cast() - .Concat(Layers) - .Concat(Markers); - foreach (CoreObject ownerRoot in ownerRoots) + var rewriteState = new RecoveredReferenceRewriteState(); + foreach (CoreObject coreObject in EnumerateSerializedGraphObjects(this).OfType()) { - var visited = new HashSet(ReferenceEqualityComparer.Instance); - foreach (CoreObject coreObject in EnumerateSerializedGraphObjects(ownerRoot).OfType()) + foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) { - foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) + if (!property.GetMetadata(coreObject.GetType()).ShouldSerialize) { - if (!property.GetMetadata(coreObject.GetType()).ShouldSerialize) - { - continue; - } - - object? currentValue = coreObject.GetValue(property); - object? migratedValue = MigrateRecoveredReferenceValue(currentValue, visited); - if (!Equals(currentValue, migratedValue) - && property is not IStaticProperty { CanWrite: false }) - { - coreObject.SetValue(property, migratedValue); - } + continue; } - if (coreObject is not EngineObject engineObject) + object? currentValue = coreObject.GetValue(property); + object? migratedValue = MigrateRecoveredReferenceValue(currentValue, rewriteState); + if (HasReferenceRewrite(currentValue, migratedValue) + && property is not IStaticProperty { CanWrite: false }) { - continue; + coreObject.ReplaceValue(property, migratedValue); } + } + + if (coreObject is not EngineObject engineObject) + { + continue; + } - foreach (IProperty property in engineObject.Properties) + foreach (IProperty property in engineObject.Properties) + { + object? currentValue = property.CurrentValue; + object? migratedValue = MigrateRecoveredReferenceValue(currentValue, rewriteState); + if (HasReferenceRewrite(currentValue, migratedValue)) { - object? currentValue = property.CurrentValue; - object? migratedValue = MigrateRecoveredReferenceValue(currentValue, visited); - if (!Equals(currentValue, migratedValue)) + if (property is IPropertyValueReplacer replacer) { - property.CurrentValue = migratedValue; + replacer.ReplaceCurrentValue(migratedValue); } - - if (property.Expression is IReferenceExpression referenceExpression - && TryGetMigratedId(referenceExpression.ObjectId, out Guid migratedExpressionId) - && referenceExpression.Rebind(migratedExpressionId) is { } reboundExpression) + else { - property.Expression = (IExpression)reboundExpression; + property.CurrentValue = migratedValue; } + } - if (property.Animation is IKeyFrameAnimation animation) + if (property.Expression is IReferenceExpression referenceExpression + && TryGetMigratedId(referenceExpression.ObjectId, out Guid migratedExpressionId) + && referenceExpression.Rebind(migratedExpressionId) is { } reboundExpression) + { + property.Expression = (IExpression)reboundExpression; + } + + if (property.Animation is IKeyFrameAnimation animation) + { + foreach (IKeyFrame keyFrame in animation.KeyFrames) { - foreach (IKeyFrame keyFrame in animation.KeyFrames) + object? keyFrameValue = keyFrame.Value; + object? migratedKeyFrameValue = MigrateRecoveredReferenceValue( + keyFrameValue, + rewriteState); + if (HasReferenceRewrite(keyFrameValue, migratedKeyFrameValue)) { - object? keyFrameValue = keyFrame.Value; - object? migratedKeyFrameValue = MigrateRecoveredReferenceValue(keyFrameValue, visited); - if (!Equals(keyFrameValue, migratedKeyFrameValue)) + if (keyFrame is IKeyFrameValueReplacer replacer) + { + replacer.ReplaceValue(migratedKeyFrameValue); + } + else { keyFrame.Value = migratedKeyFrameValue; } @@ -1489,30 +1477,31 @@ private object ResolveMigratedReference(IReference reference, Guid migratedId) return reference.Resolved(target); } - try - { - return Activator.CreateInstance(reference.GetType(), migratedId) ?? reference; - } - catch (MissingMethodException) - { - return reference; - } + return reference; } - private object? MigrateRecoveredReferenceValue(object? value, ISet visited) + private object? MigrateRecoveredReferenceValue( + object? value, + RecoveredReferenceRewriteState state) { if (value is IReference reference) { - return TryGetMigratedId(reference.Id, out Guid migratedId) + object rewritten = TryGetMigratedId(reference.Id, out Guid migratedId) ? ResolveMigratedReference(reference, migratedId) : value; + if (HasReferenceRewrite(value, rewritten)) + { + state.RewriteCount++; + } + + return rewritten; } if (value is IOptional { HasValue: true } optional) { object? item = optional.ToObject().Value; - object? migratedItem = MigrateRecoveredReferenceValue(item, visited); - if (!Equals(item, migratedItem)) + object? migratedItem = MigrateRecoveredReferenceValue(item, state); + if (HasReferenceRewrite(item, migratedItem)) { try { @@ -1530,38 +1519,116 @@ or ArgumentException return value; } - if (value is null or string - || (!value.GetType().IsValueType && !visited.Add(value))) + if (value is null or string) { return value; } - if (value is IDictionary dictionary) + bool trackReference = !value.GetType().IsValueType; + if (trackReference) { - foreach (object key in dictionary.Keys.Cast().ToArray()) + if (state.Memo.TryGetValue(value, out object? cached)) { - object? item = dictionary[key]; - object? migratedItem = MigrateRecoveredReferenceValue(item, visited); - if (!Equals(item, migratedItem)) - { - dictionary[key] = migratedItem; - } + return cached; + } + + if (!state.Active.Add(value)) + { + return value; } } - else if (value is IList list) + + try { - for (int i = 0; i < list.Count; i++) + object? rewrittenValue = value; + if (value is IReferenceRewritable rewritable) { - object? item = list[i]; - object? migratedItem = MigrateRecoveredReferenceValue(item, visited); - if (!Equals(item, migratedItem)) + int rewriteCount = state.RewriteCount; + object rewritten = rewritable.RewriteReferences( + new RecoveredReferenceRewriteContext(this, state)); + if (state.RewriteCount != rewriteCount + && rewritten is not null + && rewritten.GetType() == value.GetType()) { - list[i] = migratedItem; + rewrittenValue = rewritten; } } + else if (value is IDictionary dictionary) + { + foreach (object key in dictionary.Keys.Cast().ToArray()) + { + object? item = dictionary[key]; + object? migratedItem = MigrateRecoveredReferenceValue(item, state); + if (HasReferenceRewrite(item, migratedItem)) + { + dictionary[key] = migratedItem; + } + } + } + else if (value is IList list) + { + for (int i = 0; i < list.Count; i++) + { + object? item = list[i]; + object? migratedItem = MigrateRecoveredReferenceValue(item, state); + if (HasReferenceRewrite(item, migratedItem)) + { + list[i] = migratedItem; + } + } + } + + if (trackReference) + { + state.Memo[value] = rewrittenValue; + } + + return rewrittenValue; + } + finally + { + if (trackReference) + { + state.Active.Remove(value); + } + } + } + + private static bool HasReferenceRewrite(object? current, object? rewritten) + { + if (ReferenceEquals(current, rewritten)) + { + return false; } - return value; + if (current is null || rewritten is null) + { + return true; + } + + return current.GetType().IsValueType + ? !Equals(current, rewritten) + : true; + } + + private sealed class RecoveredReferenceRewriteContext( + Scene scene, + RecoveredReferenceRewriteState state) : IReferenceRewriteContext + { + public T Rewrite(T value) + { + object? rewritten = scene.MigrateRecoveredReferenceValue(value, state); + return rewritten is T typed ? typed : value; + } + } + + private sealed class RecoveredReferenceRewriteState + { + public HashSet Active { get; } = new(ReferenceEqualityComparer.Instance); + + public Dictionary Memo { get; } = new(ReferenceEqualityComparer.Instance); + + public int RewriteCount { get; set; } } private static IEnumerable EnumerateSerializedGraphObjects(object root) @@ -1819,25 +1886,34 @@ private static SerializedGraphPath AppendSerializedGraphPath( AppendSerializedGraphPath(path.Positional, kind, value)); } - private static bool IsRecoveredDescendantPositionalIdentityUnambiguous( + private static bool TryGetRecoveredDescendantPositionalIdentity( IReadOnlyDictionary identities, string relativePath, string positionalPath, - Guid expectedId) + out Guid identityId, + out bool ambiguous) { string keyPrefix = $"{relativePath}!path:"; string normalizedPath = NormalizeSerializedGraphPositionalPath(positionalPath); + var candidates = new HashSet(); foreach ((string key, Guid id) in identities) { - if (id != expectedId - && key.StartsWith(keyPrefix, StringComparison.Ordinal) + if (key.StartsWith(keyPrefix, StringComparison.Ordinal) && NormalizeSerializedGraphPositionalPath(key[keyPrefix.Length..]) == normalizedPath) { - return false; + candidates.Add(id); } } - return true; + ambiguous = candidates.Count > 1; + if (candidates.Count == 1) + { + identityId = candidates.Single(); + return true; + } + + identityId = Guid.Empty; + return false; } private static string NormalizeSerializedGraphPositionalPath(string path) diff --git a/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs b/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs new file mode 100644 index 0000000000..2ea96ca5aa --- /dev/null +++ b/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs @@ -0,0 +1,20 @@ +using Beutl.Serialization; + +namespace Beutl.UnitTests.Core; + +public sealed class FilePathBoundaryTests +{ + [Test] + public void IsPathInsideRoot_UsesPlatformPathCaseSemantics() + { + string root = Path.Combine(Path.GetTempPath(), "Beutl-Root"); + string differentlyCasedPath = Path.Combine( + Path.GetTempPath(), + "beutl-root", + "sidecar.json"); + + Assert.That( + FilePathBoundary.IsPathInsideRoot(root, differentlyCasedPath), + Is.EqualTo(OperatingSystem.IsWindows())); + } +} diff --git a/tests/Beutl.UnitTests/Engine/AnimatablePropertyTests.cs b/tests/Beutl.UnitTests/Engine/AnimatablePropertyTests.cs index 71e05ad297..2fcc8880cc 100644 --- a/tests/Beutl.UnitTests/Engine/AnimatablePropertyTests.cs +++ b/tests/Beutl.UnitTests/Engine/AnimatablePropertyTests.cs @@ -4,12 +4,46 @@ using Beutl.Engine; using Beutl.Engine.Expressions; using Beutl.ProjectSystem; +using Beutl.Validation; namespace Beutl.UnitTests.Engine; [TestFixture] public class AnimatablePropertyTests { + private sealed class EqualityValue(string key, string state) + { + public string Key { get; } = key; + + public string State { get; } = state; + + public override bool Equals(object? obj) + { + return obj is EqualityValue other && Key == other.Key; + } + + public override int GetHashCode() + { + return Key.GetHashCode(StringComparison.Ordinal); + } + } + + private sealed class CountingValidator : IValidator + { + public int CoerceCount { get; private set; } + + public bool TryCoerce(ValidationContext context, ref EqualityValue? value) + { + CoerceCount++; + return true; + } + + public string? Validate(ValidationContext context, EqualityValue? value) + { + return null; + } + } + // A non-EngineObject root: an EngineObject root would hijack the owner's time-anchor subscription. private sealed class TestHierarchicalRoot : Hierarchical, IHierarchicalRoot { @@ -81,6 +115,33 @@ public void CurrentValue_NewValue_RaisesValueChangedAndEdited() Assert.That(property.HasLocalValue, Is.True); } + [Test] + public void ReplaceCurrentValue_EquivalentInstance_ReplacesAndNotifiesOnce() + { + var current = new EqualityValue("same", "old"); + var replacement = new EqualityValue("same", "new"); + var validator = new CountingValidator(); + var property = new AnimatableProperty(current, validator); + property.SetAttributes("Value", []); + PropertyValueChangedEventArgs? args = null; + int edited = 0; + property.ValueChanged += (_, e) => args = e; + property.Edited += (_, _) => edited++; + + ((IPropertyValueReplacer)property).ReplaceCurrentValue(replacement); + + Assert.Multiple(() => + { + Assert.That(property.CurrentValue, Is.SameAs(replacement)); + Assert.That(property.CurrentValue.State, Is.EqualTo("new")); + Assert.That(validator.CoerceCount, Is.EqualTo(1)); + Assert.That(args!.OldValue, Is.SameAs(current)); + Assert.That(args.NewValue, Is.SameAs(replacement)); + Assert.That(edited, Is.EqualTo(1)); + Assert.That(property.HasLocalValue, Is.True); + }); + } + [Test] public void CurrentValue_SameValue_DoesNotRaiseEvents() { diff --git a/tests/Beutl.UnitTests/Engine/SimplePropertyTests.cs b/tests/Beutl.UnitTests/Engine/SimplePropertyTests.cs index aa5726cd50..46877e9b8c 100644 --- a/tests/Beutl.UnitTests/Engine/SimplePropertyTests.cs +++ b/tests/Beutl.UnitTests/Engine/SimplePropertyTests.cs @@ -1,12 +1,46 @@ using Beutl.Composition; using Beutl.Engine; using Beutl.Engine.Expressions; +using Beutl.Validation; namespace Beutl.UnitTests.Engine; [TestFixture] public class SimplePropertyTests { + private sealed class EqualityValue(string key, string state) + { + public string Key { get; } = key; + + public string State { get; } = state; + + public override bool Equals(object? obj) + { + return obj is EqualityValue other && Key == other.Key; + } + + public override int GetHashCode() + { + return Key.GetHashCode(StringComparison.Ordinal); + } + } + + private sealed class CountingValidator : IValidator + { + public int CoerceCount { get; private set; } + + public bool TryCoerce(ValidationContext context, ref EqualityValue? value) + { + CoerceCount++; + return true; + } + + public string? Validate(ValidationContext context, EqualityValue? value) + { + return null; + } + } + private static SimpleProperty Make(T defaultValue, string name = "Value") { var property = new SimpleProperty(defaultValue); @@ -66,6 +100,33 @@ public void CurrentValue_NewValue_RaisesValueChangedAndEdited() Assert.That(property.HasLocalValue, Is.True); } + [Test] + public void ReplaceCurrentValue_EquivalentInstance_ReplacesAndNotifiesOnce() + { + var current = new EqualityValue("same", "old"); + var replacement = new EqualityValue("same", "new"); + var validator = new CountingValidator(); + var property = new SimpleProperty(current, validator); + property.SetAttributes("Value", []); + PropertyValueChangedEventArgs? args = null; + int edited = 0; + property.ValueChanged += (_, e) => args = e; + property.Edited += (_, _) => edited++; + + ((IPropertyValueReplacer)property).ReplaceCurrentValue(replacement); + + Assert.Multiple(() => + { + Assert.That(property.CurrentValue, Is.SameAs(replacement)); + Assert.That(property.CurrentValue.State, Is.EqualTo("new")); + Assert.That(validator.CoerceCount, Is.EqualTo(1)); + Assert.That(args!.OldValue, Is.SameAs(current)); + Assert.That(args.NewValue, Is.SameAs(replacement)); + Assert.That(edited, Is.EqualTo(1)); + Assert.That(property.HasLocalValue, Is.True); + }); + } + [Test] public void CompoundAssign_SetsCurrentValue() { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index e091eca18d..60c8f4a640 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -10,6 +10,7 @@ using Beutl.Editor; using Beutl.Engine; using Beutl.Engine.Expressions; +using Beutl.Graphics; using Beutl.Graphics.Shapes; using Beutl.Graphics.Transformation; using Beutl.ProjectSystem; @@ -72,6 +73,99 @@ public NestedReferenceHolder() = Property.CreateAnimatable>(); } + [SuppressResourceClassGeneration] + public sealed class WrappedReferenceHolder : EngineObject + { + public WrappedReferenceHolder() + { + ScanProperties(); + } + + public IProperty Target { get; } + = Property.Create(); + + public IProperty AliasTarget { get; } + = Property.Create(); + + public IProperty EqualityTarget { get; } + = Property.Create(); + + public IProperty AnimatedTarget { get; } + = Property.CreateAnimatable(); + + public IProperty PassiveTarget { get; } + = Property.Create(); + + public IProperty InvalidTarget { get; } + = Property.Create(); + + public IProperty CyclicTarget { get; } + = Property.Create(); + } + + public sealed record ReferenceEnvelope( + Reference Target, + Optional> OptionalTarget, + string State) : IReferenceRewritable + { + public object RewriteReferences(IReferenceRewriteContext context) + { + return this with + { + Target = context.Rewrite(Target), + OptionalTarget = context.Rewrite(OptionalTarget), + }; + } + } + + public sealed record PassiveReferenceEnvelope(Reference Target, string State); + + public sealed class EqualityIgnoringReferenceEnvelope( + Reference target, + string state) : IReferenceRewritable + { + public Reference Target { get; } = target; + + public string State { get; } = state; + + public object RewriteReferences(IReferenceRewriteContext context) + { + return new EqualityIgnoringReferenceEnvelope(context.Rewrite(Target), State); + } + + public override bool Equals(object? obj) + { + return obj is EqualityIgnoringReferenceEnvelope other && State == other.State; + } + + public override int GetHashCode() + { + return State.GetHashCode(StringComparison.Ordinal); + } + } + + public sealed record InvalidReferenceEnvelope(Reference Target) : IReferenceRewritable + { + public object RewriteReferences(IReferenceRewriteContext context) + { + return "invalid replacement"; + } + } + + public sealed class CyclicReferenceEnvelope(Reference target) : IReferenceRewritable + { + public Reference Target { get; private set; } = target; + + public CyclicReferenceEnvelope? Self { get; set; } + + public object RewriteReferences(IReferenceRewriteContext context) + { + Target = context.Rewrite(Target); + Self = context.Rewrite(Self); + return this; + } + } + [SuppressResourceClassGeneration] public sealed class DictionaryTransformHolder : EngineObject { @@ -113,10 +207,22 @@ public TransformReferenceHolder() public IProperty> Target { get; } = Property.Create>(); } + [SuppressResourceClassGeneration] + public sealed class DrawableReferenceHolder : EngineObject + { + public DrawableReferenceHolder() + { + ScanProperties(); + } + + public IProperty> Target { get; } = Property.Create>(); + } + public sealed class RegisteredRecoveryElement : Element { public static readonly CoreProperty PluginTransformProperty; public static readonly CoreProperty> PluginTargetProperty; + public static readonly CoreProperty PluginWrapperProperty; static RegisteredRecoveryElement() { @@ -126,6 +232,10 @@ static RegisteredRecoveryElement() PluginTargetProperty = ConfigureProperty, RegisteredRecoveryElement>( nameof(PluginTarget)) .Register(); + PluginWrapperProperty = ConfigureProperty< + EqualityIgnoringReferenceEnvelope?, + RegisteredRecoveryElement>(nameof(PluginWrapper)) + .Register(); } public Transform? PluginTransform @@ -139,6 +249,12 @@ public Reference PluginTarget get => GetValue(PluginTargetProperty); set => SetValue(PluginTargetProperty, value); } + + public EqualityIgnoringReferenceEnvelope? PluginWrapper + { + get => GetValue(PluginWrapperProperty); + set => SetValue(PluginWrapperProperty, value); + } } public sealed class RegisteredOptionalRecoveryElement : Element @@ -159,6 +275,24 @@ public Optional PluginTransform } } + public sealed class RegisteredRecoveryScene : Scene + { + public static readonly CoreProperty> PluginTargetProperty; + + static RegisteredRecoveryScene() + { + PluginTargetProperty = ConfigureProperty, RegisteredRecoveryScene>( + nameof(PluginTarget)) + .Register(); + } + + public Reference PluginTarget + { + get => GetValue(PluginTargetProperty); + set => SetValue(PluginTargetProperty, value); + } + } + private sealed class CustomReferenceExpression : IReferenceExpression { public CustomReferenceExpression(Guid objectId) @@ -183,6 +317,27 @@ public bool Validate(out string? error) } } + private sealed class StatefulReferenceExpression(Guid objectId, string propertyPath) : IReferenceExpression + { + public Guid ObjectId { get; } = objectId; + + public string PropertyPath { get; } = propertyPath; + + public bool HasPropertyPath => !string.IsNullOrEmpty(PropertyPath); + + public string ExpressionString => $"{ObjectId}.{PropertyPath}"; + + public Type ResultType => typeof(Element); + + public string State { get; init; } = string.Empty; + + public bool Validate(out string? error) + { + error = null; + return true; + } + } + private sealed class ConstructorlessReference(Guid id, Type objectType, string marker) : IReference { public Guid Id { get; } = id; @@ -698,6 +853,17 @@ public void ReferenceExpression_Rebind_ReturnsNullForUnsupportedCustomImplementa Assert.That(((IReferenceExpression)expression).Rebind(Guid.NewGuid()), Is.Null); } + [Test] + public void ReferenceExpression_Rebind_DoesNotGuessHowToPreserveCustomState() + { + var expression = new StatefulReferenceExpression(Guid.NewGuid(), "Value") + { + State = "plugin-state", + }; + + Assert.That(((IReferenceExpression)expression).Rebind(Guid.NewGuid()), Is.Null); + } + [Test] public void StoreToUri_RecoveredElementNonFileDestinationMatchesNormalFailure() { @@ -1388,6 +1554,63 @@ public void Restore_RepairedDescendantPathSurvivesEarlierObjectRemoval() }); } + [Test] + public void Restore_UniqueDirectFallbackSurvivesEarlierObjectRemovalAndNewId() + { + (Uri sceneUri, string[] elementPaths) = + CreatePersistedSceneWithElements("repaired.belm", "holder.belm"); + Scene source = CoreSerializer.RestoreFromUri(sceneUri); + Element repairedSource = source.Children.Single(child => child.Uri!.LocalPath == elementPaths[0]); + repairedSource.AddObject(new RectShape()); + CoreSerializer.StoreToUri(repairedSource, repairedSource.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPaths[0]))!.AsObject(); + JsonObject fallbackJson = json[nameof(Element.Objects)]!.AsArray()[1]!.AsObject(); + fallbackJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Shapes:MissingShape"; + File.WriteAllText(elementPaths[0], json.ToJsonString()); + + Scene recoveredScene = CoreSerializer.RestoreFromUri(sceneUri); + Element recoveredElement = recoveredScene.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]); + Guid placeholderId = ((CoreObject)recoveredElement.Objects[1]).Id; + Element holder = recoveredScene.Children.Single(child => child.Uri!.LocalPath == elementPaths[1]); + var referenceHolder = new DrawableReferenceHolder(); + referenceHolder.Target.CurrentValue = new Reference(placeholderId); + holder.AddObject(referenceHolder); + CoreSerializer.StoreToUri(recoveredScene, sceneUri); + + Guid repairedId = Guid.NewGuid(); + var repaired = new Element + { + Id = repairedSource.Id, + Name = "Repaired", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(elementPaths[0]), + }; + repaired.AddObject(new RectShape { Id = repairedId }); + CoreSerializer.StoreToUri(repaired, repaired.Uri!); + + Scene reloaded = CoreSerializer.RestoreFromUri(sceneUri); + var application = new BeutlApplication(); + var project = new Project(); + application.Project = project; + project.Items.Add(reloaded); + var reloadedShape = (RectShape)reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[0]).Objects.Single(); + Reference migratedReference = reloaded.Children.Single( + child => child.Uri!.LocalPath == elementPaths[1]).Objects + .OfType() + .Single() + .Target.CurrentValue; + + Assert.Multiple(() => + { + Assert.That(reloadedShape.Id, Is.EqualTo(repairedId)); + Assert.That(migratedReference.Id, Is.EqualTo(repairedId)); + Assert.That(migratedReference.Value, Is.SameAs(reloadedShape)); + }); + } + [Test] public void Restore_AmbiguousRepairedDescendantAfterEarlierRemovalDoesNotMigratePlaceholders() { @@ -1559,6 +1782,112 @@ Reference migratedDictionaryReference }); } + [Test] + public void MigrateRecoveredElementReferences_RewritesOptInWrappersWithoutTouchingOtherPocos() + { + Guid originalId = Guid.NewGuid(); + var migrated = new Element + { + Id = Guid.NewGuid(), + Uri = new Uri(Path.Combine(_root, "migrated.belm")), + }; + var serializedWrapper = new ReferenceEnvelope( + new Reference(originalId), + new Optional>(new Reference(originalId)), + "plugin-state"); + string json = JsonSerializer.Serialize(serializedWrapper, JsonHelper.SerializerOptions); + ReferenceEnvelope wrapper = JsonSerializer.Deserialize( + json, + JsonHelper.SerializerOptions)!; + var passiveWrapper = new PassiveReferenceEnvelope( + new Reference(originalId), + "passive-state"); + var equalityWrapper = new EqualityIgnoringReferenceEnvelope( + new Reference(originalId), + "equality-state"); + var coreWrapper = new EqualityIgnoringReferenceEnvelope( + new Reference(originalId), + "core-state"); + var keyFrameWrapper = new EqualityIgnoringReferenceEnvelope( + new Reference(originalId), + "keyframe-state"); + var invalidWrapper = new InvalidReferenceEnvelope(new Reference(originalId)); + var cyclicWrapper = new CyclicReferenceEnvelope(new Reference(originalId)); + cyclicWrapper.Self = cyclicWrapper; + var holder = new WrappedReferenceHolder(); + holder.Target.CurrentValue = wrapper; + holder.AliasTarget.CurrentValue = wrapper; + holder.EqualityTarget.CurrentValue = equalityWrapper; + var animation = new KeyFrameAnimation(); + var keyFrame = new KeyFrame + { + KeyTime = TimeSpan.Zero, + Value = keyFrameWrapper, + }; + animation.KeyFrames.Add(keyFrame); + holder.AnimatedTarget.Animation = animation; + holder.PassiveTarget.CurrentValue = passiveWrapper; + holder.InvalidTarget.CurrentValue = invalidWrapper; + holder.CyclicTarget.CurrentValue = cyclicWrapper; + var owner = new Element { Uri = new Uri(Path.Combine(_root, "owner.belm")) }; + owner.AddObject(holder); + var registeredOwner = new RegisteredRecoveryElement + { + Uri = new Uri(Path.Combine(_root, "registered-owner.belm")), + PluginWrapper = coreWrapper, + }; + int corePropertyChanges = 0; + int keyFrameChanges = 0; + registeredOwner.PropertyChanged += (_, e) => + corePropertyChanges += e.PropertyName == nameof(RegisteredRecoveryElement.PluginWrapper) ? 1 : 0; + keyFrame.PropertyChanged += (_, e) => + keyFrameChanges += e.PropertyName == nameof(IKeyFrame.Value) ? 1 : 0; + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "migration.scene")) }; + scene.Children.Add(migrated); + scene.Children.Add(owner); + scene.Children.Add(registeredOwner); + var migrations = (Dictionary)typeof(Scene) + .GetField("_pendingRecoveredElementIdMigrations", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(scene)!; + migrations[originalId] = migrated.Id; + MethodInfo method = typeof(Scene).GetMethod( + "MigrateRecoveredElementReferences", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + method.Invoke(scene, null); + + ReferenceEnvelope rewritten = holder.Target.CurrentValue!; + Assert.Multiple(() => + { + Assert.That(rewritten, Is.Not.SameAs(wrapper)); + Assert.That(rewritten.State, Is.EqualTo("plugin-state")); + Assert.That(rewritten.Target.Id, Is.EqualTo(migrated.Id)); + Assert.That(rewritten.Target.Value, Is.SameAs(migrated)); + Assert.That(rewritten.OptionalTarget.Value.Id, Is.EqualTo(migrated.Id)); + Assert.That(rewritten.OptionalTarget.Value.Value, Is.SameAs(migrated)); + Assert.That(holder.AliasTarget.CurrentValue, Is.SameAs(rewritten)); + Assert.That(holder.AliasTarget.CurrentValue!.Target.Id, Is.EqualTo(migrated.Id)); + Assert.That(holder.EqualityTarget.CurrentValue, Is.Not.SameAs(equalityWrapper)); + Assert.That(holder.EqualityTarget.CurrentValue!.State, Is.EqualTo("equality-state")); + Assert.That(holder.EqualityTarget.CurrentValue.Target.Id, Is.EqualTo(migrated.Id)); + Assert.That(registeredOwner.PluginWrapper, Is.Not.SameAs(coreWrapper)); + Assert.That(registeredOwner.PluginWrapper!.State, Is.EqualTo("core-state")); + Assert.That(registeredOwner.PluginWrapper.Target.Id, Is.EqualTo(migrated.Id)); + Assert.That(corePropertyChanges, Is.EqualTo(1)); + Assert.That(keyFrame.Value, Is.Not.SameAs(keyFrameWrapper)); + Assert.That(keyFrame.Value!.State, Is.EqualTo("keyframe-state")); + Assert.That(keyFrame.Value.Target.Id, Is.EqualTo(migrated.Id)); + Assert.That(keyFrameChanges, Is.EqualTo(1)); + Assert.That(holder.PassiveTarget.CurrentValue, Is.SameAs(passiveWrapper)); + Assert.That(holder.PassiveTarget.CurrentValue!.Target.Id, Is.EqualTo(originalId)); + Assert.That(holder.InvalidTarget.CurrentValue, Is.SameAs(invalidWrapper)); + Assert.That(holder.InvalidTarget.CurrentValue!.Target.Id, Is.EqualTo(originalId)); + Assert.That(holder.CyclicTarget.CurrentValue, Is.SameAs(cyclicWrapper)); + Assert.That(holder.CyclicTarget.CurrentValue!.Target.Id, Is.EqualTo(migrated.Id)); + Assert.That(holder.CyclicTarget.CurrentValue.Self, Is.SameAs(cyclicWrapper)); + }); + } + [Test] public void MigrateRecoveredElementReferences_TraversesLayerAndMarkerGraphs() { @@ -1633,6 +1962,38 @@ public void MigrateRecoveredElementReferences_TraversesRegisteredCoreProperties( }); } + [Test] + public void MigrateRecoveredElementReferences_TraversesRegisteredSceneProperties() + { + Guid originalId = Guid.NewGuid(); + var migrated = new Element + { + Id = Guid.NewGuid(), + Uri = new Uri(Path.Combine(_root, "migrated.belm")), + }; + var scene = new RegisteredRecoveryScene + { + Uri = new Uri(Path.Combine(_root, "migration.scene")), + PluginTarget = new Reference(originalId), + }; + scene.Children.Add(migrated); + var migrations = (Dictionary)typeof(Scene) + .GetField("_pendingRecoveredElementIdMigrations", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(scene)!; + migrations[originalId] = migrated.Id; + MethodInfo method = typeof(Scene).GetMethod( + "MigrateRecoveredElementReferences", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + method.Invoke(scene, null); + + Assert.Multiple(() => + { + Assert.That(scene.PluginTarget.Id, Is.EqualTo(migrated.Id)); + Assert.That(scene.PluginTarget.Value, Is.SameAs(migrated)); + }); + } + [Test] public void Restore_MalformedElementIdAvoidsSerializedMarkerCollision() { From 81cfb0c162a7f0a2e7b2ca00a8c9487a74367882 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 10 Aug 2026 06:31:41 +0900 Subject: [PATCH 33/35] refactor!: harden malformed-element recovery Make recovered reference migration cycle-safe and explicit across property, keyframe, expression, and wrapper contracts. Preserve long-lived recovery state, retain sidecar identity safely, and share serialized-graph and path-boundary traversal. BREAKING CHANGE: Custom IProperty/IProperty implementations must implement ReplaceCurrentValue; custom IKeyFrame implementations must implement ReplaceValue; custom IReferenceExpression implementations must implement Rebind; IReferenceRewritable implementations must use CreateReferenceRewriteTarget plus void RewriteReferences. Beutl.AgentToolkit.Common.PathBoundary and PathComparison were replaced by an internal Beutl.Core path helper. Affected projects: Beutl.Core, Beutl.Engine, Beutl.ProjectSystem, and Beutl.AgentToolkit. --- src/Beutl.AgentToolkit/Common/PathBoundary.cs | 147 ------ .../Common/PathComparison.cs | 8 - .../Reconciliation/Reconciler.cs | 205 +------- .../Sessions/FileEditingSession.cs | 6 +- .../Sessions/ProjectOperations.cs | 48 +- src/Beutl.AgentToolkit/Tools/SessionTools.cs | 117 +---- src/Beutl.Core/CoreObject.cs | 18 +- src/Beutl.Core/Properties/AssemblyInfo.cs | 1 + src/Beutl.Core/ReferenceRewriting.cs | 17 +- .../Serialization/CoreSerializer.cs | 57 ++- .../Serialization/DeserializationIncidents.cs | 34 +- .../Serialization/FilePathBoundary.cs | 6 +- .../Serialization/SuppressedStorageSource.cs | 8 +- src/Beutl.Core/ValueReplacement.cs | 11 + src/Beutl.Engine/Animation/IKeyFrame.cs | 11 +- src/Beutl.Engine/Animation/KeyFrame.cs | 50 +- src/Beutl.Engine/Animation/KeyFrame{T}.cs | 4 +- src/Beutl.Engine/Engine/AnimatableProperty.cs | 26 +- .../Expressions/IReferenceExpression.cs | 5 +- src/Beutl.Engine/Engine/IProperty.cs | 36 +- src/Beutl.Engine/Engine/ListProperty.cs | 3 + src/Beutl.Engine/Engine/SimpleProperty.cs | 26 +- .../ProjectSystem/Scene.cs | 476 ++++++++++++------ .../ProjectSystem/SerializedGraphTraversal.cs | 168 +++++++ .../Properties/AssemblyInfo.cs | 1 + src/Beutl.Utilities/ExceptionHelpers.cs | 29 +- .../AgentHost/EditorProjectSessionGateway.cs | 4 +- .../ViewModels/Editors/BaseEditorViewModel.cs | 2 +- .../Common/PathBoundaryTests.cs | 4 +- .../Sessions/ProjectOperationsTests.cs | 58 ++- .../Tools/SessionToolsTests.cs | 71 +-- .../Core/FilePathBoundaryTests.cs | 4 +- .../Engine/AnimatablePropertyTests.cs | 2 +- .../Engine/Animation/KeyFrameTests.cs | 58 +++ .../Engine/ListPropertyTests.cs | 13 + .../Engine/SimplePropertyTests.cs | 2 +- .../MalformedElementRecoveryTests.cs | 344 +++++++++++-- 37 files changed, 1264 insertions(+), 816 deletions(-) delete mode 100644 src/Beutl.AgentToolkit/Common/PathBoundary.cs delete mode 100644 src/Beutl.AgentToolkit/Common/PathComparison.cs create mode 100644 src/Beutl.Core/ValueReplacement.cs create mode 100644 src/Beutl.ProjectSystem/ProjectSystem/SerializedGraphTraversal.cs diff --git a/src/Beutl.AgentToolkit/Common/PathBoundary.cs b/src/Beutl.AgentToolkit/Common/PathBoundary.cs deleted file mode 100644 index 6ed5a9bee4..0000000000 --- a/src/Beutl.AgentToolkit/Common/PathBoundary.cs +++ /dev/null @@ -1,147 +0,0 @@ -namespace Beutl.AgentToolkit.Common; - -// Symlink-aware path resolution shared by the workspace guard and the installer so a textual -// boundary check cannot be bypassed by a symlink/junction under the allowed root. -public static class PathBoundary -{ - // The deepest existing ancestor of with symlinks resolved to their - // final target, then the not-yet-existing remainder re-appended. A boundary check on the result - // therefore sees through a symlinked parent instead of trusting the textual path. - public static string ResolveDeepestExistingTarget(string absolute) - { - string? current = absolute; - var remainder = new Stack(); - - // Stop at the deepest node that exists, including a broken symlink (missing target ⇒ - // File/Directory.Exists both false); skipping it would boundary-check the textual path and - // let a write follow the link outside the root. - while (!string.IsNullOrEmpty(current) && !PathEntryExists(current)) - { - string? name = Path.GetFileName(current); - if (!string.IsNullOrEmpty(name)) - { - remainder.Push(name); - } - - current = Path.GetDirectoryName(current); - } - - if (string.IsNullOrEmpty(current)) - { - return absolute; - } - - string resolved = ResolveExistingPath(current); - while (remainder.Count > 0) - { - resolved = Path.Combine(resolved, remainder.Pop()); - } - - return Path.GetFullPath(resolved); - } - - public static string ResolveExistingPath(string path) - { - if (!Path.IsPathRooted(path)) - { - path = Path.GetFullPath(path); - } - - // Resolve every existing component, not just the leaf: an intermediate symlinked directory - // (e.g. /workspace/link -> /outside) whose leaf exists resolves to the textual in-root path - // under a leaf-only check, and the boundary check would accept it while a write follows the - // link outside the root. Walk component by component, following each symlink to its final - // target, so the result is the real path the filesystem would write to. - string root = Path.GetPathRoot(path) ?? path; - var components = new Stack(); - string? current = path; - while (current is not null && current.Length >= root.Length && !string.Equals(current, root, PathComparison.ForCurrentPlatform)) - { - string name = Path.GetFileName(current); - if (!string.IsNullOrEmpty(name)) - { - components.Push(name); - } - - current = Path.GetDirectoryName(current); - if (current is null) - { - break; - } - } - - string resolved = root; - while (components.Count > 0) - { - string name = components.Pop(); - string candidate = Path.Combine(resolved, name); - if (TryResolveLinkTarget(candidate, out string? target) && target is not null) - { - // Symlink targets may be relative to the link's own directory; resolve them there - // rather than against the process working directory. - resolved = Path.IsPathRooted(target) - ? Path.GetFullPath(target) - : Path.GetFullPath(Path.Combine(resolved, target)); - } - else - { - resolved = Path.GetFullPath(candidate); - } - } - - return resolved; - } - - // Path.Exists follows symlinks; a broken link is missing to it. Query the link entry directly so - // a dangling symlink still counts as an existing node. - private static bool PathEntryExists(string path) - => Path.Exists(path) || new FileInfo(path).LinkTarget is not null; - - private static FileSystemInfo? TryResolveLinkTarget(FileSystemInfo info) - { - try - { - return info.ResolveLinkTarget(returnFinalTarget: true); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) - { - // Reaching here means info IS a link (a non-link returns null without throwing) whose - // final target could not be followed — broken, permission-denied, or unsupported. Never - // propagate (this runs in workspace-boundary/installer checks) and never fall back to the - // link's own in-root path, which would fail OPEN and hide a symlink escape. Instead read - // the immediate link-target metadata (no filesystem walk) and boundary-check the REAL - // destination, resolving a relative target against the link's own directory. - try - { - string? immediateTarget = info.LinkTarget; - string? baseDirectory = Path.GetDirectoryName(info.FullName); - if (immediateTarget is null || baseDirectory is null) - { - return null; - } - - string absoluteTarget = Path.IsPathRooted(immediateTarget) - ? Path.GetFullPath(immediateTarget) - : Path.GetFullPath(Path.Combine(baseDirectory, immediateTarget)); - return Directory.Exists(absoluteTarget) - ? new DirectoryInfo(absoluteTarget) - : new FileInfo(absoluteTarget); - } - catch (Exception fallbackEx) when (fallbackEx is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - // The fallback itself must never propagate (this runs in boundary/installer checks); - // if the link metadata cannot be read, treat it as a non-link and let the caller - // boundary-check the textual path. - return null; - } - } - } - - private static bool TryResolveLinkTarget(string path, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? target) - { - FileSystemInfo info = Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path); - FileSystemInfo? resolved = TryResolveLinkTarget(info); - target = resolved?.FullName; - return target is not null; - } -} diff --git a/src/Beutl.AgentToolkit/Common/PathComparison.cs b/src/Beutl.AgentToolkit/Common/PathComparison.cs deleted file mode 100644 index b50dec21e8..0000000000 --- a/src/Beutl.AgentToolkit/Common/PathComparison.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Beutl.AgentToolkit.Common; - -public static class PathComparison -{ - // Linux file systems are case-sensitive; Windows and the default macOS volume are not. - public static StringComparison ForCurrentPlatform => - OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; -} diff --git a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs index 14f0af1c00..4692e10e95 100644 --- a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs +++ b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs @@ -381,11 +381,29 @@ private static Element[] GetAffectedSuppressedElements(CoreObject root, Reconcil return []; } + var affectedIds = new HashSet(); + const string pathPrefix = "$/Elements[Id="; + foreach (var change in plan.Changes) + { + if (Guid.TryParse(change.TargetId, out Guid targetId)) + { + affectedIds.Add(targetId); + } + + if (change.Path.StartsWith(pathPrefix, StringComparison.Ordinal)) + { + int end = change.Path.IndexOf(']', pathPrefix.Length); + if (end >= 0 + && Guid.TryParse(change.Path.AsSpan(pathPrefix.Length, end - pathPrefix.Length), out Guid pathId)) + { + affectedIds.Add(pathId); + } + } + } + return scene.Children .Where(static child => child.SuppressedStorageSource is not null) - .Where(child => plan.Changes.Any(change => - change.Path.StartsWith($"$/Elements[Id={child.Id}]", StringComparison.Ordinal) - || string.Equals(change.TargetId, child.Id.ToString(), StringComparison.Ordinal))) + .Where(child => affectedIds.Contains(child.Id)) .ToArray(); } @@ -721,8 +739,7 @@ private static CoreObject CloneCurrentRoot(IEditingSession session, JsonObject c private static Dictionary CollectFallbackIdentities(CoreObject root) { var identities = new Dictionary(); - var visited = new HashSet(ReferenceEqualityComparer.Instance); - TraverseSerializedGraph(root, "$", visited, (node, _) => + SerializedGraphTraversal.Visit(root, "$", (node, _) => { if (node is IFallback fallback) { @@ -742,8 +759,7 @@ private static Dictionary CollectFallbackIdentities(CoreO Dictionary existingFallbacks) { FallbackOccurrence? result = null; - var visited = new HashSet(ReferenceEqualityComparer.Instance); - TraverseSerializedGraph(root, path, visited, (node, nodePath) => + SerializedGraphTraversal.Visit(root, path, (node, nodePath) => { if (node is not IFallback fallback) { @@ -777,181 +793,6 @@ private static FallbackIdentity CreateFallbackIdentity(IFallback fallback) $"{fallback.GetType().AssemblyQualifiedName}|{fallback.Json?.ToJsonString()}"); } - private static bool TraverseSerializedGraph( - object? value, - string path, - HashSet visited, - Func visitObject) - { - if (value is null or string) - { - return false; - } - - if (value is IOptional optional) - { - return optional.HasValue - && TraverseSerializedGraph( - optional.ToObject().Value, - path, - visited, - visitObject); - } - - if (!value.GetType().IsValueType && !visited.Add(value)) - { - return false; - } - - if (value is CoreObject or IFallback) - { - if (visitObject(value, path)) - { - return true; - } - } - - if (value is CoreObject coreObject) - { - switch (coreObject) - { - case Scene scene: - for (int i = 0; i < scene.Children.Count; i++) - { - if (TraverseSerializedGraph( - scene.Children[i], - $"{path}/Elements[{i}]", - visited, - visitObject)) - { - return true; - } - } - break; - - case Element element: - for (int i = 0; i < element.Objects.Count; i++) - { - if (TraverseSerializedGraph( - element.Objects[i], - $"{path}/Objects[{i}]", - visited, - visitObject)) - { - return true; - } - } - break; - - case EngineObject engineObject: - foreach (IProperty property in engineObject.Properties) - { - if (TraverseSerializedGraph( - property.CurrentValue, - $"{path}/{property.Name}", - visited, - visitObject)) - { - return true; - } - - if (property.Animation is IKeyFrameAnimation animation) - { - int index = 0; - foreach (IKeyFrame keyFrame in animation.KeyFrames) - { - if (TraverseSerializedGraph( - keyFrame.Value, - $"{path}/Animations/{property.Name}/KeyFrames[{index}]/Value", - visited, - visitObject)) - { - return true; - } - - index++; - } - } - } - break; - } - - foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) - { - if (!property.GetMetadata(coreObject.GetType()).ShouldSerialize) - { - continue; - } - - if (TraverseSerializedGraph( - coreObject.GetValue(property), - $"{path}/{property.Name}", - visited, - visitObject)) - { - return true; - } - } - - if (coreObject is IHierarchical hierarchical) - { - int index = 0; - foreach (IHierarchical child in hierarchical.HierarchicalChildren) - { - if (TraverseSerializedGraph( - child, - $"{path}/HierarchicalChildren[{index}]", - visited, - visitObject)) - { - return true; - } - - index++; - } - } - - return false; - } - - if (value is IDictionary dictionary) - { - int index = 0; - foreach (object? item in dictionary.Values) - { - if (TraverseSerializedGraph( - item, - $"{path}[{index}]", - visited, - visitObject)) - { - return true; - } - - index++; - } - } - else if (value is IEnumerable enumerable) - { - int index = 0; - foreach (object? item in enumerable) - { - if (TraverseSerializedGraph( - item, - $"{path}[{index}]", - visited, - visitObject)) - { - return true; - } - - index++; - } - } - - return false; - } - private static string CreateFallbackHint(FallbackOccurrence occurrence) { string baseHint = "Call get_schema for the exact drawable/effect/brush/transform/pen/animation type and use the discriminator and PascalCase property names it returns. Timeline Elements use '$type': '[Beutl.ProjectSystem]:Element'. Objects require concrete EngineObject discriminators from get_schema; typed property values such as Pen, Brush, Transform, Effect, and Animation also require concrete schema-returned object shapes."; diff --git a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs index add0144911..bce05ce0d5 100644 --- a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs +++ b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs @@ -140,7 +140,7 @@ private void SetProjectPathCore(string projectPath) // directory unique to the new project (its file name); regenerating from the scene name // alone would collide with — and overwrite — the source project's .scene/.belm files when // both projects live in the same folder. - var usedDirs = new HashSet(StringComparer.FromComparison(PathComparison.ForCurrentPlatform)); + var usedDirs = new HashSet(StringComparer.FromComparison(PathBoundary.Comparison)); int index = 1; foreach (Scene scene in Project.Items.OfType()) { @@ -159,7 +159,7 @@ private void SetProjectPathCore(string projectPath) scene.Uri = new Uri(scenePath); string sceneDirectory = Path.GetDirectoryName(scenePath)!; var assignedElementPaths = new HashSet( - StringComparer.FromComparison(PathComparison.ForCurrentPlatform)); + StringComparer.FromComparison(PathBoundary.Comparison)); foreach (Element element in scene.Children) { // Keep each sidecar's relative path across Save As: a recovered element's stable @@ -174,7 +174,7 @@ private void SetProjectPathCore(string projectPath) string resolvedPath = Path.GetFullPath(Path.Combine(sceneRoot, relativePath)); if (!resolvedPath.StartsWith( sceneRoot + Path.DirectorySeparatorChar, - PathComparison.ForCurrentPlatform)) + PathBoundary.Comparison)) { resolvedPath = Path.Combine(sceneRoot, Path.GetFileName(previousUri.LocalPath)); } diff --git a/src/Beutl.AgentToolkit/Sessions/ProjectOperations.cs b/src/Beutl.AgentToolkit/Sessions/ProjectOperations.cs index 4b1c462ada..e1d8826225 100644 --- a/src/Beutl.AgentToolkit/Sessions/ProjectOperations.cs +++ b/src/Beutl.AgentToolkit/Sessions/ProjectOperations.cs @@ -41,7 +41,7 @@ public static Project CreateProject(ProjectCreateOptions options) string scenePath = ReserveUniqueScenePath( projectDirectory, projectName, - new HashSet(StringComparer.FromComparison(PathComparison.ForCurrentPlatform))); + new HashSet(StringComparer.FromComparison(PathBoundary.Comparison))); var scene = new Scene(options.Width, options.Height, projectName) { @@ -161,7 +161,7 @@ public static void Save(Project project) private static void NullDuplicateSidecarUris(Project project) { - var used = new HashSet(StringComparer.FromComparison(PathComparison.ForCurrentPlatform)); + var used = new HashSet(StringComparer.FromComparison(PathBoundary.Comparison)); foreach (Scene scene in project.Items.OfType()) { if (scene.Uri is not null && !used.Add(ResolveSidecarPath(scene.Uri))) @@ -184,21 +184,53 @@ private static string ResolveSidecarPath(Uri uri) return PathBoundary.ResolveDeepestExistingTarget(Path.GetFullPath(uri.LocalPath)); } - // A project loaded from disk can carry scene/element sidecar URIs pointing outside the project - // directory (hand-edited or malicious). StoreToUri writes each referenced sidecar to its own Uri, - // so drop any that escape the project tree and let the Ensure* helpers regenerate them inside it. private static void RehomeSidecarsOutsideProject(Project project, string projectDirectory, Scene scene) { + string? previousSceneDirectory = scene.Uri is { IsFile: true } previousSceneUri + ? Path.GetDirectoryName(previousSceneUri.LocalPath) + : null; if (scene.Uri is not null && !IsInsideDirectory(projectDirectory, scene.Uri.LocalPath)) { scene.Uri = null; + EnsureSceneUri(project, scene); } + if (scene.Uri is null) + { + return; + } + + string sceneDirectory = Path.GetDirectoryName(scene.Uri.LocalPath) + ?? throw new InvalidOperationException("Scene Uri must have a directory."); + var usedPaths = scene.Children + .Where(element => element.Uri is not null + && IsInsideDirectory(projectDirectory, element.Uri.LocalPath)) + .Select(element => ResolveSidecarPath(element.Uri!)) + .ToHashSet(StringComparer.FromComparison(PathBoundary.Comparison)); foreach (Element element in scene.Children) { if (element.Uri is not null && !IsInsideDirectory(projectDirectory, element.Uri.LocalPath)) { - element.Uri = null; + string relativePath = previousSceneDirectory is not null + ? Path.GetRelativePath(previousSceneDirectory, element.Uri.LocalPath) + : Path.GetFileName(element.Uri.LocalPath); + string candidate = Path.GetFullPath(Path.Combine(sceneDirectory, relativePath)); + if (!IsInsideDirectory(sceneDirectory, candidate)) + { + candidate = Path.Combine(sceneDirectory, Path.GetFileName(element.Uri.LocalPath)); + } + + string uniqueCandidate = candidate; + string name = Path.GetFileNameWithoutExtension(candidate); + string extension = Path.GetExtension(candidate); + for (int suffix = 2; !usedPaths.Add(ResolveSidecarPath(new Uri(uniqueCandidate))); suffix++) + { + uniqueCandidate = Path.Combine( + Path.GetDirectoryName(candidate)!, + $"{name}-{suffix}{extension}"); + } + + element.Uri = CreateFileUri(uniqueCandidate); } } } @@ -211,7 +243,7 @@ private static bool IsInsideDirectory(string directory, string candidate) string root = Path.TrimEndingDirectorySeparator( PathBoundary.ResolveDeepestExistingTarget(Path.GetFullPath(directory))); string full = PathBoundary.ResolveDeepestExistingTarget(Path.GetFullPath(candidate)); - StringComparison comparison = PathComparison.ForCurrentPlatform; + StringComparison comparison = PathBoundary.Comparison; return full.StartsWith(root + Path.DirectorySeparatorChar, comparison) || string.Equals(full, root, comparison); } @@ -293,7 +325,7 @@ private static Uri DeriveUniqueSceneUri(Project project, string projectDirectory .Select(item => Path.GetDirectoryName(item.Uri!.LocalPath)) .Where(dir => dir is not null) .Select(dir => Path.GetFullPath(dir!)) - .ToHashSet(StringComparer.FromComparison(PathComparison.ForCurrentPlatform)); + .ToHashSet(StringComparer.FromComparison(PathBoundary.Comparison)); return CreateFileUri(ReserveUniqueScenePath(projectDirectory, sceneName, used)); } diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs index d265afead0..a1c9fe20f4 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -106,9 +106,9 @@ private static DeserializationWarningCollection CollectDeserializationWarnings(P { foreach (Element element in scene.Children) { - var fallbacks = new List(); - var visited = new HashSet(ReferenceEqualityComparer.Instance); - CollectFallbacks(element, visited, fallbacks); + IFallback[] fallbacks = SerializedGraphTraversal.Enumerate(element) + .OfType() + .ToArray(); string elementFile = element.Uri is { IsFile: true } uri && scene.Uri is { IsFile: true } sceneUri @@ -141,18 +141,28 @@ private static DeserializationWarningCollection CollectDeserializationWarnings(P $"Element file '{elementFile}' contains content that could not be deserialized: {error}"); } - if (element.SuppressedStorageSource is { HasNonFallbackIncidents: true }) + if (element.SuppressedStorageSource is { HasNonFallbackIncidents: true } source) { - const string message - = "A value was replaced during load, and the original element file is preserved."; - incidents.Add(new RecoveryIncident( - scene.Id.ToString(), - scene.Name, - elementFile, - nameof(FallbackReason.DeserializationFailed), - null, - null)); - warnings.Add($"Element file '{elementFile}' could not be loaded without replacement: {message}"); + SuppressedRecoveryIncident[] recoveryIncidents + = source.RecoveryIncidents is { Length: > 0 } details + ? details + : [new SuppressedRecoveryIncident( + nameof(FallbackReason.DeserializationFailed), + null, + "A value was replaced during load, and the original element file is preserved.")]; + foreach (SuppressedRecoveryIncident recoveryIncident in recoveryIncidents) + { + incidents.Add(new RecoveryIncident( + scene.Id.ToString(), + scene.Name, + elementFile, + recoveryIncident.Reason, + recoveryIncident.TypeName, + recoveryIncident.Message)); + string message = recoveryIncident.Message ?? recoveryIncident.Reason; + warnings.Add( + $"Element file '{elementFile}' had a value replaced during load: {message}"); + } } } } @@ -164,83 +174,6 @@ private sealed record DeserializationWarningCollection( IReadOnlyList Warnings, IReadOnlyList RecoveryIncidents); - private static void CollectFallbacks( - object? value, - ISet visited, - ICollection fallbacks) - { - if (value is null or string) - return; - - if (value is IOptional optional) - { - if (optional.HasValue) - { - CollectFallbacks(optional.ToObject().Value, visited, fallbacks); - } - - return; - } - - if (!visited.Add(value)) - return; - - if (value is IFallback fallback) - { - fallbacks.Add(fallback); - return; - } - - if (value is IHierarchical hierarchical) - { - foreach (IHierarchical child in hierarchical.HierarchicalChildren) - { - CollectFallbacks(child, visited, fallbacks); - } - } - - if (value is CoreObject coreObject) - { - foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) - { - if (property.GetMetadata(coreObject.GetType()).ShouldSerialize) - { - CollectFallbacks(coreObject.GetValue(property), visited, fallbacks); - } - } - } - - if (value is EngineObject engineObject) - { - foreach (IProperty property in engineObject.Properties) - { - CollectFallbacks(property.CurrentValue, visited, fallbacks); - if (property.Animation is IKeyFrameAnimation animation) - { - foreach (IKeyFrame keyFrame in animation.KeyFrames) - { - CollectFallbacks(keyFrame.Value, visited, fallbacks); - } - } - } - } - - if (value is System.Collections.IDictionary dictionary) - { - foreach (object? item in dictionary.Values) - { - CollectFallbacks(item, visited, fallbacks); - } - } - else if (value is IEnumerable enumerable) - { - foreach (object? item in enumerable) - { - CollectFallbacks(item, visited, fallbacks); - } - } - } - [McpServerTool(Name = "create_project")] [Description("Creates and saves a new Beutl .bep project with one scene, then makes it the active editing session. In the in-app host the project opens in the Beutl editor (single open project, LiveEditor session); in the stdio host it becomes a file-backed session. Paths without an extension are saved as .bep; .beutl is reserved for project packages. The output path is restricted to BEUTL_WORKSPACE.")] public ValueTask> CreateProject( @@ -344,7 +277,7 @@ public ToolResult SaveProject( { string writePath = NormalizeProjectPath(workspace, path, nameof(path)); string currentPath = fileSession.Project.Uri?.LocalPath ?? string.Empty; - if (!string.Equals(Path.GetFullPath(currentPath), Path.GetFullPath(writePath), PathComparison.ForCurrentPlatform)) + if (!string.Equals(Path.GetFullPath(currentPath), Path.GetFullPath(writePath), PathBoundary.Comparison)) { destructiveGuard.EnsureOverwriteAllowed(writePath, confirmOverwrite); fileSession.SaveAs(writePath, skipConflictCheck: confirmOverwrite); diff --git a/src/Beutl.Core/CoreObject.cs b/src/Beutl.Core/CoreObject.cs index 0c706ff704..679b2feb10 100644 --- a/src/Beutl.Core/CoreObject.cs +++ b/src/Beutl.Core/CoreObject.cs @@ -235,7 +235,7 @@ private void SetValueCore( oldEntry is Entry entryT) { TValue? oldValue = entryT.Value; - if (HasValueReplacement(oldValue, value, forceReferenceReplacement)) + if (ValueReplacement.RequiresReplacement(oldValue, value, forceReferenceReplacement)) { entryT.Value = value; RaisePropertyChanged(property, metadata, value, oldValue); @@ -243,7 +243,7 @@ private void SetValueCore( } else { - if (HasValueReplacement(metadata.DefaultValue, value, forceReferenceReplacement)) + if (ValueReplacement.RequiresReplacement(metadata.DefaultValue, value, forceReferenceReplacement)) { entryT = new Entry { Value = value, }; Values[property.Id] = entryT; @@ -252,16 +252,6 @@ private void SetValueCore( } } - private static bool HasValueReplacement( - TValue? current, - TValue? replacement, - bool forceReferenceReplacement) - { - return forceReferenceReplacement && !typeof(TValue).IsValueType - ? !ReferenceEquals(current, replacement) - : !EqualityComparer.Default.Equals(current, replacement); - } - public void SetValue(CoreProperty property, object? value) { ArgumentNullException.ThrowIfNull(property); @@ -317,9 +307,7 @@ protected bool SetAndRaise( CorePropertyMetadata? metadata = property.GetMetadata>(GetType()); ValidateProperty(metadata, property, ref value!); - bool result = forceReferenceReplacement && !typeof(T).IsValueType - ? !ReferenceEquals(field, value) - : !EqualityComparer.Default.Equals(field, value); + bool result = ValueReplacement.RequiresReplacement(field, value, forceReferenceReplacement); if (result) { T old = field; diff --git a/src/Beutl.Core/Properties/AssemblyInfo.cs b/src/Beutl.Core/Properties/AssemblyInfo.cs index f3581d18a0..0891fc21df 100644 --- a/src/Beutl.Core/Properties/AssemblyInfo.cs +++ b/src/Beutl.Core/Properties/AssemblyInfo.cs @@ -14,3 +14,4 @@ [assembly: InternalsVisibleTo("Beutl.FFmpegWorker.Tests")] [assembly: InternalsVisibleTo("Beutl.AgentToolkit.Mcp")] [assembly: InternalsVisibleTo("Beutl.AgentToolkit")] +[assembly: InternalsVisibleTo("Beutl.AgentToolkit.Tests")] diff --git a/src/Beutl.Core/ReferenceRewriting.cs b/src/Beutl.Core/ReferenceRewriting.cs index 1da1fefcea..66a91f1954 100644 --- a/src/Beutl.Core/ReferenceRewriting.cs +++ b/src/Beutl.Core/ReferenceRewriting.cs @@ -16,14 +16,21 @@ public interface IReferenceRewriteContext /// Represents a value that can rebuild itself after its contained references are rewritten. /// /// -/// Implementations are responsible for preserving all non-reference state. The returned value must -/// have the same runtime type as the original value; otherwise the rewrite is ignored. +/// Implementations are responsible for preserving all non-reference state. The rewrite target must +/// have the same runtime type as the source. It is memoized before population so aliases and cycles +/// resolve to the same replacement instance. /// public interface IReferenceRewritable { /// - /// Returns an equivalent value whose contained references were processed by - /// . + /// Creates the target that receives rewritten references. Implementations that mutate in place + /// may return ; rebuilding implementations return a shallow target whose + /// reference-bearing members can be populated by . /// - object RewriteReferences(IReferenceRewriteContext context); + IReferenceRewritable CreateReferenceRewriteTarget(); + + /// + /// Rewrites this target's reference-bearing members through . + /// + void RewriteReferences(IReferenceRewriteContext context); } diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index 8f3a994105..ab70503253 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -321,19 +321,30 @@ private static void StoreToUriCore( throw new JsonException(); } - CopyReferencedStorageSources(suppressed, uri, authorizedRootPath); - // Rehomed (save-as): the retained bytes move verbatim so the new project copy keeps the // element. SourceUri stays unchanged so the source location remains skip-protected if a // failed multi-file save rolls Uri back afterwards. string rehomedPath = uri.LocalPath; if (File.Exists(rehomedPath)) { - RestoreReinstatedBytes(suppressed, rehomedPath); + try + { + EnsureExistingBytesMatch(rehomedPath, suppressed.RawBytes); + } + catch + { + suppressedObj.Uri = suppressed.SourceUri; + throw; + } + + suppressed.WasReinstated = false; + CopyReferencedStorageSources(suppressed, uri, authorizedRootPath); suppressedObj.Uri = uri; return; } + CopyReferencedStorageSources(suppressed, uri, authorizedRootPath); + string? rehomedDirectory = Path.GetDirectoryName(rehomedPath); if (rehomedDirectory != null) { @@ -359,7 +370,17 @@ private static void StoreToUriCore( } catch (IOException) when (File.Exists(rehomedPath)) { - RestoreReinstatedBytes(suppressed, rehomedPath); + try + { + EnsureExistingBytesMatch(rehomedPath, suppressed.RawBytes); + } + catch + { + suppressedObj.Uri = suppressed.SourceUri; + throw; + } + + suppressed.WasReinstated = false; suppressedObj.Uri = uri; return; } @@ -449,7 +470,7 @@ private static void CopyReferencedStorageSources( } string destinationRoot = Path.TrimEndingDirectorySeparator( - FilePathBoundary.ResolveDeepestExistingTarget( + PathBoundary.ResolveDeepestExistingTarget( authorizedRootPath ?? Path.GetDirectoryName(rehomedUri.LocalPath) ?? throw new JsonException("Rehomed element has no destination directory."))); @@ -465,8 +486,8 @@ private static void CopyReferencedStorageSources( } string destination = Path.GetFullPath(Path.Combine(destinationRoot, relativePath)); - string resolvedDestination = FilePathBoundary.ResolveDeepestExistingTarget(destination); - if (!FilePathBoundary.IsPathInsideRoot(destinationRoot, resolvedDestination)) + string resolvedDestination = PathBoundary.ResolveDeepestExistingTarget(destination); + if (!PathBoundary.IsPathInsideRoot(destinationRoot, resolvedDestination)) { throw new JsonException($"Retained sidecar escapes the Save As root: {relativePath}"); } @@ -476,14 +497,23 @@ private static void CopyReferencedStorageSources( foreach ((SuppressedReferencedStorageSource source, string destination) in copies) { - WriteBytesAtomicallyIfMissing(destination, source.RawBytes); + if (File.Exists(destination)) + { + EnsureExistingBytesMatch(destination, source.RawBytes); + } + } + + foreach ((SuppressedReferencedStorageSource source, string destination) in copies) + { + WriteBytesAtomicallyIfMatchingOrMissing(destination, source.RawBytes); } } - private static void WriteBytesAtomicallyIfMissing(string path, byte[] bytes) + private static void WriteBytesAtomicallyIfMatchingOrMissing(string path, byte[] bytes) { if (File.Exists(path)) { + EnsureExistingBytesMatch(path, bytes); return; } @@ -512,6 +542,7 @@ private static void WriteBytesAtomicallyIfMissing(string path, byte[] bytes) } catch (IOException) when (File.Exists(path)) { + EnsureExistingBytesMatch(path, bytes); } } finally @@ -526,6 +557,14 @@ private static void WriteBytesAtomicallyIfMissing(string path, byte[] bytes) } } + private static void EnsureExistingBytesMatch(string path, byte[] expectedBytes) + { + if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(expectedBytes)) + { + throw new IOException($"The retained sidecar destination already contains different data: '{path}'."); + } + } + private static void RestoreReinstatedBytes(SuppressedStorageSource suppressed, string path) { if (!suppressed.WasReinstated && File.Exists(path)) diff --git a/src/Beutl.Core/Serialization/DeserializationIncidents.cs b/src/Beutl.Core/Serialization/DeserializationIncidents.cs index 6a01b3121e..064d18f284 100644 --- a/src/Beutl.Core/Serialization/DeserializationIncidents.cs +++ b/src/Beutl.Core/Serialization/DeserializationIncidents.cs @@ -18,18 +18,31 @@ internal static class DeserializationIncidents internal static Capture BeginCapture() => new(t_capture); internal static void RecordFallback(IFallback? fallback = null) + { + Record(new DeserializationIncident(fallback, null, null, null)); + } + + internal static void RecordFallback( + FallbackReason reason, + string? typeName, + string? message) + { + Record(new DeserializationIncident(null, reason, typeName, message)); + } + + private static void Record(DeserializationIncident incident) { t_fallbackCount++; for (Capture? capture = t_capture; capture != null; capture = capture.Parent) { - capture.Record(fallback); + capture.Record(incident); } } internal sealed class Capture : IDisposable { private readonly int _initialCount; - private List? _fallbacks; + private List? _incidents; private bool _disposed; internal Capture(Capture? parent) @@ -43,15 +56,10 @@ internal Capture(Capture? parent) internal int Count => t_fallbackCount - _initialCount; - internal IReadOnlyList Fallbacks => _fallbacks ?? []; + internal IReadOnlyList Incidents => _incidents ?? []; - internal void Record(IFallback? fallback) - { - if (fallback != null) - { - (_fallbacks ??= []).Add(fallback); - } - } + internal void Record(DeserializationIncident incident) + => (_incidents ??= []).Add(incident); public void Dispose() { @@ -69,4 +77,10 @@ public void Dispose() _disposed = true; } } + + internal sealed record DeserializationIncident( + IFallback? Fallback, + FallbackReason? Reason, + string? TypeName, + string? Message); } diff --git a/src/Beutl.Core/Serialization/FilePathBoundary.cs b/src/Beutl.Core/Serialization/FilePathBoundary.cs index 6a54deed0d..91fae4d192 100644 --- a/src/Beutl.Core/Serialization/FilePathBoundary.cs +++ b/src/Beutl.Core/Serialization/FilePathBoundary.cs @@ -1,6 +1,6 @@ -namespace Beutl.Serialization; +namespace Beutl; -internal static class FilePathBoundary +internal static class PathBoundary { private static readonly StringComparison s_comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase @@ -51,7 +51,7 @@ public static string ResolveDeepestExistingTarget(string path) return Path.GetFullPath(resolved); } - private static string ResolveExistingPath(string path) + public static string ResolveExistingPath(string path) { string absolute = Path.GetFullPath(path); string root = Path.GetPathRoot(absolute) ?? absolute; diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs index 7a38f262f6..2f086c7904 100644 --- a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs +++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs @@ -13,7 +13,8 @@ internal sealed record SuppressedStorageSource( bool HasNonFallbackIncidents = false, JsonObject[]? UntraversedFallbacks = null, SuppressedReferencedStorageSource[]? ReferencedStorageSources = null, - string? SourceRootPath = null) + string? SourceRootPath = null, + SuppressedRecoveryIncident[]? RecoveryIncidents = null) { /// /// True when this suppression record was put back by undoing an in-process repair. Only a @@ -28,3 +29,8 @@ internal sealed record SuppressedReferencedStorageSource( byte[] RawBytes, string RelativePath, string ElementRelativePath); + +internal sealed record SuppressedRecoveryIncident( + string Reason, + string? TypeName, + string? Message); diff --git a/src/Beutl.Core/ValueReplacement.cs b/src/Beutl.Core/ValueReplacement.cs new file mode 100644 index 0000000000..3f49ffe1a7 --- /dev/null +++ b/src/Beutl.Core/ValueReplacement.cs @@ -0,0 +1,11 @@ +namespace Beutl; + +internal static class ValueReplacement +{ + public static bool RequiresReplacement(T current, T candidate, bool replaceEquivalent) + { + return replaceEquivalent && !typeof(T).IsValueType + ? !ReferenceEquals(current, candidate) + : !EqualityComparer.Default.Equals(current, candidate); + } +} diff --git a/src/Beutl.Engine/Animation/IKeyFrame.cs b/src/Beutl.Engine/Animation/IKeyFrame.cs index d182a5f572..5151f5cfb1 100644 --- a/src/Beutl.Engine/Animation/IKeyFrame.cs +++ b/src/Beutl.Engine/Animation/IKeyFrame.cs @@ -17,12 +17,13 @@ public interface IKeyFrame : ICoreObject, INotifyEdited, IHierarchical object? Value { get; set; } + /// + /// Installs , replacing distinct reference-type values even when they + /// compare equal while preserving the normal validation and notification semantics. + /// + void ReplaceValue(object? value); + Easing Easing { get; set; } //void SetDuration(TimeSpan timeSpan); } - -internal interface IKeyFrameValueReplacer -{ - void ReplaceValue(object? value); -} diff --git a/src/Beutl.Engine/Animation/KeyFrame.cs b/src/Beutl.Engine/Animation/KeyFrame.cs index afaf1f167a..535df90070 100644 --- a/src/Beutl.Engine/Animation/KeyFrame.cs +++ b/src/Beutl.Engine/Animation/KeyFrame.cs @@ -62,20 +62,32 @@ public override void Deserialize(ICoreSerializationContext context) { if (context.Contains(nameof(Easing))) { - UseFallbackEasing(); + UseFallbackEasing( + FallbackReason.DeserializationFailed, + null, + "The easing value is null."); } } else if (easingNode is JsonValue easingTypeValue && easingTypeValue.TryGetValue(out string? easingType)) { Type? type = TypeFormat.ToType(easingType); - if (type is null - || !type.IsAssignableTo(typeof(Easing)) + if (type is null) + { + UseFallbackEasing( + FallbackReason.TypeNotFound, + easingType, + $"The easing type '{easingType}' could not be resolved."); + } + else if (!type.IsAssignableTo(typeof(Easing)) || type.IsAbstract || type.ContainsGenericParameters || type.GetConstructor(Type.EmptyTypes) is null) { - UseFallbackEasing(); + UseFallbackEasing( + FallbackReason.DeserializationFailed, + easingType, + $"The easing type '{easingType}' cannot be instantiated as an Easing."); } else { @@ -87,7 +99,10 @@ public override void Deserialize(ICoreSerializationContext context) } else { - UseFallbackEasing(); + UseFallbackEasing( + FallbackReason.DeserializationFailed, + easingType, + $"The easing type '{easingType}' did not create an Easing instance."); } } catch (Exception ex) when (ex is MissingMethodException @@ -106,14 +121,16 @@ or TypeInitializationException throw; } - UseFallbackEasing(); + UseFallbackEasing( + FallbackReason.DeserializationFailed, + easingType, + $"{ex.GetType().Name}: {ex.Message}"); } } } else if (easingNode is JsonObject easingObject) { - if (easingObject.Count == 4 - && easingObject["X1"] is JsonValue x1Value + if (easingObject["X1"] is JsonValue x1Value && easingObject["Y1"] is JsonValue y1Value && easingObject["X2"] is JsonValue x2Value && easingObject["Y2"] is JsonValue y2Value @@ -126,18 +143,27 @@ or TypeInitializationException } else { - UseFallbackEasing(); + UseFallbackEasing( + FallbackReason.DeserializationFailed, + null, + "The spline easing object does not contain four valid control-point values."); } } else { - UseFallbackEasing(); + UseFallbackEasing( + FallbackReason.DeserializationFailed, + null, + "The easing value has an unsupported JSON representation."); } } - private void UseFallbackEasing() + private void UseFallbackEasing( + FallbackReason reason, + string? typeName, + string message) { - DeserializationIncidents.RecordFallback(); + DeserializationIncidents.RecordFallback(reason, typeName, message); _lossyFallbackEasing = new LinearEasing(); Easing = _lossyFallbackEasing; } diff --git a/src/Beutl.Engine/Animation/KeyFrame{T}.cs b/src/Beutl.Engine/Animation/KeyFrame{T}.cs index 907000cc16..8f654de76a 100644 --- a/src/Beutl.Engine/Animation/KeyFrame{T}.cs +++ b/src/Beutl.Engine/Animation/KeyFrame{T}.cs @@ -5,7 +5,7 @@ namespace Beutl.Animation; -public sealed class KeyFrame : KeyFrame, IKeyFrame, IKeyFrameValueReplacer +public sealed class KeyFrame : KeyFrame, IKeyFrame { public static readonly CoreProperty ValueProperty; internal static readonly Animator s_animator; @@ -55,7 +55,7 @@ private void SetValue(T? value, bool replaceEquivalent) } } - void IKeyFrameValueReplacer.ReplaceValue(object? value) + void IKeyFrame.ReplaceValue(object? value) { if (value is T typed) { diff --git a/src/Beutl.Engine/Engine/AnimatableProperty.cs b/src/Beutl.Engine/Engine/AnimatableProperty.cs index 3a8d512e29..56c77df6c3 100644 --- a/src/Beutl.Engine/Engine/AnimatableProperty.cs +++ b/src/Beutl.Engine/Engine/AnimatableProperty.cs @@ -9,7 +9,7 @@ namespace Beutl.Engine; -public class AnimatableProperty : IProperty, IPropertyValueReplacer +public class AnimatableProperty : IProperty { private T _currentValue; private IAnimation? _animation; @@ -48,9 +48,10 @@ public T CurrentValue private void SetCurrentValue(T value, bool replaceEquivalent) { var validatedValue = ValidateAndCoerce(value); - bool hasReplacement = replaceEquivalent && !typeof(T).IsValueType - ? !ReferenceEquals(_currentValue, validatedValue) - : !EqualityComparer.Default.Equals(_currentValue, validatedValue); + bool hasReplacement = ValueReplacement.RequiresReplacement( + _currentValue, + validatedValue, + replaceEquivalent); if (hasReplacement) { var oldValue = _currentValue; @@ -75,21 +76,8 @@ private void SetCurrentValue(T value, bool replaceEquivalent) } } - void IPropertyValueReplacer.ReplaceCurrentValue(object? value) - { - if (value is T typed) - { - SetCurrentValue(typed, replaceEquivalent: true); - } - else if (value is null && !typeof(T).IsValueType) - { - SetCurrentValue(default!, replaceEquivalent: true); - } - else - { - throw new InvalidCastException(); - } - } + public void ReplaceCurrentValue(T value) + => SetCurrentValue(value, replaceEquivalent: true); public IAnimation? Animation { diff --git a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs index 8bea60cddb..e68b59a16c 100644 --- a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs +++ b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs @@ -13,8 +13,7 @@ public interface IReferenceExpression : IExpression /// /// Returns an equivalent expression targeting , preserving the /// concrete implementation and its property path, or when the - /// implementation cannot preserve all of its state while rebinding. Implementations that - /// support rebinding must override this method explicitly. + /// implementation cannot preserve all of its state while rebinding. /// - IReferenceExpression? Rebind(Guid objectId) => null; + IReferenceExpression? Rebind(Guid objectId); } diff --git a/src/Beutl.Engine/Engine/IProperty.cs b/src/Beutl.Engine/Engine/IProperty.cs index f2edd1694e..65214ad8ed 100644 --- a/src/Beutl.Engine/Engine/IProperty.cs +++ b/src/Beutl.Engine/Engine/IProperty.cs @@ -24,6 +24,12 @@ public interface IProperty : INotifyEdited object? CurrentValue { get; set; } + /// + /// Validates and installs , replacing distinct reference-type values + /// even when they compare equal while preserving the normal notification semantics. + /// + void ReplaceCurrentValue(object? value); + string Name { get; } Type ValueType { get; } @@ -61,24 +67,14 @@ public interface IProperty : INotifyEdited JsonNode? SerializeExpression(); } -/// -/// Replaces a property's current value when a distinct reference compares equal to the current one. -/// -public interface IPropertyValueReplacer -{ - /// - /// Validates and installs using reference identity for reference types - /// and normal equality for value types, while preserving the property's notification semantics. - /// - void ReplaceCurrentValue(object? value); -} - public interface IProperty : IProperty { new T DefaultValue { get; } new T CurrentValue { get; set; } + void ReplaceCurrentValue(T value); + new IAnimation? Animation { get; set; } new IExpression? Expression { get; set; } @@ -117,6 +113,22 @@ public interface IProperty : IProperty } } + void IProperty.ReplaceCurrentValue(object? value) + { + if (value is T typed) + { + ReplaceCurrentValue(typed); + } + else if (value is null && !typeof(T).IsValueType) + { + ReplaceCurrentValue(default!); + } + else + { + throw new InvalidCastException(); + } + } + T GetValue(CompositionContext context); event EventHandler>? ValueChanged; diff --git a/src/Beutl.Engine/Engine/ListProperty.cs b/src/Beutl.Engine/Engine/ListProperty.cs index a87f82f69b..f1a5033ae9 100644 --- a/src/Beutl.Engine/Engine/ListProperty.cs +++ b/src/Beutl.Engine/Engine/ListProperty.cs @@ -66,6 +66,9 @@ public ICoreList CurrentValue set => _items.Replace(value); } + public void ReplaceCurrentValue(ICoreList value) + => _items.Replace(value); + public IAnimation>? Animation { get => null; diff --git a/src/Beutl.Engine/Engine/SimpleProperty.cs b/src/Beutl.Engine/Engine/SimpleProperty.cs index cd2d6756df..9b1bcf351a 100644 --- a/src/Beutl.Engine/Engine/SimpleProperty.cs +++ b/src/Beutl.Engine/Engine/SimpleProperty.cs @@ -10,7 +10,7 @@ namespace Beutl.Engine; public class SimpleProperty(T defaultValue, IValidator? validator = null) - : IProperty, IPropertyValueReplacer + : IProperty { private IValidator? _validator = validator; private T _currentValue = defaultValue; @@ -39,9 +39,10 @@ public T CurrentValue private void SetCurrentValue(T value, bool replaceEquivalent) { var validatedValue = ValidateAndCoerce(value); - bool hasReplacement = replaceEquivalent && !typeof(T).IsValueType - ? !ReferenceEquals(_currentValue, validatedValue) - : !EqualityComparer.Default.Equals(_currentValue, validatedValue); + bool hasReplacement = ValueReplacement.RequiresReplacement( + _currentValue, + validatedValue, + replaceEquivalent); if (hasReplacement) { var oldValue = _currentValue; @@ -66,21 +67,8 @@ private void SetCurrentValue(T value, bool replaceEquivalent) } } - void IPropertyValueReplacer.ReplaceCurrentValue(object? value) - { - if (value is T typed) - { - SetCurrentValue(typed, replaceEquivalent: true); - } - else if (value is null && !typeof(T).IsValueType) - { - SetCurrentValue(default!, replaceEquivalent: true); - } - else - { - throw new InvalidCastException(); - } - } + public void ReplaceCurrentValue(T value) + => SetCurrentValue(value, replaceEquivalent: true); public IAnimation? Animation { diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 7999cc2b4f..1ec1d575ae 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -6,6 +6,7 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Reflection; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -80,12 +81,11 @@ public class Scene : ProjectItem, INotifyEdited private readonly HierarchicalList _markers; private readonly Dictionary _recoveredDescendantIds = new(StringComparer.Ordinal); private readonly Dictionary _recoveredDescendantIdentities = new(StringComparer.Ordinal); - private readonly Dictionary _recoveredDescendantRemaps - = new(ReferenceEqualityComparer.Instance); + private readonly ConditionalWeakTable _recoveredDescendantRemaps = new(); private readonly Dictionary _recoveredElementIds = new(StringComparer.Ordinal); private readonly Dictionary _pendingRecoveredElementIdMigrations = []; private readonly Dictionary _pendingRecoveredDescendantIdMigrations = []; - private readonly System.Collections.Concurrent.ConcurrentDictionary _idlessRecoveredDescendants = new(); + private readonly ConditionalWeakTable _idlessRecoveredDescendants = new(); private TimeSpan _start = TimeSpan.FromMinutes(0); private TimeSpan _duration = TimeSpan.FromMinutes(5); private PixelSize _frameSize; @@ -570,11 +570,13 @@ static void Process(JsonObject jobject, string jsonName, List list) context.SetValue("Height", FrameSize.Height); context.SetValue("Groups", Groups.Select(ids => string.Join(':', ids)).ToArray()); context.SetValue(nameof(Markers), Markers); - RebuildRecoveredElementIds(); - if (_recoveredElementIds.Count > 0) + RecoveredSerializationState recoveredState = BuildRecoveredSerializationState(); + if (recoveredState.ElementIds.Count > 0) { var recoveredElementIds = new JsonObject(); - foreach ((string path, Guid id) in _recoveredElementIds.OrderBy(static item => item.Key, StringComparer.Ordinal)) + foreach ((string path, Guid id) in recoveredState.ElementIds.OrderBy( + static item => item.Key, + StringComparer.Ordinal)) { recoveredElementIds[path] = id.ToString(); } @@ -582,10 +584,10 @@ static void Process(JsonObject jobject, string jsonName, List list) context.SetValue(RecoveredElementIdsKey, recoveredElementIds); } - if (_recoveredDescendantIds.Count > 0) + if (recoveredState.DescendantIds.Count > 0) { var recoveredDescendantIds = new JsonObject(); - foreach ((string key, Guid id) in _recoveredDescendantIds.OrderBy( + foreach ((string key, Guid id) in recoveredState.DescendantIds.OrderBy( static item => item.Key, StringComparer.Ordinal)) { @@ -595,10 +597,10 @@ static void Process(JsonObject jobject, string jsonName, List list) context.SetValue(RecoveredDescendantIdsKey, recoveredDescendantIds); } - if (_recoveredDescendantIdentities.Count > 0) + if (recoveredState.DescendantIdentities.Count > 0) { var recoveredDescendantIdentities = new JsonObject(); - foreach ((string key, Guid id) in _recoveredDescendantIdentities.OrderBy( + foreach ((string key, Guid id) in recoveredState.DescendantIdentities.OrderBy( static item => item.Key, StringComparer.Ordinal)) { @@ -1008,7 +1010,7 @@ var pendingDescendantRemaps continue; } - bool idless = _idlessRecoveredDescendants.ContainsKey(descendant); + bool idless = _idlessRecoveredDescendants.TryGetValue(descendant, out _); Guid originalId = idless ? Guid.Empty : descendant.Id; int occurrence = idless ? idlessOccurrence++ @@ -1062,10 +1064,6 @@ var pendingDescendantRemaps candidate, remap.Occurrence); - if (descendant is IFallback fallback) - { - EnsureFallbackProjection(fallback); - } } } @@ -1152,7 +1150,10 @@ private void RecordRecoveredDescendantRemap( int occurrence) { _recoveredDescendantIds[remapKey] = assignedId; - _recoveredDescendantRemaps[descendant] = (originalId, assignedId, occurrence); + _recoveredDescendantRemaps.Remove(descendant); + _recoveredDescendantRemaps.Add( + descendant, + new RecoveredDescendantRemap(originalId, assignedId, occurrence)); if (originalId != Guid.Empty) { _pendingRecoveredDescendantIdMigrations.TryAdd(originalId, assignedId); @@ -1178,9 +1179,17 @@ private Element RestoreElementOrFallback(Uri uri) var traversedFallbacks = new HashSet( fallbacks, ReferenceEqualityComparer.Instance); - JsonObject[] untraversedFallbacks = incidentCapture.Fallbacks - .Where(fallback => !traversedFallbacks.Contains(fallback) && fallback.Json != null) - .Select(fallback => fallback.Json!.DeepClone().AsObject()) + DeserializationIncidents.DeserializationIncident[] untraversedIncidents + = incidentCapture.Incidents + .Where(incident => incident.Fallback is null + || !traversedFallbacks.Contains(incident.Fallback)) + .ToArray(); + JsonObject[] untraversedFallbacks = untraversedIncidents + .Where(static incident => incident.Fallback?.Json != null) + .Select(static incident => incident.Fallback!.Json!.DeepClone().AsObject()) + .ToArray(); + SuppressedRecoveryIncident[] recoveryIncidents = untraversedIncidents + .Select(CreateSuppressedRecoveryIncident) .ToArray(); foreach (IFallback fallback in fallbacks) { @@ -1192,7 +1201,9 @@ private Element RestoreElementOrFallback(Uri uri) } else { - _idlessRecoveredDescendants.TryAdd(fallbackObject, 0); + _idlessRecoveredDescendants.GetValue( + fallbackObject, + static _ => new IdlessRecoveredDescendant()); } } @@ -1203,16 +1214,15 @@ private Element RestoreElementOrFallback(Uri uri) element, File.ReadAllBytes(uri.LocalPath), uri, - incidentCount > fallbacks.Length, - untraversedFallbacks); + recoveryIncidents.Length > 0, + untraversedFallbacks, + recoveryIncidents); } return element; } - // Any non-filesystem failure is a content problem the recovery path must absorb — value - // converters throw freely (e.g. FormatException from Color.Parse); filesystem failures - // still propagate so a genuinely unreadable project keeps failing loudly. - catch (Exception ex) when (!ExceptionHelpers.ContainsFileSystemFailure(ex)) + catch (Exception ex) when (!ExceptionHelpers.ContainsFatalFailure(ex) + && !ExceptionHelpers.ContainsNonRecoverableFileSystemFailure(ex)) { // Raw bytes, not text: the sidecar must survive rehoming byte-identically even when it // holds a BOM, another encoding, or undecodable bytes. The lossy decode is only scanned @@ -1243,7 +1253,9 @@ private Element RestoreElementOrFallback(Uri uri) }; fallback.Json = CreateFallbackProjection(fallback, topLevelTypeName); element.AddObject(fallback); - _idlessRecoveredDescendants.TryAdd(fallback, 0); + _idlessRecoveredDescendants.GetValue( + fallback, + static _ => new IdlessRecoveredDescendant()); MarkRecoveredElement(element, rawBytes, uri); return element; } @@ -1260,12 +1272,15 @@ private static bool TryGetSerializedId(JsonObject? json, out Guid id) && id != Guid.Empty; } + private sealed class IdlessRecoveredDescendant; + private void MarkRecoveredElement( Element element, byte[] rawBytes, Uri uri, bool hasNonFallbackIncidents = false, - JsonObject[]? untraversedFallbacks = null) + JsonObject[]? untraversedFallbacks = null, + SuppressedRecoveryIncident[]? recoveryIncidents = null) { string sourceRootPath = Path.GetDirectoryName(Uri?.LocalPath ?? uri.LocalPath) ?? throw new JsonException("Recovered element has no source directory."); @@ -1275,7 +1290,26 @@ private void MarkRecoveredElement( hasNonFallbackIncidents, untraversedFallbacks, CollectReferencedStorageSources(element, uri, sourceRootPath), - sourceRootPath); + sourceRootPath, + recoveryIncidents); + } + + private static SuppressedRecoveryIncident CreateSuppressedRecoveryIncident( + DeserializationIncidents.DeserializationIncident incident) + { + if (incident.Fallback is { } fallback) + { + fallback.TryGetTypeName(out string? typeName); + return new SuppressedRecoveryIncident( + fallback.Reason.ToString(), + typeName, + fallback.ErrorMessage); + } + + return new SuppressedRecoveryIncident( + incident.Reason?.ToString() ?? nameof(FallbackReason.DeserializationFailed), + incident.TypeName, + incident.Message); } private static SuppressedReferencedStorageSource[]? CollectReferencedStorageSources( @@ -1285,29 +1319,29 @@ private void MarkRecoveredElement( { string sourceRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceRootPath)); string resolvedSourceRoot = Path.TrimEndingDirectorySeparator( - FilePathBoundary.ResolveDeepestExistingTarget(sourceRoot)); + PathBoundary.ResolveDeepestExistingTarget(sourceRoot)); string elementPath = Path.GetFullPath(elementUri.LocalPath); - string resolvedElementPath = FilePathBoundary.ResolveDeepestExistingTarget(elementPath); - if (!FilePathBoundary.IsPathInsideRoot(resolvedSourceRoot, resolvedElementPath)) + string resolvedElementPath = PathBoundary.ResolveDeepestExistingTarget(elementPath); + if (!PathBoundary.IsPathInsideRoot(resolvedSourceRoot, resolvedElementPath)) { return null; } string elementDirectory = Path.GetDirectoryName(elementPath) ?? throw new JsonException("Recovered element has no source directory."); - var seenPaths = new HashSet(FilePathBoundary.Comparer); + var seenPaths = new HashSet(PathBoundary.Comparer); var result = new List(); foreach (string sourcePath in EnumerateSerializedGraphObjects(element) .OfType() .Where(static coreObject => coreObject.Uri is { IsFile: true }) .Select(static coreObject => Path.GetFullPath(coreObject.Uri!.LocalPath))) { - string resolvedSourcePath = FilePathBoundary.ResolveDeepestExistingTarget(sourcePath); + string resolvedSourcePath = PathBoundary.ResolveDeepestExistingTarget(sourcePath); if (string.Equals( resolvedSourcePath, resolvedElementPath, - FilePathBoundary.Comparison) - || !FilePathBoundary.IsPathInsideRoot(resolvedSourceRoot, resolvedSourcePath) + PathBoundary.Comparison) + || !PathBoundary.IsPathInsideRoot(resolvedSourceRoot, resolvedSourcePath) || !File.Exists(sourcePath) || !seenPaths.Add(resolvedSourcePath)) { @@ -1337,6 +1371,53 @@ private void MarkRecoveredElement( return source; } + internal static SuppressedStorageSource? TryResumeElementPersistence( + Element element, + object? removedValue) + { + if (element.SuppressedStorageSource is not { } source + || !MayContainRemovedRecoveryBlocker(element, removedValue, source)) + { + return null; + } + + return TryResumeElementPersistence(element); + } + + private static bool MayContainRemovedRecoveryBlocker( + Element element, + object? removedValue, + SuppressedStorageSource source) + { + if (removedValue is null) + { + return false; + } + + if (SerializedGraphTraversal.Enumerate(removedValue).Any(static value => + value is IFallback or KeyFrame { HasLossyEasing: true })) + { + return true; + } + + if (source.UntraversedFallbacks is not { Length: > 0 } snapshots) + { + return false; + } + + try + { + JsonNode serializedValue = CoreSerializer.SerializeToJsonNode( + removedValue, + new CoreSerializerOptions { BaseUri = element.Uri }); + return snapshots.Any(snapshot => ContainsEquivalentJsonNode(serializedValue, snapshot)); + } + catch (Exception ex) when (!ExceptionHelpers.ContainsFatalFailure(ex)) + { + return true; + } + } + private static bool HasUnresolvedUntraversedFallback( Element element, SuppressedStorageSource source) @@ -1389,7 +1470,13 @@ private void MigrateRecoveredElementReferences() return; } - var rewriteState = new RecoveredReferenceRewriteState(); + var referenceTargets = new Dictionary(); + foreach (CoreObject candidate in EnumerateSerializedGraphObjects(Children).OfType()) + { + referenceTargets.TryAdd(candidate.Id, candidate); + } + + var rewriteState = new RecoveredReferenceRewriteState(referenceTargets); foreach (CoreObject coreObject in EnumerateSerializedGraphObjects(this).OfType()) { foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) @@ -1419,14 +1506,7 @@ private void MigrateRecoveredElementReferences() object? migratedValue = MigrateRecoveredReferenceValue(currentValue, rewriteState); if (HasReferenceRewrite(currentValue, migratedValue)) { - if (property is IPropertyValueReplacer replacer) - { - replacer.ReplaceCurrentValue(migratedValue); - } - else - { - property.CurrentValue = migratedValue; - } + property.ReplaceCurrentValue(migratedValue); } if (property.Expression is IReferenceExpression referenceExpression @@ -1446,14 +1526,7 @@ private void MigrateRecoveredElementReferences() rewriteState); if (HasReferenceRewrite(keyFrameValue, migratedKeyFrameValue)) { - if (keyFrame is IKeyFrameValueReplacer replacer) - { - replacer.ReplaceValue(migratedKeyFrameValue); - } - else - { - keyFrame.Value = migratedKeyFrameValue; - } + keyFrame.ReplaceValue(migratedKeyFrameValue); } } } @@ -1467,12 +1540,13 @@ private bool TryGetMigratedId(Guid originalId, out Guid migratedId) || _pendingRecoveredDescendantIdMigrations.TryGetValue(originalId, out migratedId); } - private object ResolveMigratedReference(IReference reference, Guid migratedId) + private static object ResolveMigratedReference( + IReference reference, + Guid migratedId, + RecoveredReferenceRewriteState state) { - CoreObject? target = EnumerateSerializedGraphObjects(Children) - .OfType() - .FirstOrDefault(candidate => candidate.Id == migratedId); - if (target is not null && reference.ObjectType.IsInstanceOfType(target)) + if (state.ReferenceTargets.TryGetValue(migratedId, out CoreObject? target) + && reference.ObjectType.IsInstanceOfType(target)) { return reference.Resolved(target); } @@ -1487,11 +1561,11 @@ private object ResolveMigratedReference(IReference reference, Guid migratedId) if (value is IReference reference) { object rewritten = TryGetMigratedId(reference.Id, out Guid migratedId) - ? ResolveMigratedReference(reference, migratedId) + ? ResolveMigratedReference(reference, migratedId, state) : value; if (HasReferenceRewrite(value, rewritten)) { - state.RewriteCount++; + state.RecordRewrite(); } return rewritten; @@ -1527,9 +1601,16 @@ or ArgumentException bool trackReference = !value.GetType().IsValueType; if (trackReference) { - if (state.Memo.TryGetValue(value, out object? cached)) + if (state.Memo.TryGetValue(value, out RecoveredReferenceRewriteEntry? cached)) { - return cached; + if (state.ActiveRewritables.TryPeek(out RecoveredReferenceRewriteEntry? parent)) + { + parent.Dependencies.Add(cached); + } + + return cached.ShouldUseTargetDuringTraversal() + ? cached.Target + : cached.Source; } if (!state.Active.Add(value)) @@ -1543,44 +1624,95 @@ or ArgumentException object? rewrittenValue = value; if (value is IReferenceRewritable rewritable) { - int rewriteCount = state.RewriteCount; - object rewritten = rewritable.RewriteReferences( - new RecoveredReferenceRewriteContext(this, state)); - if (state.RewriteCount != rewriteCount - && rewritten is not null - && rewritten.GetType() == value.GetType()) + IReferenceRewritable target = rewritable.CreateReferenceRewriteTarget(); + if (target is not null && target.GetType() == value.GetType()) { - rewrittenValue = rewritten; + var entry = new RecoveredReferenceRewriteEntry(value, target); + if (state.ActiveRewritables.TryPeek(out RecoveredReferenceRewriteEntry? parent)) + { + parent.Dependencies.Add(entry); + } + + state.Memo[value] = entry; + state.ActiveRewritables.Push(entry); + try + { + target.RewriteReferences(new RecoveredReferenceRewriteContext(this, state)); + } + finally + { + state.ActiveRewritables.Pop(); + } + + entry.Complete = true; + if (entry.ShouldUseTargetDuringTraversal()) + { + rewrittenValue = target; + } } } else if (value is IDictionary dictionary) { + int rewriteCount = state.RewriteCount; foreach (object key in dictionary.Keys.Cast().ToArray()) { object? item = dictionary[key]; object? migratedItem = MigrateRecoveredReferenceValue(item, state); if (HasReferenceRewrite(item, migratedItem)) { + if (dictionary.IsReadOnly) + { + state.RewriteCount = rewriteCount; + return value; + } + dictionary[key] = migratedItem; } } } else if (value is IList list) { + int rewriteCount = state.RewriteCount; + object?[] rewrittenItems = new object?[list.Count]; + bool hasRewrittenItem = false; for (int i = 0; i < list.Count; i++) { object? item = list[i]; object? migratedItem = MigrateRecoveredReferenceValue(item, state); - if (HasReferenceRewrite(item, migratedItem)) + rewrittenItems[i] = migratedItem; + hasRewrittenItem |= HasReferenceRewrite(item, migratedItem); + } + + if (hasRewrittenItem) + { + if (!list.IsReadOnly) + { + for (int i = 0; i < list.Count; i++) + { + list[i] = rewrittenItems[i]; + } + } + else if (TryRebuildReadOnlyList(list, rewrittenItems) is { } rebuilt) + { + rewrittenValue = rebuilt; + } + else { - list[i] = migratedItem; + state.RewriteCount = rewriteCount; } } } if (trackReference) { - state.Memo[value] = rewrittenValue; + if (!state.Memo.ContainsKey(value)) + { + state.Memo[value] = new RecoveredReferenceRewriteEntry(value, rewrittenValue) + { + Complete = true, + DirectChanged = HasReferenceRewrite(value, rewrittenValue), + }; + } } return rewrittenValue; @@ -1594,6 +1726,45 @@ or ArgumentException } } + private static object? TryRebuildReadOnlyList(IList source, object?[] items) + { + Type sourceType = source.GetType(); + Type? elementType = sourceType.GetInterfaces() + .Where(static type => type.IsGenericType + && type.GetGenericTypeDefinition() == typeof(IList<>)) + .Select(static type => type.GetGenericArguments()[0]) + .FirstOrDefault(); + if (elementType is null) + { + return null; + } + + Array array = Array.CreateInstance(elementType, items.Length); + try + { + for (int i = 0; i < items.Length; i++) + { + array.SetValue(items[i], i); + } + + foreach (ConstructorInfo constructor in sourceType.GetConstructors()) + { + ParameterInfo[] parameters = constructor.GetParameters(); + if (parameters.Length == 1 && parameters[0].ParameterType.IsInstanceOfType(array)) + { + return constructor.Invoke([array]); + } + } + } + catch (Exception ex) when (ex is ArgumentException + or TargetInvocationException + or MemberAccessException) + { + } + + return null; + } + private static bool HasReferenceRewrite(object? current, object? rewritten) { if (ReferenceEquals(current, rewritten)) @@ -1622,100 +1793,65 @@ public T Rewrite(T value) } } - private sealed class RecoveredReferenceRewriteState + private sealed class RecoveredReferenceRewriteState( + IReadOnlyDictionary referenceTargets) { + public IReadOnlyDictionary ReferenceTargets { get; } = referenceTargets; + public HashSet Active { get; } = new(ReferenceEqualityComparer.Instance); - public Dictionary Memo { get; } = new(ReferenceEqualityComparer.Instance); + public Dictionary Memo { get; } + = new(ReferenceEqualityComparer.Instance); + + public Stack ActiveRewritables { get; } = new(); public int RewriteCount { get; set; } - } - private static IEnumerable EnumerateSerializedGraphObjects(object root) - { - var objects = new List(); - var visited = new HashSet(ReferenceEqualityComparer.Instance); - CollectSerializedGraphObjects(root, visited, objects); - return objects; + public void RecordRewrite() + { + RewriteCount++; + if (ActiveRewritables.TryPeek(out RecoveredReferenceRewriteEntry? entry)) + { + entry.DirectChanged = true; + } + } } - private static void CollectSerializedGraphObjects( - object? value, - ISet visited, - ICollection objects) + private sealed class RecoveredReferenceRewriteEntry(object source, object? target) { - if (value is null or string - || (!value.GetType().IsValueType && !visited.Add(value))) - { - return; - } + public object Source { get; } = source; - if (value is IOptional optional) - { - if (optional.HasValue) - { - CollectSerializedGraphObjects(optional.ToObject().Value, visited, objects); - } + public object? Target { get; } = target; - return; - } + public HashSet Dependencies { get; } + = new(ReferenceEqualityComparer.Instance); - if (value is CoreObject or IFallback) - { - objects.Add(value); - } + public bool DirectChanged { get; set; } - if (value is IHierarchical hierarchical) - { - foreach (IHierarchical child in hierarchical.HierarchicalChildren) - { - CollectSerializedGraphObjects(child, visited, objects); - } - } + public bool Complete { get; set; } - if (value is EngineObject engineObject) + public bool ShouldUseTargetDuringTraversal() { - foreach (IProperty property in engineObject.Properties) - { - CollectSerializedGraphObjects(property.CurrentValue, visited, objects); - if (property.Animation is IKeyFrameAnimation animation) - { - foreach (IKeyFrame keyFrame in animation.KeyFrames) - { - CollectSerializedGraphObjects(keyFrame, visited, objects); - CollectSerializedGraphObjects(keyFrame.Value, visited, objects); - } - } - } + return !Complete + || Dependencies.Any(static dependency => !dependency.Complete) + || IsChanged(new HashSet(ReferenceEqualityComparer.Instance)); } - if (value is CoreObject coreObject) + private bool IsChanged(ISet visited) { - foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) + if (DirectChanged) { - if (property.GetMetadata(coreObject.GetType()).ShouldSerialize) - { - CollectSerializedGraphObjects(coreObject.GetValue(property), visited, objects); - } + return true; } - } - if (value is System.Collections.IDictionary dictionary) - { - foreach (object? item in dictionary.Values) - { - CollectSerializedGraphObjects(item, visited, objects); - } - } - else if (value is IEnumerable enumerable) - { - foreach (object? item in enumerable) - { - CollectSerializedGraphObjects(item, visited, objects); - } + return visited.Add(this) + && Dependencies.Any(dependency => dependency.IsChanged(visited)); } } + private static IEnumerable EnumerateSerializedGraphObjects(object root) + => SerializedGraphTraversal.Enumerate(root); + private static IEnumerable<(CoreObject Object, SerializedGraphPath Path)> EnumerateSerializedGraphDescendantPaths( Element element) { @@ -2139,70 +2275,80 @@ private static bool TryGetTopLevelStringProperty( return false; } - private void RebuildRecoveredElementIds() + private RecoveredSerializationState BuildRecoveredSerializationState() { if (Uri is null) { - return; + return new RecoveredSerializationState( + new Dictionary(_recoveredElementIds, StringComparer.Ordinal), + new Dictionary(_recoveredDescendantIds, StringComparer.Ordinal), + new Dictionary(_recoveredDescendantIdentities, StringComparer.Ordinal)); } var recoveredChildren = Children.Where( static child => child.SuppressedStorageSource is not null) .ToArray(); - if (recoveredChildren.Length == 0 - && _recoveredElementIds.Count == 0 - && _recoveredDescendantIds.Count == 0 - && _recoveredDescendantIdentities.Count == 0 - && _recoveredDescendantRemaps.Count == 0) + if (recoveredChildren.Length == 0) { - return; + return RecoveredSerializationState.Empty; } string sceneDirectory = Path.GetDirectoryName(Uri.LocalPath)!; - var descendantRemaps = new Dictionary( - _recoveredDescendantRemaps, - ReferenceEqualityComparer.Instance); - _recoveredDescendantIds.Clear(); - _recoveredDescendantIdentities.Clear(); - _recoveredDescendantRemaps.Clear(); - _recoveredElementIds.Clear(); + var elementIds = new Dictionary(StringComparer.Ordinal); + var descendantIds = new Dictionary(StringComparer.Ordinal); + var descendantIdentities = new Dictionary(StringComparer.Ordinal); foreach (Element child in recoveredChildren) { string relativePath = NormalizeRelativePath( Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)); - _recoveredElementIds[relativePath] = child.Id; + elementIds[relativePath] = child.Id; foreach ((CoreObject descendant, SerializedGraphPath graphPath) in EnumerateSerializedGraphDescendantPaths(child)) { if (descendant is IFallback) { string identityKey = CreateRecoveredDescendantIdentityKey(relativePath, graphPath.Stable); - _recoveredDescendantIdentities[identityKey] = descendant.Id; + descendantIdentities[identityKey] = descendant.Id; if (graphPath.Positional != graphPath.Stable) { string positionalIdentityKey = CreateRecoveredDescendantIdentityKey( relativePath, graphPath.Positional); - _recoveredDescendantIdentities[positionalIdentityKey] = descendant.Id; + descendantIdentities[positionalIdentityKey] = descendant.Id; } } - if (descendantRemaps.TryGetValue( - descendant, - out (Guid OriginalId, Guid AssignedId, int Occurrence) remap) + if (_recoveredDescendantRemaps.TryGetValue(descendant, out RecoveredDescendantRemap? remap) && descendant.Id == remap.AssignedId) { string remapKey = CreateRecoveredDescendantKey( relativePath, remap.OriginalId, remap.Occurrence); - _recoveredDescendantIds[remapKey] = remap.AssignedId; - _recoveredDescendantRemaps[descendant] = remap; + descendantIds[remapKey] = remap.AssignedId; } } } + + return new RecoveredSerializationState(elementIds, descendantIds, descendantIdentities); } + private sealed record RecoveredSerializationState( + IReadOnlyDictionary ElementIds, + IReadOnlyDictionary DescendantIds, + IReadOnlyDictionary DescendantIdentities) + { + public static RecoveredSerializationState Empty { get; } = new( + new Dictionary(), + new Dictionary(), + new Dictionary()); + } + + private sealed record RecoveredDescendantRemap( + Guid OriginalId, + Guid AssignedId, + int Occurrence); + private static string CreateRecoveredDescendantKey(string relativePath, Guid originalId, int occurrence) { return $"{relativePath}!{originalId:D}#{occurrence}"; diff --git a/src/Beutl.ProjectSystem/ProjectSystem/SerializedGraphTraversal.cs b/src/Beutl.ProjectSystem/ProjectSystem/SerializedGraphTraversal.cs new file mode 100644 index 0000000000..32734b4a6a --- /dev/null +++ b/src/Beutl.ProjectSystem/ProjectSystem/SerializedGraphTraversal.cs @@ -0,0 +1,168 @@ +using System.Collections; +using Beutl.Animation; +using Beutl.Engine; +using Beutl.Serialization; + +namespace Beutl.ProjectSystem; + +internal static class SerializedGraphTraversal +{ + public static IEnumerable Enumerate(object root) + { + var result = new List(); + Visit(root, "$", (value, _) => + { + result.Add(value); + return false; + }); + return result; + } + + public static bool Visit( + object? root, + string rootPath, + Func visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + return VisitCore(root, rootPath, visited, visitor); + } + + private static bool VisitCore( + object? value, + string path, + ISet visited, + Func visitor) + { + if (value is null or string) + { + return false; + } + + if (value is IOptional optional) + { + return optional.HasValue + && VisitCore(optional.ToObject().Value, path, visited, visitor); + } + + if (!value.GetType().IsValueType && !visited.Add(value)) + { + return false; + } + + if (value is CoreObject or IFallback) + { + if (visitor(value, path)) + { + return true; + } + } + + if (value is CoreObject coreObject) + { + switch (coreObject) + { + case Scene scene: + for (int i = 0; i < scene.Children.Count; i++) + { + if (VisitCore(scene.Children[i], $"{path}/Elements[{i}]", visited, visitor)) + { + return true; + } + } + break; + + case Element element: + for (int i = 0; i < element.Objects.Count; i++) + { + if (VisitCore(element.Objects[i], $"{path}/Objects[{i}]", visited, visitor)) + { + return true; + } + } + break; + + case EngineObject engineObject: + foreach (IProperty property in engineObject.Properties) + { + if (VisitCore(property.CurrentValue, $"{path}/{property.Name}", visited, visitor)) + { + return true; + } + + if (property.Animation is IKeyFrameAnimation animation) + { + int index = 0; + foreach (IKeyFrame keyFrame in animation.KeyFrames) + { + string keyFramePath + = $"{path}/Animations/{property.Name}/KeyFrames[{index}]"; + if (VisitCore(keyFrame, keyFramePath, visited, visitor) + || VisitCore(keyFrame.Value, $"{keyFramePath}/Value", visited, visitor)) + { + return true; + } + + index++; + } + } + } + break; + } + + foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType())) + { + if (property.GetMetadata(coreObject.GetType()).ShouldSerialize + && VisitCore(coreObject.GetValue(property), $"{path}/{property.Name}", visited, visitor)) + { + return true; + } + } + + if (coreObject is IHierarchical hierarchical) + { + int index = 0; + foreach (IHierarchical child in hierarchical.HierarchicalChildren) + { + if (VisitCore(child, $"{path}/HierarchicalChildren[{index}]", visited, visitor)) + { + return true; + } + + index++; + } + } + + return false; + } + + if (value is IDictionary dictionary) + { + int index = 0; + foreach (object? item in dictionary.Values) + { + if (VisitCore(item, $"{path}[{index}]", visited, visitor)) + { + return true; + } + + index++; + } + } + else if (value is IEnumerable enumerable) + { + int index = 0; + foreach (object? item in enumerable) + { + if (VisitCore(item, $"{path}[{index}]", visited, visitor)) + { + return true; + } + + index++; + } + } + + return false; + } +} diff --git a/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs b/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs index 6366d8421d..0b1e497617 100644 --- a/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs +++ b/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs @@ -6,3 +6,4 @@ [assembly: InternalsVisibleTo("Beutl.Editor.Components")] [assembly: InternalsVisibleTo("Beutl.UnitTests")] [assembly: InternalsVisibleTo("Beutl.AgentToolkit")] +[assembly: InternalsVisibleTo("Beutl.AgentToolkit.Tests")] diff --git a/src/Beutl.Utilities/ExceptionHelpers.cs b/src/Beutl.Utilities/ExceptionHelpers.cs index c0dce1d802..af9a53722b 100644 --- a/src/Beutl.Utilities/ExceptionHelpers.cs +++ b/src/Beutl.Utilities/ExceptionHelpers.cs @@ -1,4 +1,6 @@ -namespace Beutl.Utilities; +using System.Reflection; + +namespace Beutl.Utilities; /// /// Exception inspection helpers shared by recovery paths that must distinguish @@ -12,6 +14,19 @@ public static class ExceptionHelpers /// including failures wrapped by reflection or aggregation. /// public static bool ContainsFileSystemFailure(Exception exception) + => Contains(exception, static current => current is IOException or UnauthorizedAccessException); + + public static bool ContainsFatalFailure(Exception exception) + => Contains(exception, static current => current is OutOfMemoryException + or StackOverflowException + or AccessViolationException + or OperationCanceledException); + + public static bool ContainsNonRecoverableFileSystemFailure(Exception exception) + => Contains(exception, static current => current is UnauthorizedAccessException + or IOException and not FileNotFoundException); + + private static bool Contains(Exception exception, Func predicate) { var pending = new Stack(); var visited = new HashSet(ReferenceEqualityComparer.Instance); @@ -24,7 +39,7 @@ public static bool ContainsFileSystemFailure(Exception exception) continue; } - if (current is IOException or UnauthorizedAccessException) + if (predicate(current)) { return true; } @@ -36,6 +51,16 @@ public static bool ContainsFileSystemFailure(Exception exception) pending.Push(inner); } } + else if (current is ReflectionTypeLoadException reflectionLoad) + { + foreach (Exception? loaderException in reflectionLoad.LoaderExceptions) + { + if (loaderException is not null) + { + pending.Push(loaderException); + } + } + } else if (current.InnerException is { } inner) { pending.Push(inner); diff --git a/src/Beutl/AgentHost/EditorProjectSessionGateway.cs b/src/Beutl/AgentHost/EditorProjectSessionGateway.cs index afdba1c86d..96ef0cc0a0 100644 --- a/src/Beutl/AgentHost/EditorProjectSessionGateway.cs +++ b/src/Beutl/AgentHost/EditorProjectSessionGateway.cs @@ -51,7 +51,7 @@ await Dispatcher.UIThread.InvokeAsync(() => // it would leave the live session on the stale in-memory scene while a new file sits on // disk. A different path is likewise rejected (the in-app host edits one open project). string currentPath = Path.GetFullPath(current.Uri!.LocalPath); - if (string.Equals(currentPath, fullPath, PathComparison.ForCurrentPlatform)) + if (string.Equals(currentPath, fullPath, PathBoundary.Comparison)) { throw new ReconcileException(new ToolError( ErrorCode.ValidationRejected, @@ -117,7 +117,7 @@ public async ValueTask AddSceneAsync(IEditingSession activeS private static void RequireSameProject(Project current, string requestedFullPath) { string currentPath = Path.GetFullPath(current.Uri!.LocalPath); - if (!string.Equals(currentPath, requestedFullPath, PathComparison.ForCurrentPlatform)) + if (!string.Equals(currentPath, requestedFullPath, PathBoundary.Comparison)) { throw new ReconcileException(new ToolError( ErrorCode.ValidationRejected, diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index 2e6ddd9560..fe5a70abb8 100644 --- a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs @@ -188,7 +188,7 @@ protected BaseEditorViewModel(IPropertyAdapter property) protected void ResumeElementPersistenceAfterFallbackReplacement(object? previous) { if (_element is { SuppressedStorageSource: not null } - && Scene.TryResumeElementPersistence(_element) is { } suppression) + && Scene.TryResumeElementPersistence(_element, previous) is { } suppression) { Element element = _element; this.GetRequiredService().Record( diff --git a/tests/Beutl.AgentToolkit.Tests/Common/PathBoundaryTests.cs b/tests/Beutl.AgentToolkit.Tests/Common/PathBoundaryTests.cs index 9dd470c10d..5745787321 100644 --- a/tests/Beutl.AgentToolkit.Tests/Common/PathBoundaryTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Common/PathBoundaryTests.cs @@ -34,7 +34,7 @@ public void ResolveDeepestExistingTarget_BrokenSymlink_FollowsLinkTargetOutsideR string resolved = PathBoundary.ResolveDeepestExistingTarget(linkPath); Assert.That( - resolved.StartsWith(_tempRoot, PathComparison.ForCurrentPlatform), + resolved.StartsWith(_tempRoot, PathBoundary.Comparison), Is.False, $"A broken symlink must resolve to its target, not its in-root path. Got: {resolved}"); Assert.That(resolved, Is.EqualTo(Path.GetFullPath(missingTarget))); @@ -83,7 +83,7 @@ public void ResolveExistingPath_IntermediateSymlinkedDirectory_FollowsLinkTarget string resolved = PathBoundary.ResolveExistingPath(Path.Combine(link, "leaf.txt")); Assert.That( - resolved.StartsWith(_tempRoot, PathComparison.ForCurrentPlatform), + resolved.StartsWith(_tempRoot, PathBoundary.Comparison), Is.False, $"An intermediate symlinked directory must resolve to its target, not its in-root path. Got: {resolved}"); Assert.That(resolved, Is.EqualTo(outsideLeaf)); diff --git a/tests/Beutl.AgentToolkit.Tests/Sessions/ProjectOperationsTests.cs b/tests/Beutl.AgentToolkit.Tests/Sessions/ProjectOperationsTests.cs index 4653626141..1d8bc172d0 100644 --- a/tests/Beutl.AgentToolkit.Tests/Sessions/ProjectOperationsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Sessions/ProjectOperationsTests.cs @@ -1,6 +1,6 @@ -using Beutl.AgentToolkit.Common; -using Beutl.AgentToolkit.Sessions; +using Beutl.AgentToolkit.Sessions; using Beutl.ProjectSystem; +using Beutl.Serialization; namespace Beutl.AgentToolkit.Tests.Sessions; @@ -73,12 +73,58 @@ public void Save_RehomesSceneSidecarOutsideProject_RegeneratesInsideProject() string regeneratedDir = Path.GetDirectoryName(scene.Uri!.LocalPath)!; Assert.That( - regeneratedDir.StartsWith(projectDir, PathComparison.ForCurrentPlatform), + regeneratedDir.StartsWith(projectDir, PathBoundary.Comparison), Is.True, $"Scene sidecar must be regenerated inside the project directory, got: {scene.Uri.LocalPath}"); Assert.That(File.Exists(outside), Is.False, "The out-of-project sidecar must not be written."); } + [Test] + public void Save_RehomesRecoveredElementOutsideProject_WithoutChangingItsRelativeName() + { + Project project = ProjectOperations.CreateProject(new ProjectCreateOptions( + Path.Combine(_tempRoot, "proj.bep"), + Width: 1920, + Height: 1080, + FrameRate: 30, + Duration: TimeSpan.FromSeconds(10))); + Scene scene = project.Items.OfType().Single(); + string outsideDirectory = Path.Combine( + Path.GetDirectoryName(_tempRoot)!, + "project-operations-outside-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outsideDirectory); + try + { + string outsidePath = Path.Combine(outsideDirectory, "recovered.belm"); + byte[] retainedBytes = "{ malformed recovered element"u8.ToArray(); + File.WriteAllBytes(outsidePath, retainedBytes); + var recovered = new Element + { + Name = "Recovered", + Length = TimeSpan.FromSeconds(1), + Uri = new Uri(outsidePath), + SuppressedStorageSource = new SuppressedStorageSource(retainedBytes, new Uri(outsidePath)), + }; + scene.Children.Add(recovered); + + ProjectOperations.Save(project); + + string sceneDirectory = Path.GetDirectoryName(scene.Uri!.LocalPath)!; + Assert.Multiple(() => + { + Assert.That(Path.GetDirectoryName(recovered.Uri!.LocalPath), Is.EqualTo(sceneDirectory)); + Assert.That(Path.GetFileName(recovered.Uri.LocalPath), Is.EqualTo("recovered.belm")); + Assert.That(File.ReadAllBytes(recovered.Uri.LocalPath), Is.EqualTo(retainedBytes)); + Assert.That(CoreSerializer.RestoreFromUri(project.Uri!).Items.OfType() + .Single().Children, Has.Count.EqualTo(1)); + }); + } + finally + { + Directory.Delete(outsideDirectory, recursive: true); + } + } + // Two scenes carrying the same in-project sidecar Uri would overwrite each other on save; Save must // null the duplicate so the Ensure* helper regenerates it on a distinct path. [Test] @@ -137,7 +183,7 @@ public void Save_RehomesSceneSidecarThroughInProjectSymlink_RegeneratesInsidePro string regenerated = PathBoundary.ResolveDeepestExistingTarget(scene.Uri!.LocalPath); Assert.That( - regenerated.StartsWith(projectDir, PathComparison.ForCurrentPlatform), + regenerated.StartsWith(projectDir, PathBoundary.Comparison), Is.True, $"Scene sidecar must be regenerated inside the project directory, got: {regenerated}"); Assert.That(Directory.EnumerateFileSystemEntries(outsideDir), Is.Empty); @@ -208,12 +254,12 @@ public void NormalizeSidecarUrisWithinProject_RehomesOutOfProjectUris_WithoutTou Assert.Multiple(() => { Assert.That( - Path.GetFullPath(scene.Uri!.LocalPath).StartsWith(projectDir, PathComparison.ForCurrentPlatform), + Path.GetFullPath(scene.Uri!.LocalPath).StartsWith(projectDir, PathBoundary.Comparison), Is.True, $"Scene sidecar must be rehomed inside the project, got: {scene.Uri.LocalPath}"); Assert.That(element.Uri, Is.Not.Null); Assert.That( - Path.GetFullPath(element.Uri!.LocalPath).StartsWith(projectDir, PathComparison.ForCurrentPlatform), + Path.GetFullPath(element.Uri!.LocalPath).StartsWith(projectDir, PathBoundary.Comparison), Is.True, $"Element sidecar must be inside the project, got: {element.Uri!.LocalPath}"); Assert.That(Directory.Exists(outsideDir), Is.False, "No out-of-project directory may be created."); diff --git a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs index fe2b350719..20d0eb31f0 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -186,9 +186,9 @@ public async Task Open_project_reports_fallback_and_lossy_easing_incidents_toget && incident.Message is null)); Assert.That(opened.Value.RecoveryIncidents, Has.One.Matches(incident => - incident.Reason == nameof(FallbackReason.DeserializationFailed) - && incident.TypeName is null - && incident.Message is null)); + incident.Reason == nameof(FallbackReason.TypeNotFound) + && incident.TypeName == "[Missing.Assembly]Missing.Namespace:MissingEasing" + && incident.Message!.Contains("could not be resolved", StringComparison.Ordinal))); }); } @@ -250,9 +250,13 @@ public async Task Open_project_warns_about_unresolvable_keyframe_easing_without_ Assert.That(opened.Value.RecoveryIncidents, Has.Count.EqualTo(1)); Assert.That(opened.Value.RecoveryIncidents[0].ElementFile, Is.EqualTo(elementRelativePath)); Assert.That(opened.Value.RecoveryIncidents[0].Reason, - Is.EqualTo(nameof(FallbackReason.DeserializationFailed))); - Assert.That(opened.Value.RecoveryIncidents[0].TypeName, Is.Null); - Assert.That(opened.Value.RecoveryIncidents[0].Message, Is.Null); + Is.EqualTo(nameof(FallbackReason.TypeNotFound))); + Assert.That( + opened.Value.RecoveryIncidents[0].TypeName, + Is.EqualTo("[Missing.Assembly]Missing.Namespace:MissingEasing")); + Assert.That( + opened.Value.RecoveryIncidents[0].Message, + Does.Contain("could not be resolved")); }); } @@ -350,45 +354,26 @@ public async Task Open_project_warns_about_malformed_element_json_and_keeps_heal } [Test] - public void CollectFallbacks_TraversesDictionaryValues() + public void SerializedGraphTraversal_TraversesDictionaryValues() { var fallback = new FallbackTransform(); - var fallbacks = new List(); - MethodInfo method = typeof(SessionTools).GetMethod( - "CollectFallbacks", - BindingFlags.NonPublic | BindingFlags.Static)!; - - method.Invoke( - null, - new object?[] - { - new Dictionary { ["broken"] = fallback }, - new HashSet(), - fallbacks, - }); + IFallback[] fallbacks = SerializedGraphTraversal.Enumerate( + new Dictionary { ["broken"] = fallback }) + .OfType() + .ToArray(); Assert.That(fallbacks, Has.One.SameAs(fallback)); } [Test] - public void CollectFallbacks_TraversesElementHierarchyOutsideObjects() + public void SerializedGraphTraversal_TraversesElementHierarchyOutsideObjects() { var element = new Element(); var fallback = new FallbackTransform(); ((IModifiableHierarchical)element).AddChild(fallback); - var fallbacks = new List(); - MethodInfo method = typeof(SessionTools).GetMethod( - "CollectFallbacks", - BindingFlags.NonPublic | BindingFlags.Static)!; - - method.Invoke( - null, - new object?[] - { - element, - new HashSet(), - fallbacks, - }); + IFallback[] fallbacks = SerializedGraphTraversal.Enumerate(element) + .OfType() + .ToArray(); Assert.Multiple(() => { @@ -398,26 +383,16 @@ public void CollectFallbacks_TraversesElementHierarchyOutsideObjects() } [Test] - public void CollectFallbacks_TraversesRegisteredCorePropertiesAndOptionalValues() + public void SerializedGraphTraversal_TraversesRegisteredCorePropertiesAndOptionalValues() { var fallback = new FallbackTransform(); var element = new RegisteredOptionalTransformElement { PluginTransform = new Optional(fallback), }; - var fallbacks = new List(); - MethodInfo method = typeof(SessionTools).GetMethod( - "CollectFallbacks", - BindingFlags.NonPublic | BindingFlags.Static)!; - - method.Invoke( - null, - new object?[] - { - element, - new HashSet(), - fallbacks, - }); + IFallback[] fallbacks = SerializedGraphTraversal.Enumerate(element) + .OfType() + .ToArray(); Assert.That(fallbacks, Has.One.SameAs(fallback)); } diff --git a/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs b/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs index 2ea96ca5aa..7f75a79ba6 100644 --- a/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs +++ b/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs @@ -2,7 +2,7 @@ namespace Beutl.UnitTests.Core; -public sealed class FilePathBoundaryTests +public sealed class PathBoundaryTests { [Test] public void IsPathInsideRoot_UsesPlatformPathCaseSemantics() @@ -14,7 +14,7 @@ public void IsPathInsideRoot_UsesPlatformPathCaseSemantics() "sidecar.json"); Assert.That( - FilePathBoundary.IsPathInsideRoot(root, differentlyCasedPath), + PathBoundary.IsPathInsideRoot(root, differentlyCasedPath), Is.EqualTo(OperatingSystem.IsWindows())); } } diff --git a/tests/Beutl.UnitTests/Engine/AnimatablePropertyTests.cs b/tests/Beutl.UnitTests/Engine/AnimatablePropertyTests.cs index 2fcc8880cc..732d927f8d 100644 --- a/tests/Beutl.UnitTests/Engine/AnimatablePropertyTests.cs +++ b/tests/Beutl.UnitTests/Engine/AnimatablePropertyTests.cs @@ -128,7 +128,7 @@ public void ReplaceCurrentValue_EquivalentInstance_ReplacesAndNotifiesOnce() property.ValueChanged += (_, e) => args = e; property.Edited += (_, _) => edited++; - ((IPropertyValueReplacer)property).ReplaceCurrentValue(replacement); + property.ReplaceCurrentValue(replacement); Assert.Multiple(() => { diff --git a/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs b/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs index f3e106b431..1c66046782 100644 --- a/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs +++ b/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs @@ -9,6 +9,18 @@ namespace Beutl.UnitTests.Engine.Animation; public class KeyFrameTests { + private sealed class EqualityValue(string key, string state) + { + public string Key { get; } = key; + + public string State { get; } = state; + + public override bool Equals(object? obj) + => obj is EqualityValue other && Key == other.Key; + + public override int GetHashCode() => Key.GetHashCode(StringComparison.Ordinal); + } + public abstract class AbstractTestEasing : Easing { } @@ -119,6 +131,52 @@ public void Deserialize_ShouldCorrectlyDeserializeSplineEasing() Assert.That(easing.Y2, Is.EqualTo(0.4f)); } + [Test] + public void Deserialize_SplineEasingWithExtensionData_PreservesControlPoints() + { + var keyFrame = new KeyFrame(); + var context = new Mock(); + var easingNode = new JsonObject + { + ["X1"] = 0.1f, + ["Y1"] = 0.2f, + ["X2"] = 0.3f, + ["Y2"] = 0.4f, + ["Extension"] = true, + }; + context.Setup(c => c.GetValue(It.IsAny())).Returns(easingNode); + context.Setup(c => c.Contains(It.IsAny())).Returns(false); + + keyFrame.Deserialize(context.Object); + + Assert.Multiple(() => + { + Assert.That(keyFrame.Easing, Is.InstanceOf()); + Assert.That(((SplineEasing)keyFrame.Easing).X1, Is.EqualTo(0.1f)); + Assert.That(keyFrame.HasLossyEasing, Is.False); + }); + } + + [Test] + public void ReplaceValue_ThroughBaseContract_ReplacesEquivalentReferenceOnce() + { + var current = new EqualityValue("same", "old"); + var replacement = new EqualityValue("same", "new"); + IKeyFrame keyFrame = new KeyFrame { Value = current }; + int changes = 0; + ((CoreObject)keyFrame).PropertyChanged += (_, e) => + changes += e.PropertyName == nameof(IKeyFrame.Value) ? 1 : 0; + + keyFrame.ReplaceValue(replacement); + + Assert.Multiple(() => + { + Assert.That(keyFrame.Value, Is.SameAs(replacement)); + Assert.That(((EqualityValue)keyFrame.Value!).State, Is.EqualTo("new")); + Assert.That(changes, Is.EqualTo(1)); + }); + } + [TestCase(typeof(AbstractTestEasing))] [TestCase(typeof(PrivateConstructorTestEasing))] [TestCase(typeof(ThrowingConstructorTestEasing))] diff --git a/tests/Beutl.UnitTests/Engine/ListPropertyTests.cs b/tests/Beutl.UnitTests/Engine/ListPropertyTests.cs index 05de68c828..6853b07db6 100644 --- a/tests/Beutl.UnitTests/Engine/ListPropertyTests.cs +++ b/tests/Beutl.UnitTests/Engine/ListPropertyTests.cs @@ -1,4 +1,5 @@ using Beutl.Composition; +using Beutl.Collections; using Beutl.Engine; namespace Beutl.UnitTests.Engine; @@ -67,6 +68,18 @@ public void Replace_KeepsSameInstance() Assert.That(property, Is.EqualTo(new[] { 10, 20 })); } + [Test] + public void ReplaceCurrentValue_ThroughBaseContract_ReplacesContents() + { + var property = Make(); + property.AddRange([1, 2, 3]); + IProperty baseProperty = property; + + baseProperty.ReplaceCurrentValue(new CoreList([4, 5])); + + Assert.That(property, Is.EqualTo(new[] { 4, 5 })); + } + [Test] public void Indexer_AssignmentReplacesValue() { diff --git a/tests/Beutl.UnitTests/Engine/SimplePropertyTests.cs b/tests/Beutl.UnitTests/Engine/SimplePropertyTests.cs index 46877e9b8c..bdb1fd0578 100644 --- a/tests/Beutl.UnitTests/Engine/SimplePropertyTests.cs +++ b/tests/Beutl.UnitTests/Engine/SimplePropertyTests.cs @@ -113,7 +113,7 @@ public void ReplaceCurrentValue_EquivalentInstance_ReplacesAndNotifiesOnce() property.ValueChanged += (_, e) => args = e; property.Edited += (_, _) => edited++; - ((IPropertyValueReplacer)property).ReplaceCurrentValue(replacement); + ((IProperty)property).ReplaceCurrentValue(replacement); Assert.Multiple(() => { diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index 60c8f4a640..d69791538b 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1,6 +1,8 @@ using System.Collections; +using System.Collections.ObjectModel; using System.Collections.Immutable; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -30,6 +32,22 @@ public IOExceptionElement() } } + private sealed class MissingAssemblyElement : Element + { + public MissingAssemblyElement() + { + throw new FileNotFoundException("Plugin assembly is not installed.", "Missing.Plugin.dll"); + } + } + + private sealed class FatalElement : Element + { + public FatalElement() + { + throw new AccessViolationException("Fatal plugin failure."); + } + } + [SuppressResourceClassGeneration] public sealed class ElementReferenceHolder : EngineObject { @@ -43,6 +61,17 @@ public ElementReferenceHolder() public IProperty ExpressionTarget { get; } = Property.Create(); } + [SuppressResourceClassGeneration] + public sealed class CustomReferenceHolder : EngineObject + { + public CustomReferenceHolder() + { + ScanProperties(); + } + + public IProperty Target { get; } = Property.Create(); + } + [SuppressResourceClassGeneration] public sealed class OptionalReferenceHolder : EngineObject { @@ -69,6 +98,9 @@ public NestedReferenceHolder() public IProperty>> DictionaryTargets { get; } = Property.Create>>(); + public IProperty>> ReadOnlyTargets { get; } + = Property.Create>>(); + public IProperty> AnimatedTarget { get; } = Property.CreateAnimatable>(); } @@ -101,20 +133,29 @@ public WrappedReferenceHolder() public IProperty CyclicTarget { get; } = Property.Create(); + + public IProperty OrderedCycleTarget { get; } + = Property.Create(); } - public sealed record ReferenceEnvelope( - Reference Target, - Optional> OptionalTarget, - string State) : IReferenceRewritable + public sealed class ReferenceEnvelope( + Reference target, + Optional> optionalTarget, + string state) : IReferenceRewritable { - public object RewriteReferences(IReferenceRewriteContext context) + public Reference Target { get; private set; } = target; + + public Optional> OptionalTarget { get; private set; } = optionalTarget; + + public string State { get; } = state; + + public IReferenceRewritable CreateReferenceRewriteTarget() + => new ReferenceEnvelope(Target, OptionalTarget, State); + + public void RewriteReferences(IReferenceRewriteContext context) { - return this with - { - Target = context.Rewrite(Target), - OptionalTarget = context.Rewrite(OptionalTarget), - }; + Target = context.Rewrite(Target); + OptionalTarget = context.Rewrite(OptionalTarget); } } @@ -124,13 +165,16 @@ public sealed class EqualityIgnoringReferenceEnvelope( Reference target, string state) : IReferenceRewritable { - public Reference Target { get; } = target; + public Reference Target { get; private set; } = target; public string State { get; } = state; - public object RewriteReferences(IReferenceRewriteContext context) + public IReferenceRewritable CreateReferenceRewriteTarget() + => new EqualityIgnoringReferenceEnvelope(Target, State); + + public void RewriteReferences(IReferenceRewriteContext context) { - return new EqualityIgnoringReferenceEnvelope(context.Rewrite(Target), State); + Target = context.Rewrite(Target); } public override bool Equals(object? obj) @@ -146,10 +190,11 @@ public override int GetHashCode() public sealed record InvalidReferenceEnvelope(Reference Target) : IReferenceRewritable { - public object RewriteReferences(IReferenceRewriteContext context) - { - return "invalid replacement"; - } + public IReferenceRewritable CreateReferenceRewriteTarget() + => new CyclicReferenceEnvelope(Target); + + public void RewriteReferences(IReferenceRewriteContext context) + => throw new InvalidOperationException("An invalid target must not be populated."); } public sealed class CyclicReferenceEnvelope(Reference target) : IReferenceRewritable @@ -158,11 +203,39 @@ public sealed class CyclicReferenceEnvelope(Reference target) : IRefere public CyclicReferenceEnvelope? Self { get; set; } - public object RewriteReferences(IReferenceRewriteContext context) + public IReferenceRewritable CreateReferenceRewriteTarget() + { + return new CyclicReferenceEnvelope(Target) + { + Self = Self, + }; + } + + public void RewriteReferences(IReferenceRewriteContext context) { Target = context.Rewrite(Target); Self = context.Rewrite(Self); - return this; + } + } + + public sealed class OrderedCyclicReferenceEnvelope(Reference target) : IReferenceRewritable + { + public Reference Target { get; private set; } = target; + + public OrderedCyclicReferenceEnvelope? Next { get; set; } + + public IReferenceRewritable CreateReferenceRewriteTarget() + { + return new OrderedCyclicReferenceEnvelope(Target) + { + Next = Next, + }; + } + + public void RewriteReferences(IReferenceRewriteContext context) + { + Next = context.Rewrite(Next); + Target = context.Rewrite(Target); } } @@ -315,6 +388,8 @@ public bool Validate(out string? error) error = null; return true; } + + public IReferenceExpression? Rebind(Guid objectId) => null; } private sealed class StatefulReferenceExpression(Guid objectId, string propertyPath) : IReferenceExpression @@ -336,6 +411,8 @@ public bool Validate(out string? error) error = null; return true; } + + public IReferenceExpression? Rebind(Guid objectId) => null; } private sealed class ConstructorlessReference(Guid id, Type objectType, string marker) : IReference @@ -722,6 +799,44 @@ public void Restore_ElementConstructorIOException_PropagatesWrappedFailure() Assert.That(ContainsException(exception!), Is.True); } + [Test] + public void Restore_ElementConstructorMissingAssembly_RecoversFallback() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + var json = new JsonObject + { + ["$type"] = TypeFormat.ToString(typeof(MissingAssemblyElement)), + [nameof(CoreObject.Id)] = Guid.NewGuid().ToString(), + }; + File.WriteAllText(elementPath, json.ToJsonString()); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + + Assert.Multiple(() => + { + Assert.That(recovered.IsEnabled, Is.False); + Assert.That(recovered.Objects, Has.One.InstanceOf()); + Assert.That(recovered.SuppressedStorageSource, Is.Not.Null); + }); + } + + [Test] + public void Restore_ElementConstructorFatalFailure_PropagatesWrappedFailure() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + var json = new JsonObject + { + ["$type"] = TypeFormat.ToString(typeof(FatalElement)), + [nameof(CoreObject.Id)] = Guid.NewGuid().ToString(), + }; + File.WriteAllText(elementPath, json.ToJsonString()); + + Exception? exception = Assert.Catch(() => CoreSerializer.RestoreFromUri(sceneUri)); + + Assert.That(exception, Is.Not.Null); + Assert.That(ContainsException(exception!), Is.True); + } + [Test] public void Restore_SceneDiscriminatorWithSelfInclude_RecoversWithoutRecursing() { @@ -1711,22 +1826,32 @@ public void Deserialize_PreservesBackslashesInRecoveredDescendantIdentityGraphPa [Test] public void ResolveMigratedReference_CustomReferenceWithoutGuidConstructorIsRetained() { + Guid originalId = Guid.NewGuid(); Guid migratedId = Guid.NewGuid(); var scene = new Scene { Uri = new Uri(Path.Combine(_root, "scene.scene")) }; - scene.Children.Add(new Element + var migrated = new Element { Id = migratedId, Uri = new Uri(Path.Combine(_root, "target.belm")), - }); - var reference = new ConstructorlessReference(Guid.NewGuid(), typeof(RectShape), "custom"); + }; + scene.Children.Add(migrated); + var reference = new ConstructorlessReference(originalId, typeof(RectShape), "custom"); + var holder = new CustomReferenceHolder(); + holder.Target.CurrentValue = reference; + var owner = new Element { Uri = new Uri(Path.Combine(_root, "owner.belm")) }; + owner.AddObject(holder); + scene.Children.Add(owner); + var migrations = (Dictionary)typeof(Scene) + .GetField("_pendingRecoveredElementIdMigrations", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(scene)!; + migrations[originalId] = migratedId; MethodInfo method = typeof(Scene).GetMethod( - "ResolveMigratedReference", + "MigrateRecoveredElementReferences", BindingFlags.Instance | BindingFlags.NonPublic)!; - object? result = null; - Assert.DoesNotThrow(() => result = method.Invoke(scene, [reference, migratedId])); + Assert.DoesNotThrow(() => method.Invoke(scene, null)); - Assert.That(result, Is.SameAs(reference)); + Assert.That(holder.Target.CurrentValue, Is.SameAs(reference)); } [Test] @@ -1744,6 +1869,8 @@ public void MigrateRecoveredElementReferences_TraversesContainersAndKeyFrames() { ["migrated"] = new Reference(originalId), }; + holder.ReadOnlyTargets.CurrentValue = new ReadOnlyCollection>( + [new Reference(originalId)]); var animation = new KeyFrameAnimation>(); animation.KeyFrames.Add(new KeyFrame> { @@ -1771,12 +1898,15 @@ Reference migratedReference Reference migratedListReference = holder.ListTargets.CurrentValue!.Single(); Reference migratedDictionaryReference = holder.DictionaryTargets.CurrentValue!["migrated"]; + Reference migratedReadOnlyReference = holder.ReadOnlyTargets.CurrentValue!.Single(); Assert.Multiple(() => { Assert.That(migratedListReference.Id, Is.EqualTo(migrated.Id)); Assert.That(migratedListReference.Value, Is.SameAs(migrated)); Assert.That(migratedDictionaryReference.Id, Is.EqualTo(migrated.Id)); Assert.That(migratedDictionaryReference.Value, Is.SameAs(migrated)); + Assert.That(migratedReadOnlyReference.Id, Is.EqualTo(migrated.Id)); + Assert.That(migratedReadOnlyReference.Value, Is.SameAs(migrated)); Assert.That(migratedReference.Id, Is.EqualTo(migrated.Id)); Assert.That(migratedReference.Value, Is.SameAs(migrated)); }); @@ -1882,9 +2012,87 @@ public void MigrateRecoveredElementReferences_RewritesOptInWrappersWithoutTouchi Assert.That(holder.PassiveTarget.CurrentValue!.Target.Id, Is.EqualTo(originalId)); Assert.That(holder.InvalidTarget.CurrentValue, Is.SameAs(invalidWrapper)); Assert.That(holder.InvalidTarget.CurrentValue!.Target.Id, Is.EqualTo(originalId)); - Assert.That(holder.CyclicTarget.CurrentValue, Is.SameAs(cyclicWrapper)); + Assert.That(holder.CyclicTarget.CurrentValue, Is.Not.SameAs(cyclicWrapper)); Assert.That(holder.CyclicTarget.CurrentValue!.Target.Id, Is.EqualTo(migrated.Id)); - Assert.That(holder.CyclicTarget.CurrentValue.Self, Is.SameAs(cyclicWrapper)); + Assert.That(holder.CyclicTarget.CurrentValue.Self, Is.SameAs(holder.CyclicTarget.CurrentValue)); + }); + } + + [Test] + public void MigrateRecoveredElementReferences_UnrelatedMigrationDoesNotRebuildCycle() + { + var migrated = new Element + { + Id = Guid.NewGuid(), + Uri = new Uri(Path.Combine(_root, "migrated.belm")), + }; + var wrapper = new CyclicReferenceEnvelope(new Reference(Guid.NewGuid())); + wrapper.Self = wrapper; + var holder = new WrappedReferenceHolder(); + holder.CyclicTarget.CurrentValue = wrapper; + int changes = 0; + holder.CyclicTarget.ValueChanged += (_, _) => changes++; + var owner = new Element { Uri = new Uri(Path.Combine(_root, "owner.belm")) }; + owner.AddObject(holder); + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "migration.scene")) }; + scene.Children.Add(migrated); + scene.Children.Add(owner); + var migrations = (Dictionary)typeof(Scene) + .GetField("_pendingRecoveredElementIdMigrations", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(scene)!; + migrations[Guid.NewGuid()] = migrated.Id; + MethodInfo method = typeof(Scene).GetMethod( + "MigrateRecoveredElementReferences", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + method.Invoke(scene, null); + + Assert.Multiple(() => + { + Assert.That(holder.CyclicTarget.CurrentValue, Is.SameAs(wrapper)); + Assert.That(holder.CyclicTarget.CurrentValue!.Self, Is.SameAs(wrapper)); + Assert.That(changes, Is.Zero); + }); + } + + [Test] + public void MigrateRecoveredElementReferences_RebuildsMutualCycleWhenLeafFollowsBackEdge() + { + Guid originalId = Guid.NewGuid(); + var migrated = new Element + { + Id = Guid.NewGuid(), + Uri = new Uri(Path.Combine(_root, "migrated.belm")), + }; + var first = new OrderedCyclicReferenceEnvelope(new Reference(originalId)); + var second = new OrderedCyclicReferenceEnvelope(new Reference(Guid.NewGuid())); + first.Next = second; + second.Next = first; + var holder = new WrappedReferenceHolder(); + holder.OrderedCycleTarget.CurrentValue = first; + var owner = new Element { Uri = new Uri(Path.Combine(_root, "owner.belm")) }; + owner.AddObject(holder); + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "migration.scene")) }; + scene.Children.Add(migrated); + scene.Children.Add(owner); + var migrations = (Dictionary)typeof(Scene) + .GetField("_pendingRecoveredElementIdMigrations", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(scene)!; + migrations[originalId] = migrated.Id; + MethodInfo method = typeof(Scene).GetMethod( + "MigrateRecoveredElementReferences", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + method.Invoke(scene, null); + + OrderedCyclicReferenceEnvelope rewritten = holder.OrderedCycleTarget.CurrentValue!; + Assert.Multiple(() => + { + Assert.That(rewritten, Is.Not.SameAs(first)); + Assert.That(rewritten.Next, Is.Not.SameAs(second)); + Assert.That(rewritten.Next!.Next, Is.SameAs(rewritten)); + Assert.That(rewritten.Target.Id, Is.EqualTo(migrated.Id)); + Assert.That(rewritten.Target.Value, Is.SameAs(migrated)); }); } @@ -2464,7 +2672,7 @@ public void Restore_RootArrayId_DoesNotAdoptInnerIdAndRemainsStable() } [Test] - public void StoreToUri_RehomeTarget_NeverOverwritesAnExistingFile() + public void StoreToUri_RehomeTargetCollision_FailsWithoutRepointingOrOverwriting() { (Uri sceneUri, string elementPath) = CreatePersistedScene(); byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); @@ -2476,9 +2684,61 @@ public void StoreToUri_RehomeTarget_NeverOverwritesAnExistingFile() byte[] repairedBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":[]}"u8.ToArray(); File.WriteAllBytes(rehomedPath, repairedBytes); - CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + Assert.Throws(() => CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath))); - Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(repairedBytes)); + Assert.Multiple(() => + { + Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(repairedBytes)); + Assert.That(recovered.Uri, Is.EqualTo(new Uri(elementPath))); + }); + } + + [Test] + public void Serialize_DoesNotMutateLongLivedRecoveryMaps() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + File.WriteAllText(elementPath, "{ malformed element"); + Scene recovered = CoreSerializer.RestoreFromUri(sceneUri); + var sentinelId = Guid.NewGuid(); + var elementIds = (Dictionary)typeof(Scene) + .GetField("_recoveredElementIds", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(recovered)!; + var descendantIds = (Dictionary)typeof(Scene) + .GetField("_recoveredDescendantIds", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(recovered)!; + var descendantIdentities = (Dictionary)typeof(Scene) + .GetField("_recoveredDescendantIdentities", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(recovered)!; + elementIds["sentinel-element"] = sentinelId; + descendantIds["sentinel-descendant"] = sentinelId; + descendantIdentities["sentinel-identity"] = sentinelId; + + CoreSerializer.SerializeToJsonObject( + recovered, + new CoreSerializerOptions { Mode = CoreSerializationMode.EmbedReferencedObjects }); + + Assert.Multiple(() => + { + Assert.That(elementIds["sentinel-element"], Is.EqualTo(sentinelId)); + Assert.That(descendantIds["sentinel-descendant"], Is.EqualTo(sentinelId)); + Assert.That(descendantIdentities["sentinel-identity"], Is.EqualTo(sentinelId)); + }); + } + + [Test] + public void RemovedIdlessRecoveredDescendant_IsNotKeptAliveByRecoveryState() + { + (Scene scene, WeakReference descendant) = CreateDetachedIdlessRecoveredDescendant(); + + for (int i = 0; i < 3 && descendant.IsAlive; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + Assert.That(descendant.IsAlive, Is.False); + GC.KeepAlive(scene); } [Test] @@ -2511,6 +2771,28 @@ public void Restore_IdlessRecoveredDescendant_IsAssignedStableOccurrenceId() }); } + [MethodImpl(MethodImplOptions.NoInlining)] + private (Scene Scene, WeakReference Descendant) CreateDetachedIdlessRecoveredDescendant() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + Element source = CoreSerializer.RestoreFromUri(new Uri(elementPath)); + var sourceShape = (RectShape)source.Objects.Single(); + sourceShape.Transform.CurrentValue = new RotationTransform(); + CoreSerializer.StoreToUri(source, source.Uri!); + + JsonObject json = JsonNode.Parse(File.ReadAllText(elementPath))!.AsObject(); + JsonObject transformJson = FindObjectByDiscriminator(json, "RotationTransform")!; + transformJson["$type"] = "[Beutl.Engine]Beutl.Graphics.Transformation:DoesNotExist"; + transformJson.Remove(nameof(CoreObject.Id)); + File.WriteAllText(elementPath, json.ToJsonString()); + + Scene scene = CoreSerializer.RestoreFromUri(sceneUri); + var descendant = (CoreObject)GetTransformFallback(scene, elementPath); + var weakReference = new WeakReference(descendant); + scene.Children.Clear(); + return (scene, weakReference); + } + [Test] public void TryResumeElementPersistence_DictionaryValuedFallback_StaysBlocked() { From 5dd66cd8ecf2e4b00a6bfbc3ab71e59a0abdd670 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 10 Aug 2026 06:37:27 +0900 Subject: [PATCH 34/35] style: satisfy format check --- src/Beutl.Core/ValueReplacement.cs | 2 +- src/Beutl.ProjectSystem/ProjectSystem/Scene.cs | 2 +- tests/Beutl.UnitTests/Engine/ListPropertyTests.cs | 4 ++-- .../ProjectSystem/MalformedElementRecoveryTests.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Beutl.Core/ValueReplacement.cs b/src/Beutl.Core/ValueReplacement.cs index 3f49ffe1a7..8a338b4cd3 100644 --- a/src/Beutl.Core/ValueReplacement.cs +++ b/src/Beutl.Core/ValueReplacement.cs @@ -1,4 +1,4 @@ -namespace Beutl; +namespace Beutl; internal static class ValueReplacement { diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 1ec1d575ae..6af354482c 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -1010,7 +1010,7 @@ var pendingDescendantRemaps continue; } - bool idless = _idlessRecoveredDescendants.TryGetValue(descendant, out _); + bool idless = _idlessRecoveredDescendants.TryGetValue(descendant, out _); Guid originalId = idless ? Guid.Empty : descendant.Id; int occurrence = idless ? idlessOccurrence++ diff --git a/tests/Beutl.UnitTests/Engine/ListPropertyTests.cs b/tests/Beutl.UnitTests/Engine/ListPropertyTests.cs index 6853b07db6..7b8a7971b8 100644 --- a/tests/Beutl.UnitTests/Engine/ListPropertyTests.cs +++ b/tests/Beutl.UnitTests/Engine/ListPropertyTests.cs @@ -1,5 +1,5 @@ -using Beutl.Composition; -using Beutl.Collections; +using Beutl.Collections; +using Beutl.Composition; using Beutl.Engine; namespace Beutl.UnitTests.Engine; diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index d69791538b..db9611345d 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -1,6 +1,6 @@ using System.Collections; -using System.Collections.ObjectModel; using System.Collections.Immutable; +using System.Collections.ObjectModel; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; From 9c0ccf95327fbc76d3661d748b1a348d4294ba11 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Mon, 10 Aug 2026 07:00:20 +0900 Subject: [PATCH 35/35] fix: preserve recovery repair workflows --- .../Serialization/CoreSerializer.cs | 6 ++ .../ViewModels/Editors/BaseEditorViewModel.cs | 62 +++++++++++++++---- .../MalformedElementRecoveryTests.cs | 26 ++++++++ 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs index ab70503253..aea7948c4e 100644 --- a/src/Beutl.Core/Serialization/CoreSerializer.cs +++ b/src/Beutl.Core/Serialization/CoreSerializer.cs @@ -316,6 +316,12 @@ private static void StoreToUriCore( return; } + if (suppressed.WasReinstated && uri == suppressedObj.Uri) + { + RestoreReinstatedBytes(suppressed, uri.LocalPath); + return; + } + if (uri.Scheme != "file") { throw new JsonException(); diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index fe5a70abb8..b630e68610 100644 --- a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs @@ -15,6 +15,7 @@ using Beutl.Logging; using Beutl.Media; using Beutl.ProjectSystem; +using Beutl.PropertyAdapters; using Beutl.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -190,17 +191,30 @@ protected void ResumeElementPersistenceAfterFallbackReplacement(object? previous if (_element is { SuppressedStorageSource: not null } && Scene.TryResumeElementPersistence(_element, previous) is { } suppression) { - Element element = _element; - this.GetRequiredService().Record( - () => element.SuppressedStorageSource = null, - () => - { - suppression.WasReinstated = true; - element.SuppressedStorageSource = suppression; - }); + RecordPersistenceResume(_element, suppression); } } + protected void ResumeElementPersistenceAfterKnownRecoveryBlockerReplacement() + { + if (_element is { SuppressedStorageSource: not null } + && Scene.TryResumeElementPersistence(_element) is { } suppression) + { + RecordPersistenceResume(_element, suppression); + } + } + + private void RecordPersistenceResume(Element element, SuppressedStorageSource suppression) + { + this.GetRequiredService().Record( + () => element.SuppressedStorageSource = null, + () => + { + suppression.WasReinstated = true; + element.SuppressedStorageSource = suppression; + }); + } + public void Dispose() { if (!IsDisposed) @@ -567,6 +581,7 @@ internal void SetValue(T? oldValue, T? newValue, string? commandName) { if (!EqualityComparer.Default.Equals(oldValue, newValue)) { + bool replacesKnownRecoveryBlocker = ReplacesLossyEasing(); if (EditingKeyFrame.Value is { } kf) { kf.Value = newValue!; @@ -577,7 +592,7 @@ internal void SetValue(T? oldValue, T? newValue, string? commandName) prop.SetValue(newValue); } - ResumeElementPersistenceAfterFallbackReplacement(oldValue); + ResumeElementPersistenceAfterReplacement(oldValue, replacesKnownRecoveryBlocker); Commit(commandName); } } @@ -585,6 +600,7 @@ internal void SetValue(T? oldValue, T? newValue, string? commandName) public void SetValue(T? newValue) { T? oldValue; + bool replacesKnownRecoveryBlocker = ReplacesLossyEasing(); if (EditingKeyFrame.Value is { } kf) { oldValue = kf.Value; @@ -597,10 +613,31 @@ public void SetValue(T? newValue) prop.SetValue(newValue); } - ResumeElementPersistenceAfterFallbackReplacement(oldValue); + ResumeElementPersistenceAfterReplacement(oldValue, replacesKnownRecoveryBlocker); Commit(); } + private bool ReplacesLossyEasing() + => PropertyAdapter.GetCoreProperty() == KeyFrame.EasingProperty + && PropertyAdapter is CorePropertyAdapter + { + Object: KeyFrame { HasLossyEasing: true }, + }; + + private void ResumeElementPersistenceAfterReplacement( + object? previous, + bool replacesKnownRecoveryBlocker) + { + if (replacesKnownRecoveryBlocker) + { + ResumeElementPersistenceAfterKnownRecoveryBlockerReplacement(); + } + else + { + ResumeElementPersistenceAfterFallbackReplacement(previous); + } + } + public T? SetCurrentValueAndGetCoerced(T? value) { if (EditingKeyFrame.Value != null) @@ -641,11 +678,14 @@ public override void RemoveKeyFrame(TimeSpan keyTime) { if (GetAnimation() is not KeyFrameAnimation kfAnimation) return; + IKeyFrame[] previousKeyFrames = [.. kfAnimation.KeyFrames]; AnimationOperations.RemoveKeyFrame( animation: kfAnimation, keyTime: keyTime, logger: Logger); - ResumeElementPersistenceAfterFallbackReplacement(kfAnimation); + IKeyFrame? removedKeyFrame = previousKeyFrames.FirstOrDefault( + keyFrame => !kfAnimation.KeyFrames.Contains(keyFrame)); + ResumeElementPersistenceAfterFallbackReplacement(removedKeyFrame); Commit(); } diff --git a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs index db9611345d..b99ce61e48 100644 --- a/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -2693,6 +2693,32 @@ public void StoreToUri_RehomeTargetCollision_FailsWithoutRepointingOrOverwriting }); } + [Test] + public void StoreToUri_ReinstatedRehomeToDifferentCollisionFailsWithoutOverwriting() + { + (Uri sceneUri, string elementPath) = CreatePersistedScene(); + byte[] corruptBytes = "{\"Id\":\"85f4d478-e16d-4cb1-ab71-ee1a90a03fe0\",\"Objects\":["u8.ToArray(); + File.WriteAllBytes(elementPath, corruptBytes); + + Element recovered = CoreSerializer.RestoreFromUri(sceneUri).Children.Single(); + string rehomedPath = Path.Combine(_root, "rehomed", Path.GetFileName(elementPath)); + CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath)); + recovered.SuppressedStorageSource!.WasReinstated = true; + + string foreignPath = Path.Combine(_root, "foreign", Path.GetFileName(elementPath)); + Directory.CreateDirectory(Path.GetDirectoryName(foreignPath)!); + byte[] foreignBytes = "{ foreign sidecar"u8.ToArray(); + File.WriteAllBytes(foreignPath, foreignBytes); + + Assert.Throws(() => CoreSerializer.StoreToUri(recovered, new Uri(foreignPath))); + + Assert.Multiple(() => + { + Assert.That(File.ReadAllBytes(foreignPath), Is.EqualTo(foreignBytes)); + Assert.That(recovered.Uri, Is.EqualTo(new Uri(elementPath))); + }); + } + [Test] public void Serialize_DoesNotMutateLongLivedRecoveryMaps() {