diff --git a/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs b/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs
index 1902fa6..676dbef 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs
@@ -52,9 +52,9 @@ public sealed class PlateCuttingState
///
/// Captures on the caller thread. Unsupported or malformed programs throw
- /// or . Settings whose
- /// authored state cannot be captured exactly are recorded as refused: the plate stays
- /// plannable but every later commit against it reports Stale .
+ /// or ; so do part or
+ /// plate settings that are not exact built-in settings types, whose state cannot be
+ /// captured exactly.
///
public static PlateCuttingState Capture(Plate plate, CancellationToken token = default)
{
@@ -85,8 +85,6 @@ public sealed class PlateCuttingState
void Fingerprint(CuttingParameters parameters)
{
- // A refused capture is stored as-is; Difference treats it as never current, so
- // every Apply for such settings is Stale and no foreign code ever runs at Apply.
if (parameters != null && !settings.ContainsKey(parameters))
settings.Add(parameters, StateFingerprint.Of(parameters));
}
@@ -98,9 +96,7 @@ public sealed class PlateCuttingState
/// Null when current; otherwise the first observed difference.
public string Difference(CancellationToken token = default)
{
- const string Current = "current";
- const string Stale = "stale";
- var checkedSettings = new Dictionary(ReferenceEqualityComparer.Instance);
+ var checkedSettings = new Dictionary(ReferenceEqualityComparer.Instance);
var plate = Plate;
if (!ReferenceEquals(plate.Parts, partList) || !ReferenceEquals(plate.CutOffs, cutOffList))
return "The plate's part or cutoff list was replaced.";
@@ -158,30 +154,26 @@ public sealed class PlateCuttingState
return null;
// References were compared already; this catches in-place edits of the same object.
- // Each distinct settings object is fingerprinted at most once per check. A refused
- // capture (Invalid) is never current: the state cannot be proven unchanged, and the
- // live object is deliberately not re-read. A fingerprint that fails on re-read is
- // likewise stale, never an escaping exception (cancellation stays distinct).
+ // Each distinct settings object is fingerprinted at most once per check. A nested
+ // settings object replaced since capture by an unsupported type cannot equal the
+ // captured built-in state: Stale, not an exception.
bool SameSettings(CuttingParameters parameters)
{
if (parameters == null)
return true;
- if (checkedSettings.TryGetValue(parameters, out var decision))
- return decision == Current;
- var current = false;
- if (settings.TryGetValue(parameters, out var captured)
- && captured != StateFingerprint.Invalid)
+ if (checkedSettings.TryGetValue(parameters, out var known))
+ return known;
+ bool current;
+ try
{
- try
- {
- current = captured == StateFingerprint.Of(parameters);
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- current = false;
- }
+ current = settings.TryGetValue(parameters, out var captured)
+ && captured == StateFingerprint.Of(parameters);
}
- checkedSettings[parameters] = current ? Current : Stale;
+ catch (NotSupportedException)
+ {
+ current = false;
+ }
+ checkedSettings[parameters] = current;
return current;
}
}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs
index 71dcc01..92bf9c9 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs
@@ -1,174 +1,216 @@
using System;
-using System.Collections;
-using System.Collections.Generic;
using System.Globalization;
-using System.Linq;
-using System.Reflection;
+using System.Text;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.CNC.CuttingPlanning;
///
-/// Exact text record of a settings object's authored state for detecting in-place edits of
-/// settings after capture. It is a freshness fingerprint, not a serializer.
-/// No code of a foreign (non-OpenNest) type is ever executed: capture reads no foreign
-/// property getter (it could mutate live state or throw) and enumerates no foreign collection
-/// (its enumerator could throw after capture). Concretely it renders invariant scalars
-/// (doubles by bit pattern, so results are culture-free); arrays and the exact BCL containers
-/// List/Dictionary/HashSet/KeyValuePair, whose contents are rendered recursively and, where
-/// order is insertion order rather than authored semantics, sorted; concrete public types of
-/// an OpenNest assembly through their public readable properties and fields; and any other
-/// concrete type through its declared instance fields only, which include auto-property
-/// backing fields, so edits to them are seen. Behavioral enumerables and delegates are
-/// refused, as are depth and node-budget overflow and member-read failures; a reference
-/// already on the current path renders as a stable cycle marker, which loses nothing because
-/// the first visit rendered everything reachable from it. A refusal yields
-/// , which never compares equal, so refused state is unequal (Stale)
-/// instead of silently equal.
+/// Exact text record of cutting settings for detecting in-place edits after capture. It is a
+/// freshness fingerprint, not a serializer.
+/// Only the exact built-in settings types are supported — the same set
+/// copies: ,
+/// , and the built-in
+/// lead-in, lead-out and tab types. Every member is written explicitly by type, so nothing is
+/// discovered reflectively and no code of another type ever runs: each object's runtime type
+/// is checked before any of its members is read, and any other runtime type (a subclass
+/// included) throws . Doubles are written by bit pattern
+/// and every number is formatted invariantly, so the text is exact and culture-free.
///
internal static class StateFingerprint
{
- internal const string Invalid = "";
- private const int MaxDepth = 24;
- private const int NodeBudget = 100000;
- private static readonly Assembly Bcl = typeof(object).Assembly;
- private static readonly Assembly[] OpenNestAssemblies =
- [typeof(CuttingParameters).Assembly, typeof(OpenNest.Geometry.Vector).Assembly];
-
- ///
- /// Fingerprint of , or when the exact
- /// authored state cannot be captured without executing foreign code or guessing.
- ///
- internal static string Of(object value)
+ /// Fingerprint of ; null renders as a marker.
+ /// A settings object is not an exact built-in type.
+ internal static string Of(CuttingParameters settings)
{
- var nodes = 0;
- var exact = true;
- var path = new HashSet(ReferenceEqualityComparer.Instance);
- var result = Render(value, 0);
- return exact ? result : Invalid;
+ var text = new StringBuilder();
+ Settings(settings);
+ return text.ToString();
- string Render(object item, int depth)
+ void Settings(CuttingParameters p)
{
- if (!exact)
- return Invalid;
- if (++nodes > NodeBudget || depth > MaxDepth)
- {
- exact = false; // Truncation is lossy: never claim it as equal-able state.
- return Invalid;
- }
- if (item == null)
- return "null";
- var type = item.GetType();
- switch (item)
- {
- case double number:
- // Bit pattern: NaN, infinities and signed zero stay exact. Explicit
- // invariant formatting: ambient culture can substitute other digits.
- return $"{type.FullName}#{BitConverter.DoubleToInt64Bits(number).ToString(CultureInfo.InvariantCulture)}";
- case float number:
- return $"{type.FullName}#{BitConverter.SingleToInt32Bits(number).ToString(CultureInfo.InvariantCulture)}";
- case string characters:
- return $"{type.FullName}#{characters.Length.ToString(CultureInfo.InvariantCulture)}:{characters}";
- }
- if (type.IsPrimitive || type.IsEnum || item is decimal or Guid || item is DateTime
- || item is DateTimeOffset || item is TimeSpan)
- return $"{type.FullName}#{Convert.ToString(item, CultureInfo.InvariantCulture)}";
- if (typeof(Delegate).IsAssignableFrom(type))
- {
- exact = false; // A delegate is behavior, not authored state.
- return Invalid;
- }
+ if (!Begin(p, typeof(CuttingParameters)))
+ return;
+ Int(p.Id);
+ Text(p.MachineName);
+ Text(p.MaterialName);
+ Text(p.Grade);
+ Number(p.Thickness);
+ Number(p.Kerf);
+ Number(p.PartSpacing);
+ Lead(p.ExternalLeadIn);
+ Out(p.ExternalLeadOut);
+ Lead(p.InternalLeadIn);
+ Out(p.InternalLeadOut);
+ Lead(p.ArcCircleLeadIn);
+ Out(p.ArcCircleLeadOut);
+ Number(p.PierceClearance);
+ Flag(p.RoundLeadInAngles);
+ Number(p.LeadInAngleIncrement);
+ Number(p.AutoTabMinSize);
+ Number(p.AutoTabMaxSize);
+ TabSettings(p.TabConfig);
+ Flag(p.TabsEnabled);
+ Sequence(p.Sequencing);
+ Assignment(p.Assignment);
+ text.Append('}');
+ }
- var definition = type.IsGenericType ? type.GetGenericTypeDefinition() : null;
- var exactContainer = type.Assembly == Bcl && (type.IsArray
- || definition == typeof(List<>) || definition == typeof(Dictionary<,>)
- || definition == typeof(HashSet<>) || definition == typeof(KeyValuePair<,>));
- if (!exactContainer && typeof(IEnumerable).IsAssignableFrom(type))
- {
- // A behavioral collection: enumerating it would run foreign code now, or the
- // enumerator could throw after capture. Refuse instead of reading it.
- exact = false;
- return Invalid;
- }
+ void Sequence(SequenceParameters s)
+ {
+ if (!Begin(s, typeof(SequenceParameters)))
+ return;
+ Int((int)s.Method);
+ Number(s.SmallCutoutWidth);
+ Number(s.SmallCutoutHeight);
+ Number(s.MediumCutoutWidth);
+ Number(s.MediumCutoutHeight);
+ Number(s.DistanceMediumSmall);
+ Flag(s.AlternateRowsColumns);
+ Flag(s.AlternateCutoutsWithinRowColumn);
+ Number(s.MinDistanceBetweenRowsColumns);
+ text.Append('}');
+ }
- var tracked = !type.IsValueType && path.Add(item);
- if (!type.IsValueType && !tracked)
- return $"cycle@{type.FullName}"; // Already rendered on this path.
- try
- {
- if (definition == typeof(KeyValuePair<,>))
- return $"{type.FullName}({Render(Property("Key"), depth + 1)}" +
- $"=>{Render(Property("Value"), depth + 1)})";
- var entries = exactContainer
- ? ((IEnumerable)item).Cast().Select(element => Render(element, depth + 1)).ToList()
- : StateMembers(item, depth + 1);
- if (!exact)
- return Invalid;
- if (definition == typeof(Dictionary<,>) || definition == typeof(HashSet<>))
- // Container order is insertion order, not authored semantics.
- entries.Sort(StringComparer.Ordinal);
- return exactContainer
- ? $"{type.FullName}[{string.Join("|", entries)}]"
- : $"{type.FullName}({string.Join(";", entries)})";
- }
- finally
- {
- if (tracked)
- path.Remove(item);
- }
+ void Assignment(AssignmentParameters a)
+ {
+ if (!Begin(a, typeof(AssignmentParameters)))
+ return;
+ Int((int)a.Method);
+ Text(a.Preference);
+ Number(a.MinGeometryLength);
+ text.Append('}');
+ }
- List StateMembers(object target, int childDepth)
+ void Lead(LeadIn lead)
+ {
+ if (lead == null)
{
- var parts = new List();
- if (OpenNestAssemblies.Contains(type.Assembly))
- // Own code: public readable properties and fields are the authored state.
- // A property and a field sharing a name must not collapse; order by both.
- foreach (var member in type
- .GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy)
- .Where(m => m is FieldInfo || m is PropertyInfo property && property.CanRead
- && property.GetIndexParameters().Length == 0)
- .OrderBy(m => m.Name, StringComparer.Ordinal)
- .ThenBy(m => m.MemberType))
- {
- var value = member switch
- {
- PropertyInfo property => Read(() => property.GetValue(target)),
- _ => Read(() => ((FieldInfo)member).GetValue(target)),
- };
- if (!exact)
- return parts;
- parts.Add($"{member.Name}={Render(value, childDepth)}");
- }
- else
- // Foreign type: declared instance fields only, including auto-property
- // backing fields. Reading a field executes no foreign code.
- for (var walk = type; walk != null && walk != typeof(Delegate) && exact; walk = walk.BaseType)
- foreach (var field in walk.GetFields(BindingFlags.Public | BindingFlags.NonPublic
- | BindingFlags.Instance | BindingFlags.DeclaredOnly)
- .OrderBy(f => f.Name, StringComparer.Ordinal))
- {
- var value = Read(() => field.GetValue(target));
- if (!exact)
- return parts;
- parts.Add($"{walk.Name}.{field.Name}={Render(value, childDepth)}");
- }
- return parts;
+ text.Append("null;");
+ return;
}
-
- object Property(string name) => type.GetProperty(name)!.GetValue(item);
-
- object Read(Func read)
+ switch (lead)
{
- try
- {
- return read();
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- exact = false; // A member that cannot be read makes the state non-exact.
- return null;
- }
+ case NoLeadIn when Begin(lead, typeof(NoLeadIn)):
+ break;
+ case LineLeadIn l when Begin(lead, typeof(LineLeadIn)):
+ Number(l.Length);
+ Number(l.ApproachAngle);
+ break;
+ case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
+ Number(l.Radius);
+ break;
+ case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
+ Number(l.LineLength);
+ Number(l.ApproachAngle);
+ Number(l.ArcRadius);
+ break;
+ case LineLineLeadIn l when Begin(lead, typeof(LineLineLeadIn)):
+ Number(l.Length1);
+ Number(l.ApproachAngle1);
+ Number(l.Length2);
+ Number(l.ApproachAngle2);
+ break;
+ case CleanHoleLeadIn l when Begin(lead, typeof(CleanHoleLeadIn)):
+ Number(l.LineLength);
+ Number(l.ArcRadius);
+ Number(l.Kerf);
+ break;
+ default:
+ throw Unsupported(lead);
}
+ text.Append('}');
+ }
+
+ void Out(LeadOut lead)
+ {
+ if (lead == null)
+ {
+ text.Append("null;");
+ return;
+ }
+ switch (lead)
+ {
+ case NoLeadOut when Begin(lead, typeof(NoLeadOut)):
+ break;
+ case LineLeadOut l when Begin(lead, typeof(LineLeadOut)):
+ Number(l.Length);
+ Number(l.ApproachAngle);
+ break;
+ case ArcLeadOut l when Begin(lead, typeof(ArcLeadOut)):
+ Number(l.Radius);
+ break;
+ default:
+ throw Unsupported(lead);
+ }
+ text.Append('}');
+ }
+
+ void TabSettings(Tab tab)
+ {
+ if (tab == null)
+ {
+ text.Append("null;");
+ return;
+ }
+ switch (tab)
+ {
+ case NormalTab t when Begin(tab, typeof(NormalTab)):
+ Number(t.CutoutMinWidth);
+ Number(t.CutoutMinHeight);
+ Number(t.CutoutMaxWidth);
+ Number(t.CutoutMaxHeight);
+ break;
+ case BreakerTab t when Begin(tab, typeof(BreakerTab)):
+ Number(t.BreakerDepth);
+ Number(t.BreakerLeadInLength);
+ Number(t.BreakerAngle);
+ break;
+ case MachineTab t when Begin(tab, typeof(MachineTab)):
+ Int(t.MachineTabId);
+ break;
+ default:
+ throw Unsupported(tab);
+ }
+ Number(tab.Size);
+ Lead(tab.TabLeadIn);
+ Out(tab.TabLeadOut);
+ text.Append('}');
+ }
+
+ // Writes the exact type tag and returns true; null writes a marker and returns false.
+ // A runtime type other than the expected exact type throws before any member is read.
+ bool Begin(object value, Type exact)
+ {
+ if (value == null)
+ {
+ text.Append("null;");
+ return false;
+ }
+ if (value.GetType() != exact)
+ throw Unsupported(value);
+ text.Append(exact.Name).Append('{');
+ return true;
+ }
+
+ void Number(double value) =>
+ text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';');
+
+ void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
+
+ void Flag(bool value) => text.Append(value ? "1;" : "0;");
+
+ // Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "".
+ void Text(string value)
+ {
+ if (value == null)
+ text.Append("~;");
+ else
+ text.Append(value.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(value).Append(';');
}
}
+
+ private static NotSupportedException Unsupported(object value) =>
+ new($"Cutting settings of type {value.GetType().FullName} cannot be captured exactly; " +
+ "only the built-in settings types are supported.");
}
diff --git a/OpenNest.Tests/CuttingPlanning/SettingsFreshnessTests.cs b/OpenNest.Tests/CuttingPlanning/SettingsFreshnessTests.cs
index 3c29136..335d92f 100644
--- a/OpenNest.Tests/CuttingPlanning/SettingsFreshnessTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/SettingsFreshnessTests.cs
@@ -9,102 +9,192 @@ using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
///
-/// Delta-review regressions for settings freshness: capture must see accepted custom settings
-/// state exactly, must execute no foreign getter or enumerator, must never throw out of a
-/// commit, and must be culture-independent. Unsupported shapes refuse conservatively (Stale),
-/// never silently compare equal.
+/// Settings freshness for plate-scoped plans. Only the exact built-in settings types are
+/// captured, each member explicitly; any other runtime type is refused at capture without
+/// running its code, and becoming unsupported after capture is Stale. The coverage tests
+/// fail when a supported type gains state the fingerprint does not write.
///
public class SettingsFreshnessTests
{
- [Fact]
- public void Capture_DictionarySettingsEditInPlace_IsStale()
- {
- var settings = new DictionarySettings();
- settings.Values["leadLength"] = 0.3;
- var (plate, _) = SinglePart(settings);
- var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
- Assert.Equal(CuttingPlanStatus.Ready, result.Status);
- settings.Values["leadLength"] = 9;
- Assert.Equal(CuttingCommitStatus.Stale, CuttingPlanService.Apply([result]).Status);
- }
+ private static readonly Type[] SupportedTypes =
+ [
+ typeof(CuttingParameters), typeof(SequenceParameters), typeof(AssignmentParameters),
+ typeof(NoLeadIn), typeof(LineLeadIn), typeof(ArcLeadIn), typeof(LineArcLeadIn),
+ typeof(LineLineLeadIn), typeof(CleanHoleLeadIn),
+ typeof(NoLeadOut), typeof(LineLeadOut), typeof(ArcLeadOut),
+ typeof(NormalTab), typeof(BreakerTab), typeof(MachineTab)
+ ];
- [Fact]
- public void Capture_PropertyBackedStructSettingsEdit_IsStale()
+ [Theory]
+ [InlineData("dictionary")]
+ [InlineData("struct")]
+ [InlineData("cycle")]
+ [InlineData("date")]
+ [InlineData("array")]
+ [InlineData("mutating-getter")]
+ [InlineData("failing-enumerable")]
+ public void Plan_CustomSettingsType_IsUnsupportedWithoutRunningItsCode(string shape)
{
- var settings = new StructSettings { Extra = new ScalarSettings { Length = 0.3 } };
- var (plate, _) = SinglePart(settings);
- var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
- Assert.Equal(CuttingPlanStatus.Ready, result.Status);
- settings.Extra = new ScalarSettings { Length = 9 };
- Assert.Equal(CuttingCommitStatus.Stale, CuttingPlanService.Apply([result]).Status);
- }
-
- [Fact]
- public void Capture_DeepSettingsChain_IsFullyCompared()
- {
- var root = new Chain();
- var leaf = root;
- for (var i = 0; i < 8; i++)
+ CuttingParameters settings = shape switch
{
- leaf.Next = new Chain();
- leaf = leaf.Next;
- }
- var settings = new DeepSettings { Extra = root };
- var (plate, _) = SinglePart(settings);
- var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
- Assert.Equal(CuttingPlanStatus.Ready, result.Status);
- Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
+ "dictionary" => new DictionarySettings { Values = { ["leadLength"] = 0.3 } },
+ "struct" => new StructSettings { Extra = new ScalarSettings { Length = 0.3 } },
+ "cycle" => CycleSettings.Create(),
+ "date" => new DateSettings { When = new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc) },
+ "array" => new ArraySettings { Values = new int[2, 3] },
+ "mutating-getter" => new MutatingSettings(),
+ "failing-enumerable" => new EnumerableSettings { Fail = true },
+ _ => throw new ArgumentOutOfRangeException(nameof(shape))
+ };
+ var (plate, part) = SinglePart(settings);
- var (plate2, _) = SinglePart(settings);
- var second = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate2));
- Assert.Equal(CuttingPlanStatus.Ready, second.Status);
- leaf.Length = 9;
- Assert.Equal(CuttingCommitStatus.Stale, CuttingPlanService.Apply([second]).Status);
+ var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
+
+ Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
+ Assert.Contains(result.Findings, f => f.Message.Contains("cannot be captured exactly"));
+ Assert.Equal(0, settings.Kerf); // A Bump => ++Kerf getter never ran.
+ Assert.Same(settings, part.CuttingParameters);
}
- [Fact]
- public void Capture_UnsupportedSettingsDepth_RefusesConservatively()
+ [Theory]
+ [InlineData("lead-in")]
+ [InlineData("lead-out")]
+ [InlineData("tab")]
+ [InlineData("tab-lead-in")]
+ [InlineData("sequence")]
+ [InlineData("assignment")]
+ [InlineData("plate")]
+ public void Plan_CustomNestedOrPlateSettingsType_IsUnsupported(string slot)
{
- var root = new Chain();
- var leaf = root;
- for (var i = 0; i < 40; i++)
+ var settings = new CuttingParameters();
+ var (plate, _) = SinglePart(settings);
+ switch (slot)
{
- leaf.Next = new Chain();
- leaf = leaf.Next;
+ case "lead-in": settings.InternalLeadIn = new CustomLeadIn(); break;
+ case "lead-out": settings.ArcCircleLeadOut = new CustomLeadOut(); break;
+ case "tab": settings.TabConfig = new CustomTab(); break;
+ case "tab-lead-in": settings.TabConfig = new NormalTab { TabLeadIn = new CustomLeadIn() }; break;
+ case "sequence": settings.Sequencing = new CustomSequence(); break;
+ case "assignment": settings.Assignment = new CustomAssignment(); break;
+ case "plate": plate.CuttingParameters = new MutatingSettings(); break;
}
- var settings = new DeepSettings { Extra = root };
+
+ Assert.Equal(CuttingPlanStatus.UnsupportedGeometry,
+ CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate)).Status);
+ }
+
+ [Theory]
+ [InlineData("lead-in")]
+ [InlineData("tab")]
+ [InlineData("plate")]
+ public void Apply_SettingsReplacedWithUnsupportedTypeAfterCapture_IsStaleWithoutException(string slot)
+ {
+ var settings = new CuttingParameters();
var (plate, _) = SinglePart(settings);
+ plate.CuttingParameters = new CuttingParameters();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
- // The deep leaf cannot be captured; an edit there must still never apply.
- leaf.Length = 9;
- Assert.Equal(CuttingCommitStatus.Stale, CuttingPlanService.Apply([result]).Status);
- var (untouched, _) = SinglePart(new DeepSettings { Extra = new Chain() });
- var other = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(untouched));
- Assert.Equal(CuttingPlanStatus.Ready, other.Status);
+ switch (slot)
+ {
+ case "lead-in": settings.ExternalLeadIn = new CustomLeadIn(); break;
+ case "tab": settings.TabConfig = new CustomTab(); break;
+ case "plate": plate.CuttingParameters.Sequencing = new CustomSequence(); break;
+ }
+
+ CuttingCommitResult? commit = null;
+ Assert.Null(Record.Exception(() => commit = CuttingPlanService.Apply([result])));
+ Assert.Equal(CuttingCommitStatus.Stale, commit!.Status);
}
[Fact]
- public void Capture_ReadsNoForeignGetterState()
+ public void Plan_DetachedRequestWithCustomSettings_IsUnaffected()
{
- var settings = new MutatingSettings();
- var (plate, _) = SinglePart(settings);
- var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
+ // Only plate-scoped capture records settings; detached part lists never fingerprint them.
+ var (plate, _) = SinglePart(new MutatingSettings());
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate.Parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
- Assert.Equal(0, settings.Kerf); // The nominally read-only capture never ran ++Kerf.
- Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
}
[Fact]
- public void Apply_FailingSettingsEnumerable_IsStaleWithoutException()
+ public void Fingerprint_SeesAnEditToEveryPublicPropertyOfEverySupportedType()
{
- var settings = new EnumerableSettings();
- var (plate, _) = SinglePart(settings);
- var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
- Assert.Equal(CuttingPlanStatus.Ready, result.Status);
- settings.EnableFailure();
- var commit = CuttingPlanService.Apply([result]);
- Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
+ var edits = 0;
+ foreach (var type in SupportedTypes)
+ {
+ Assert.Empty(type.GetFields(BindingFlags.Public | BindingFlags.Instance));
+ var instance = Activator.CreateInstance(type)!;
+ var settings = Graph(instance);
+ foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
+ {
+ Assert.True(property.CanRead && property.CanWrite && property.GetIndexParameters().Length == 0,
+ $"{type.Name}.{property.Name}: extend StateFingerprint for non-settable state.");
+ var before = StateFingerprint.Of(settings);
+ property.SetValue(instance, Different(property.GetValue(instance), property.PropertyType,
+ $"{type.Name}.{property.Name}"));
+ Assert.NotEqual(before, StateFingerprint.Of(settings));
+ edits++;
+ }
+ }
+ Assert.True(edits > 40, $"Only {edits} property edits were exercised.");
+ }
+
+ [Fact]
+ public void Fingerprint_SupportedTypesAreEveryBuiltInLeadAndTab()
+ {
+ var builtIn = typeof(LeadIn).Assembly.GetTypes()
+ .Where(t => !t.IsAbstract && (typeof(LeadIn).IsAssignableFrom(t)
+ || typeof(LeadOut).IsAssignableFrom(t) || typeof(Tab).IsAssignableFrom(t)))
+ .OrderBy(t => t.FullName).ToArray();
+ var supported = SupportedTypes
+ .Where(t => typeof(LeadIn).IsAssignableFrom(t) || typeof(LeadOut).IsAssignableFrom(t)
+ || typeof(Tab).IsAssignableFrom(t))
+ .OrderBy(t => t.FullName).ToArray();
+ Assert.Equal(builtIn, supported);
+ foreach (var type in builtIn)
+ StateFingerprint.Of(Graph(Activator.CreateInstance(type)!)); // Must not throw.
+ }
+
+ [Fact]
+ public void Fingerprint_BuiltInSettingsAreDeterministicAndDetectSignedZero()
+ {
+ LeadIn[] leads = [new NoLeadIn(), new LineLeadIn(), new ArcLeadIn(), new LineArcLeadIn(),
+ new LineLineLeadIn(), new CleanHoleLeadIn()];
+ LeadOut[] outs = [new NoLeadOut(), new LineLeadOut(), new ArcLeadOut()];
+ Tab[] tabs = [new NormalTab(), new BreakerTab(), new MachineTab()];
+ foreach (var lead in leads)
+ foreach (var leadOut in outs)
+ foreach (var tab in tabs)
+ {
+ // Shared instances across slots are allowed: they are values, not cycles.
+ tab.TabLeadIn = lead;
+ tab.TabLeadOut = leadOut;
+ var settings = new CuttingParameters
+ {
+ ExternalLeadIn = lead,
+ InternalLeadIn = lead,
+ ArcCircleLeadIn = lead,
+ ExternalLeadOut = leadOut,
+ InternalLeadOut = leadOut,
+ ArcCircleLeadOut = leadOut,
+ TabConfig = tab,
+ TabsEnabled = true
+ };
+ var one = StateFingerprint.Of(settings);
+ Assert.Equal(one, StateFingerprint.Of(settings));
+ settings.Kerf = BitConverter.Int64BitsToDouble(unchecked((long)0x8000000000000000));
+ Assert.NotEqual(one, StateFingerprint.Of(settings));
+ }
+ }
+
+ [Fact]
+ public void Fingerprint_TextFieldsCannotShiftIntoNeighbours()
+ {
+ var a = new CuttingParameters { MachineName = "a;", MaterialName = "b" };
+ var b = new CuttingParameters { MachineName = "a", MaterialName = ";b" };
+ var n = new CuttingParameters { MachineName = null };
+ var e = new CuttingParameters { MachineName = "" };
+ Assert.NotEqual(StateFingerprint.Of(a), StateFingerprint.Of(b));
+ Assert.NotEqual(StateFingerprint.Of(n), StateFingerprint.Of(e));
}
[Fact]
@@ -147,50 +237,42 @@ public class SettingsFreshnessTests
CuttingPlanService.Apply([result]).Status);
}
- [Fact]
- public void Fingerprint_BuiltInSettingsAreDeterministicAndDetectSignedZero()
+ // Places one supported settings object somewhere the fingerprint of the root reaches it.
+ private static CuttingParameters Graph(object instance) => instance switch
{
- LeadIn[] leads = [new NoLeadIn(), new LineLeadIn(), new ArcLeadIn(), new LineArcLeadIn(),
- new LineLineLeadIn(), new CleanHoleLeadIn()];
- LeadOut[] outs = [new NoLeadOut(), new LineLeadOut(), new ArcLeadOut()];
- Tab[] tabs = [new NormalTab(), new BreakerTab(), new MachineTab()];
- foreach (var lead in leads)
- foreach (var leadOut in outs)
- foreach (var tab in tabs)
- {
- var settings = new CuttingParameters
- {
- ExternalLeadIn = lead,
- InternalLeadIn = lead,
- ArcCircleLeadIn = lead,
- ExternalLeadOut = leadOut,
- InternalLeadOut = leadOut,
- ArcCircleLeadOut = leadOut,
- TabConfig = tab,
- TabsEnabled = true
- };
- tab.TabLeadIn = lead;
- tab.TabLeadOut = leadOut;
- var one = StateFingerprint.Of(settings);
- Assert.NotEqual(StateFingerprint.Invalid, one);
- Assert.Equal(one, StateFingerprint.Of(settings));
- settings.Kerf = BitConverter.Int64BitsToDouble(unchecked((long)0x8000000000000000));
- Assert.NotEqual(one, StateFingerprint.Of(settings));
- }
- }
+ CuttingParameters p => p,
+ SequenceParameters s => new CuttingParameters { Sequencing = s },
+ AssignmentParameters a => new CuttingParameters { Assignment = a },
+ LeadIn l => new CuttingParameters { ExternalLeadIn = l },
+ LeadOut l => new CuttingParameters { ExternalLeadOut = l },
+ Tab t => new CuttingParameters { TabConfig = t },
+ _ => throw new ArgumentOutOfRangeException(nameof(instance))
+ };
- [Fact]
- public void Fingerprint_CycleThroughLeadInIsDeterministic()
+ private static object? Different(object? value, Type type, string member)
{
- var tab = new NormalTab();
- var settings = new CuttingParameters { TabConfig = tab };
- tab.TabLeadIn = new LineLeadIn { Length = 1 };
- tab.TabLeadOut = new LineLeadOut { Length = 2 };
- var one = StateFingerprint.Of(settings);
- Assert.NotEqual(StateFingerprint.Invalid, one);
- Assert.Equal(one, StateFingerprint.Of(settings));
- ((LineLeadIn)tab.TabLeadIn).Length = 3;
- Assert.NotEqual(one, StateFingerprint.Of(settings));
+ if (type == typeof(double))
+ return (double)value! + 1.25;
+ if (type == typeof(int))
+ return (int)value! + 1;
+ if (type == typeof(bool))
+ return !(bool)value!;
+ if (type == typeof(string))
+ return ((string?)value ?? "") + "x";
+ if (type.IsEnum)
+ return Enum.GetValues(type).Cast().First(v => !v.Equals(value));
+ if (type == typeof(LeadIn))
+ return new ArcLeadIn { Radius = 7.77 };
+ if (type == typeof(LeadOut))
+ return new ArcLeadOut { Radius = 7.77 };
+ if (type == typeof(Tab))
+ return new MachineTab { MachineTabId = 77 };
+ if (type == typeof(SequenceParameters))
+ return new SequenceParameters { SmallCutoutWidth = 7.77 };
+ if (type == typeof(AssignmentParameters))
+ return new AssignmentParameters { MinGeometryLength = 7.77 };
+ Assert.Fail($"{member}: type {type.Name} is not fingerprinted; extend StateFingerprint.");
+ return null;
}
private static (Plate Plate, Part Part) SinglePart(CuttingParameters settings)
@@ -219,15 +301,32 @@ public class SettingsFreshnessTests
public ScalarSettings Extra { get; set; }
}
- private sealed class Chain
+ private sealed class Node
{
- public Chain? Next { get; set; }
+ public Node? Next { get; set; }
public double Length { get; set; }
}
- private sealed class DeepSettings : CuttingParameters
+ private sealed class CycleSettings : CuttingParameters
{
- public Chain? Extra { get; set; }
+ public Node? Root { get; set; }
+
+ public static CycleSettings Create()
+ {
+ var root = new Node();
+ root.Next = new Node { Next = root };
+ return new CycleSettings { Root = root };
+ }
+ }
+
+ private sealed class DateSettings : CuttingParameters
+ {
+ public DateTime When { get; set; }
+ }
+
+ private sealed class ArraySettings : CuttingParameters
+ {
+ public int[,]? Values { get; set; }
}
private sealed class MutatingSettings : CuttingParameters
@@ -237,18 +336,22 @@ public class SettingsFreshnessTests
private sealed class EnumerableSettings : CuttingParameters
{
- private bool fail;
-
- public void EnableFailure() => fail = true;
+ public bool Fail { get; set; }
public IEnumerable Values
{
get
{
yield return 1;
- if (fail)
+ if (Fail)
throw new IOException("changed enumerable");
}
}
}
+
+ private sealed class CustomLeadIn : LineLeadIn { }
+ private sealed class CustomLeadOut : ArcLeadOut { }
+ private sealed class CustomTab : NormalTab { }
+ private sealed class CustomSequence : SequenceParameters { }
+ private sealed class CustomAssignment : AssignmentParameters { }
}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index d2bf3c8..325cff5 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -166,12 +166,14 @@ A malformed live program is also `Stale`, not an exception. A part repeated on
two plates of one scope is `InvalidInput`. Caller-confirmed planning settings are
input, not plate state: editing a separate confirmed-settings object after
capture does not stale the result (confirmed settings that are also a part's or
-the plate's live settings are live state, and editing them does). Settings state
-whose exact capture would require executing foreign code — custom property
-getters, behavioral enumerables, structures beyond the capture limits — is
-recorded as refused at capture: the plate stays plannable, but every commit
-against it reports `Stale`, and capture or commit never runs or enumerates
-foreign code.
+the plate's live settings are live state, and editing them does). Settings are
+compared member by member, and only the exact built-in settings types are
+supported (the same set regeneration copies): a plate-scoped request whose part
+or plate settings, or any lead-in, lead-out, tab, sequencing or assignment
+object inside them, is another type (a subclass included) returns
+`UnsupportedGeometry` without running that type's code. A settings object
+replaced by such a type after capture makes `Apply` return `Stale`. Detached
+part-list requests do not capture settings and are unaffected.
The whole scope is validated and its bounds staged first; cancellation is checked
immediately before the install. Order changes through `ObservableList.Reorder`