Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions Navigation.Test/JsonFilePersistenceProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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;
Expand Down Expand Up @@ -170,6 +171,66 @@
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<NavigationItem> provider = new(_filePath!);
Navigation<NavigationItem> navigation = new(null, provider);
navigation.NavigateTo(new NavigationItem("a", "A"));
await navigation.SaveStateAsync().ConfigureAwait(false);

Check warning on line 181 in Navigation.Test/JsonFilePersistenceProviderTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Navigation&issues=AaG67yTiYqINDK50_nTQ&open=AaG67yTiYqINDK50_nTQ&pullRequest=113
string lastSave = await File.ReadAllTextAsync(_filePath!).ConfigureAwait(false);

Check warning on line 182 in Navigation.Test/JsonFilePersistenceProviderTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Navigation&issues=AaG67yTiYqINDK50_nTR&open=AaG67yTiYqINDK50_nTR&pullRequest=113
navigation.NavigateTo(new DocumentPageItem("b", "B") { FilePath = "/docs/b.txt" });

// Act
NotSupportedException exception = await Assert.ThrowsExactlyAsync<NotSupportedException>(() => navigation.SaveStateAsync()).ConfigureAwait(false);

Check warning on line 186 in Navigation.Test/JsonFilePersistenceProviderTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Navigation&issues=AaG67yTiYqINDK50_nTS&open=AaG67yTiYqINDK50_nTS&pullRequest=113

// Assert
Assert.Contains(typeof(DocumentPageItem).FullName!, exception.Message);
Assert.AreEqual(lastSave, await File.ReadAllTextAsync(_filePath!).ConfigureAwait(false));

Check warning on line 190 in Navigation.Test/JsonFilePersistenceProviderTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Navigation&issues=AaG67yTiYqINDK50_nTT&open=AaG67yTiYqINDK50_nTT&pullRequest=113
}

[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<NavigationItem> provider = new(_filePath!, options);
Navigation<NavigationItem> 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);

Check warning on line 221 in Navigation.Test/JsonFilePersistenceProviderTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Navigation&issues=AaG67yTiYqINDK50_nTU&open=AaG67yTiYqINDK50_nTU&pullRequest=113

// Act
Navigation<NavigationItem> restored = new(null, provider);
bool loaded = await restored.LoadStateAsync().ConfigureAwait(false);

Check warning on line 225 in Navigation.Test/JsonFilePersistenceProviderTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Navigation&issues=AaG67yTiYqINDK50_nTV&open=AaG67yTiYqINDK50_nTV&pullRequest=113

// Assert
Assert.IsTrue(loaded);
Assert.IsExactInstanceOfType<NavigationItem>(restored.GetHistory()[0]);
DocumentPageItem current = Assert.IsExactInstanceOfType<DocumentPageItem>(restored.Current);
Assert.AreEqual("/docs/b.txt", current.FilePath);
}

[TestMethod]
public async Task SaveThenLoad_ReadmeCustomItem_RoundTrips()
{
Expand Down Expand Up @@ -368,6 +429,11 @@
}

/// <summary>The README's custom item before it gained a JSON constructor: <c>filePath</c> binds to no property.</summary>
public class DocumentPageItem(string id, string displayName) : NavigationItem(id, displayName)
{
public string FilePath { get; set; } = "";
}

public class UnboundConstructorItem : INavigationItem
{
private readonly Dictionary<string, object> _metadata = [];
Expand Down
44 changes: 44 additions & 0 deletions Navigation/Services/JsonFilePersistenceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,14 @@ public JsonFilePersistenceProvider(string filePath, JsonSerializerOptions? jsonO
}

/// <inheritdoc />
/// <exception cref="NotSupportedException">
/// An item is a subclass of <typeparamref name="T"/> and the JSON options configure no polymorphism for
/// <typeparamref name="T"/>, so its type and the properties it adds would not be saved.
/// </exception>
public async Task SaveStateAsync(INavigationState<T> state, CancellationToken cancellationToken = default)
{
Ensure.NotNull(state);
EnsureItemsKeepTheirType(state);

string? directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
Expand Down Expand Up @@ -92,6 +97,45 @@ public async Task SaveStateAsync(INavigationState<T> state, CancellationToken ca

private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);

/// <summary>
/// Refuses a state holding an item that JSON would save as the base <typeparamref name="T"/>
/// </summary>
/// <param name="state">The state about to be saved</param>
/// <remarks>
/// System.Text.Json writes each item as the declared <typeparamref name="T"/>, so without polymorphism a
/// subclass's own properties were dropped and the item came back as a plain <typeparamref name="T"/>, with
/// nothing reporting the loss. An interface or abstract <typeparamref name="T"/> is left alone: its saved
/// state cannot be loaded back in any case, and <see cref="LoadStateAsync"/> reports that by returning null.
/// </remarks>
private void EnsureItemsKeepTheirType(INavigationState<T> 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;
}
Comment thread
matt-edmondson marked this conversation as resolved.
Resolved
}

private static void TryDelete(string path)
{
try
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,41 @@ await navigation.LoadStateAsync();
Console.WriteLine($"Restored to: {navigation.Current?.DisplayName}");
```

#### Saving subclasses of the item type

`JsonFilePersistenceProvider<T>` writes each item as `T`. If a `Navigation<NavigationItem>` 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<NavigationItem>("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
Expand Down
Loading