From a6e813bc851eb6650da48afa4e969a5eac6bc107 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Mar 2026 20:19:56 -0400 Subject: [PATCH] feat: add IDataProvider interface and LocalJsonProvider with JSON file CRUD One JSON file per machine named by GUID, stored in a configurable directory. Supports save, load, list (as summaries), and delete with IO-error retry. Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Data/IDataProvider.cs | 9 + OpenNest.Data/LocalJsonProvider.cs | 89 ++++++++++ OpenNest.Tests/Data/LocalJsonProviderTests.cs | 163 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 OpenNest.Data/IDataProvider.cs create mode 100644 OpenNest.Data/LocalJsonProvider.cs create mode 100644 OpenNest.Tests/Data/LocalJsonProviderTests.cs diff --git a/OpenNest.Data/IDataProvider.cs b/OpenNest.Data/IDataProvider.cs new file mode 100644 index 0000000..f499848 --- /dev/null +++ b/OpenNest.Data/IDataProvider.cs @@ -0,0 +1,9 @@ +namespace OpenNest.Data; + +public interface IDataProvider +{ + IReadOnlyList GetMachines(); + MachineConfig? GetMachine(Guid id); + void SaveMachine(MachineConfig machine); + void DeleteMachine(Guid id); +} diff --git a/OpenNest.Data/LocalJsonProvider.cs b/OpenNest.Data/LocalJsonProvider.cs new file mode 100644 index 0000000..b618bd7 --- /dev/null +++ b/OpenNest.Data/LocalJsonProvider.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenNest.Data; + +public class LocalJsonProvider : IDataProvider +{ + private readonly string _directory; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + public LocalJsonProvider(string directory) + { + _directory = directory; + Directory.CreateDirectory(_directory); + } + + public IReadOnlyList GetMachines() + { + var summaries = new List(); + foreach (var file in Directory.GetFiles(_directory, "*.json")) + { + var machine = ReadFile(file); + if (machine is not null) + summaries.Add(new MachineSummary(machine.Id, machine.Name)); + } + return summaries; + } + + public MachineConfig? GetMachine(Guid id) + { + var path = GetPath(id); + return File.Exists(path) ? ReadFile(path) : null; + } + + public void SaveMachine(MachineConfig machine) + { + var json = JsonSerializer.Serialize(machine, JsonOptions); + var path = GetPath(machine.Id); + WriteWithRetry(path, json); + } + + public void DeleteMachine(Guid id) + { + var path = GetPath(id); + if (File.Exists(path)) + File.Delete(path); + } + + private string GetPath(Guid id) => Path.Combine(_directory, $"{id}.json"); + + private static MachineConfig? ReadFile(string path) + { + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + catch (IOException) + { + return null; + } + } + + private static void WriteWithRetry(string path, string json, int maxRetries = 3) + { + for (var attempt = 0; attempt < maxRetries; attempt++) + { + try + { + File.WriteAllText(path, json); + return; + } + catch (IOException) when (attempt < maxRetries - 1) + { + Thread.Sleep(100); + } + } + } +} diff --git a/OpenNest.Tests/Data/LocalJsonProviderTests.cs b/OpenNest.Tests/Data/LocalJsonProviderTests.cs new file mode 100644 index 0000000..9225147 --- /dev/null +++ b/OpenNest.Tests/Data/LocalJsonProviderTests.cs @@ -0,0 +1,163 @@ +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class LocalJsonProviderTests : IDisposable +{ + private readonly string _testDir; + + public LocalJsonProviderTests() + { + _testDir = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDir); + } + + public void Dispose() + { + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, true); + } + + private LocalJsonProvider CreateProvider() => new(_testDir); + + [Fact] + public void GetMachines_EmptyDirectory_ReturnsEmpty() + { + var provider = CreateProvider(); + var result = provider.GetMachines(); + Assert.Empty(result); + } + + [Fact] + public void SaveMachine_ThenGetMachine_RoundTrips() + { + var provider = CreateProvider(); + var machine = new MachineConfig + { + Name = "Test Laser", + Type = MachineType.Laser, + Units = UnitSystem.Inches, + Materials = new List + { + new() + { + Name = "Mild Steel", + Grade = "A36", + Density = 0.2836, + Thicknesses = new List + { + new() + { + Value = 0.250, + Kerf = 0.012, + AssistGas = "O2", + LeadIn = new LeadConfig { Type = "Arc", Length = 0.25, Angle = 90.0, Radius = 0.125 }, + LeadOut = new LeadConfig { Type = "Line", Length = 0.125 }, + CutOff = new CutOffConfig + { + PartClearance = 0.5, + Overtravel = 0.25, + Direction = "AwayFromOrigin", + MinSegmentLength = 1.0 + }, + PlateSizes = new List { "60x120", "48x96" } + } + } + } + } + }; + + provider.SaveMachine(machine); + var loaded = provider.GetMachine(machine.Id); + + Assert.NotNull(loaded); + Assert.Equal("Test Laser", loaded.Name); + Assert.Equal(MachineType.Laser, loaded.Type); + Assert.Equal(UnitSystem.Inches, loaded.Units); + Assert.Single(loaded.Materials); + + var mat = loaded.Materials[0]; + Assert.Equal("Mild Steel", mat.Name); + Assert.Equal("A36", mat.Grade); + Assert.Equal(0.2836, mat.Density); + Assert.Single(mat.Thicknesses); + + var thick = mat.Thicknesses[0]; + Assert.Equal(0.250, thick.Value); + Assert.Equal(0.012, thick.Kerf); + Assert.Equal("O2", thick.AssistGas); + Assert.Equal("Arc", thick.LeadIn.Type); + Assert.Equal(0.25, thick.LeadIn.Length); + Assert.Equal("Line", thick.LeadOut.Type); + Assert.Equal(0.125, thick.LeadOut.Length); + Assert.Equal(0.5, thick.CutOff.PartClearance); + Assert.Equal("AwayFromOrigin", thick.CutOff.Direction); + Assert.Equal(new List { "60x120", "48x96" }, thick.PlateSizes); + } + + [Fact] + public void GetMachines_ReturnsSummaries() + { + var provider = CreateProvider(); + var m1 = new MachineConfig { Name = "Laser A" }; + var m2 = new MachineConfig { Name = "Plasma B" }; + + provider.SaveMachine(m1); + provider.SaveMachine(m2); + var summaries = provider.GetMachines(); + + Assert.Equal(2, summaries.Count); + Assert.Contains(summaries, s => s.Name == "Laser A" && s.Id == m1.Id); + Assert.Contains(summaries, s => s.Name == "Plasma B" && s.Id == m2.Id); + } + + [Fact] + public void GetMachine_NotFound_ReturnsNull() + { + var provider = CreateProvider(); + var result = provider.GetMachine(Guid.NewGuid()); + Assert.Null(result); + } + + [Fact] + public void DeleteMachine_RemovesFile() + { + var provider = CreateProvider(); + var machine = new MachineConfig { Name = "To Delete" }; + provider.SaveMachine(machine); + + provider.DeleteMachine(machine.Id); + + Assert.Null(provider.GetMachine(machine.Id)); + Assert.Empty(provider.GetMachines()); + } + + [Fact] + public void SaveMachine_OverwritesExisting() + { + var provider = CreateProvider(); + var machine = new MachineConfig { Name = "Original" }; + provider.SaveMachine(machine); + + machine.Name = "Updated"; + provider.SaveMachine(machine); + + var loaded = provider.GetMachine(machine.Id); + Assert.NotNull(loaded); + Assert.Equal("Updated", loaded.Name); + Assert.Single(provider.GetMachines()); + } + + [Fact] + public void SaveMachine_PreservesSchemaVersion() + { + var provider = CreateProvider(); + var machine = new MachineConfig { Name = "Versioned", SchemaVersion = 1 }; + + provider.SaveMachine(machine); + var loaded = provider.GetMachine(machine.Id); + + Assert.NotNull(loaded); + Assert.Equal(1, loaded.SchemaVersion); + } +}