fix(defaults): accept only defined unit names, with a caller fallback

Enum.TryParse accepted numeric strings ("7" loaded an undefined unit,
"1" loaded millimeters) and comma-joined names, and a JSON number for
units failed the whole file. Units now load only from a defined unit
name; anything else falls back for that field alone, while the other
valid fields still load.

Add Load(path, fallbackUnits, out status) so callers can keep their
existing unit preference when the file is missing, unusable, or lacks a
usable unit (previously a readable file without units silently became
inches). An undefined fallback is ignored in favour of the built-in
units.

The numeric/comma/JSON-number rows failed against the previous commit;
dropping the fallback assignment or its IsDefined guard fails the new
fallback tests.
This commit is contained in:
aj committed 2026-10-04 12:01:12 -04:00
1 parent 39a15aee14
commit b7dc7dee13
2 files changed
+115 -7

No files matched your search

+47 -7
View File
@@ -73,9 +73,26 @@ public sealed class NestDefaults
/// present but unreadable/invalid, so callers can warn about a corrupt
/// file while still returning usable values.
/// </summary>
public static NestDefaults Load(string path, out NestDefaultsStatus status)
public static NestDefaults Load(string path, out NestDefaultsStatus status) =>
Load(path, Fallback.Units, out status);
/// <summary>
/// Loads defaults like <see cref="Load(string, out NestDefaultsStatus)"/>,
/// but a missing file, an unusable file, or a missing/undefined unit
/// field yields <paramref name="fallbackUnits"/> (the caller's existing
/// unit preference) instead of the built-in units. Other valid fields
/// still load. An undefined <paramref name="fallbackUnits"/> is ignored.
/// </summary>
public static NestDefaults Load(
string path,
Units fallbackUnits,
out NestDefaultsStatus status
)
{
var defaults = Fallback;
if (Enum.IsDefined(fallbackUnits))
defaults.Units = fallbackUnits;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
status = NestDefaultsStatus.Missing;
@@ -104,10 +121,7 @@ public sealed class NestDefaults
status = NestDefaultsStatus.Ok;
if (
dto.Units is not null
&& Enum.TryParse<Units>(dto.Units, ignoreCase: true, out var units)
)
if (TryParseUnits(dto.Units, out var units))
defaults.Units = units;
if (
@@ -192,7 +206,7 @@ public sealed class NestDefaults
var dto = new NestDefaultsDto
{
Version = CurrentVersion,
Units = Units.ToString().ToLowerInvariant(),
Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()),
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
Quadrant = Quadrant,
PartSpacing = PartSpacing,
@@ -225,6 +239,30 @@ public sealed class NestDefaults
}
}
/// <summary>
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse
/// would also accept numeric strings ("7") and comma-joined names,
/// producing undefined or unintended values.
/// </summary>
private static bool TryParseUnits(JsonElement? element, out Units units)
{
units = default;
if (element is not { ValueKind: JsonValueKind.String } value)
return false;
var text = value.GetString();
foreach (var candidate in Enum.GetValues<Units>())
{
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
{
units = candidate;
return true;
}
}
return false;
}
private static bool IsUnreadableFile(Exception ex) =>
ex
is JsonException
@@ -252,7 +290,9 @@ public sealed class NestDefaults
private sealed record NestDefaultsDto
{
public int? Version { get; init; } = CurrentVersion;
public string? Units { get; init; }
// Read as raw JSON so a wrongly typed unit falls back on its own
// instead of failing the whole file.
public JsonElement? Units { get; init; }
public SizeDto? Size { get; init; }
public int? Quadrant { get; init; }
public double? PartSpacing { get; init; }
+68
View File
@@ -147,6 +147,74 @@ public class NestDefaultsTests : IDisposable
Assert.Equal(1, loaded.PartSpacing);
}
[Theory]
[InlineData("\"7\"")]
[InlineData("\"1\"")]
[InlineData("\"-1\"")]
[InlineData("\"inches, millimeters\"")]
[InlineData("1")]
public void Load_UndefinedOrNumericUnits_FallBackPerField(string unitsJson)
{
File.WriteAllText(_path, $$"""{ "units": {{unitsJson}}, "partSpacing": 4 }""");
var loaded = NestDefaults.Load(_path, out var status);
// Only a defined unit name is accepted; the other fields still load.
Assert.Equal(NestDefaultsStatus.Ok, status);
Assert.Equal(Units.Inches, loaded.Units);
Assert.Equal(4, loaded.PartSpacing);
}
[Theory]
[InlineData("""{ "partSpacing": 4 }""")]
[InlineData("""{ "units": "7", "partSpacing": 4 }""")]
[InlineData("""{ "units": "furlongs", "partSpacing": 4 }""")]
[InlineData("""{ "units": null, "partSpacing": 4 }""")]
public void Load_MissingOrInvalidUnits_UseCallerFallbackUnits(string json)
{
File.WriteAllText(_path, json);
var loaded = NestDefaults.Load(_path, Units.Millimeters, out var status);
// The caller's existing unit preference survives a readable file
// that lacks a usable unit, and the valid fields still load.
Assert.Equal(NestDefaultsStatus.Ok, status);
Assert.Equal(Units.Millimeters, loaded.Units);
Assert.Equal(4, loaded.PartSpacing);
}
[Fact]
public void Load_SavedUnits_OverrideCallerFallbackUnits()
{
new NestDefaults { Units = Units.Inches }.Save(_path);
var loaded = NestDefaults.Load(_path, Units.Millimeters, out var status);
Assert.Equal(NestDefaultsStatus.Ok, status);
Assert.Equal(Units.Inches, loaded.Units);
}
[Fact]
public void Load_MissingOrCorruptFile_UsesCallerFallbackUnits()
{
var missing = NestDefaults.Load(_path, Units.Millimeters, out var missingStatus);
File.WriteAllText(_path, "{ this is not json");
var corrupt = NestDefaults.Load(_path, Units.Millimeters, out var corruptStatus);
Assert.Equal(NestDefaultsStatus.Missing, missingStatus);
Assert.Equal(Units.Millimeters, missing.Units);
Assert.Equal(NestDefaultsStatus.Invalid, corruptStatus);
Assert.Equal(Units.Millimeters, corrupt.Units);
}
[Fact]
public void Load_UndefinedCallerFallbackUnits_UseBuiltInUnits()
{
var loaded = NestDefaults.Load(_path, (Units)7, out _);
Assert.Equal(Units.Inches, loaded.Units);
}
[Fact]
public void Load_UnknownFieldsAndFutureVersion_Ignored()
{