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..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 } }`. +- **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/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/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 23c895ea05..4692e10e95 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,20 @@ 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, + () => + { + suppression.WasReinstated = true; + element.SuppressedStorageSource = suppression; + }); + } + } }, "Agent edit"); @@ -349,6 +364,49 @@ 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 []; + } + + 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 => affectedIds.Contains(child.Id)) + .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( @@ -643,8 +701,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" @@ -678,139 +736,61 @@ 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(); - if (root is IHierarchical hierarchical) + var identities = new Dictionary(); + SerializedGraphTraversal.Visit(root, "$", (node, _) => { - foreach (IFallback fallback in hierarchical.EnumerateAllChildren()) + if (node is IFallback fallback) { - if (fallback is CoreObject coreObject) - { - ids.Add(coreObject.Id); - } + FallbackIdentity identity = CreateFallbackIdentity(fallback); + identities[identity] = identities.GetValueOrDefault(identity) + 1; } - } - if (root is IFallback rootFallback) - { - ids.Add(((CoreObject)rootFallback).Id); - } + return false; + }); - return ids; + return identities; } private static FallbackOccurrence? FindFirstNewFallback( CoreObject root, string path, - HashSet existingFallbackIds) - { - var visited = new HashSet(); - return FindFirstNewFallbackCore(root, path, existingFallbackIds, visited); - } - - private static FallbackOccurrence? FindFirstNewFallbackCore( - CoreObject node, - string path, - HashSet existingFallbackIds, - HashSet visited) + Dictionary existingFallbacks) { - if (!visited.Add(node.Id)) + FallbackOccurrence? result = null; + SerializedGraphTraversal.Visit(root, path, (node, nodePath) => { - return null; - } + 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; + } - if (node is IFallback fallback && !existingFallbackIds.Contains(node.Id)) - { fallback.TryGetTypeName(out string? fallbackTypeName); - return new FallbackOccurrence( - path, - node.Id, + result = new FallbackOccurrence( + nodePath, fallbackTypeName, fallback.Reason.ToString(), fallback.ErrorMessage); - } - - switch (node) - { - 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; - - case Element element: - for (int i = 0; i < element.Objects.Count; i++) - { - if (FindFirstNewFallbackCore( - element.Objects[i], - $"{path}/Objects[{i}]", - existingFallbackIds, - visited) is { } occurrence) - { - return occurrence; - } - } - break; - - case EngineObject engineObject: - foreach (IProperty property in engineObject.Properties) - { - if (FindFirstNewFallbackInValue( - property.CurrentValue, - $"{path}/{property.Name}", - existingFallbackIds, - visited) is { } occurrence) - { - return occurrence; - } - } - break; - } - - return null; + return true; + }); + return result; } - private static FallbackOccurrence? FindFirstNewFallbackInValue( - object? value, - string path, - HashSet existingFallbackIds, - HashSet visited) + private static FallbackIdentity CreateFallbackIdentity(IFallback fallback) { - 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) - { - if (FindFirstNewFallbackInValue( - item, - $"{path}[{index}]", - existingFallbackIds, - visited) is { } occurrence) - { - return occurrence; - } - - index++; - } - - break; - } - } - - return null; + 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 string CreateFallbackHint(FallbackOccurrence occurrence) @@ -1242,9 +1222,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.AgentToolkit/Sessions/FileEditingSession.cs b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs index ae34c11e19..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()) { @@ -153,10 +153,39 @@ 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)!; + var assignedElementPaths = new HashSet( + StringComparer.FromComparison(PathBoundary.Comparison)); foreach (Element element in scene.Children) { - element.Uri = null; + // 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 + // 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); + string sceneRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sceneDirectory)); + string resolvedPath = Path.GetFullPath(Path.Combine(sceneRoot, relativePath)); + if (!resolvedPath.StartsWith( + sceneRoot + Path.DirectorySeparatorChar, + PathBoundary.Comparison)) + { + resolvedPath = Path.Combine(sceneRoot, Path.GetFileName(previousUri.LocalPath)); + } + + resolvedPath = ReserveUniqueElementPath(resolvedPath, assignedElementPaths); + element.Uri = new Uri(resolvedPath); + } + else + { + element.Uri = null; + } } index++; @@ -165,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. @@ -195,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.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 76762f4af6..a1c9fe20f4 100644 --- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs +++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs @@ -1,12 +1,16 @@ -using System.ComponentModel; +using System.Collections; +using System.ComponentModel; using System.Globalization; using Beutl.AgentToolkit.Common; using Beutl.AgentToolkit.Reconciliation; using Beutl.AgentToolkit.Rendering; using Beutl.AgentToolkit.Sessions; using Beutl.AgentToolkit.Workspace; +using Beutl.Animation; using Beutl.Editor; +using Beutl.Engine; using Beutl.ProjectSystem; +using Beutl.Serialization; using ModelContextProtocol.Server; namespace Beutl.AgentToolkit.Tools; @@ -15,7 +19,20 @@ 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 RecoveryIncident( + string SceneId, + string SceneName, + 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); @@ -68,13 +85,95 @@ 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)); + CreateSummary(result.Session, result.Project)) + { + Warnings = recovery.Warnings, + RecoveryIncidents = recovery.RecoveryIncidents, + }; }); } + 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) + { + IFallback[] fallbacks = SerializedGraphTraversal.Enumerate(element) + .OfType() + .ToArray(); + + string elementFile = element.Uri is { IsFile: true } uri + && scene.Uri is { IsFile: true } sceneUri + ? Path.GetRelativePath( + Path.GetDirectoryName(sceneUri.LocalPath)!, + uri.LocalPath).Replace('\\', '/') + : 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( + scene.Id.ToString(), + scene.Name, + elementFile, + fallback.Reason.ToString(), + typeName, + fallback.ErrorMessage)); + string error = string.IsNullOrWhiteSpace(fallback.ErrorMessage) + ? fallback.Reason.ToString() + : fallback.ErrorMessage; + warnings.Add( + $"Element file '{elementFile}' contains content that could not be deserialized: {error}"); + } + + if (element.SuppressedStorageSource is { HasNonFallbackIncidents: true } source) + { + 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}"); + } + } + } + } + + return new DeserializationWarningCollection(warnings, incidents); + } + + private sealed record DeserializationWarningCollection( + IReadOnlyList Warnings, + IReadOnlyList RecoveryIncidents); + [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( @@ -178,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 3818b1637b..679b2feb10 100644 --- a/src/Beutl.Core/CoreObject.cs +++ b/src/Beutl.Core/CoreObject.cs @@ -74,6 +74,10 @@ public string Name public Uri? Uri { 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 ??= []; private Dictionary Errors => _errors ??= []; @@ -184,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)) { @@ -211,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 (ValueReplacement.RequiresReplacement(oldValue, value, forceReferenceReplacement)) { entryT.Value = value; RaisePropertyChanged(property, metadata, value, oldValue); @@ -219,7 +243,7 @@ public void SetValue(CoreProperty property, TValue? value) } else { - if (!EqualityComparer.Default.Equals(metadata.DefaultValue, value)) + if (ValueReplacement.RequiresReplacement(metadata.DefaultValue, value, forceReferenceReplacement)) { entryT = new Entry { Value = value, }; Values[property.Id] = entryT; @@ -270,11 +294,20 @@ 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 = ValueReplacement.RequiresReplacement(field, value, forceReferenceReplacement); 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/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/Properties/AssemblyInfo.cs b/src/Beutl.Core/Properties/AssemblyInfo.cs index ee38697b06..0891fc21df 100644 --- a/src/Beutl.Core/Properties/AssemblyInfo.cs +++ b/src/Beutl.Core/Properties/AssemblyInfo.cs @@ -13,3 +13,5 @@ [assembly: InternalsVisibleTo("Beutl.FFmpegWorker")] [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 new file mode 100644 index 0000000000..66a91f1954 --- /dev/null +++ b/src/Beutl.Core/ReferenceRewriting.cs @@ -0,0 +1,36 @@ +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 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 +{ + /// + /// 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 . + /// + 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 961f3c66af..aea7948c4e 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) { @@ -67,6 +70,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}."); @@ -83,6 +92,7 @@ public static object DeserializeFromJsonObject(JsonObject json, Type baseType, C if (obj is IFallback fallbackObj) { fallbackObj.Reason = FallbackReason.TypeNotFound; + DeserializationIncidents.RecordFallback(fallbackObj); } return obj; @@ -158,7 +168,10 @@ public static object RestoreFromUri(Uri uri, Type type) // 互換性処理 // 1.x で作成されたファイルでは一部のオブジェクトに $type が付与されないため、 // 期待される型に基づいてディスクリミネータを補完する。 - if (!node.TryGetDiscriminator(out Type? _)) + // 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)) { @@ -170,12 +183,51 @@ 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 (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."); } + 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). + 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 { var obj = Activator.CreateInstance(actualType) as ICoreSerializable @@ -192,6 +244,7 @@ public static object RestoreFromUri(Uri uri, Type type) if (obj is IFallback fallbackObj) { fallbackObj.Reason = FallbackReason.TypeNotFound; + DeserializationIncidents.RecordFallback(fallbackObj); } return obj; @@ -227,6 +280,133 @@ 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) + { + 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 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; + RestoreReinstatedBytes(suppressed, sourcePath); + return; + } + + if (suppressed.WasReinstated && uri == suppressedObj.Uri) + { + RestoreReinstatedBytes(suppressed, uri.LocalPath); + return; + } + + if (uri.Scheme != "file") + { + throw new JsonException(); + } + + // 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)) + { + 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) + { + Directory.CreateDirectory(rehomedDirectory); + } + + string tempPath = $"{rehomedPath}.{Guid.NewGuid():N}.tmp"; + try + { + 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)) + { + try + { + EnsureExistingBytesMatch(rehomedPath, suppressed.RawBytes); + } + catch + { + suppressedObj.Uri = suppressed.SourceUri; + throw; + } + + suppressed.WasReinstated = false; + suppressedObj.Uri = uri; + return; + } + } + finally + { + try + { + File.Delete(tempPath); + } + catch + { + } + } + + suppressed.WasReinstated = false; + suppressedObj.Uri = uri; + return; + } + if (uri.Scheme == "file") { if (obj is CoreObject coreObj) @@ -279,4 +459,166 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n throw new JsonException(); } } + + private static void CopyReferencedStorageSources( + SuppressedStorageSource suppressed, + 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( + PathBoundary.ResolveDeepestExistingTarget( + authorizedRootPath + ?? Path.GetDirectoryName(rehomedUri.LocalPath) + ?? throw new JsonException("Rehomed element has no destination directory."))); + var copies = new List<(SuppressedReferencedStorageSource Source, string Destination)>(); + foreach (SuppressedReferencedStorageSource source in referencedSources) + { + string relativePath = authorizedRootPath is null + ? source.ElementRelativePath + : source.RelativePath; + if (Path.IsPathRooted(relativePath)) + { + throw new JsonException($"Invalid retained sidecar path: {relativePath}"); + } + + string destination = Path.GetFullPath(Path.Combine(destinationRoot, relativePath)); + string resolvedDestination = PathBoundary.ResolveDeepestExistingTarget(destination); + if (!PathBoundary.IsPathInsideRoot(destinationRoot, resolvedDestination)) + { + throw new JsonException($"Retained sidecar escapes the Save As root: {relativePath}"); + } + + copies.Add((source, destination)); + } + + foreach ((SuppressedReferencedStorageSource source, string destination) in copies) + { + if (File.Exists(destination)) + { + EnsureExistingBytesMatch(destination, source.RawBytes); + } + } + + foreach ((SuppressedReferencedStorageSource source, string destination) in copies) + { + WriteBytesAtomicallyIfMatchingOrMissing(destination, source.RawBytes); + } + } + + private static void WriteBytesAtomicallyIfMatchingOrMissing(string path, byte[] bytes) + { + if (File.Exists(path)) + { + EnsureExistingBytesMatch(path, bytes); + 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)) + { + EnsureExistingBytesMatch(path, bytes); + } + } + finally + { + try + { + File.Delete(tempPath); + } + catch + { + } + } + } + + 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)) + { + return; + } + + 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); + } + + suppressed.WasReinstated = false; + } + + 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.Core/Serialization/DeserializationIncidents.cs b/src/Beutl.Core/Serialization/DeserializationIncidents.cs new file mode 100644 index 0000000000..064d18f284 --- /dev/null +++ b/src/Beutl.Core/Serialization/DeserializationIncidents.cs @@ -0,0 +1,86 @@ +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; + + [ThreadStatic] + private static Capture? t_capture; + + internal static int FallbackCount => t_fallbackCount; + + 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(incident); + } + } + + internal sealed class Capture : IDisposable + { + private readonly int _initialCount; + private List? _incidents; + 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 Incidents => _incidents ?? []; + + internal void Record(DeserializationIncident incident) + => (_incidents ??= []).Add(incident); + + 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; + } + } + + internal sealed record DeserializationIncident( + IFallback? Fallback, + FallbackReason? Reason, + string? TypeName, + string? Message); +} diff --git a/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs b/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs index 1bf111f1b2..485bec0e11 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(fallback); return fallback; } } diff --git a/src/Beutl.Core/Serialization/FilePathBoundary.cs b/src/Beutl.Core/Serialization/FilePathBoundary.cs new file mode 100644 index 0000000000..91fae4d192 --- /dev/null +++ b/src/Beutl.Core/Serialization/FilePathBoundary.cs @@ -0,0 +1,142 @@ +namespace Beutl; + +internal static class PathBoundary +{ + 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) + { + 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); + } + + public 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.Core/Serialization/JsonSerializationContext.Deserialize.cs b/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs index c6a3922b1f..54a1eeede8 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}."); @@ -110,6 +116,7 @@ private static bool TryDeserializeCoreSerializable( if (instance is IFallback fallbackObj) { fallbackObj.Reason = FallbackReason.TypeNotFound; + DeserializationIncidents.RecordFallback(fallbackObj); } result = instance; diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs new file mode 100644 index 0000000000..2f086c7904 --- /dev/null +++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Nodes; + +namespace Beutl; + +/// +/// 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( + byte[] RawBytes, + Uri SourceUri, + bool HasNonFallbackIncidents = false, + JsonObject[]? UntraversedFallbacks = null, + SuppressedReferencedStorageSource[]? ReferencedStorageSources = null, + string? SourceRootPath = null, + SuppressedRecoveryIncident[]? RecoveryIncidents = null) +{ + /// + /// 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; } +} + +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/TypeFormat.cs b/src/Beutl.Core/TypeFormat.cs index 540a8cf4df..0f116d6ee9 100644 --- a/src/Beutl.Core/TypeFormat.cs +++ b/src/Beutl.Core/TypeFormat.cs @@ -10,8 +10,20 @@ 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 ArgumentException + or InvalidOperationException) + { + return null; + } } public static string ToString(Type type) @@ -324,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/src/Beutl.Core/ValueReplacement.cs b/src/Beutl.Core/ValueReplacement.cs new file mode 100644 index 0000000000..8a338b4cd3 --- /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.Editor/AutoSaveService.cs b/src/Beutl.Editor/AutoSaveService.cs index 5082bb0456..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.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.Editor/Services/ElementObjectService.cs b/src/Beutl.Editor/Services/ElementObjectService.cs index c86864e9f9..a4bd221ab4 100644 --- a/src/Beutl.Editor/Services/ElementObjectService.cs +++ b/src/Beutl.Editor/Services/ElementObjectService.cs @@ -45,6 +45,17 @@ public bool Remove(Element element, EngineObject obj) if (!element.Objects.Contains(obj)) return false; element.RemoveObject(obj); + if (Scene.TryResumeElementPersistence(element) is { } suppression) + { + _historyManager.Record( + () => element.SuppressedStorageSource = null, + () => + { + suppression.WasReinstated = true; + element.SuppressedStorageSource = suppression; + }); + } + _historyManager.Commit(CommandNames.RemoveObject); return true; } @@ -85,6 +96,17 @@ public ObjectPasteOutcome PasteOver(Element element, int index, string json) CoreSerializer.PopulateFromJsonObject(obj, type, newJson); element.Objects[index] = obj; + if (Scene.TryResumeElementPersistence(element) is { } suppression) + { + _historyManager.Record( + () => element.SuppressedStorageSource = null, + () => + { + suppression.WasReinstated = true; + element.SuppressedStorageSource = suppression; + }); + } + _historyManager.Commit(CommandNames.PasteObject); return ObjectPasteOutcome.Pasted; } diff --git a/src/Beutl.Engine/Animation/IKeyFrame.cs b/src/Beutl.Engine/Animation/IKeyFrame.cs index f2cb8bb502..5151f5cfb1 100644 --- a/src/Beutl.Engine/Animation/IKeyFrame.cs +++ b/src/Beutl.Engine/Animation/IKeyFrame.cs @@ -17,6 +17,12 @@ 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); diff --git a/src/Beutl.Engine/Animation/KeyFrame.cs b/src/Beutl.Engine/Animation/KeyFrame.cs index 401c947f59..535df90070 100644 --- a/src/Beutl.Engine/Animation/KeyFrame.cs +++ b/src/Beutl.Engine/Animation/KeyFrame.cs @@ -1,7 +1,11 @@ -using System.Text.Json.Nodes; +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; @@ -11,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() @@ -37,6 +42,9 @@ public Easing Easing set => SetAndRaise(EasingProperty, ref _easing, value); } + [NotAutoSerialized] + internal bool HasLossyEasing => ReferenceEquals(_easing, _lossyFallbackEasing); + public TimeSpan KeyTime { get => _keyTime; @@ -49,28 +57,115 @@ 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 (easingNode is JsonValue easingTypeValue - && easingTypeValue.TryGetValue(out string? easingType)) + if (context.Contains(nameof(Easing))) { - Type type = TypeFormat.ToType(easingType) ?? typeof(LinearEasing); - - if (Activator.CreateInstance(type) is Easing easing) + 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) + { + 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( + FallbackReason.DeserializationFailed, + easingType, + $"The easing type '{easingType}' cannot be instantiated as an Easing."); + } + else + { + try + { + if (Activator.CreateInstance(type) is Easing easing) + { + Easing = easing; + } + else + { + UseFallbackEasing( + FallbackReason.DeserializationFailed, + easingType, + $"The easing type '{easingType}' did not create an Easing instance."); + } + } + catch (Exception ex) when (ex is MissingMethodException + or MemberAccessException + or TargetInvocationException + or TypeInitializationException + or NotSupportedException) { - Easing = easing; + if (ExceptionHelpers.ContainsFileSystemFailure(ex)) + { + if (ex.InnerException is { } inner) + { + ExceptionDispatchInfo.Capture(inner).Throw(); + } + + throw; + } + + UseFallbackEasing( + FallbackReason.DeserializationFailed, + easingType, + $"{ex.GetType().Name}: {ex.Message}"); } } - else if (easingNode is JsonObject easingObject) + } + else if (easingNode is JsonObject easingObject) + { + if (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); } + else + { + UseFallbackEasing( + FallbackReason.DeserializationFailed, + null, + "The spline easing object does not contain four valid control-point values."); + } } + else + { + UseFallbackEasing( + FallbackReason.DeserializationFailed, + null, + "The easing value has an unsupported JSON representation."); + } + } + + private void UseFallbackEasing( + FallbackReason reason, + string? typeName, + string message) + { + DeserializationIncidents.RecordFallback(reason, typeName, message); + _lossyFallbackEasing = new LinearEasing(); + Easing = _lossyFallbackEasing; } public override void Serialize(ICoreSerializationContext context) diff --git a/src/Beutl.Engine/Animation/KeyFrame{T}.cs b/src/Beutl.Engine/Animation/KeyFrame{T}.cs index a17f5ab04b..8f654de76a 100644 --- a/src/Beutl.Engine/Animation/KeyFrame{T}.cs +++ b/src/Beutl.Engine/Animation/KeyFrame{T}.cs @@ -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 IKeyFrame.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..56c77df6c3 100644 --- a/src/Beutl.Engine/Engine/AnimatableProperty.cs +++ b/src/Beutl.Engine/Engine/AnimatableProperty.cs @@ -42,34 +42,43 @@ public AnimatableProperty(T defaultValue, IValidator? validator = null) public T CurrentValue { get => _currentValue; - set - { - var validatedValue = ValidateAndCoerce(value); - if (!EqualityComparer.Default.Equals(_currentValue, validatedValue)) - { - var oldValue = _currentValue; - _currentValue = validatedValue; - HasLocalValue = true; + set => SetCurrentValue(value, replaceEquivalent: false); + } - 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); + private void SetCurrentValue(T value, bool replaceEquivalent) + { + var validatedValue = ValidateAndCoerce(value); + bool hasReplacement = ValueReplacement.RequiresReplacement( + _currentValue, + validatedValue, + replaceEquivalent); + if (hasReplacement) + { + var oldValue = _currentValue; + _currentValue = validatedValue; + HasLocalValue = true; - if (validatedValue is IHierarchical newHierarchical) - ownerHierarchical.AddChild(newHierarchical); - } + 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 (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; } } + public void ReplaceCurrentValue(T value) + => SetCurrentValue(value, replaceEquivalent: true); + public IAnimation? Animation { get => _animation; diff --git a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs index 0ef9839948..e68b59a16c 100644 --- a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs +++ b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs @@ -9,4 +9,11 @@ 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 preserve all of its state while rebinding. + /// + IReferenceExpression? Rebind(Guid objectId); } 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.Engine/Engine/IProperty.cs b/src/Beutl.Engine/Engine/IProperty.cs index 626fc6bf4b..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; } @@ -67,6 +73,8 @@ public interface IProperty : IProperty new T CurrentValue { get; set; } + void ReplaceCurrentValue(T value); + new IAnimation? Animation { get; set; } new IExpression? Expression { get; set; } @@ -105,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 20b83d9c83..9b1bcf351a 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 { private IValidator? _validator = validator; private T _currentValue = defaultValue; @@ -32,34 +33,43 @@ 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 = ValueReplacement.RequiresReplacement( + _currentValue, + validatedValue, + replaceEquivalent); + 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; } } + public void ReplaceCurrentValue(T value) + => SetCurrentValue(value, replaceEquivalent: true); + public IAnimation? Animation { get => null; diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs index 6668d59fc9..6af354482c 100644 --- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs +++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs @@ -1,12 +1,22 @@ -using System.Collections.Immutable; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Immutable; using System.Collections.Specialized; using System.ComponentModel; 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; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Beutl.Animation; using Beutl.Collections; using Beutl.Configuration; +using Beutl.Engine; +using Beutl.Engine.Expressions; using Beutl.Language; using Beutl.Media; using Beutl.Serialization; @@ -43,6 +53,20 @@ public enum ElementOverlapHandling 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( + "\"Id\"\\s*:\\s*\"(?[0-9a-fA-F-]{36})\"", + RegexOptions.CultureInvariant); + 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; @@ -55,6 +79,13 @@ 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 _recoveredDescendantIdentities = new(StringComparer.Ordinal); + private readonly ConditionalWeakTable _recoveredDescendantRemaps = new(); + private readonly Dictionary _recoveredElementIds = new(StringComparer.Ordinal); + private readonly Dictionary _pendingRecoveredElementIdMigrations = []; + private readonly Dictionary _pendingRecoveredDescendantIdMigrations = []; + private readonly ConditionalWeakTable _idlessRecoveredDescendants = new(); private TimeSpan _start = TimeSpan.FromMinutes(0); private TimeSpan _duration = TimeSpan.FromMinutes(5); private PixelSize _frameSize; @@ -539,12 +570,53 @@ 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); + RecoveredSerializationState recoveredState = BuildRecoveredSerializationState(); + if (recoveredState.ElementIds.Count > 0) + { + var recoveredElementIds = new JsonObject(); + foreach ((string path, Guid id) in recoveredState.ElementIds.OrderBy( + static item => item.Key, + StringComparer.Ordinal)) + { + recoveredElementIds[path] = id.ToString(); + } + + context.SetValue(RecoveredElementIdsKey, recoveredElementIds); + } + + if (recoveredState.DescendantIds.Count > 0) + { + var recoveredDescendantIds = new JsonObject(); + foreach ((string key, Guid id) in recoveredState.DescendantIds.OrderBy( + static item => item.Key, + StringComparer.Ordinal)) + { + recoveredDescendantIds[key] = id.ToString(); + } + + context.SetValue(RecoveredDescendantIdsKey, recoveredDescendantIds); + } + + if (recoveredState.DescendantIdentities.Count > 0) + { + var recoveredDescendantIdentities = new JsonObject(); + foreach ((string key, Guid id) in recoveredState.DescendantIdentities.OrderBy( + static item => item.Key, + StringComparer.Ordinal)) + { + recoveredDescendantIdentities[key] = id.ToString(); + } + + context.SetValue(RecoveredDescendantIdentitiesKey, recoveredDescendantIdentities); + } 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); } } @@ -596,6 +668,59 @@ static void Process(Func add, JsonNode node, List list) FrameSize = new PixelSize(context.GetValue("Width"), context.GetValue("Height")); } + _pendingRecoveredElementIdMigrations.Clear(); + _pendingRecoveredDescendantIdMigrations.Clear(); + _idlessRecoveredDescendants.Clear(); + _recoveredDescendantIds.Clear(); + _recoveredDescendantIdentities.Clear(); + _recoveredDescendantRemaps.Clear(); + _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(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(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[key] = id; + } + } + } + + 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) @@ -637,6 +762,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) @@ -646,12 +772,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) @@ -670,13 +790,1689 @@ private void SyncronizeFiles(IEnumerable pathToElement) Children.Remove(item); } - Children.AddRange(urisAdd.AsParallel().Select(CoreSerializer.RestoreFromUri)); + Children.AddRange(urisAdd.AsParallel().Select(RestoreElementOrFallback)); + ReassignDuplicateRecoveredIds(); + MigrateRecoveredElementReferences(); activity?.SetTag("addCount", urisAdd.Length); activity?.SetTag("removeCount", elementsRemove.Length); activity?.SetTag("childrenCount", Children.Count); } + private void ReassignDuplicateRecoveredIds() + { + string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; + var recoveredChildren = Children + .Where(static child => child.SuppressedStorageSource is not null) + .Select(child => ( + Child: child, + RelativePath: NormalizeRelativePath( + Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)))) + .OrderBy(static item => item.RelativePath, StringComparer.Ordinal) + .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 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 => ( + Child: child, + RelativePath: NormalizeRelativePath( + Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath)))) + .OrderBy(static item => item.RelativePath, StringComparer.Ordinal) + .ToArray(); + + void ClaimHealthyDescendants(Element child) + { + foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + { + seenDescendants.Add(descendant); + claimedIds.Add(descendant.Id); + } + } + + void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePath) + { + var occurrences = new Dictionary(); + var legacyIndices = new Dictionary(ReferenceEqualityComparer.Instance); + int legacyIndex = 0; + foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + { + 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; + string remapKey = CreateRecoveredDescendantKey(relativePath, originalId, occurrence); + bool hasPersistedId = persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId); + bool hasPersistedIdentity = persistedDescendantIdentities.TryGetValue( + identityKey, + out Guid persistedIdentityId); + bool ambiguousPositionalIdentity = false; + if (!hasPersistedIdentity && graphPath.Positional != graphPath.Stable) + { + hasPersistedIdentity = TryGetRecoveredDescendantPositionalIdentity( + persistedDescendantIdentities, + relativePath, + graphPath.Positional, + out persistedIdentityId, + out ambiguousPositionalIdentity); + } + + if (!hasPersistedIdentity + && !ambiguousPositionalIdentity + && 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; + + if (claimedIds.Add(originalId)) + { + if (hasPreviousAssignedId) + { + _pendingRecoveredDescendantIdMigrations.TryAdd(previousAssignedId, originalId); + } + + continue; + } + + Guid assignedId = hasPreviousAssignedId && claimedIds.Add(previousAssignedId) + ? previousAssignedId + : ClaimRecoveredDescendantId(relativePath, remapKey, claimedIds); + descendant.Id = assignedId; + _pendingRecoveredDescendantIdMigrations.TryAdd( + hasPreviousAssignedId ? previousAssignedId : assignedId, + assignedId); + } + } + + 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); + } + + ClaimPreviouslyRecoveredHealthyDescendants(child, relativePath); + } + + foreach ((Element child, string relativePath) in healthyChildren) + { + if (_recoveredElementIds.Remove(relativePath, out Guid placeholderId)) + { + _pendingRecoveredElementIdMigrations.TryAdd(placeholderId, child.Id); + } + } + + 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; + } + + child.Id = ClaimRecoveredElementId(relativePath, claimedIds); + } + + foreach ((Element child, string relativePath) in recoveredChildren) + { + _recoveredElementIds[relativePath] = child.Id; + } + + _recoveredDescendantIds.Clear(); + _recoveredDescendantRemaps.Clear(); + var pendingDescendantRemaps + = new Dictionary( + ReferenceEqualityComparer.Instance); + foreach ((Element child, string relativePath) in recoveredChildren) + { + var occurrences = new Dictionary(); + int idlessOccurrence = 0; + foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child)) + { + if (!seenDescendants.Add(descendant)) + { + continue; + } + + bool idless = _idlessRecoveredDescendants.TryGetValue(descendant, out _); + 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)) + { + 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)) + { + if (!pendingDescendantRemaps.Remove( + descendant, + out (string RemapKey, Guid OriginalId, int Occurrence) remap)) + { + continue; + } + + Guid candidate = ClaimRecoveredDescendantId(relativePath, remap.RemapKey, claimedIds); + descendant.Id = candidate; + RecordRecoveredDescendantRemap( + descendant, + remap.RemapKey, + remap.OriginalId, + candidate, + remap.Occurrence); + + } + } + + // 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)) + { + foreach (CoreObject graphObject in EnumerateSerializedGraphObjects(sceneObject).OfType()) + { + retainedIds.Add(graphObject.Id); + } + } + + 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 (_pendingRecoveredDescendantIdMigrations[originalId] != originalId + && retainedIds.Contains(originalId)) + { + _pendingRecoveredDescendantIdMigrations.Remove(originalId); + } + } + } + + 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 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, + Guid originalId, + Guid assignedId, + int occurrence) + { + _recoveredDescendantIds[remapKey] = assignedId; + _recoveredDescendantRemaps.Remove(descendant); + _recoveredDescendantRemaps.Add( + descendant, + new RecoveredDescendantRemap(originalId, assignedId, occurrence)); + if (originalId != Guid.Empty) + { + _pendingRecoveredDescendantIdMigrations.TryAdd(originalId, assignedId); + } + + if (descendant is IFallback fallback) + { + EnsureFallbackProjection(fallback); + } + } + + private Element RestoreElementOrFallback(Uri uri) + { + using DeserializationIncidents.Capture incidentCapture = DeserializationIncidents.BeginCapture(); + try + { + Element element = CoreSerializer.RestoreFromUri(uri); + IFallback[] fallbacks = EnumerateSerializedGraphFallbacks(element).ToArray(); + int incidentCount = incidentCapture.Count; + + if (fallbacks.Length > 0 || incidentCount > 0) + { + var traversedFallbacks = new HashSet( + fallbacks, + ReferenceEqualityComparer.Instance); + 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) + { + if (fallback is CoreObject fallbackObject) + { + if (TryGetSerializedId(fallback.Json, out Guid serializedId)) + { + fallbackObject.Id = serializedId; + } + else + { + _idlessRecoveredDescendants.GetValue( + fallbackObject, + static _ => new IdlessRecoveredDescendant()); + } + } + + EnsureFallbackProjection(fallback); + } + + MarkRecoveredElement( + element, + File.ReadAllBytes(uri.LocalPath), + uri, + recoveryIncidents.Length > 0, + untraversedFallbacks, + recoveryIncidents); + } + + return element; + } + 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 + // for top-level recovery metadata. + byte[] rawBytes = File.ReadAllBytes(uri.LocalPath); + string rawText = DecodeRecoveryMetadata(rawBytes); + byte[] metadataBytes = Encoding.UTF8.GetBytes(rawText); + JsonObject? root = TryParseTopLevelObject(rawText); + var element = new Element + { + Id = ResolveRecoveredElementId(metadataBytes, rawText, root, uri), + Name = Path.GetFileNameWithoutExtension(uri.LocalPath), + Uri = uri, + IsEnabled = false, + }; + string? topLevelTypeName = TryGetTopLevelTypeName(metadataBytes, rawText, root); + 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, + ErrorMessage = fallbackReason == FallbackReason.DeserializationFailed + ? $"{ex.GetType().Name}: {ex.Message}" + : null, + }; + fallback.Json = CreateFallbackProjection(fallback, topLevelTypeName); + element.AddObject(fallback); + _idlessRecoveredDescendants.GetValue( + fallback, + static _ => new IdlessRecoveredDescendant()); + MarkRecoveredElement(element, rawBytes, uri); + return element; + } + } + + 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 id) + && id != Guid.Empty; + } + + private sealed class IdlessRecoveredDescendant; + + private void MarkRecoveredElement( + Element element, + byte[] rawBytes, + Uri uri, + bool hasNonFallbackIncidents = false, + JsonObject[]? untraversedFallbacks = null, + SuppressedRecoveryIncident[]? recoveryIncidents = 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, + CollectReferencedStorageSources(element, uri, 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( + Element element, + Uri elementUri, + string sourceRootPath) + { + string sourceRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceRootPath)); + string resolvedSourceRoot = Path.TrimEndingDirectorySeparator( + PathBoundary.ResolveDeepestExistingTarget(sourceRoot)); + string elementPath = Path.GetFullPath(elementUri.LocalPath); + 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(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 = PathBoundary.ResolveDeepestExistingTarget(sourcePath); + if (string.Equals( + resolvedSourcePath, + resolvedElementPath, + PathBoundary.Comparison) + || !PathBoundary.IsPathInsideRoot(resolvedSourceRoot, resolvedSourcePath) + || !File.Exists(sourcePath) + || !seenPaths.Add(resolvedSourcePath)) + { + continue; + } + + result.Add(new SuppressedReferencedStorageSource( + File.ReadAllBytes(sourcePath), + Path.GetRelativePath(sourceRoot, sourcePath), + Path.GetRelativePath(elementDirectory, sourcePath))); + } + + return result.Count > 0 ? result.ToArray() : null; + } + + 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; + } + + element.SuppressedStorageSource = null; + 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) + { + 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(); + } + + private static IEnumerable EnumerateSerializedGraphDescendants(Element element) + { + return EnumerateSerializedGraphObjects(element) + .OfType() + .Where(value => !ReferenceEquals(value, element)); + } + + private void MigrateRecoveredElementReferences() + { + if (_pendingRecoveredElementIdMigrations.Count == 0 + && _pendingRecoveredDescendantIdMigrations.Count == 0) + { + return; + } + + 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())) + { + if (!property.GetMetadata(coreObject.GetType()).ShouldSerialize) + { + continue; + } + + object? currentValue = coreObject.GetValue(property); + object? migratedValue = MigrateRecoveredReferenceValue(currentValue, rewriteState); + if (HasReferenceRewrite(currentValue, migratedValue) + && property is not IStaticProperty { CanWrite: false }) + { + coreObject.ReplaceValue(property, migratedValue); + } + } + + if (coreObject is not EngineObject engineObject) + { + continue; + } + + foreach (IProperty property in engineObject.Properties) + { + object? currentValue = property.CurrentValue; + object? migratedValue = MigrateRecoveredReferenceValue(currentValue, rewriteState); + if (HasReferenceRewrite(currentValue, migratedValue)) + { + property.ReplaceCurrentValue(migratedValue); + } + + 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) + { + object? keyFrameValue = keyFrame.Value; + object? migratedKeyFrameValue = MigrateRecoveredReferenceValue( + keyFrameValue, + rewriteState); + if (HasReferenceRewrite(keyFrameValue, migratedKeyFrameValue)) + { + keyFrame.ReplaceValue(migratedKeyFrameValue); + } + } + } + } + } + } + + private bool TryGetMigratedId(Guid originalId, out Guid migratedId) + { + return _pendingRecoveredElementIdMigrations.TryGetValue(originalId, out migratedId) + || _pendingRecoveredDescendantIdMigrations.TryGetValue(originalId, out migratedId); + } + + private static object ResolveMigratedReference( + IReference reference, + Guid migratedId, + RecoveredReferenceRewriteState state) + { + if (state.ReferenceTargets.TryGetValue(migratedId, out CoreObject? target) + && reference.ObjectType.IsInstanceOfType(target)) + { + return reference.Resolved(target); + } + + return reference; + } + + private object? MigrateRecoveredReferenceValue( + object? value, + RecoveredReferenceRewriteState state) + { + if (value is IReference reference) + { + object rewritten = TryGetMigratedId(reference.Id, out Guid migratedId) + ? ResolveMigratedReference(reference, migratedId, state) + : value; + if (HasReferenceRewrite(value, rewritten)) + { + state.RecordRewrite(); + } + + return rewritten; + } + + if (value is IOptional { HasValue: true } optional) + { + object? item = optional.ToObject().Value; + object? migratedItem = MigrateRecoveredReferenceValue(item, state); + if (HasReferenceRewrite(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) + { + return value; + } + + bool trackReference = !value.GetType().IsValueType; + if (trackReference) + { + if (state.Memo.TryGetValue(value, out RecoveredReferenceRewriteEntry? cached)) + { + if (state.ActiveRewritables.TryPeek(out RecoveredReferenceRewriteEntry? parent)) + { + parent.Dependencies.Add(cached); + } + + return cached.ShouldUseTargetDuringTraversal() + ? cached.Target + : cached.Source; + } + + if (!state.Active.Add(value)) + { + return value; + } + } + + try + { + object? rewrittenValue = value; + if (value is IReferenceRewritable rewritable) + { + IReferenceRewritable target = rewritable.CreateReferenceRewriteTarget(); + if (target is not null && target.GetType() == value.GetType()) + { + 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); + 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 + { + state.RewriteCount = rewriteCount; + } + } + } + + if (trackReference) + { + if (!state.Memo.ContainsKey(value)) + { + state.Memo[value] = new RecoveredReferenceRewriteEntry(value, rewrittenValue) + { + Complete = true, + DirectChanged = HasReferenceRewrite(value, rewrittenValue), + }; + } + } + + return rewrittenValue; + } + finally + { + if (trackReference) + { + state.Active.Remove(value); + } + } + } + + 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)) + { + return false; + } + + 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( + IReadOnlyDictionary referenceTargets) + { + public IReadOnlyDictionary ReferenceTargets { get; } = referenceTargets; + + public HashSet Active { get; } = new(ReferenceEqualityComparer.Instance); + + public Dictionary Memo { get; } + = new(ReferenceEqualityComparer.Instance); + + public Stack ActiveRewritables { get; } = new(); + + public int RewriteCount { get; set; } + + public void RecordRewrite() + { + RewriteCount++; + if (ActiveRewritables.TryPeek(out RecoveredReferenceRewriteEntry? entry)) + { + entry.DirectChanged = true; + } + } + } + + private sealed class RecoveredReferenceRewriteEntry(object source, object? target) + { + public object Source { get; } = source; + + public object? Target { get; } = target; + + public HashSet Dependencies { get; } + = new(ReferenceEqualityComparer.Instance); + + public bool DirectChanged { get; set; } + + public bool Complete { get; set; } + + public bool ShouldUseTargetDuringTraversal() + { + return !Complete + || Dependencies.Any(static dependency => !dependency.Complete) + || IsChanged(new HashSet(ReferenceEqualityComparer.Instance)); + } + + private bool IsChanged(ISet visited) + { + if (DirectChanged) + { + return true; + } + + 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) + { + 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 IOptional optional) + { + if (optional.HasValue) + { + CollectSerializedGraphObjectPaths(optional.ToObject().Value, path, visited, objects); + } + + 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 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) + { + 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 bool TryGetRecoveredDescendantPositionalIdentity( + IReadOnlyDictionary identities, + string relativePath, + string positionalPath, + 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 (key.StartsWith(keyPrefix, StringComparison.Ordinal) + && NormalizeSerializedGraphPositionalPath(key[keyPrefix.Length..]) == normalizedPath) + { + candidates.Add(id); + } + } + + ambiguous = candidates.Count > 1; + if (candidates.Count == 1) + { + identityId = candidates.Single(); + return true; + } + + identityId = Guid.Empty; + return false; + } + + 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"); + 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) + { + return; + } + + JsonObject json = fallback.Json ?? new JsonObject(); + if (!json.ContainsKey("$type") && !json.ContainsKey("@type")) + { + json.WriteDiscriminator(coreObject.GetType()); + } + + json[nameof(CoreObject.Id)] = coreObject.Id.ToString(); + fallback.Json = json; + } + + private static JsonObject CreateFallbackProjection(FallbackEngineObject fallback, string? typeName = null) + { + var json = new JsonObject + { + [nameof(CoreObject.Id)] = fallback.Id.ToString(), + [nameof(CoreObject.Name)] = fallback.Name, + }; + if (typeName is not null) + { + json["$type"] = typeName; + } + else + { + json.WriteDiscriminator(typeof(FallbackEngineObject)); + } + + return json; + } + + private static JsonObject? TryParseTopLevelObject(string rawText) + { + try + { + return JsonNode.Parse(rawText) as JsonObject; + } + catch (JsonException) + { + return null; + } + } + + 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, + 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) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(match.Groups["type"].Value); + } + catch (JsonException) + { + return null; + } + } + + 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); + Match? topLevelMatch = FindTopLevelMatch(rawText, matches); + if (topLevelMatch != null + && Guid.TryParse(topLevelMatch.Groups["id"].Value, out Guid topLevelId) + && topLevelId != Guid.Empty) + { + return topLevelId; + } + + string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!; + string relativePath = NormalizeRelativePath(Path.GetRelativePath(sceneDirectory, uri.LocalPath)); + 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 RecoveredSerializationState BuildRecoveredSerializationState() + { + if (Uri is null) + { + 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) + { + return RecoveredSerializationState.Empty; + } + + string sceneDirectory = Path.GetDirectoryName(Uri.LocalPath)!; + 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)); + elementIds[relativePath] = child.Id; + foreach ((CoreObject descendant, SerializedGraphPath graphPath) in + EnumerateSerializedGraphDescendantPaths(child)) + { + if (descendant is IFallback) + { + string identityKey = CreateRecoveredDescendantIdentityKey(relativePath, graphPath.Stable); + descendantIdentities[identityKey] = descendant.Id; + if (graphPath.Positional != graphPath.Stable) + { + string positionalIdentityKey = CreateRecoveredDescendantIdentityKey( + relativePath, + graphPath.Positional); + descendantIdentities[positionalIdentityKey] = descendant.Id; + } + } + + if (_recoveredDescendantRemaps.TryGetValue(descendant, out RecoveredDescendantRemap? remap) + && descendant.Id == remap.AssignedId) + { + string remapKey = CreateRecoveredDescendantKey( + relativePath, + remap.OriginalId, + remap.Occurrence); + 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}"; + } + + private static string CreateRecoveredDescendantIdentityKey(string relativePath, string graphPath) + { + return $"{relativePath}!path:{graphPath}"; + } + + private static string CreateLegacyRecoveredDescendantIdentityKey(string relativePath, int index) + { + return $"{relativePath}!@{index}"; + } + + private static string NormalizeRelativePath(string path) + { + return path.Replace('\\', '/'); + } + + 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 = rootStart; i < rawText.Length && matchIndex < matches.Count; i++) + { + Match match = matches[matchIndex]; + if (i == match.Index) + { + if (!inString && objectDepth == 1 && arrayDepth == 0) + { + 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--; + if (objectDepth == 0) + { + return null; + } + } + else if (current == '[') + { + arrayDepth++; + } + else if (current == ']' && arrayDepth > 0) + { + arrayDepth--; + } + } + + 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 void UpdateInclude() { string dirPath = Path.GetDirectoryName(Uri!.LocalPath)!; @@ -1035,7 +2831,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/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 fccf3009d5..0b1e497617 100644 --- a/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs +++ b/src/Beutl.ProjectSystem/Properties/AssemblyInfo.cs @@ -1,6 +1,9 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Beutl")] +[assembly: InternalsVisibleTo("Beutl.Editor")] [assembly: InternalsVisibleTo("Beutl.NodeGraph")] [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 new file mode 100644 index 0000000000..af9a53722b --- /dev/null +++ b/src/Beutl.Utilities/ExceptionHelpers.cs @@ -0,0 +1,72 @@ +using System.Reflection; + +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) + => 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); + pending.Push(exception); + + while (pending.TryPop(out Exception? current)) + { + if (!visited.Add(current)) + { + continue; + } + + if (predicate(current)) + { + return true; + } + + if (current is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + { + 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); + } + } + + return false; + } +} 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/AudioEffectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs index 228d2494ba..4c83f3f30b 100644 --- a/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs @@ -122,7 +122,8 @@ public AudioEffectEditorViewModel(IPropertyAdapter property) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + AudioEffect? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); } public override void Accept(IPropertyEditorContextVisitor visitor) diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index 242d8d055e..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; @@ -185,6 +186,35 @@ protected BaseEditorViewModel(IPropertyAdapter property) protected ImmutableArray GetStorables() => [_element]; + protected void ResumeElementPersistenceAfterFallbackReplacement(object? previous) + { + if (_element is { SuppressedStorageSource: not null } + && Scene.TryResumeElementPersistence(_element, previous) is { } 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) @@ -543,9 +573,15 @@ 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)) { + bool replacesKnownRecoveryBlocker = ReplacesLossyEasing(); if (EditingKeyFrame.Value is { } kf) { kf.Value = newValue!; @@ -556,25 +592,52 @@ public void SetValue(T? oldValue, T? newValue) prop.SetValue(newValue); } - Commit(); + ResumeElementPersistenceAfterReplacement(oldValue, replacesKnownRecoveryBlocker); + Commit(commandName); } } public void SetValue(T? newValue) { + T? oldValue; + bool replacesKnownRecoveryBlocker = ReplacesLossyEasing(); if (EditingKeyFrame.Value is { } kf) { + oldValue = kf.Value; kf.Value = newValue!; } else { IPropertyAdapter prop = PropertyAdapter; + oldValue = prop.GetValue(); prop.SetValue(newValue); } + 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) @@ -615,10 +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); + IKeyFrame? removedKeyFrame = previousKeyFrames.FirstOrDefault( + keyFrame => !kfAnimation.KeyFrames.Contains(keyFrame)); + ResumeElementPersistenceAfterFallbackReplacement(removedKeyFrame); Commit(); } @@ -650,7 +717,9 @@ public override void RemoveAnimation() { if (PropertyAdapter is IAnimatablePropertyAdapter animatableProperty) { + IAnimation? previous = animatableProperty.Animation; animatableProperty.Animation = null; + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } @@ -667,7 +736,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/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BrushEditorViewModel.cs index d3c2c574ac..99c902849e 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,8 @@ private void AcceptChildren(PropertiesEditorViewModel? obj) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + Brush? previous = GetEditingValue(); + SetValue(previous, FallbackHelper.DeserializeInstance(str)); } public void UpdateBrushPreview() @@ -163,19 +165,40 @@ public override void Reset() { if (GetDefaultValue() is { } defaultValue) { - SetValue(Value.Value, (Brush?)defaultValue); + SetValue(GetEditingValue(), (Brush?)defaultValue); } } 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); - Commit(); + 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; @@ -185,8 +208,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(GetEditingValue(), instance, CommandNames.ApplyTemplate); return true; } @@ -195,8 +217,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(GetEditingValue(), pasted, CommandNames.PasteObject); return true; } @@ -246,7 +267,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(); } } @@ -256,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); @@ -265,12 +289,15 @@ public void SetTarget(Brush? target) presenter.Target.Expression = null; presenter.Target.CurrentValue = null; } + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } 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(); @@ -293,6 +320,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 6d0683fd2a..10605045ab 100644 --- a/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/CoreObjectEditorViewModel.cs @@ -152,7 +152,8 @@ public CoreObjectEditorViewModel(IPropertyAdapter property) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + T? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); } public void SetNull() @@ -181,8 +182,15 @@ public override bool ApplyTemplate(ObjectTemplateItem template) { if (template.CreateInstance() is not T instance) return false; IsExpanded.Value = true; - PropertyAdapter.SetValue(instance); - Commit(CommandNames.ApplyTemplate); + if (EditingKeyFrame.Value is { } keyFrame) + { + SetValue(keyFrame.Value, instance, CommandNames.ApplyTemplate); + } + else + { + SetValue(PropertyAdapter.GetValue(), instance, CommandNames.ApplyTemplate); + } + return true; } @@ -193,28 +201,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); } @@ -230,6 +240,7 @@ public void SetTarget(CoreObject? target) presenter.Target.CurrentValue = null; } + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } diff --git a/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs index 448c4b6b4f..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(); } } @@ -280,7 +282,8 @@ 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)); } protected override void Dispose(bool disposing) diff --git a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs index d24add847a..27338e744c 100644 --- a/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/GeometryEditorViewModel.cs @@ -101,7 +101,8 @@ public GeometryEditorViewModel(IPropertyAdapter property) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + Geometry? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); } public override void Accept(IPropertyEditorContextVisitor visitor) @@ -132,8 +133,15 @@ public override bool ApplyTemplate(ObjectTemplateItem template) { if (template.CreateInstance() is not Geometry instance) return false; IsExpanded.Value = true; - PropertyAdapter.SetValue(instance); - Commit(CommandNames.ApplyTemplate); + if (EditingKeyFrame.Value is { } keyFrame) + { + SetValue(keyFrame.Value, instance, CommandNames.ApplyTemplate); + } + else + { + SetValue(PropertyAdapter.GetValue(), instance, CommandNames.ApplyTemplate); + } + return true; } @@ -144,18 +152,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/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/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/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs b/src/Beutl/ViewModels/Editors/TextureSourceEditorViewModel.cs index 30d2a4243b..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(); } } @@ -117,8 +118,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(); } } @@ -131,9 +134,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/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs index 3ae2f08260..65ec4de10f 100644 --- a/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs @@ -212,7 +212,8 @@ public TransformEditorViewModel(IPropertyAdapter property) public void SetJsonString(string? str) { - SetValue(Value.Value, FallbackHelper.DeserializeInstance(str)); + Transform? previous = Value.Value; + SetValue(previous, FallbackHelper.DeserializeInstance(str)); } public override void Accept(IPropertyEditorContextVisitor visitor) @@ -294,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); @@ -304,6 +306,7 @@ public void SetTarget(Transform? target) presenter.Target.Expression = null; presenter.Target.CurrentValue = null; } + ResumeElementPersistenceAfterFallbackReplacement(previous); Commit(); } } 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/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 4a14ddb84e..847175edc7 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,87 @@ 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 + { + public DictionaryTransformHolder() + { + ScanProperties(); + } + + public IProperty> Transforms { get; } + = Property.Create>(); + } + + [SuppressResourceClassGeneration] + public sealed class ArbitraryValueHolder : EngineObject + { + public ArbitraryValueHolder() + { + ScanProperties(); + } + + 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() + { + ["$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() { @@ -135,6 +219,198 @@ 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 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() + { + 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 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.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs b/tests/Beutl.AgentToolkit.Tests/Reconciliation/ReconcilerIdIntegrityTests.cs index 7d6011a290..63a5b43222 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; @@ -13,6 +14,52 @@ 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(); + 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() { @@ -176,6 +223,57 @@ 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(); + holder.Value.CurrentValue = new RectShape(); + carrier.AddObject(holder); + scene.Children.Add(carrier); + CoreSerializer.StoreToUri(scene, scene.Uri!); + + 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); + 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(recoveredHealthy.Name, Is.EqualTo("Renamed healthy element")); + }); + } + private static JsonObject CreateDocumentWithIdlessRect(out string mintPath) { mintPath = "$/Elements[0]/Objects[0]"; @@ -211,4 +309,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/Sessions/FileEditingSessionTests.cs b/tests/Beutl.AgentToolkit.Tests/Sessions/FileEditingSessionTests.cs index 686c145fce..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; @@ -54,6 +58,149 @@ 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 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 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 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() { @@ -81,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.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 553884cca0..20d0eb31f0 100644 --- a/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs +++ b/tests/Beutl.AgentToolkit.Tests/Tools/SessionToolsTests.cs @@ -1,17 +1,590 @@ -using Beutl.AgentToolkit.Common; +using System.Reflection; +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.Animation; using Beutl.Editor; +using Beutl.Engine; +using Beutl.Graphics; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; using Beutl.ProjectSystem; +using Beutl.Serialization; 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() + { + 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; + 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"; + 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(elementRelativePath).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_reports_fallback_and_lossy_easing_incidents_together() + { + 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( + 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; + 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(); + 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(); + 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(nameof(FallbackReason.TypeNotFound))); + 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.TypeNotFound) + && incident.TypeName == "[Missing.Assembly]Missing.Namespace:MissingEasing" + && incident.Message!.Contains("could not be resolved", StringComparison.Ordinal))); + }); + } + + [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.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")); + }); + } + + [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"); + string malformedRelativePath = Path.GetRelativePath( + sceneDirectory, + malformed.Uri.LocalPath).Replace('\\', '/'); + + 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); + 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); + var recoveredFallback = (IFallback)((Scene)manager.CurrentSession!.Root) + .Children.Single(item => item.Uri!.LocalPath == malformed.Uri.LocalPath) + .Objects.Single(); + + 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(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); + }); + } + + [Test] + public void SerializedGraphTraversal_TraversesDictionaryValues() + { + var fallback = new FallbackTransform(); + IFallback[] fallbacks = SerializedGraphTraversal.Enumerate( + new Dictionary { ["broken"] = fallback }) + .OfType() + .ToArray(); + + Assert.That(fallbacks, Has.One.SameAs(fallback)); + } + + [Test] + public void SerializedGraphTraversal_TraversesElementHierarchyOutsideObjects() + { + var element = new Element(); + var fallback = new FallbackTransform(); + ((IModifiableHierarchical)element).AddChild(fallback); + IFallback[] fallbacks = SerializedGraphTraversal.Enumerate(element) + .OfType() + .ToArray(); + + Assert.Multiple(() => + { + Assert.That(element.Objects, Is.Empty); + Assert.That(fallbacks, Has.One.SameAs(fallback)); + }); + } + + [Test] + public void SerializedGraphTraversal_TraversesRegisteredCorePropertiesAndOptionalValues() + { + var fallback = new FallbackTransform(); + var element = new RegisteredOptionalTransformElement + { + PluginTransform = new Optional(fallback), + }; + IFallback[] fallbacks = SerializedGraphTraversal.Enumerate(element) + .OfType() + .ToArray(); + + Assert.That(fallbacks, Has.One.SameAs(fallback)); + } + + [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")); + Assert.That(opened.Value.RecoveryIncidents.Select(static item => item.ElementFile), + Is.EquivalentTo(new[] { "first/clip.belm", "second/clip.belm" })); + }); + } + + [Test] + 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(); + 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, + $$"""{"\u0024type":"{{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)); + 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); + }); + } + + [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); + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); + 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); + Assert.That(opened.IsSuccess, Is.True, opened.Error?.Message); + 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 +1010,75 @@ 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); + } + + public sealed class AnimatedValueHolder : EngineObject + { + public AnimatedValueHolder() + { + ScanProperties(); + } + + public IProperty AnimatedValue { get; } = Property.CreateAnimatable(); + } + + 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.HeadlessUITests/FallbackEditorPersistenceTests.cs b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs new file mode 100644 index 0000000000..578674391c --- /dev/null +++ b/tests/Beutl.HeadlessUITests/FallbackEditorPersistenceTests.cs @@ -0,0 +1,989 @@ +using System.Text.Json.Nodes; +using Avalonia.Headless.NUnit; +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.Graphics.Transformation; +using Beutl.Graphics3D.Textures; +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 +{ + [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 PenValue { get; } = Property.Create(); + + public IProperty TextureValue { get; } = Property.Create(); + } + + [AvaloniaTest] + public void BrushTryPasteJson_LastFallbackResumesPersistenceInReplacementTransaction() + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + 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(); + 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); + } + } + } + + [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() + { + 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() + { + 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!; + 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 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() + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + 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() + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + 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() + { + TestReset.ResetShellAsync().GetAwaiter().GetResult(); + + 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() + { + 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.SetDrawableType(typeof(RectShape)); + 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(recoveredTexture.Drawable.CurrentValue, Is.InstanceOf()); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(originalBytes)); + }); + } + finally + { + Directory.Delete(root, true); + } + } + + [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() + { + 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(() => + { + 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 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(); + animation.KeyFrames.Add(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 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) + { + 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, + 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( + 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 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; + 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 + { + 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) + { + } + } + + private enum PresenterKind + { + Brush, + Transform, + FilterEffect, + } + + private enum AnimationDiscardKind + { + RemoveKeyFrame, + RemoveAnimation, + SetExpression, + } +} diff --git a/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs b/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs new file mode 100644 index 0000000000..7f75a79ba6 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/FilePathBoundaryTests.cs @@ -0,0 +1,20 @@ +using Beutl.Serialization; + +namespace Beutl.UnitTests.Core; + +public sealed class PathBoundaryTests +{ + [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( + PathBoundary.IsPathInsideRoot(root, differentlyCasedPath), + Is.EqualTo(OperatingSystem.IsWindows())); + } +} 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 1ea43dec4d..e08b2ac939 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() { @@ -116,6 +127,152 @@ 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_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(() => + { + Assert.That(removed, Is.True); + Assert.That(_element.SuppressedStorageSource, Is.Null); + }); + } + + [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_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() + { + 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() { @@ -201,6 +358,115 @@ 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_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_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(() => + { + Assert.That(outcome, Is.EqualTo(ObjectPasteOutcome.Pasted)); + Assert.That(_element.SuppressedStorageSource, Is.Null); + }); + } + + [Test] + public void PasteOver_StaleNonFallbackIncidentFlagWithoutLiveMarker_ResumesPersistence() + { + _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.Null); + Assert.That(File.ReadAllBytes(_element.Uri.LocalPath), Is.Not.EqualTo(originalBytes)); + }); + } + + [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/Engine/AnimatablePropertyTests.cs b/tests/Beutl.UnitTests/Engine/AnimatablePropertyTests.cs index 71e05ad297..732d927f8d 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++; + + 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/Animation/KeyFrameTests.cs b/tests/Beutl.UnitTests/Engine/Animation/KeyFrameTests.cs index c994fb3b5f..1c66046782 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; @@ -8,6 +9,51 @@ 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 + { + } + + 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; + } + + 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() { @@ -84,4 +130,197 @@ public void Deserialize_ShouldCorrectlyDeserializeSplineEasing() Assert.That(easing.X2, Is.EqualTo(0.3f)); 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))] + 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); + } + + [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(); + 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/Engine/ListPropertyTests.cs b/tests/Beutl.UnitTests/Engine/ListPropertyTests.cs index 05de68c828..7b8a7971b8 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.Composition; 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 aa5726cd50..bdb1fd0578 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++; + + ((IProperty)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 new file mode 100644 index 0000000000..b99ce61e48 --- /dev/null +++ b/tests/Beutl.UnitTests/ProjectSystem/MalformedElementRecoveryTests.cs @@ -0,0 +1,3462 @@ +using System.Collections; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +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; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.ProjectSystem; +using Beutl.Serialization; + +namespace Beutl.UnitTests.ProjectSystem; + +public sealed class MalformedElementRecoveryTests +{ + private string _root = null!; + + private sealed class IOExceptionElement : Element + { + public IOExceptionElement() + { + throw new IOException("Constructor could not access its storage."); + } + } + + 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 + { + public ElementReferenceHolder() + { + ScanProperties(); + } + + public IProperty> Target { get; } = Property.Create>(); + + 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 + { + public OptionalReferenceHolder() + { + ScanProperties(); + } + + public IProperty>> Target { 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>> ReadOnlyTargets { get; } + = Property.Create>>(); + + public IProperty> AnimatedTarget { get; } + = 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 IProperty OrderedCycleTarget { get; } + = Property.Create(); + } + + public sealed class ReferenceEnvelope( + Reference target, + Optional> optionalTarget, + string state) : IReferenceRewritable + { + 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) + { + 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; private set; } = target; + + public string State { get; } = state; + + public IReferenceRewritable CreateReferenceRewriteTarget() + => new EqualityIgnoringReferenceEnvelope(Target, State); + + public void RewriteReferences(IReferenceRewriteContext context) + { + Target = context.Rewrite(Target); + } + + 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 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 + { + public Reference Target { get; private set; } = target; + + public CyclicReferenceEnvelope? Self { get; set; } + + public IReferenceRewritable CreateReferenceRewriteTarget() + { + return new CyclicReferenceEnvelope(Target) + { + Self = Self, + }; + } + + public void RewriteReferences(IReferenceRewriteContext context) + { + Target = context.Rewrite(Target); + Self = context.Rewrite(Self); + } + } + + 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); + } + } + + [SuppressResourceClassGeneration] + public sealed class DictionaryTransformHolder : EngineObject + { + public DictionaryTransformHolder() + { + ScanProperties(); + } + + public IProperty> Transforms { get; } + = 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 + { + public TransformReferenceHolder() + { + ScanProperties(); + } + + 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() + { + PluginTransformProperty = ConfigureProperty( + nameof(PluginTransform)) + .Register(); + PluginTargetProperty = ConfigureProperty, RegisteredRecoveryElement>( + nameof(PluginTarget)) + .Register(); + PluginWrapperProperty = ConfigureProperty< + EqualityIgnoringReferenceEnvelope?, + RegisteredRecoveryElement>(nameof(PluginWrapper)) + .Register(); + } + + public Transform? PluginTransform + { + get => GetValue(PluginTransformProperty); + set => SetValue(PluginTransformProperty, value); + } + + 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 + { + public static readonly CoreProperty> PluginTransformProperty; + + static RegisteredOptionalRecoveryElement() + { + PluginTransformProperty = ConfigureProperty, RegisteredOptionalRecoveryElement>( + nameof(PluginTransform)) + .Register(); + } + + public Optional PluginTransform + { + get => GetValue(PluginTransformProperty); + set => SetValue(PluginTransformProperty, value); + } + } + + 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) + { + 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; + } + + public IReferenceExpression? Rebind(Guid objectId) => null; + } + + 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; + } + + public IReferenceExpression? Rebind(Guid objectId) => null; + } + + 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() + { + _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 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() + { + (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 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 Save_RepairedFallbackAndKeyFrameEasing_ResumesPersistenceAfterBothRepairs() + { + (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? blocked = Scene.TryResumeElementPersistence(recoveredElement); + recoveredAnimation.KeyFrames.Single().Easing = new SplineEasing(); + SuppressedStorageSource? resumed = Scene.TryResumeElementPersistence(recoveredElement); + CoreSerializer.StoreToUri(recovered, sceneUri); + + Assert.Multiple(() => + { + 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.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() + { + (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.Multiple(() => + { + Assert.That(first, Is.Not.EqualTo(Guid.Empty)); + Assert.That(second, Is.EqualTo(first)); + }); + } + + [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_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)); + } + + [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() + { + (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() + { + (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_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() + { + (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 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_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_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_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() + { + (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 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() + { + (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_RecreatesMissingProtectedSource() + { + (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.True); + Assert.That(File.ReadAllBytes(elementPath), Is.EqualTo(corruptBytes)); + Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(corruptBytes)); + }); + } + + [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 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() + { + (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() + { + (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 Restore_IdlessFallbackOutsideHierarchy_ProjectsRuntimeIdAndPreservesSidecarBytes() + { + (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(); + 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.Multiple(() => + { + Assert.That(fallback.Json![nameof(CoreObject.Id)]!.GetValue(), + Is.EqualTo(fallbackObject.Id.ToString())); + 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 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() + { + (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_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() + { + (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() + { + (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_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_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)); + 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(); + 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); + // 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]); + ElementReferenceHolder reloadedHolder = reloadedHealthy.Objects + .OfType() + .Single(); + Reference migratedReference = reloadedHolder.Target.CurrentValue; + Reference migratedOptionalReference = reloadedHealthy.Objects + .OfType() + .Single() + .Target.CurrentValue.Value; + var migratedExpression = (IReferenceExpression)reloadedHolder.ExpressionTarget.Expression!; + + Assert.Multiple(() => + { + 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)); + 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), + Is.SameAs(reloadedRepaired)); + }); + } + + [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 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 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 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 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() + { + (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 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() + { + Guid originalId = Guid.NewGuid(); + Guid migratedId = Guid.NewGuid(); + var scene = new Scene { Uri = new Uri(Path.Combine(_root, "scene.scene")) }; + var migrated = new Element + { + Id = migratedId, + Uri = new Uri(Path.Combine(_root, "target.belm")), + }; + 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( + "MigrateRecoveredElementReferences", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + Assert.DoesNotThrow(() => method.Invoke(scene, null)); + + Assert.That(holder.Target.CurrentValue, Is.SameAs(reference)); + } + + [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), + }; + holder.ReadOnlyTargets.CurrentValue = new ReadOnlyCollection>( + [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"]; + 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)); + }); + } + + [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.Not.SameAs(cyclicWrapper)); + Assert.That(holder.CyclicTarget.CurrentValue!.Target.Id, Is.EqualTo(migrated.Id)); + 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)); + }); + } + + [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 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 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() + { + (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_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_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() + { + (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_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() + { + (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}#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(); + firstRecovered.Objects.Move(0, 1); + 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[$"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())); + }); + } + + [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() + { + (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_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() + { + (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_RehomeTargetCollision_FailsWithoutRepointingOrOverwriting() + { + (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); + Assert.Throws(() => CoreSerializer.StoreToUri(recovered, new Uri(rehomedPath))); + + Assert.Multiple(() => + { + Assert.That(File.ReadAllBytes(rehomedPath), Is.EqualTo(repairedBytes)); + Assert.That(recovered.Uri, Is.EqualTo(new Uri(elementPath))); + }); + } + + [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() + { + (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] + 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)); + }); + } + + [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() + { + (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 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() + { + (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]); + Guid remappedId = ((CoreObject)GetTransformFallback(recovered, elementPaths[1])).Id; + var reference = (Reference)recoveredHealthy.Objects + .OfType() + .Single() + .Target.CurrentValue; + + Assert.Multiple(() => + { + Assert.That(remappedId, Is.Not.EqualTo(claimantId)); + // 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( + recoveredHealthy.Objects.OfType().Single().Transform.CurrentValue!.Id, + Is.EqualTo(claimantId)); + }); + } + + [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() + { + (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)); + Assert.That( + Directory.GetFiles( + Path.GetDirectoryName(rehomedPath)!, + $"{Path.GetFileName(rehomedPath)}.*.tmp"), + Is.Empty); + }); + } + + [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 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 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() + { + (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 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() + { + (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 static JsonObject? FindObjectByDiscriminator(JsonNode node, string containsToken) + { + if (node is JsonObject obj) + { + if (obj.TryGetPropertyValue("$type", out JsonNode? typeNode) + && typeNode is JsonValue typeValue + && typeValue.TryGetValue(out string? typeName) + && typeName.Contains(containsToken)) + { + return obj; + } + + foreach ((string _, JsonNode? child) in obj) + { + if (child != null && FindObjectByDiscriminator(child, containsToken) is { } result) + { + return result; + } + } + } + else if (node is JsonArray array) + { + foreach (JsonNode? child in array) + { + if (child != null && FindObjectByDiscriminator(child, containsToken) is { } result) + { + return result; + } + } + } + + 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); + 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"); + return (sceneUri, elementPaths[0]); + } + + private (Uri SceneUri, string[] ElementPaths) CreatePersistedSceneWithElements( + params string[] elementFileNames) + { + var sceneUri = new Uri(Path.Combine(_root, "scene.scene")); + var scene = new Scene(64, 64, "Scene") + { + Uri = sceneUri, + }; + string[] elementPaths = new string[elementFileNames.Length]; + for (int i = 0; i < elementFileNames.Length; i++) + { + 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, elementPaths); + } + + private static Dictionary GetIdsBySidecarName(Scene scene) + { + return scene.Children.ToDictionary( + child => Path.GetFileName(child.Uri!.LocalPath), + child => child.Id, + StringComparer.Ordinal); + } +} 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)); + }); + } +}