Files
OpenNest/OpenNest.Tests/Data/ClampSettingsTests.cs
T
aj 87849e5326 feat(desktop): show clamps to pull and their time
Slice 3b task 3 (D9 + display). OpenNest.Core: TimingInfo gains
ClampRemovalMinutes (included in +, -, * so every aggregation stays
consistent); Timing.GetTimingInfo(Plate) adds plate.ClampRemovalMinutes
once per physical sheet, before the quantity multiply so it scales
with everything else cut on that sheet; CalculateTime adds it to the
estimate as a flat number of minutes regardless of the cut parameters'
unit. A plate with nothing removed is unaffected (zero).

OpenNest.Data: ClampSettings and ClampRailEditor carry RemovalMinutes
through the same three boundaries as clamp size - the rail/constructor,
the settings file (missing key falls back to
ClampRail.DefaultRemovalMinutes; present but negative/non-numeric/null
is ignored and reported Partial, same as width/depth), and
ToMachineDefaults - except it is a time, not a length, so
ConvertedTo never scales it between inches and millimeters.

Desktop: ClampsForm gets a 'Minutes to remove and replace one clamp'
field wired to the editor; PlateRenderer.DrawRemovedClamps draws each
clamp Plate.RemovedClamps lists (resolved from the full rail, since a
removed clamp is absent from Plate.ClampZones) as a dashed grey
outline with a small 'remove' marker, distinct from an active clamp's
solid hatch and still display-only - no hit test, selection or
sequencing; PlateView calls it right after the active-zone pass.
MainForm's Auto Nest completion flow lists every committed sheet with
a removal as 'Sheet N: remove clamp-X (+5 min)', using
Plate.RemovedClamps/ClampRemovalMinutes from Slice 3b tasks 1-2.

Tests: 10 new Data (ClampSettingsTests, ClampRailEditorTests) covering
round-trip/default/invalid-value handling and the never-scaled-by-units
rule; 4 new Core (TimingTests) covering the per-sheet add, the quantity
scaling, CalculateTime, and nest-level aggregation across plates.
Desktop changes (dialog field, renderer, Auto Nest summary) are
compile-verified only (-p:EnableWindowsTargeting=true), matching the
project's existing WinForms testing bar - no automated UI test exists
for the sibling DrawClampZones either.

Gates: Release OpenNest.Tests 3898 passed/31 skipped, OpenNest.IO.Tests
105 passed, OpenNest.Engine.Tests 878 passed/2 skipped; desktop and
OpenNest.WinForms.Tests cross-builds clean; scoped dotnet format
--verify-no-changes clean.

This completes Slice 3b (D8/D9) on top of Slice 3's rail-following
plates. Not published; master and other feature/clamp-zones-* branches
untouched. Windows runtime/visual acceptance not claimed - the plan's
click-through checklist (after slices 3-5) still applies.
2026-10-13 18:15:49 -04:00

409 lines
13 KiB
C#

