mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-15 11:11:54 -04:00
feat(data): clamp settings store
Persist the machine clamp rail in %APPDATA%\OpenNest\clamps.json, in the same shape as the nest defaults store: camelCase JSON written with retry, and a read that never throws. Clamps are off by default: a missing or unreadable file keeps them off, and nothing changes until the settings are enabled with at least one center. Each value is read independently; an invalid one (including a single bad clamp center) is ignored and reported as Partial so the desktop can warn that the rail may lack a physical clamp. Values carry their units and convert between inches and millimeters for a new nest.
This commit is contained in:
1 parent
cc85a9e068
commit
81e3e58f13
2 files changed
+592
No files matched your search
@@ -0,0 +1,307 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Outcome of <see cref="ClampSettings.Load(string, out ClampSettingsStatus)"/>.</summary>
|
||||
public enum ClampSettingsStatus
|
||||
{
|
||||
/// <summary>Every value present in the file was valid.</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>No file exists at the path; clamps stay off.</summary>
|
||||
Missing,
|
||||
|
||||
/// <summary>
|
||||
/// The file loaded, but some values were invalid and were ignored (an invalid clamp
|
||||
/// center is dropped, other fields fall back individually). Callers should warn: the
|
||||
/// resulting rail may lack a physical clamp.
|
||||
/// </summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>The file exists but could not be read or parsed; clamps stay off.</summary>
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The machine's fixed edge-clamp rail, persisted to a single JSON file (by default
|
||||
/// %APPDATA%\OpenNest\clamps.json). Off by default: until <see cref="Enabled"/> is set
|
||||
/// with at least one center, new nests get no clamps and behave exactly as before.
|
||||
/// Loading never throws; values are validated field by field.
|
||||
/// </summary>
|
||||
public sealed class ClampSettings
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
/// <summary>Default clamp extent along the edge, in <see cref="Units"/> (inches).</summary>
|
||||
public const double DefaultWidth = 1.5;
|
||||
|
||||
/// <summary>Default clamp intrusion into the sheet, in <see cref="Units"/> (inches).</summary>
|
||||
public const double DefaultDepth = 0.625;
|
||||
|
||||
private const double MillimetersPerInch = 25.4;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>Whether new nests receive this rail. Default off.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Units of <see cref="Width"/>, <see cref="Depth"/> and <see cref="Centers"/>.</summary>
|
||||
public Units Units { get; set; } = Units.Inches;
|
||||
|
||||
public double Width { get; set; } = DefaultWidth;
|
||||
|
||||
public double Depth { get; set; } = DefaultDepth;
|
||||
|
||||
/// <summary>Clamp centers along X, measured from the plate origin.</summary>
|
||||
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
|
||||
|
||||
/// <summary>The built-in settings used for a missing or unreadable file: clamps off.</summary>
|
||||
public static ClampSettings Fallback => new();
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\clamps.json.</summary>
|
||||
public static string DefaultPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest",
|
||||
"clamps.json"
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Captures a nest's rail (or the default clamp size and no centers when it has none)
|
||||
/// in that nest's units, e.g. for "save as machine defaults".
|
||||
/// </summary>
|
||||
public static ClampSettings FromRail(ClampRail? rail, Units units, bool enabled) =>
|
||||
new()
|
||||
{
|
||||
Enabled = enabled,
|
||||
Units = units,
|
||||
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
|
||||
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
|
||||
Centers = rail?.Centers.ToArray() ?? Array.Empty<double>(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The rail a new nest in <paramref name="units"/> receives, or null when clamps are
|
||||
/// disabled or no center is configured. Values are converted between inches and
|
||||
/// millimeters as needed.
|
||||
/// </summary>
|
||||
public ClampRail? RailFor(Units units)
|
||||
{
|
||||
if (!Enabled || Centers.Count == 0)
|
||||
return null;
|
||||
|
||||
var scale = Scale(Units, units);
|
||||
return new ClampRail(Width * scale, Depth * scale, Centers.Select(c => c * scale));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives a newly created nest this machine rail when enabled (after the nest's units are
|
||||
/// set); otherwise leaves it untouched.
|
||||
/// </summary>
|
||||
public void ApplyToNewNest(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
var rail = RailFor(nest.Units);
|
||||
if (rail != null)
|
||||
nest.SetClampRail(rail);
|
||||
}
|
||||
|
||||
public static ClampSettings Load(string path) => Load(path, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the settings, never throwing. A missing or unreadable file yields
|
||||
/// <see cref="Fallback"/> (clamps off); a readable file merges valid values over it and
|
||||
/// reports <see cref="ClampSettingsStatus.Partial"/> if anything was ignored.
|
||||
/// </summary>
|
||||
public static ClampSettings Load(string path, out ClampSettingsStatus status)
|
||||
{
|
||||
var settings = Fallback;
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
status = ClampSettingsStatus.Missing;
|
||||
return settings;
|
||||
}
|
||||
|
||||
ClampSettingsDto? dto;
|
||||
try
|
||||
{
|
||||
dto = JsonSerializer.Deserialize<ClampSettingsDto>(File.ReadAllText(path), JsonOptions);
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||
{
|
||||
status = ClampSettingsStatus.Invalid;
|
||||
return settings;
|
||||
}
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
status = ClampSettingsStatus.Invalid;
|
||||
return settings;
|
||||
}
|
||||
|
||||
var ignored = false;
|
||||
|
||||
if (dto.Enabled is { } enabled)
|
||||
{
|
||||
if (enabled.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
settings.Enabled = enabled.GetBoolean();
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
if (dto.Units is { } units)
|
||||
{
|
||||
if (TryParseUnits(units, out var parsed))
|
||||
settings.Units = parsed;
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
if (dto.Width is { } width)
|
||||
{
|
||||
if (TryPositive(width, out var value))
|
||||
settings.Width = value;
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
if (dto.Depth is { } depth)
|
||||
{
|
||||
if (TryPositive(depth, out var value))
|
||||
settings.Depth = value;
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
|
||||
if (dto.Centers is { } centers)
|
||||
{
|
||||
if (centers.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var valid = new List<double>();
|
||||
foreach (var center in centers.EnumerateArray())
|
||||
{
|
||||
if (
|
||||
center.ValueKind == JsonValueKind.Number
|
||||
&& center.TryGetDouble(out var x)
|
||||
&& double.IsFinite(x)
|
||||
&& x >= 0
|
||||
)
|
||||
valid.Add(x);
|
||||
else
|
||||
ignored = true;
|
||||
}
|
||||
settings.Centers = valid.Distinct().OrderBy(x => x).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
ignored = true;
|
||||
}
|
||||
}
|
||||
|
||||
status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok;
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
version = CurrentVersion,
|
||||
enabled = Enabled,
|
||||
units = Units.ToString().ToLowerInvariant(),
|
||||
width = Width,
|
||||
depth = Depth,
|
||||
centers = Centers,
|
||||
};
|
||||
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 double Scale(Units from, Units to) =>
|
||||
from == to ? 1
|
||||
: from == Units.Inches ? MillimetersPerInch
|
||||
: 1 / MillimetersPerInch;
|
||||
|
||||
private static double ConvertDefault(double inches, Units units) =>
|
||||
inches * Scale(Units.Inches, units);
|
||||
|
||||
private static bool TryPositive(JsonElement element, out double value)
|
||||
{
|
||||
value = 0;
|
||||
return element.ValueKind == JsonValueKind.Number
|
||||
&& element.TryGetDouble(out value)
|
||||
&& double.IsFinite(value)
|
||||
&& value > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
|
||||
/// numeric strings and comma-joined names.
|
||||
/// </summary>
|
||||
private static bool TryParseUnits(JsonElement element, out Units units)
|
||||
{
|
||||
units = default;
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
|
||||
var text = element.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
|
||||
or IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format. Every value is read as raw JSON so a wrongly typed field falls back on
|
||||
/// its own instead of failing the whole file; unknown fields (including a future higher
|
||||
/// <c>version</c>) are ignored.
|
||||
/// </summary>
|
||||
private sealed record ClampSettingsDto
|
||||
{
|
||||
public JsonElement? Version { get; init; }
|
||||
public JsonElement? Enabled { get; init; }
|
||||
public JsonElement? Units { get; init; }
|
||||
public JsonElement? Width { get; init; }
|
||||
public JsonElement? Depth { get; init; }
|
||||
public JsonElement? Centers { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
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));
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user