diff --git a/Navigation.Test/JsonFilePersistenceProviderTests.cs b/Navigation.Test/JsonFilePersistenceProviderTests.cs new file mode 100644 index 0000000..455ad1f --- /dev/null +++ b/Navigation.Test/JsonFilePersistenceProviderTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Navigation.Test; + +using ktsu.Navigation.Models; +using ktsu.Navigation.Services; + +[TestClass] +public class JsonFilePersistenceProviderTests +{ + private string? _filePath; + + [TestInitialize] + public void Setup() => _filePath = Path.Combine(Path.GetTempPath(), $"navigation-{Guid.NewGuid():N}.json"); + + [TestCleanup] + public void Cleanup() + { + if (File.Exists(_filePath)) + { + File.Delete(_filePath); + } + } + + [TestMethod] + public async Task SaveThenLoad_IntoNewNavigation_RestoresCurrentAndStacks() + { + // Arrange + JsonFilePersistenceProvider provider = new(_filePath!); + Navigation navigation = new(null, provider); + navigation.NavigateTo(new NavigationItem("a", "A")); + navigation.NavigateTo(new NavigationItem("b", "B")); + navigation.NavigateTo(new NavigationItem("c", "C")); + navigation.GoBack(); + await navigation.SaveStateAsync().ConfigureAwait(false); + + // Act + Navigation restored = new(null, provider); + bool loaded = await restored.LoadStateAsync().ConfigureAwait(false); + + // Assert + Assert.IsTrue(loaded); + Assert.AreEqual(3, restored.Count); + Assert.AreEqual("b", restored.Current?.Id); + Assert.AreEqual("B", restored.Current?.DisplayName); + Assert.AreEqual("a", string.Join(",", restored.GetBackStack().Select(item => item.Id))); + Assert.AreEqual("c", string.Join(",", restored.GetForwardStack().Select(item => item.Id))); + } + + [TestMethod] + public async Task LoadStateAsync_UnreadableData_ReturnsNull() + { + // Arrange + await File.WriteAllTextAsync(_filePath!, """{"items":[{"id":"a","displayName":"A"}],"currentIndex":5}""").ConfigureAwait(false); + JsonFilePersistenceProvider provider = new(_filePath!); + + // Act & Assert + Assert.IsNull(await provider.LoadStateAsync().ConfigureAwait(false)); + } +} diff --git a/Navigation/Models/NavigationState.cs b/Navigation/Models/NavigationState.cs index 083f8c1..bfd05eb 100644 --- a/Navigation/Models/NavigationState.cs +++ b/Navigation/Models/NavigationState.cs @@ -5,6 +5,7 @@ namespace ktsu.Navigation.Models; using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; using ktsu.Navigation.Contracts; /// @@ -35,6 +36,18 @@ public NavigationState(IEnumerable items, int currentIndex) CreatedAt = DateTime.UtcNow; } + /// + /// Initializes a new instance of the class when deserializing. + /// System.Text.Json requires each constructor parameter to have the same type as the property it binds to. + /// + /// The items in the navigation stack + /// The index of the current item + [JsonConstructor] + public NavigationState(IReadOnlyList items, int currentIndex) + : this((IEnumerable)items, currentIndex) + { + } + /// public IReadOnlyList Items { get; } diff --git a/Navigation/Services/JsonFilePersistenceProvider.cs b/Navigation/Services/JsonFilePersistenceProvider.cs index ae470c6..7efbd85 100644 --- a/Navigation/Services/JsonFilePersistenceProvider.cs +++ b/Navigation/Services/JsonFilePersistenceProvider.cs @@ -77,6 +77,11 @@ public async Task SaveStateAsync(INavigationState state, CancellationToken ca // If file read fails, return null return null; } + catch (ArgumentException) + { + // If the saved state is invalid (for example, a current index outside the items), return null + return null; + } } ///