mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 19:04:13 -04:00
fix(cutting): fingerprint only the built-in settings types
The second delta review found four more false-equal classes in the general reflective fingerprint, all reachable only through custom settings subclasses: cycle markers that dropped the target ancestor, display-formatted DateTime/DateTimeOffset, ignored dictionary and set comparers, and arrays flattened without their dimensions. Safe arrays of OpenNest elements were also newly refused. Every repair of the generic traversal opened another such case. Settings capture now supports exactly the types regeneration already accepts (OwnedCuttingParameters): CuttingParameters, SequenceParameters, AssignmentParameters and the built-in lead-in, lead-out and tab types. Each member is written explicitly, doubles by bit pattern with invariant numerals and text length-prefixed. Every object's runtime type is checked before any member is read, so no other type's code runs. A plate-scoped request whose part or plate settings contain any other type, subclasses included, is UnsupportedGeometry at capture instead of a Ready plan that can never apply. A nested settings object replaced by such a type after capture makes Apply Stale. Detached part-list requests are unaffected. Coverage tests fail when a supported type gains a property or field the fingerprint does not write, or when a new built-in lead or tab type is added without fingerprint support.
This commit is contained in:
1 parent
bb104a07bb
commit
20f8b046de
4 files changed
+443
-304
No files matched your search
@@ -52,9 +52,9 @@ public sealed class PlateCuttingState
|
||||
|
||||
/// <summary>
|
||||
/// Captures on the caller thread. Unsupported or malformed programs throw
|
||||
/// <see cref="ArgumentException"/> or <see cref="NotSupportedException"/>. Settings whose
|
||||
/// authored state cannot be captured exactly are recorded as refused: the plate stays
|
||||
/// plannable but every later commit against it reports <c>Stale</c>.
|
||||
/// <see cref="ArgumentException"/> or <see cref="NotSupportedException"/>; so do part or
|
||||
/// plate settings that are not exact built-in settings types, whose state cannot be
|
||||
/// captured exactly.
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>Null when current; otherwise the first observed difference.</summary>
|
||||
public string Difference(CancellationToken token = default)
|
||||
{
|
||||
const string Current = "current";
|
||||
const string Stale = "stale";
|
||||
var checkedSettings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
|
||||
var checkedSettings = new Dictionary<CuttingParameters, bool>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="Invalid"/>, 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
|
||||
/// <see cref="OwnedCuttingParameters"/> copies: <see cref="CuttingParameters"/>,
|
||||
/// <see cref="SequenceParameters"/>, <see cref="AssignmentParameters"/> 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 <see cref="NotSupportedException"/>. Doubles are written by bit pattern
|
||||
/// and every number is formatted invariantly, so the text is exact and culture-free.
|
||||
/// </summary>
|
||||
internal static class StateFingerprint
|
||||
{
|
||||
internal const string Invalid = "<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];
|
||||
|
||||
/// <summary>
|
||||
/// Fingerprint of <paramref name="value"/>, or <see cref="Invalid"/> when the exact
|
||||
/// authored state cannot be captured without executing foreign code or guessing.
|
||||
/// </summary>
|
||||
internal static string Of(object value)
|
||||
/// <summary>Fingerprint of <paramref name="settings"/>; null renders as a marker.</summary>
|
||||
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
|
||||
internal static string Of(CuttingParameters settings)
|
||||
{
|
||||
var nodes = 0;
|
||||
var exact = true;
|
||||
var path = new HashSet<object>(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<object>().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<string> StateMembers(object target, int childDepth)
|
||||
void Lead(LeadIn lead)
|
||||
{
|
||||
if (lead == null)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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<object> 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.");
|
||||
}
|
||||
@@ -9,102 +9,192 @@ using OpenNest.Geometry;
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<object>().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<int> 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 { }
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user