feat(defaults): JSON nest defaults store with per-field fallback
Replaces the .nstdot nest-template mechanism for new-nest plate defaults. NestDefaults persists units, plate size, quadrant, part spacing, and edge spacing to a single JSON file (DefaultPath: %APPDATA%/OpenNest/defaults.json). Load never throws: a missing, corrupt, or partially valid file degrades field-by-field to the built-in fallback values, and unknown fields or a future version number are ignored.
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Plate/nest defaults persisted to a single JSON file
|
||||
/// (by default %APPDATA%\OpenNest\defaults.json), replacing the
|
||||
/// .nstdot nest-template mechanism. Loading never throws: a missing,
|
||||
/// corrupt, or partially valid file degrades field-by-field to
|
||||
/// <see cref="Fallback"/> values so creating a new nest is never blocked.
|
||||
/// </summary>
|
||||
public sealed class NestDefaults
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
public Units Units { get; set; } = Units.Inches;
|
||||
|
||||
public Size Size { get; set; } = new(100, 100);
|
||||
|
||||
public int Quadrant { get; set; } = 1;
|
||||
|
||||
public double PartSpacing { get; set; } = 1;
|
||||
|
||||
public Spacing EdgeSpacing { get; set; } = new(1, 1, 1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// The built-in defaults used when no file exists and for every field
|
||||
/// that is missing or invalid. Matches the historical
|
||||
/// MainForm.CreateDefaultNest values (units default to Inches; callers
|
||||
/// may override from their own settings).
|
||||
/// </summary>
|
||||
public static NestDefaults Fallback => new();
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\defaults.json.</summary>
|
||||
public static string DefaultPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest",
|
||||
"defaults.json"
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Loads defaults from <paramref name="path"/>, falling back field by
|
||||
/// field for a missing file, invalid JSON, or invalid values.
|
||||
/// </summary>
|
||||
public static NestDefaults Load(string path) => Load(path, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Loads defaults; <paramref name="fileExisted"/> is true when the file
|
||||
/// was present (even if it failed to parse), so callers can surface a
|
||||
/// warning when a present file did not load cleanly.
|
||||
/// </summary>
|
||||
public static NestDefaults Load(string path, out bool fileExisted)
|
||||
{
|
||||
fileExisted = false;
|
||||
var defaults = Fallback;
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return defaults;
|
||||
|
||||
fileExisted = true;
|
||||
|
||||
NestDefaultsDto? dto;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return defaults;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return defaults;
|
||||
}
|
||||
|
||||
if (dto is null)
|
||||
return defaults;
|
||||
|
||||
if (
|
||||
dto.Units is not null
|
||||
&& Enum.TryParse<Units>(dto.Units, ignoreCase: true, out var units)
|
||||
)
|
||||
defaults.Units = units;
|
||||
|
||||
if (
|
||||
dto.Size?.Width is { } width
|
||||
&& dto.Size.Length is { } length
|
||||
&& IsValidSize(width, length)
|
||||
)
|
||||
defaults.Size = new Size(width, length);
|
||||
|
||||
if (dto.Quadrant is { } quadrant && quadrant is >= 1 and <= 4)
|
||||
defaults.Quadrant = quadrant;
|
||||
|
||||
if (dto.PartSpacing is { } partSpacing && IsValidSpacing(partSpacing))
|
||||
defaults.PartSpacing = partSpacing;
|
||||
|
||||
if (
|
||||
dto.EdgeSpacing?.Left is { } left
|
||||
&& dto.EdgeSpacing.Bottom is { } bottom
|
||||
&& dto.EdgeSpacing.Right is { } right
|
||||
&& dto.EdgeSpacing.Top is { } top
|
||||
&& IsValidSpacing(left)
|
||||
&& IsValidSpacing(bottom)
|
||||
&& IsValidSpacing(right)
|
||||
&& IsValidSpacing(top)
|
||||
)
|
||||
defaults.EdgeSpacing = new Spacing(left, bottom, right, top);
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current units and plate defaults from a nest.
|
||||
/// </summary>
|
||||
public static NestDefaults FromNest(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
var plate = nest.PlateDefaults;
|
||||
return new NestDefaults
|
||||
{
|
||||
Units = nest.Units,
|
||||
Size = plate.Size,
|
||||
Quadrant = plate.Quadrant,
|
||||
PartSpacing = plate.PartSpacing,
|
||||
EdgeSpacing = plate.EdgeSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyTo(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
nest.Units = Units;
|
||||
var plate = nest.PlateDefaults;
|
||||
plate.Size = Size;
|
||||
plate.Quadrant = Quadrant;
|
||||
plate.PartSpacing = PartSpacing;
|
||||
plate.EdgeSpacing = EdgeSpacing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the file (creating the parent directory), retrying briefly on
|
||||
/// IO collisions the same way <see cref="LocalJsonProvider"/> does.
|
||||
/// </summary>
|
||||
public void Save(string path, int maxRetries = 3)
|
||||
{
|
||||
var dto = new NestDefaultsDto
|
||||
{
|
||||
Version = CurrentVersion,
|
||||
Units = Units.ToString().ToLowerInvariant(),
|
||||
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
|
||||
Quadrant = Quadrant,
|
||||
PartSpacing = PartSpacing,
|
||||
EdgeSpacing = new SpacingDto
|
||||
{
|
||||
Left = EdgeSpacing.Left,
|
||||
Bottom = EdgeSpacing.Bottom,
|
||||
Right = EdgeSpacing.Right,
|
||||
Top = EdgeSpacing.Top,
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidSize(double width, double length) =>
|
||||
!double.IsNaN(width)
|
||||
&& !double.IsNaN(length)
|
||||
&& !double.IsInfinity(width)
|
||||
&& !double.IsInfinity(length)
|
||||
&& width > 0
|
||||
&& length > 0;
|
||||
|
||||
private static bool IsValidSpacing(double value) =>
|
||||
!double.IsNaN(value) && !double.IsInfinity(value) && value >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format. Every field is nullable so a partial file merges over
|
||||
/// the fallback field by field; unknown fields (including a future
|
||||
/// higher <c>version</c>) are ignored rather than rejected.
|
||||
/// </summary>
|
||||
private sealed record NestDefaultsDto
|
||||
{
|
||||
public int? Version { get; init; } = CurrentVersion;
|
||||
public string? Units { get; init; }
|
||||
public SizeDto? Size { get; init; }
|
||||
public int? Quadrant { get; init; }
|
||||
public double? PartSpacing { get; init; }
|
||||
public SpacingDto? EdgeSpacing { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SizeDto
|
||||
{
|
||||
public double? Width { get; init; }
|
||||
public double? Length { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SpacingDto
|
||||
{
|
||||
public double? Left { get; init; }
|
||||
public double? Bottom { get; init; }
|
||||
public double? Right { get; init; }
|
||||
public double? Top { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Data;
|
||||
|
||||
public class NestDefaultsTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
private readonly string _path;
|
||||
|
||||
public NestDefaultsTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
_path = Path.Combine(_testDir, "defaults.json");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
Directory.Delete(_testDir, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_ThenLoad_RoundTrips()
|
||||
{
|
||||
var original = new NestDefaults
|
||||
{
|
||||
Units = Units.Millimeters,
|
||||
Size = new Size(1220, 2440),
|
||||
Quadrant = 3,
|
||||
PartSpacing = 2.5,
|
||||
EdgeSpacing = new Spacing(1.5, 3, 1.5, 3),
|
||||
};
|
||||
|
||||
original.Save(_path);
|
||||
var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
|
||||
|
||||
Assert.True(loadedFromDisk);
|
||||
Assert.Equal(Units.Millimeters, loaded.Units);
|
||||
Assert.Equal(original.Size, loaded.Size);
|
||||
Assert.Equal(3, loaded.Quadrant);
|
||||
Assert.Equal(2.5, loaded.PartSpacing);
|
||||
Assert.Equal(original.EdgeSpacing.Left, loaded.EdgeSpacing.Left);
|
||||
Assert.Equal(original.EdgeSpacing.Bottom, loaded.EdgeSpacing.Bottom);
|
||||
Assert.Equal(original.EdgeSpacing.Right, loaded.EdgeSpacing.Right);
|
||||
Assert.Equal(original.EdgeSpacing.Top, loaded.EdgeSpacing.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_CreatesMissingDirectory()
|
||||
{
|
||||
var nested = Path.Combine(_testDir, "nested", "defaults.json");
|
||||
new NestDefaults().Save(nested);
|
||||
Assert.True(File.Exists(nested));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_MissingFile_ReturnsFallback()
|
||||
{
|
||||
var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
|
||||
|
||||
Assert.False(loadedFromDisk);
|
||||
AssertFallback(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_CorruptJson_ReturnsFallbackButReportsFile()
|
||||
{
|
||||
File.WriteAllText(_path, "{ this is not json");
|
||||
|
||||
var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
|
||||
|
||||
Assert.True(loadedFromDisk);
|
||||
AssertFallback(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_PartialFile_MergesPerField()
|
||||
{
|
||||
File.WriteAllText(_path, """{ "units": "millimeters", "partSpacing": 4 }""");
|
||||
|
||||
var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
|
||||
|
||||
Assert.True(loadedFromDisk);
|
||||
Assert.Equal(Units.Millimeters, loaded.Units);
|
||||
Assert.Equal(4, loaded.PartSpacing);
|
||||
// Untouched fields keep fallback values.
|
||||
Assert.Equal(new Size(100, 100), loaded.Size);
|
||||
Assert.Equal(1, loaded.Quadrant);
|
||||
Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_OutOfRangeValues_FallBackPerField()
|
||||
{
|
||||
File.WriteAllText(
|
||||
_path,
|
||||
"""
|
||||
{
|
||||
"units": "furlongs",
|
||||
"size": { "width": -50, "length": 100 },
|
||||
"quadrant": 9,
|
||||
"partSpacing": -1,
|
||||
"edgeSpacing": { "left": 1, "bottom": -2, "right": 1, "top": 1 }
|
||||
}
|
||||
"""
|
||||
);
|
||||
|
||||
var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
|
||||
|
||||
Assert.True(loadedFromDisk);
|
||||
Assert.Equal(Units.Inches, loaded.Units);
|
||||
Assert.Equal(new Size(100, 100), loaded.Size);
|
||||
Assert.Equal(1, loaded.Quadrant);
|
||||
Assert.Equal(1, loaded.PartSpacing);
|
||||
Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_NonFiniteValues_FallBack()
|
||||
{
|
||||
File.WriteAllText(_path, """{ "partSpacing": 1e400 }""");
|
||||
|
||||
var loaded = NestDefaults.Load(_path);
|
||||
|
||||
// 1e400 deserializes to Infinity, which is rejected.
|
||||
Assert.Equal(1, loaded.PartSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_UnknownFieldsAndFutureVersion_Ignored()
|
||||
{
|
||||
File.WriteAllText(
|
||||
_path,
|
||||
"""
|
||||
{
|
||||
"version": 2,
|
||||
"units": "inches",
|
||||
"partSpacing": 2,
|
||||
"futureFeature": { "enabled": true },
|
||||
"material": { "name": "steel" }
|
||||
}
|
||||
"""
|
||||
);
|
||||
|
||||
var loaded = NestDefaults.Load(_path, out var loadedFromDisk);
|
||||
|
||||
Assert.True(loadedFromDisk);
|
||||
Assert.Equal(2, loaded.PartSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromNest_CapturesUnitsAndPlateDefaults()
|
||||
{
|
||||
var nest = new Nest
|
||||
{
|
||||
Units = Units.Millimeters,
|
||||
};
|
||||
nest.PlateDefaults.Size = new Size(60, 120);
|
||||
nest.PlateDefaults.Quadrant = 2;
|
||||
nest.PlateDefaults.PartSpacing = 0.5;
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(2, 2, 2, 2);
|
||||
|
||||
var captured = NestDefaults.FromNest(nest);
|
||||
|
||||
Assert.Equal(Units.Millimeters, captured.Units);
|
||||
Assert.Equal(new Size(60, 120), captured.Size);
|
||||
Assert.Equal(2, captured.Quadrant);
|
||||
Assert.Equal(0.5, captured.PartSpacing);
|
||||
Assert.Equal(new Spacing(2, 2, 2, 2), captured.EdgeSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyTo_SetsUnitsAndPlateDefaults_AndDoesNotAliasSourceNest()
|
||||
{
|
||||
var source = new Nest();
|
||||
source.PlateDefaults.Size = new Size(48, 96);
|
||||
source.PlateDefaults.EdgeSpacing = new Spacing(1.25, 1.25, 1.25, 1.25);
|
||||
var defaults = NestDefaults.FromNest(source);
|
||||
|
||||
// Mutating the source nest afterwards must not change the capture.
|
||||
source.PlateDefaults.Size = new Size(1, 1);
|
||||
source.PlateDefaults.EdgeSpacing = new Spacing(9, 9, 9, 9);
|
||||
|
||||
var target = new Nest();
|
||||
defaults.ApplyTo(target);
|
||||
|
||||
Assert.Equal(new Size(48, 96), target.PlateDefaults.Size);
|
||||
Assert.Equal(
|
||||
new Spacing(1.25, 1.25, 1.25, 1.25),
|
||||
target.PlateDefaults.EdgeSpacing
|
||||
);
|
||||
|
||||
// And the applied target owns its own values too.
|
||||
target.PlateDefaults.Size = new Size(2, 2);
|
||||
Assert.Equal(new Size(48, 96), defaults.Size);
|
||||
}
|
||||
|
||||
private static void AssertFallback(NestDefaults loaded)
|
||||
{
|
||||
Assert.Equal(Units.Inches, loaded.Units);
|
||||
Assert.Equal(new Size(100, 100), loaded.Size);
|
||||
Assert.Equal(1, loaded.Quadrant);
|
||||
Assert.Equal(1, loaded.PartSpacing);
|
||||
Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user