feat: write app.json only when changed and fall back per invalid field

This commit is contained in:
doyaGu committed 2026-10-03 02:54:27 -04:00
1 parent bb4f96a9c5
commit 4b56297135
5 files changed
+308 -25

No files matched your search

@@ -158,4 +158,148 @@ public class AppConfigStoreTests
Assert.AreEqual(config.ColumnWidth.Preset, reloaded.ColumnWidth.Preset);
Assert.AreEqual(config.Confirmations.DiscardEdit, reloaded.Confirmations.DiscardEdit);
}
[TestMethod]
public void LoadNonObjectRootBacksUpAndReturnsDefaults()
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
File.WriteAllText(path, "[1, 2]");
var store = new AppConfigStore(path);
Assert.AreEqual(AppConfig.DefaultLanguage, store.Load().Language);
Assert.AreEqual("[1, 2]", File.ReadAllText(path + ".bak"));
}
[TestMethod]
[DataRow("\"theme\": \"sepia\"")]
[DataRow("\"theme\": 1")]
[DataRow("\"theme\": {}")]
[DataRow("\"language\": \"fr\"")]
[DataRow("\"language\": \"zh-hans\"")]
[DataRow("\"language\": 5")]
[DataRow("\"sort\": { \"card\": \"created-at\" }")]
[DataRow("\"sort\": 7")]
[DataRow("\"column-width\": { \"preset\": \"huge\", \"custom-pixels\": 159 }")]
[DataRow("\"column-width\": { \"custom-pixels\": 721 }")]
[DataRow("\"column-width\": { \"custom-pixels\": \"wide\" }")]
[DataRow("\"confirmations\": { \"delete-card\": \"nope\" }")]
[DataRow("\"version\": \"one\"")]
public void InvalidFieldFallsBackAloneWithoutBackup(string invalidMember)
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
// The valid fields are non-defaults, so any whole-file fallback would show.
File.WriteAllText(path, $$"""
{
"theme": "dark",
"confirmations": { "discard-edit": false },
{{invalidMember}}
}
""");
var store = new AppConfigStore(path);
AppConfig config = store.Load();
Assert.IsFalse(File.Exists(path + ".bak"));
AppConfig defaults = new();
Assert.AreEqual(invalidMember.Contains("theme") ? defaults.Theme : ThemeOption.Dark, config.Theme);
if (!invalidMember.Contains("confirmations"))
{
Assert.IsFalse(config.Confirmations.DiscardEdit);
}
Assert.AreEqual(defaults.Language, config.Language);
Assert.AreEqual(defaults.Sort.Card, config.Sort.Card);
Assert.AreEqual(defaults.ColumnWidth.Preset, config.ColumnWidth.Preset);
Assert.AreEqual(defaults.ColumnWidth.CustomPixels, config.ColumnWidth.CustomPixels);
Assert.AreEqual(defaults.Confirmations.DeleteCard, config.Confirmations.DeleteCard);
Assert.AreEqual(defaults.Version, config.Version);
}
[TestMethod]
public void InvalidNestedFieldKeepsItsSiblings()
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
File.WriteAllText(path, """{ "column-width": { "preset": "bogus", "custom-pixels": 500 } }""");
AppConfig config = new AppConfigStore(path).Load();
Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset);
Assert.AreEqual(500, config.ColumnWidth.CustomPixels);
}
[TestMethod]
public void ColumnWidthBoundsAreInclusive()
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
File.WriteAllText(path, """{ "column-width": { "custom-pixels": 160 } }""");
Assert.AreEqual(160, new AppConfigStore(path).Load().ColumnWidth.CustomPixels);
File.WriteAllText(path, """{ "column-width": { "custom-pixels": 720 } }""");
Assert.AreEqual(720, new AppConfigStore(path).Load().ColumnWidth.CustomPixels);
}
[TestMethod]
public void UnchangedConfigurationIsNotWritten()
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
var store = new AppConfigStore(path);
AppConfig config = store.Load();
Assert.IsFalse(store.SaveIfChanged(config));
Assert.IsFalse(File.Exists(path));
}
[TestMethod]
public void FieldFallbackAloneDoesNotRewriteTheFile()
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
const string original = """{ "theme": "sepia" }""";
File.WriteAllText(path, original);
var store = new AppConfigStore(path);
AppConfig config = store.Load();
Assert.IsFalse(store.SaveIfChanged(config));
Assert.AreEqual(original, File.ReadAllText(path));
}
[TestMethod]
public void ChangedConfigurationIsWrittenOnce()
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
var store = new AppConfigStore(path);
AppConfig config = store.Load();
config.Sort.Card = CardSortOption.UpdatedAt;
Assert.IsTrue(store.SaveIfChanged(config));
Assert.AreEqual(CardSortOption.UpdatedAt, new AppConfigStore(path).Load().Sort.Card);
// Saved state is the new baseline.
Assert.IsFalse(store.SaveIfChanged(config));
}
[TestMethod]
public void ChangeRevertedWithinSessionIsNotWritten()
{
using var directory = new TempDirectory();
string path = Path.Combine(directory.FullPath, "app.json");
var store = new AppConfigStore(path);
AppConfig config = store.Load();
config.Theme = ThemeOption.Dark;
config.Theme = ThemeOption.FollowSystem;
Assert.IsFalse(store.SaveIfChanged(config));
Assert.IsFalse(File.Exists(path));
}
}
+3 -3
View File
@@ -6,7 +6,7 @@ namespace YKanBan.Services;
/// <summary>
/// Composition root for the whole process: owns the loaded configuration and
/// the single open workspace session. The configuration is persisted once at
/// shutdown; the session (lock plus connection) lives until disposal.
/// shutdown, and only when this session changed it; the session (lock plus connection) lives until disposal.
/// </summary>
public sealed class AppServices : IDisposable
{
@@ -49,9 +49,9 @@ public sealed class AppServices : IDisposable
}
/// <summary>
/// Persists the configuration; called once at shutdown.
/// Persists the configuration when this session changed a setting; called once at shutdown.
/// </summary>
public void SaveConfiguration() => ConfigStore.Save(Config);
public void SaveConfiguration() => ConfigStore.SaveIfChanged(Config);
/// <summary>
/// Releases the workspace session, if any.
+20
View File
@@ -141,6 +141,26 @@ public sealed class AppConfig
/// </summary>
public const string DefaultLanguage = "en";
/// <summary>
/// Simplified Chinese UI language code.
/// </summary>
public const string SimplifiedChineseLanguage = "zh-Hans";
/// <summary>
/// Gets the supported UI language codes; any other stored value falls back to <see cref="DefaultLanguage"/>.
/// </summary>
public static IReadOnlyList<string> SupportedLanguages { get; } = [DefaultLanguage, SimplifiedChineseLanguage];
/// <summary>
/// Smallest accepted custom column width in pixels.
/// </summary>
public const int MinColumnPixels = 160;
/// <summary>
/// Largest accepted custom column width in pixels.
/// </summary>
public const int MaxColumnPixels = 720;
/// <summary>
/// Gets or sets the configuration format version of this file.
/// </summary>
+140 -21
View File
@@ -1,13 +1,17 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace YKanBan.Storage.AppData;
/// <summary>
/// Loads and saves app.json with graceful degradation: a missing file yields
/// defaults; a corrupt file is backed up to app.json.bak and then defaults are
/// used. Saving writes the file directly (no atomic replace) — losing the last
/// session's settings to a crash is an accepted trade-off.
/// defaults; a file that is not a JSON object is backed up to app.json.bak and
/// then defaults are used; an individual invalid field (unknown enum value or
/// language, out-of-range width, wrong type) falls back to its own default while
/// the other fields are kept. Saving happens only when the configuration differs
/// from what was loaded, and writes the file directly (no atomic replace) —
/// losing the last session's settings to a crash is an accepted trade-off.
/// </summary>
public sealed class AppConfigStore
{
@@ -16,10 +20,24 @@ public sealed class AppConfigStore
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore,
// Enums round-trip through the kebab-case strings declared via [EnumMember].
Converters = { new StringEnumConverter() },
// Enums round-trip through the kebab-case strings declared via [EnumMember];
// numeric enum values are rejected like any other unknown value.
Converters = { new StringEnumConverter { AllowIntegerValues = false } },
};
private static readonly JsonSerializer FieldTolerantSerializer = JsonSerializer.Create(new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
Converters = { new StringEnumConverter { AllowIntegerValues = false } },
// The JSON text is already known to be well formed here, so every error is a
// single member that fails to convert: skip it and keep that member's default.
Error = (_, args) => args.ErrorContext.Handled = true,
});
/// <summary>The serialized form of the configuration as last loaded or saved.</summary>
private string _persistedSnapshot = Serialize(new AppConfig());
/// <summary>
/// Initializes a store bound to a specific app.json path.
/// </summary>
@@ -32,12 +50,57 @@ public sealed class AppConfigStore
public string FilePath { get; }
/// <summary>
/// Loads the configuration. A missing file yields defaults; a corrupt file
/// is backed up to app.json.bak and then defaults are used.
/// Loads the configuration and records it as the persisted baseline for
/// <see cref="SaveIfChanged"/>. A missing file yields defaults; a file that
/// is not a JSON object is backed up to app.json.bak and then defaults are
/// used; invalid fields fall back to their defaults individually.
/// </summary>
/// <returns>The loaded configuration, or defaults when none could be read.</returns>
/// <exception cref="IOException">The file exists but cannot be read.</exception>
public AppConfig Load()
{
AppConfig config = Read();
// The baseline is the effective configuration, so field fallbacks alone never trigger a write.
_persistedSnapshot = Serialize(config);
return config;
}
/// <summary>
/// Writes the configuration only when it differs from the loaded or last
/// saved one, i.e. when this session changed a setting.
/// </summary>
/// <param name="config">The live configuration.</param>
/// <returns><see langword="true"/> when the file was written.</returns>
public bool SaveIfChanged(AppConfig config)
{
string json = Serialize(config);
if (json == _persistedSnapshot)
{
return false;
}
Write(json);
_persistedSnapshot = json;
return true;
}
/// <summary>
/// Writes the configuration directly to disk (no atomic replace), unconditionally.
/// </summary>
/// <param name="config">The configuration to serialize.</param>
public void Save(AppConfig config)
{
string json = Serialize(config);
Write(json);
_persistedSnapshot = json;
}
/// <summary>
/// Reads and sanitizes the file.
/// </summary>
/// <returns>The effective configuration.</returns>
private AppConfig Read()
{
// Missing file is a normal first-run state: run with defaults.
if (!File.Exists(FilePath))
@@ -46,42 +109,98 @@ public sealed class AppConfigStore
}
string json = File.ReadAllText(FilePath);
JToken root;
try
{
// A JSON null literal deserializes to null; treat it like defaults.
AppConfig? config = JsonConvert.DeserializeObject<AppConfig>(json, SerializerSettings);
if (config is null)
root = JToken.Parse(json);
}
catch (JsonException)
{
return BackUpAndUseDefaults();
}
// A JSON null literal carries no settings at all; treat it like a missing file.
if (root.Type == JTokenType.Null)
{
return new AppConfig();
}
// Well-formed JSON of the wrong shape has no field to keep: handle it like corrupt text.
if (root is not JObject rootObject)
{
return BackUpAndUseDefaults();
}
AppConfig config = rootObject.ToObject<AppConfig>(FieldTolerantSerializer) ?? new AppConfig();
Sanitize(config);
return config;
}
/// <summary>
/// Keeps a copy of an unusable file for inspection, then continues with defaults.
/// </summary>
/// <returns>The default configuration.</returns>
private AppConfig BackUpAndUseDefaults()
{
File.Copy(FilePath, FilePath + ".bak", overwrite: true);
return new AppConfig();
}
/// <summary>
/// Replaces every value that converted but is outside its domain with the field default.
/// </summary>
/// <param name="config">The freshly deserialized configuration.</param>
private static void Sanitize(AppConfig config)
{
var defaults = new AppConfig();
// Older or hand-edited files may miss whole sections; each falls back to its defaults.
config.Sort ??= new SortSettings();
config.ColumnWidth ??= new ColumnWidthSettings();
config.Confirmations ??= new ConfirmationSettings();
return config;
}
catch (JsonException)
if (config.Language is null || !AppConfig.SupportedLanguages.Contains(config.Language, StringComparer.Ordinal))
{
// Corrupt: keep a copy for inspection, then continue with defaults.
File.Copy(FilePath, FilePath + ".bak", overwrite: true);
return new AppConfig();
config.Language = defaults.Language;
}
if (!Enum.IsDefined(config.Theme))
{
config.Theme = defaults.Theme;
}
if (!Enum.IsDefined(config.Sort.Card))
{
config.Sort.Card = defaults.Sort.Card;
}
if (!Enum.IsDefined(config.ColumnWidth.Preset))
{
config.ColumnWidth.Preset = defaults.ColumnWidth.Preset;
}
if (config.ColumnWidth.CustomPixels is < AppConfig.MinColumnPixels or > AppConfig.MaxColumnPixels)
{
config.ColumnWidth.CustomPixels = defaults.ColumnWidth.CustomPixels;
}
}
/// <summary>
/// Writes the configuration directly to disk (no atomic replace).
/// Writes the serialized configuration, creating the parent folder on demand.
/// </summary>
/// <param name="config">The configuration to serialize.</param>
public void Save(AppConfig config)
/// <param name="json">The serialized configuration.</param>
private void Write(string json)
{
// Create the parent folder on demand; normally it already exists.
// Normally the folder already exists.
string? directory = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(FilePath, JsonConvert.SerializeObject(config, SerializerSettings));
File.WriteAllText(FilePath, json);
}
/// <summary>
/// Serializes a configuration in the on-disk format.
/// </summary>
/// <param name="config">The configuration.</param>
/// <returns>The JSON text.</returns>
private static string Serialize(AppConfig config) => JsonConvert.SerializeObject(config, SerializerSettings);
}
@@ -26,7 +26,7 @@ public sealed partial class SettingsDialogViewModel : ViewModelBase
Languages =
[
new OptionItem(AppConfig.DefaultLanguage, Resources.Settings_LanguageEnglish),
new OptionItem("zh-Hans", Resources.Settings_LanguageChinese),
new OptionItem(AppConfig.SimplifiedChineseLanguage, Resources.Settings_LanguageChinese),
];
selectedLanguage = Languages.FirstOrDefault(option => (string)option.Value == _config.Language)
?? Languages[0];