diff --git a/Navigation.Test/JsonFilePersistenceProviderTests.cs b/Navigation.Test/JsonFilePersistenceProviderTests.cs index 8f21969..96a1c27 100644 --- a/Navigation.Test/JsonFilePersistenceProviderTests.cs +++ b/Navigation.Test/JsonFilePersistenceProviderTests.cs @@ -5,6 +5,7 @@ namespace ktsu.Navigation.Test; using System.Security.AccessControl; using System.Security.Principal; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using ktsu.Navigation.Contracts; using ktsu.Navigation.Models; using ktsu.Navigation.Services; @@ -170,6 +171,66 @@ public async Task LoadStateAsync_InterfaceItemType_ReturnsNullInsteadOfThrowing( Assert.IsNull(await provider.LoadStateAsync().ConfigureAwait(false)); } + [TestMethod] + public async Task SaveStateAsync_SubclassItemWithoutPolymorphism_ThrowsNamingTheTypeAndKeepsTheLastSave() + { + // Arrange: a good save, then a subclass item whose FilePath JSON would drop + JsonFilePersistenceProvider provider = new(_filePath!); + Navigation navigation = new(null, provider); + navigation.NavigateTo(new NavigationItem("a", "A")); + await navigation.SaveStateAsync().ConfigureAwait(false); + string lastSave = await File.ReadAllTextAsync(_filePath!).ConfigureAwait(false); + navigation.NavigateTo(new DocumentPageItem("b", "B") { FilePath = "/docs/b.txt" }); + + // Act + NotSupportedException exception = await Assert.ThrowsExactlyAsync(() => navigation.SaveStateAsync()).ConfigureAwait(false); + + // Assert + Assert.Contains(typeof(DocumentPageItem).FullName!, exception.Message); + Assert.AreEqual(lastSave, await File.ReadAllTextAsync(_filePath!).ConfigureAwait(false)); + } + + [TestMethod] + public async Task SaveThenLoad_SubclassItemWithPolymorphismConfigured_RoundTripsItsTypeAndProperties() + { + // Arrange + JsonSerializerOptions options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + typeInfo => + { + if (typeInfo.Type == typeof(NavigationItem)) + { + typeInfo.PolymorphismOptions = new JsonPolymorphismOptions + { + DerivedTypes = { new JsonDerivedType(typeof(DocumentPageItem), "document") }, + }; + } + }, + }, + }, + }; + JsonFilePersistenceProvider provider = new(_filePath!, options); + Navigation navigation = new(null, provider); + navigation.NavigateTo(new NavigationItem("a", "A")); + navigation.NavigateTo(new DocumentPageItem("b", "B") { FilePath = "/docs/b.txt" }); + await navigation.SaveStateAsync().ConfigureAwait(false); + + // Act + Navigation restored = new(null, provider); + bool loaded = await restored.LoadStateAsync().ConfigureAwait(false); + + // Assert + Assert.IsTrue(loaded); + Assert.IsExactInstanceOfType(restored.GetHistory()[0]); + DocumentPageItem current = Assert.IsExactInstanceOfType(restored.Current); + Assert.AreEqual("/docs/b.txt", current.FilePath); + } + [TestMethod] public async Task SaveThenLoad_ReadmeCustomItem_RoundTrips() { @@ -368,6 +429,11 @@ private static void RestoreReadAccess(string path) } /// The README's custom item before it gained a JSON constructor: filePath binds to no property. +public class DocumentPageItem(string id, string displayName) : NavigationItem(id, displayName) +{ + public string FilePath { get; set; } = ""; +} + public class UnboundConstructorItem : INavigationItem { private readonly Dictionary _metadata = []; diff --git a/Navigation/Services/JsonFilePersistenceProvider.cs b/Navigation/Services/JsonFilePersistenceProvider.cs index 61bb7e4..71a0c50 100644 --- a/Navigation/Services/JsonFilePersistenceProvider.cs +++ b/Navigation/Services/JsonFilePersistenceProvider.cs @@ -41,9 +41,14 @@ public JsonFilePersistenceProvider(string filePath, JsonSerializerOptions? jsonO } /// + /// + /// An item is a subclass of and the JSON options configure no polymorphism for + /// , so its type and the properties it adds would not be saved. + /// public async Task SaveStateAsync(INavigationState state, CancellationToken cancellationToken = default) { Ensure.NotNull(state); + EnsureItemsKeepTheirType(state); string? directory = Path.GetDirectoryName(_filePath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) @@ -92,6 +97,45 @@ public async Task SaveStateAsync(INavigationState state, CancellationToken ca private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + /// + /// Refuses a state holding an item that JSON would save as the base + /// + /// The state about to be saved + /// + /// System.Text.Json writes each item as the declared , so without polymorphism a + /// subclass's own properties were dropped and the item came back as a plain , with + /// nothing reporting the loss. An interface or abstract is left alone: its saved + /// state cannot be loaded back in any case, and reports that by returning null. + /// + private void EnsureItemsKeepTheirType(INavigationState state) + { + Type declaredType = typeof(T); + if (declaredType.IsInterface || declaredType.IsAbstract) + { + return; + } + + foreach (T item in state.Items) + { + Type? itemType = item?.GetType(); + if (itemType is null || itemType == declaredType) + { + continue; + } + + if (_jsonOptions.GetTypeInfo(declaredType).PolymorphismOptions is null) + { + throw new NotSupportedException( + $"Cannot save a navigation item of type '{itemType.FullName}' as '{declaredType.FullName}': JSON would " + + $"save it as a plain '{declaredType.Name}', losing its type and the properties it adds. Configure " + + $"polymorphism for '{declaredType.Name}' in the JSON options (a JsonDerivedType for '{itemType.Name}'), " + + $"or use a navigation stack of '{itemType.Name}'."); + } + + return; + } + } + private static void TryDelete(string path) { try diff --git a/README.md b/README.md index 2d09316..1b2b209 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,41 @@ await navigation.LoadStateAsync(); Console.WriteLine($"Restored to: {navigation.Current?.DisplayName}"); ``` +#### Saving subclasses of the item type + +`JsonFilePersistenceProvider` writes each item as `T`. If a `Navigation` holds a subclass such as `DocumentPage : NavigationItem`, JSON can't save its type or the properties it adds. So `SaveStateAsync` throws `NotSupportedException` naming the subclass, and the last save stays in place. To keep several page types in one stack, configure polymorphism for `T` through the JSON options: + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +var options = new JsonSerializerOptions +{ + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + typeInfo => + { + if (typeInfo.Type == typeof(NavigationItem)) + { + typeInfo.PolymorphismOptions = new JsonPolymorphismOptions + { + DerivedTypes = { new JsonDerivedType(typeof(DocumentPage), "document") }, + }; + } + }, + }, + }, +}; + +var persistenceProvider = new JsonFilePersistenceProvider("navigation-state.json", options); +``` + +Every subclass you save needs a `JsonDerivedType` entry. Plain `NavigationItem` items keep working alongside them. + ### Complete Example with All Features ```csharp