feat(persistence): add ConfigStore with atomic JSON writes and corruption-safe load
Some checks failed
CI / build-and-test (push) Failing after 22s
Some checks failed
CI / build-and-test (push) Failing after 22s
This commit is contained in:
parent
464f559576
commit
3f8b5f1a7b
2 changed files with 136 additions and 0 deletions
59
src/TeamsISO.Engine/Persistence/ConfigStore.cs
Normal file
59
src/TeamsISO.Engine/Persistence/ConfigStore.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace TeamsISO.Engine.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and saves <see cref="EngineConfig"/> as JSON.
|
||||
/// Writes are atomic: serialize to a temp file in the same directory, then <c>File.Move</c> with overwrite.
|
||||
/// </summary>
|
||||
public sealed class ConfigStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private readonly string _path;
|
||||
private readonly ILogger<ConfigStore> _logger;
|
||||
|
||||
public ConfigStore(string path, ILogger<ConfigStore> logger)
|
||||
{
|
||||
_path = path;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public EngineConfig Load()
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
_logger.LogInformation("Config file not found at {Path}; using defaults.", _path);
|
||||
return EngineConfig.Default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_path);
|
||||
var loaded = JsonSerializer.Deserialize<EngineConfig>(json, Options);
|
||||
return loaded ?? EngineConfig.Default;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Config file at {Path} is not valid JSON; using defaults.", _path);
|
||||
return EngineConfig.Default;
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(EngineConfig config)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
var temp = _path + ".tmp";
|
||||
var json = JsonSerializer.Serialize(config, Options);
|
||||
File.WriteAllText(temp, json);
|
||||
File.Move(temp, _path, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Persistence;
|
||||
|
||||
namespace TeamsISO.Engine.Tests.Persistence;
|
||||
|
||||
public class ConfigStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ConfigStoreTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"teamsiso-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(_dir, recursive: true);
|
||||
|
||||
private ConfigStore NewStore() =>
|
||||
new(Path.Combine(_dir, "config.json"), NullLogger<ConfigStore>.Instance);
|
||||
|
||||
[Fact]
|
||||
public void Load_WhenFileMissing_ReturnsDefault()
|
||||
{
|
||||
var store = NewStore();
|
||||
var config = store.Load();
|
||||
config.Should().Be(EngineConfig.Default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveThenLoad_RoundTripsExactly()
|
||||
{
|
||||
var store = NewStore();
|
||||
var input = new EngineConfig(
|
||||
new FrameProcessingSettings(TargetFramerate.Fps59_94, TargetResolution.R720p,
|
||||
AspectMode.Letterbox, AudioMode.Isolated),
|
||||
new[]
|
||||
{
|
||||
new IsoAssignment(Guid.NewGuid(), IsEnabled: true, CustomOutputName: "TEAMSISO_A"),
|
||||
new IsoAssignment(Guid.NewGuid(), IsEnabled: false, CustomOutputName: null),
|
||||
});
|
||||
|
||||
store.Save(input);
|
||||
var loaded = store.Load();
|
||||
|
||||
loaded.Should().BeEquivalentTo(input);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_WhenFileCorrupt_ReturnsDefault()
|
||||
{
|
||||
var path = Path.Combine(_dir, "config.json");
|
||||
File.WriteAllText(path, "not valid json {{{");
|
||||
|
||||
var store = NewStore();
|
||||
var config = store.Load();
|
||||
config.Should().Be(EngineConfig.Default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_WritesAtomically_NoPartialFileVisible()
|
||||
{
|
||||
var store = NewStore();
|
||||
var path = Path.Combine(_dir, "config.json");
|
||||
|
||||
store.Save(EngineConfig.Default);
|
||||
var firstWrite = File.ReadAllText(path);
|
||||
|
||||
store.Save(new EngineConfig(
|
||||
FrameProcessingSettings.Default with { Framerate = TargetFramerate.Fps60 },
|
||||
Array.Empty<IsoAssignment>()));
|
||||
|
||||
File.Exists(path).Should().BeTrue();
|
||||
File.ReadAllText(path).Should().NotBe(firstWrite);
|
||||
Directory.GetFiles(_dir).Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue