Add mock LibraryBook and Configuration capabilities

- Added `MockLibraryBook` which contains factories for easily creating mock LibraryBooks and Books
- Added mock Configuration
  - New `IPersistentDictionary` interface
  - New `MockPersistentDictionary` class which uses a `JObject` as its data store
  - Added `public static Configuration CreateMockInstance()`
    - This method returns a mock Configuration instance **and also sets the `Configuration.Instance` property**
    - Throws an exception if not in debug
- Updated all chardonnay controls to use the mocks in design mode. Previously I was using my actual database and settings file, but that approach is fragile and is unfriendly towards anyone else trying to work on it.
This commit is contained in:
Michael Bucari-Tovo
2025-11-05 12:39:23 -07:00
parent 01b5c18b2b
commit 69a8eaad4a
24 changed files with 273 additions and 106 deletions

View File

@@ -0,0 +1,116 @@
using System;
using System.Linq;
using System.Reflection;
using System.Text;
#nullable enable
namespace DataLayer;
public class MockLibraryBook : LibraryBook
{
protected MockLibraryBook(Book book, DateTime dateAdded, string account, DateTime? includedUntil)
: base(book, dateAdded, account)
{
SetIncludedUntil(includedUntil);
}
public MockLibraryBook AddSeries(string seriesName, int order)
{
var series = new Series(new AudibleSeriesId(CalculateAsin(seriesName)), seriesName);
Book.UpsertSeries(series, order.ToString());
return this;
}
public MockLibraryBook AddCategoryLadder(params string[] ladder)
{
var newLadder = new CategoryLadder(ladder.Select(c => new Category(new AudibleCategoryId(CalculateAsin(c)), c)).ToList());
Book.SetCategoryLadders(Book.Categories.Select(c => c.CategoryLadder).Append(newLadder));
return this;
}
public MockLibraryBook AddNarrator(string name)
{
var newNarrator = new Contributor(name, CalculateAsin(name));
Book.ReplaceNarrators(Book.Narrators.Append(newNarrator));
return this;
}
public MockLibraryBook AddAuthor(string name)
{
var newAuthor = new Contributor(name, CalculateAsin(name));
Book.ReplaceAuthors(Book.Authors.Append(newAuthor));
return this;
}
public MockLibraryBook WithBookStatus(LiberatedStatus liberatedStatus)
{
//Set the backing field directly to preserve LiberatedStatus.PartialDownload
typeof(UserDefinedItem)
.GetField("_bookStatus", BindingFlags.NonPublic | BindingFlags.Instance)
?.SetValue(Book.UserDefinedItem, liberatedStatus);
return this;
}
public MockLibraryBook WithPdfStatus(LiberatedStatus liberatedStatus)
{
Book.UserDefinedItem.PdfStatus = liberatedStatus;
return this;
}
public MockLibraryBook WithLastDownloaded(Version? lastVersion = null, AudioFormat? format = null, string audioVersion = "1")
{
lastVersion ??= new Version(10, 0, 0, 0);
format ??= AudioFormat.Default;
Book.UserDefinedItem.SetLastDownloaded(lastVersion, format, audioVersion);
return this;
}
public MockLibraryBook WithMyRating(float overallRating = 4, float performanceRating = 4.5f, float storyRating = 5)
{
Book.UserDefinedItem.UpdateRating(overallRating, performanceRating, storyRating);
return this;
}
public static MockLibraryBook CreateBook(
string account = "someone@email.co",
bool absetFromLastScan = false,
DateTime? dateAdded = null,
DateTime? datePublished = null,
DateTime? includedUntil = null,
string title = "Mock Book Title",
string subtitle = "Mock Book Subtitle",
string description = "This is a mock book description.",
int lengthInMinutes = 1400,
ContentType contentType = ContentType.Product,
string firstAuthor = "Author One",
string firstNarrator = "Narrator One",
string localeName = "us",
bool isAbridged = false,
bool isSpatial = false,
string language = "English")
{
var book = new Book(
new AudibleProductId(CalculateAsin(title + subtitle)),
title,
subtitle,
description,
lengthInMinutes,
contentType,
[new Contributor(firstAuthor, CalculateAsin(firstAuthor))],
[new Contributor(firstNarrator, CalculateAsin(firstNarrator))],
localeName);
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language);
return new MockLibraryBook(
book,
dateAdded ?? DateTime.Now,
account,
includedUntil)
{
AbsentFromLastScan = absetFromLastScan
};
}
private static string CalculateAsin(string name)
=> Convert.ToHexString(System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(name))).Substring(0, 10);
}

