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`