using System.Text.Json;
using OpenNest.Data;
using OpenNest.Geometry;
namespace OpenNest.Tests.Data;
/// <summary>
/// The machine clamp settings store (%APPDATA%\OpenNest\clamps.json): off by default, a
/// never-throwing read that degrades value by value, and no effect until enabled.
/// </summary>
public class ClampSettingsTests : IDisposable
{
private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
private readonly string _directory = Path.Combine(
Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
private readonly string _path;
public ClampSettingsTests()
{
Directory.CreateDirectory(_directory);
_path = Path.Combine(_directory, "clamps.json");
}
public void Dispose()
{
if (!Directory.Exists(_directory))
return;
if (!OperatingSystem.IsWindows() && File.Exists(_path))
File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
Directory.Delete(_directory, recursive: true);
}
private static ClampSettings Machine(bool enabled = true) => new()
{
Enabled = enabled,
Width = 1.5,
Depth = 0.625,
Centers = MachineCenters,
};
private static void AssertFallback(ClampSettings settings)
{
Assert.False(settings.Enabled);
Assert.Equal(Units.Inches, settings.Units);
Assert.Equal(1.5, settings.Width);
Assert.Equal(0.625, settings.Depth);
Assert.Empty(settings.Centers);
Assert.Null(settings.RailFor(Units.Inches));
}
[Fact]
public void DefaultPathUsesTheApplicationDataOpenNestDirectory()
{
Assert.Equal(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest", "clamps.json"),
ClampSettings.DefaultPath);
}
[Fact]
public void MissingFileKeepsClampsOff()
{
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Missing, status);
AssertFallback(loaded);
Assert.Equal(ClampSettingsStatus.Missing, LoadStatus(""));
}
[Fact]
public void SaveThenLoadRoundTripsAsCamelCaseJson()
{
Machine().Save(_path);
var loaded = ClampSettings.Load(_path, out var status);
using var json = JsonDocument.Parse(File.ReadAllText(_path));
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.True(loaded.Enabled);
Assert.Equal(Units.Inches, loaded.Units);
Assert.Equal(1.5, loaded.Width);
Assert.Equal(0.625, loaded.Depth);
Assert.Equal(MachineCenters, loaded.Centers);
var root = json.RootElement;
Assert.Equal(1, root.GetProperty("version").GetInt32());
Assert.True(root.GetProperty("enabled").GetBoolean());
Assert.Equal("inches", root.GetProperty("units").GetString());
Assert.Equal(8, root.GetProperty("centers").GetArrayLength());
}
[Fact]
public void SaveCreatesTheSettingsDirectory()
{
var nested = Path.Combine(_directory, "AppData", "OpenNest", "clamps.json");
Machine().Save(nested);
Assert.True(ClampSettings.Load(nested).Enabled);
}
[Fact]
public void NothingChangesUntilEnabled()
{
var nest = new Nest();
Machine(enabled: false).ApplyToNewNest(nest);
Assert.Null(Machine(enabled: false).RailFor(Units.Inches));
Assert.Null(nest.PlateDefaults.ClampRail);
Assert.Empty(nest.CreatePlate().ClampZones);
// Enabled without any clamp center is still no clamps.
var empty = Machine();
empty.Centers = Array.Empty<double>();
Assert.Null(empty.RailFor(Units.Inches));
}
[Fact]
public void EnabledSettingsGiveANewNestTheMachineRail()
{
var nest = new Nest { Units = Units.Inches };
nest.PlateDefaults.Size = new Size(48, 60);
Machine().ApplyToNewNest(nest);
var plate = nest.CreatePlate();
Assert.Equal(new ClampRail(1.5, 0.625, MachineCenters), nest.PlateDefaults.ClampRail);
Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, plate.ClampZones.Select(z => z.Bounds.X));
}
[Fact]
public void RailIsConvertedToTheNestsUnits()
{
var metric = Machine().RailFor(Units.Millimeters)!;
Assert.Equal(1.5 * 25.4, metric.Width, 12);
Assert.Equal(0.625 * 25.4, metric.Depth, 12);
Assert.Equal(6 * 25.4, metric.Centers[0], 12);
var settings = new ClampSettings
{
Enabled = true,
Units = Units.Millimeters,
Width = 38.1,
Depth = 15.875,
Centers = new[] { 152.4 },
};
var inches = settings.RailFor(Units.Inches)!;
Assert.Equal(1.5, inches.Width, 12);
Assert.Equal(6, inches.Centers[0], 12);
Assert.Equal(38.1, settings.RailFor(Units.Millimeters)!.Width);
}
[Fact]
public void PartialFileMergesOverTheDefaults()
{
File.WriteAllText(_path, """{ "enabled": true, "centers": [21, 6] }""");
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.True(loaded.Enabled);
Assert.Equal(1.5, loaded.Width);
Assert.Equal(0.625, loaded.Depth);
Assert.Equal(new[] { 6.0, 21 }, loaded.Centers);
}
[Fact]
public void InvalidValuesFallBackIndividuallyAndAreReported()
{
File.WriteAllText(_path, """
{
"enabled": "yes",
"units": "7",
"width": -1,
"depth": "deep",
"centers": [6, "x", -3, 21, 1e400, 21],
"future": { "ignored": true }
}
""");
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Partial, status);
Assert.False(loaded.Enabled);
Assert.Equal(Units.Inches, loaded.Units);
Assert.Equal(1.5, loaded.Width);
Assert.Equal(0.625, loaded.Depth);
Assert.Equal(new[] { 6.0, 21 }, loaded.Centers);
}
[Theory]
[InlineData("""{ "centers": 6 }""")]
[InlineData("""{ "centers": [6, -1] }""")]
[InlineData("""{ "centers": [6, "21"] }""")]
[InlineData("""{ "units": ["inches"] }""")]
[InlineData("""{ "enabled": 1 }""")]
[InlineData("""{ "width": 0 }""")]
public void AnySingleInvalidValueIsReported(string json)
{
File.WriteAllText(_path, json);
Assert.Equal(ClampSettingsStatus.Partial, LoadStatus(_path));
}
[Theory]
[InlineData("enabled")]
[InlineData("units")]
[InlineData("width")]
[InlineData("depth")]
[InlineData("centers")]
public void ExplicitNullIsAnIgnoredValueAndIsReported(string field)
{
File.WriteAllText(_path, $$"""{ "enabled": true, "centers": [6], "{{field}}": null }""");
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Partial, status);
Assert.Equal(1.5, loaded.Width);
Assert.Equal(0.625, loaded.Depth);
}
[Fact]
public void MetricFileWithoutSizesGetsThePhysicalDefaultClamp()
{
File.WriteAllText(_path, """{ "enabled": true, "units": "millimeters", "centers": [152.4] }""");
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.Equal(1.5 * 25.4, loaded.Width, 9);
Assert.Equal(0.625 * 25.4, loaded.Depth, 9);
Assert.Equal(1.5, loaded.RailFor(Units.Inches)!.Width, 9);
}
[Fact]
public void MetricFileWithAnInvalidSizeFallsBackToThePhysicalDefault()
{
File.WriteAllText(_path, """{ "enabled": true, "units": "millimeters", "width": -1, "depth": 15, "centers": [152.4] }""");
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Partial, status);
Assert.Equal(38.1, loaded.Width, 9);
Assert.Equal(15, loaded.Depth);
}
[Theory]
[InlineData("""{ "enabled": true, "width": 1e308, "centers": [6] }""")]
[InlineData("""{ "enabled": true, "depth": 1e308, "centers": [6] }""")]
[InlineData("""{ "enabled": true, "centers": [6, 1e308] }""")]
public void ValuesThatCannotConvertBetweenUnitsAreIgnored(string json)
{
File.WriteAllText(_path, json);
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Partial, status);
var nest = new Nest { Units = Units.Millimeters };
loaded.ApplyToNewNest(nest); // must not throw while creating a nest
Assert.Equal(new[] { 6 * 25.4 }, nest.PlateDefaults.ClampRail!.Centers);
}
[Fact]
public void AnInvalidCenterIsDroppedButTheRestOfTheRailStillLoads()
{
File.WriteAllText(_path, """{ "enabled": true, "centers": [6, "21", 36] }""");
var loaded = ClampSettings.Load(_path, out var status);
// The operator must be warned: the rail is now missing a physical clamp.
Assert.Equal(ClampSettingsStatus.Partial, status);
Assert.True(loaded.Enabled);
Assert.Equal(new[] { 6.0, 36 }, loaded.Centers);
}
[Theory]
[InlineData("{ this is not json")]
[InlineData("[6, 21, 36]")]
[InlineData("null")]
[InlineData("")]
public void UnparseableFileKeepsClampsOffAndIsReported(string json)
{
File.WriteAllText(_path, json);
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Invalid, status);
AssertFallback(loaded);
}
[SkippableFact]
public void UnreadableFileKeepsClampsOffAndIsReported()
{
if (OperatingSystem.IsWindows())
throw new SkipException("Unix file modes deny the read on Linux/macOS");
Machine().Save(_path);
File.SetUnixFileMode(_path, UnixFileMode.None);
Skip.If(CanRead(_path), "Process can read a mode-000 file (running as root)");
Assert.True(File.Exists(_path));
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Invalid, status);
AssertFallback(loaded);
}
[Fact]
public void FromRailCapturesANestRailForMachineDefaults()
{
var rail = new ClampRail(38.1, 15.875, new[] { 152.4, 533.4 });
var captured = ClampSettings.FromRail(rail, Units.Millimeters, enabled: true);
Assert.True(captured.Enabled);
Assert.Equal(Units.Millimeters, captured.Units);
Assert.Equal(38.1, captured.Width);
Assert.Equal(15.875, captured.Depth);
Assert.Equal(new[] { 152.4, 533.4 }, captured.Centers);
Assert.Equal(rail, captured.RailFor(Units.Millimeters));
var none = ClampSettings.FromRail(null, Units.Inches, enabled: false);
AssertFallback(none);
}
// D9: minutes to remove one clamp, stored alongside the rail's physical size.
[Fact]
public void RemovalMinutesDefaultsToThePlannerValue()
{
Assert.Equal(ClampRail.DefaultRemovalMinutes, new ClampSettings().RemovalMinutes);
Assert.Equal(ClampRail.DefaultRemovalMinutes, ClampSettings.Fallback.RemovalMinutes);
}
[Fact]
public void RemovalMinutesRoundTripsAndIsNeverScaledByUnits()
{
var settings = Machine();
settings.RemovalMinutes = 8;
settings.Save(_path);
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.Equal(8, loaded.RemovalMinutes);
using var json = JsonDocument.Parse(File.ReadAllText(_path));
Assert.Equal(8, json.RootElement.GetProperty("removalMinutes").GetDouble());
// Width/depth/centers scale between units; removal time (not a length) never does.
var metric = loaded.ConvertedTo(Units.Millimeters);
Assert.Equal(8, metric.RemovalMinutes);
Assert.Equal(8, loaded.RailFor(Units.Millimeters)!.RemovalMinutes);
}
[Fact]
public void MissingRemovalMinutesDefaultsOnLoad()
{
File.WriteAllText(_path, """{ "enabled": true, "centers": [6] }""");
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes);
}
[Theory]
[InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": -1 }""")]
[InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": "five" }""")]
[InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": null }""")]
public void InvalidRemovalMinutesFallsBackAndIsReported(string json)
{
File.WriteAllText(_path, json);
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Partial, status);
Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes);
}
[Fact]
public void ZeroRemovalMinutesIsValid()
{
var settings = Machine();
settings.RemovalMinutes = 0;
settings.Save(_path);
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.Equal(0, loaded.RemovalMinutes);
}
private static ClampSettingsStatus LoadStatus(string path)
{
ClampSettings.Load(path, out var status);
return status;
}
private static bool CanRead(string path)
{
try
{
using var _ = File.OpenRead(path);
return true;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
}