View File

@@ -0,0 +1,40 @@
using Newtonsoft.Json.Linq;
using System;
using System.Linq;
#nullable enable
namespace FileManager;
public interface IPersistentDictionary
{
bool Exists(string propertyName);
string? GetString(string propertyName, string? defaultValue = null);
T? GetNonString<T>(string propertyName, T? defaultValue = default);
object? GetObject(string propertyName);
void SetString(string propertyName, string? newValue);
void SetNonString(string propertyName, object? newValue);
bool RemoveProperty(string propertyName);
bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false);
string? GetStringFromJsonPath(string jsonPath);
string? GetStringFromJsonPath(string jsonPath, string propertyName)
=> GetStringFromJsonPath($"{jsonPath}.{propertyName}");
static T? UpCast<T>(object obj)
{
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
if (obj is JObject jObject) return jObject.ToObject<T>();
if (obj is JValue jValue)
{
if (typeof(T).IsAssignableTo(typeof(Enum)))
{
return
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)
? (T)enumVal
: Enum.GetValues(typeof(T)).Cast<T>().First();
}
return jValue.Value<T>();
}
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
}
}

View File

@@ -2,14 +2,13 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
#nullable enable
namespace FileManager
{
public class PersistentDictionary
public class PersistentDictionary : IPersistentDictionary
{
public string Filepath { get; }
public bool IsReadOnly { get; }
@@ -60,21 +59,8 @@ namespace FileManager
objectCache[propertyName] = defaultValue;
return defaultValue;
}
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
if (obj is JObject jObject) return jObject.ToObject<T>();
if (obj is JValue jValue)
{
if (typeof(T).IsAssignableTo(typeof(Enum)))
{
return
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)
? (T)enumVal
: Enum.GetValues(typeof(T)).Cast<T>().First();
}
return jValue.Value<T>();
}
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
}
return IPersistentDictionary.UpCast<T>(obj);
}
public object? GetObject(string propertyName)
{
@@ -89,7 +75,6 @@ namespace FileManager
return objectCache[propertyName];
}
public string? GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}");
public string? GetStringFromJsonPath(string jsonPath)
{
if (!stringCache.ContainsKey(jsonPath))

View File

@@ -19,8 +19,7 @@ namespace LibationAvalonia.Controls.Settings
InitializeComponent();
if (Design.IsDesignMode)
{
_ = Configuration.Instance.LibationFiles;
DataContext = new AudioSettingsVM(Configuration.Instance);
DataContext = new AudioSettingsVM(Configuration.CreateMockInstance());
}
}

View File

@@ -16,8 +16,7 @@ namespace LibationAvalonia.Controls.Settings
InitializeComponent();
if (Design.IsDesignMode)
{
_ = Configuration.Instance.LibationFiles;
DataContext = new DownloadDecryptSettingsVM(Configuration.Instance);
DataContext = new DownloadDecryptSettingsVM(Configuration.CreateMockInstance());
}
}

View File

@@ -12,8 +12,7 @@ namespace LibationAvalonia.Controls.Settings
if (Design.IsDesignMode)
{
_ = Configuration.Instance.LibationFiles;
DataContext = new ImportSettingsVM(Configuration.Instance);
DataContext = new ImportSettingsVM(Configuration.CreateMockInstance());
}
}
}

View File

@@ -18,8 +18,7 @@ namespace LibationAvalonia.Controls.Settings
InitializeComponent();
if (Design.IsDesignMode)
{
_ = Configuration.Instance.LibationFiles;
DataContext = new ImportantSettingsVM(Configuration.Instance);
DataContext = new ImportantSettingsVM(Configuration.CreateMockInstance());
}
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;

View File

@@ -2,11 +2,8 @@ using Avalonia.Controls;
using DataLayer;
using Dinah.Core.ErrorHandling;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using LibationUiBase.ProcessQueue;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@@ -32,9 +29,7 @@ public partial class ThemePreviewControl : UserControl
if (Design.IsDesignMode)
{
using var ms1 = new MemoryStream();
App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg").CopyTo(ms1);
PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray());
MainVM.Configure_NonUI();
}
QueuedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Queued };
@@ -56,32 +51,12 @@ public partial class ThemePreviewControl : UserControl
private IEnumerable<LibraryBook> CreateMockBooks()
{
var author = new Contributor("Some Author", "asin_contributor");
var narrator = new Contributor("Some Narrator", "asin_narrator");
var book1 = new Book(new AudibleProductId("asin_book1"), "Some Book 1", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
var book2 = new Book(new AudibleProductId("asin_book2"), "Some Book 2", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
var book3 = new Book(new AudibleProductId("asin_book3"), "Some Book 3", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
var book4 = new Book(new AudibleProductId("asin_book4"), "Some Book 4", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
var seriesParent = new Book(new AudibleProductId("asin_series"), "Some Series", "", "Demo Series Entry", 0, ContentType.Parent, [author], [narrator], "us");
var episode = new Book(new AudibleProductId("asin_episode"), "Some Episode", "Episode 1", "Demo Episode Entry", 56, ContentType.Episode, [author], [narrator], "us");
var series = new Series(new AudibleSeriesId(seriesParent.AudibleProductId), seriesParent.Title);
seriesParent.UpsertSeries(series, "");
episode.UpsertSeries(series, "1");
book1.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
book4.UserDefinedItem.BookStatus = LiberatedStatus.Error;
//Set the backing field directly to preserve LiberatedStatus.PartialDownload
typeof(UserDefinedItem).GetField("_bookStatus", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(book2.UserDefinedItem, LiberatedStatus.PartialDownload);
yield return new LibraryBook(book1, System.DateTime.Now.AddDays(4), "someone@email.co");
yield return new LibraryBook(book2, System.DateTime.Now.AddDays(3), "someone@email.co");
yield return new LibraryBook(book3, System.DateTime.Now.AddDays(2), "someone@email.co") { AbsentFromLastScan = true };
yield return new LibraryBook(book4, System.DateTime.Now.AddDays(1), "someone@email.co");
yield return new LibraryBook(seriesParent, System.DateTime.Now, "someone@email.co");
yield return new LibraryBook(episode, System.DateTime.Now, "someone@email.co");
yield return MockLibraryBook.CreateBook(title: "Some Book 1", subtitle: "The Theming", dateAdded: System.DateTime.Now.AddDays(4)).WithBookStatus(LiberatedStatus.Liberated);
yield return MockLibraryBook.CreateBook(title: "Some Book 2", dateAdded: System.DateTime.Now.AddDays(3)).WithBookStatus(LiberatedStatus.PartialDownload);
yield return MockLibraryBook.CreateBook(title: "Some Book 3", dateAdded: System.DateTime.Now.AddDays(2), absetFromLastScan: true).WithPdfStatus(LiberatedStatus.NotLiberated);
yield return MockLibraryBook.CreateBook(title: "Some Book 4", dateAdded: System.DateTime.Now.AddDays(1)).WithBookStatus(LiberatedStatus.Error);
yield return MockLibraryBook.CreateBook(title: "Some Series", subtitle: "", contentType: ContentType.Parent).AddSeries("Some Series", 0);
yield return MockLibraryBook.CreateBook(title: "Some Episode", subtitle: "Episode 1", contentType: ContentType.Episode).AddSeries("Some Series", 1);
}
private class MockProcessable : FileLiberator.Processable

View File

@@ -16,9 +16,6 @@ namespace LibationAvalonia.Dialogs
private readonly AboutVM _viewModel;
public AboutDialog() : base(saveAndRestorePosition:false)
{
if (Design.IsDesignMode)
_ = Configuration.Instance.LibationFiles;
InitializeComponent();
DataContext = _viewModel = new AboutVM();

View File

@@ -37,8 +37,17 @@ namespace LibationAvalonia.Dialogs
if (Design.IsDesignMode)
{
using var context = DbContexts.GetContext();
LibraryBook = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G");
MainVM.Configure_NonUI();
LibraryBook
= MockLibraryBook
.CreateBook()
.AddAuthor("Author 2")
.AddNarrator("Narrator 2")
.AddSeries("Series Name", 1)
.AddCategoryLadder("Parent", "Child Category")
.AddCategoryLadder("Parent", "Child Category 2")
.WithBookStatus(LiberatedStatus.NotLiberated)
.WithPdfStatus(LiberatedStatus.Liberated);
}
}
public BookDetailsDialog(LibraryBook libraryBook) : this()

View File

@@ -27,7 +27,15 @@ namespace LibationAvalonia.Dialogs
Closing += DialogWindow_Closing;
if (Design.IsDesignMode)
RequestedThemeVariant = ThemeVariant.Dark;
{
var themeVariant = Configuration.CreateMockInstance().GetString(propertyName: nameof(ThemeVariant));
RequestedThemeVariant = themeVariant switch
{
nameof(ThemeVariant.Dark) => ThemeVariant.Dark,
nameof(ThemeVariant.Light) => ThemeVariant.Light,
_ => ThemeVariant.Default,
};
}
}
private void DialogWindow_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)

View File

@@ -25,10 +25,11 @@ public partial class EditTemplateDialog : DialogWindow
if (Design.IsDesignMode)
{
_ = Configuration.Instance.LibationFiles;
var mockInstance = Configuration.CreateMockInstance();
mockInstance.Books = Configuration.DefaultBooksDirectory;
RequestedThemeVariant = ThemeVariant.Dark;
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
_viewModel = new(Configuration.Instance, editor);
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(mockInstance.Books, mockInstance.FileTemplate);
_viewModel = new(mockInstance, editor);
_viewModel.ResetTextBox(editor.EditingTemplate.TemplateText);
Title = $"Edit {editor.TemplateName}";
DataContext = _viewModel;

View File

@@ -20,9 +20,10 @@ namespace LibationAvalonia.Dialogs.Login
if (Design.IsDesignMode)
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.Accounts;
Account = accounts.FirstOrDefault();
Account = new Account("someemail.somedomain.co")
{
IdentityTokens = new AudibleApi.Authorization.Identity(AudibleApi.Localization.Locales.First())
};
ExternalLoginUrl = "ht" + "tps://us.audible.com/Test_url";
DataContext = this;
}

View File

@@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="200"
MinWidth="900" MinHeight="200"
MinWidth="900" MinHeight="750"
Width="900" Height="750"
x:Class="LibationAvalonia.Dialogs.SettingsDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"

View File

@@ -12,11 +12,9 @@ namespace LibationAvalonia.Dialogs
{
private SettingsVM settingsDisp;
private readonly Configuration config = Configuration.Instance;
private readonly Configuration config = Design.IsDesignMode ? Configuration.CreateMockInstance() : Configuration.Instance;
public SettingsDialog()
{
if (Design.IsDesignMode)
_ = Configuration.Instance.LibationFiles;
InitializeComponent();
DataContext = settingsDisp = new(config);

View File

@@ -14,12 +14,11 @@ using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
{
public partial class TrashBinDialog : Window
public partial class TrashBinDialog : DialogWindow
{
public TrashBinDialog()
{
InitializeComponent();
this.RestoreSizeAndLocation(Configuration.Instance);
DataContext = new TrashBinViewModel();
this.Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);

View File

@@ -29,7 +29,7 @@ namespace LibationAvalonia.ViewModels.Settings
GridScaleFactor = scaleFactorToLinearRange(config.GridScaleFactor);
GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor);
themeVariant = initialThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) ?? "";
themeVariant = initialThemeVariant = config.GetString(propertyName: nameof(ThemeVariant)) ?? "";
if (string.IsNullOrWhiteSpace(initialThemeVariant))
themeVariant = initialThemeVariant = "System";
}

View File

@@ -23,7 +23,7 @@ namespace LibationAvalonia.Views
public MainWindow()
{
if (Design.IsDesignMode)
_ = Configuration.Instance.LibationFiles;
Configuration.CreateMockInstance();
DataContext = new MainVM(this);
ApiExtended.LoginChoiceFactory = account => Dispatcher.UIThread.Invoke(() => new Dialogs.Login.AvaloniaLoginChoiceEager(account));

View File

@@ -30,10 +30,8 @@ namespace LibationAvalonia.Views
if (Design.IsDesignMode)
{
using var context = DbContexts.GetContext();
ViewModels.MainVM.Configure_NonUI();
if (context.GetLibraryBook_Flat_NoTracking("B017V4IM1G") is LibraryBook book)
DataContext = new ProcessBookViewModel(book);
DataContext = new ProcessBookViewModel(MockLibraryBook.CreateBook());
return;
}
}

View File

@@ -2,6 +2,7 @@
using Avalonia.Controls;
using Avalonia.Data.Converters;
using DataLayer;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.ProcessQueue;
using System;
@@ -29,18 +30,12 @@ namespace LibationAvalonia.Views
#if DEBUG
if (Design.IsDesignMode)
{
_ = LibationFileManager.Configuration.Instance.LibationFiles;
ViewModels.MainVM.Configure_NonUI();
Configuration.CreateMockInstance();
var vm = new ProcessQueueViewModel();
DataContext = vm;
using var context = DbContexts.GetContext();
var trialBook = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G") ?? context.GetLibrary_Flat_NoTracking().FirstOrDefault();
if (trialBook is null)
return;
var trialBook = MockLibraryBook.CreateBook();
List<ProcessBookViewModel> testList = new()
{
new ProcessBookViewModel(trialBook)

View File

@@ -62,20 +62,14 @@ namespace LibationAvalonia.Views
#if DEBUG
if (Design.IsDesignMode)
{
using var context = DbContexts.GetContext();
LibraryBook?[] sampleEntries;
try
{
sampleEntries = [
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"),
context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"),
context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"),
context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"),
context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"),
context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"),
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")];
}
catch { sampleEntries = []; }
MainVM.Configure_NonUI();
LibraryBook[] sampleEntries = [
MockLibraryBook.CreateBook(title: "Book 1"),
MockLibraryBook.CreateBook(title: "Book 2"),
MockLibraryBook.CreateBook(title: "Book 3"),
MockLibraryBook.CreateBook(title: "Book 4"),
MockLibraryBook.CreateBook(title: "Book 5"),
MockLibraryBook.CreateBook(title: "Book 6")];
var pdvm = new ProductsDisplayViewModel();
_ = pdvm.BindToGridAsync(sampleEntries.OfType<LibraryBook>().ToList());

View File

@@ -20,9 +20,9 @@ namespace LibationFileManager
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
// exceptions: appsettings.json, LibationFiles dir, Settings.json
private PersistentDictionary? persistentDictionary;
private IPersistentDictionary? persistentDictionary;
private PersistentDictionary Settings
private IPersistentDictionary Settings
{
get
{

View File

@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Dinah.Core;
@@ -58,7 +59,25 @@ namespace LibationFileManager
}
#region singleton stuff
public static Configuration Instance { get; } = new Configuration();
private static readonly Configuration s_SingletonInstance = new();
public static Configuration Instance { get; private set; } = s_SingletonInstance;
public static Configuration CreateMockInstance()
{
#if !DEBUG
throw new InvalidOperationException("CreateMockInstance should only be called in design mode.");
#endif
var mockInstance = new Configuration() { persistentDictionary = new MockPersistentDictionary() };
mockInstance.SetString("Light", "ThemeVariant");
Instance = mockInstance;
return mockInstance;
}
public static void RestoreSingletonInstance()
{
Instance = s_SingletonInstance;
}
private Configuration() { }
#endregion
}

View File

@@ -0,0 +1,36 @@
using FileManager;
using Newtonsoft.Json.Linq;
#nullable enable
namespace LibationFileManager;
internal class MockPersistentDictionary : IPersistentDictionary
{
private JObject JsonObject { get; } = new();
public bool Exists(string propertyName)
=> JsonObject.ContainsKey(propertyName);
public string? GetString(string propertyName, string? defaultValue = null)
=> JsonObject[propertyName]?.Value<string>() ?? defaultValue;
public T? GetNonString<T>(string propertyName, T? defaultValue = default)
=> GetObject(propertyName) is object obj ? IPersistentDictionary.UpCast<T>(obj) : defaultValue;
public object? GetObject(string propertyName)
=> JsonObject[propertyName]?.Value<object>();
public void SetString(string propertyName, string? newValue)
=> JsonObject[propertyName] = newValue;
public void SetNonString(string propertyName, object? newValue)
=> JsonObject[propertyName] = newValue is null ? null : JToken.FromObject(newValue);
public bool RemoveProperty(string propertyName)
=> JsonObject.Remove(propertyName);
public string? GetStringFromJsonPath(string jsonPath)
=> JsonObject.SelectToken(jsonPath)?.Value<string>();
public bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false)
{
if (JsonObject.SelectToken(jsonPath) is JToken token && token?[propertyName] is not null)
{
token[propertyName] = newValue;
return true;
}
return false;
}
}