diff --git a/OpenNest.Core/PlateManager.cs b/OpenNest.Core/PlateManager.cs index f7618dc..47617e0 100644 --- a/OpenNest.Core/PlateManager.cs +++ b/OpenNest.Core/PlateManager.cs @@ -19,6 +19,10 @@ namespace OpenNest { private readonly Nest nest; private bool disposed; + private bool suppressNavigation; + private bool batching; + private Plate subscribedLast; + private Plate subscribedSecondToLast; public event EventHandler CurrentPlateChanged; public event EventHandler PlateListChanged; @@ -40,6 +44,8 @@ namespace OpenNest public bool IsLast => CurrentIndex + 1 >= Count; + public bool CanRemoveCurrent => Count > 1 && CurrentPlate != null && CurrentPlate.Parts.Count > 0; + public void LoadFirst() { if (Count == 0) @@ -87,6 +93,109 @@ namespace OpenNest FireCurrentPlateChanged(); } + public void EnsureSentinel() + { + suppressNavigation = true; + try + { + if (Count == 0 || nest.Plates[^1].Parts.Count > 0) + nest.CreatePlate(); + + while (Count > 1 + && nest.Plates[^1].Parts.Count == 0 + && nest.Plates[^2].Parts.Count == 0) + { + nest.Plates.RemoveAt(Count - 1); + } + } + finally + { + suppressNavigation = false; + } + + SubscribeToTailPlates(); + } + + public void BeginBatch() + { + batching = true; + } + + public void EndBatch() + { + batching = false; + EnsureSentinel(); + PlateListChanged?.Invoke(this, EventArgs.Empty); + FireCurrentPlateChanged(); + } + + public Plate GetOrCreateEmpty() + { + for (var i = Count - 1; i >= 0; i--) + { + if (nest.Plates[i].Parts.Count == 0) + return nest.Plates[i]; + } + + return nest.CreatePlate(); + } + + public void RemoveCurrent() + { + if (Count < 2) + return; + + nest.Plates.RemoveAt(CurrentIndex); + } + + private void SubscribeToTailPlates() + { + UnsubscribeFromTailPlates(); + + if (Count > 0) + { + subscribedLast = nest.Plates[^1]; + subscribedLast.PartAdded += OnTailPartAdded; + subscribedLast.PartRemoved += OnTailPartRemoved; + } + + if (Count > 1) + { + subscribedSecondToLast = nest.Plates[^2]; + subscribedSecondToLast.PartAdded += OnTailPartAdded; + subscribedSecondToLast.PartRemoved += OnTailPartRemoved; + } + } + + private void UnsubscribeFromTailPlates() + { + if (subscribedLast != null) + { + subscribedLast.PartAdded -= OnTailPartAdded; + subscribedLast.PartRemoved -= OnTailPartRemoved; + subscribedLast = null; + } + + if (subscribedSecondToLast != null) + { + subscribedSecondToLast.PartAdded -= OnTailPartAdded; + subscribedSecondToLast.PartRemoved -= OnTailPartRemoved; + subscribedSecondToLast = null; + } + } + + private void OnTailPartAdded(object sender, ItemAddedEventArgs e) + { + if (!batching) + EnsureSentinel(); + } + + private void OnTailPartRemoved(object sender, ItemRemovedEventArgs e) + { + if (!batching) + EnsureSentinel(); + } + private void OnPlateAdded(object sender, ItemAddedEventArgs e) { PlateListChanged?.Invoke(this, EventArgs.Empty); @@ -98,6 +207,9 @@ namespace OpenNest CurrentIndex = Count - 1; PlateListChanged?.Invoke(this, EventArgs.Empty); + + if (!suppressNavigation) + FireCurrentPlateChanged(); } private void FireCurrentPlateChanged() @@ -111,6 +223,7 @@ namespace OpenNest return; disposed = true; + UnsubscribeFromTailPlates(); nest.Plates.ItemAdded -= OnPlateAdded; nest.Plates.ItemRemoved -= OnPlateRemoved; } diff --git a/OpenNest.Tests/PlateManagerTests.cs b/OpenNest.Tests/PlateManagerTests.cs index 787a675..4e7a494 100644 --- a/OpenNest.Tests/PlateManagerTests.cs +++ b/OpenNest.Tests/PlateManagerTests.cs @@ -205,4 +205,191 @@ public class PlateManagerTests nest.CreatePlate(); Assert.False(eventFired); } + + // Task 3: Sentinel plate invariant + + [Fact] + public void EnsureSentinel_EmptyNest_CreatesOnePlate() + { + var nest = CreateNest(); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + Assert.Equal(1, nest.Plates.Count); + Assert.Equal(0, nest.Plates[0].Parts.Count); + } + + [Fact] + public void EnsureSentinel_LastPlateHasParts_CreatesNewEmpty() + { + var nest = CreateNest(); + var plate = nest.CreatePlate(); + plate.Parts.Add(MakePart()); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + Assert.Equal(2, nest.Plates.Count); + Assert.Equal(0, nest.Plates[^1].Parts.Count); + } + + [Fact] + public void EnsureSentinel_TwoTrailingEmpties_TrimsToOne() + { + var nest = CreateNest(); + nest.CreatePlate(); + nest.CreatePlate(); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + Assert.Equal(1, nest.Plates.Count); + } + + [Fact] + public void EnsureSentinel_PreservesCurrentIndex() + { + var nest = CreateNest(); + var plate = nest.CreatePlate(); + plate.Parts.Add(MakePart()); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + Assert.Equal(0, mgr.CurrentIndex); + Assert.Same(plate, mgr.CurrentPlate); + } + + // Task 4: Reactive tail-plate subscriptions + + [Fact] + public void PartAddedToLastPlate_CreatesSentinel() + { + var nest = CreateNest(); + nest.CreatePlate(); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + var initialCount = nest.Plates.Count; + nest.Plates[^1].Parts.Add(MakePart()); + Assert.Equal(initialCount + 1, nest.Plates.Count); + Assert.Equal(0, nest.Plates[^1].Parts.Count); + } + + [Fact] + public void PartRemovedFromSecondToLast_TrimsExtraEmpty() + { + var nest = CreateNest(); + var plate = nest.CreatePlate(); + plate.Parts.Add(MakePart()); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + Assert.Equal(2, nest.Plates.Count); + nest.Plates[0].Parts.RemoveAt(0); + Assert.Equal(1, nest.Plates.Count); + } + + [Fact] + public void ReactiveSubscription_ResubscribesAfterPlateListChange() + { + var nest = CreateNest(); + var plate1 = nest.CreatePlate(); + plate1.Parts.Add(MakePart()); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + nest.Plates[^1].Parts.Add(MakePart()); + Assert.Equal(3, nest.Plates.Count); + nest.Plates[^1].Parts.Add(MakePart()); + Assert.Equal(4, nest.Plates.Count); + Assert.Equal(0, nest.Plates[^1].Parts.Count); + } + + // Task 5: Batch mode and plate operations + + [Fact] + public void BeginBatch_DefersSentinelEnforcement() + { + var nest = CreateNest(); + var plate = nest.CreatePlate(); + plate.Parts.Add(MakePart()); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + + mgr.BeginBatch(); + nest.Plates[^1].Parts.Add(MakePart()); + Assert.Equal(2, nest.Plates.Count); + } + + [Fact] + public void EndBatch_EnforcesSentinelAndFiresEvents() + { + var nest = CreateNest(); + var plate = nest.CreatePlate(); + plate.Parts.Add(MakePart()); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + + mgr.BeginBatch(); + nest.Plates[^1].Parts.Add(MakePart()); + + var listChangedCount = 0; + mgr.PlateListChanged += (s, e) => listChangedCount++; + + mgr.EndBatch(); + + Assert.Equal(3, nest.Plates.Count); + Assert.Equal(0, nest.Plates[^1].Parts.Count); + Assert.True(listChangedCount > 0); + } + + [Fact] + public void GetOrCreateEmpty_ReturnsSentinelWhenEmpty() + { + var nest = CreateNest(); + var plate1 = nest.CreatePlate(); + plate1.Parts.Add(MakePart()); + using var mgr = new PlateManager(nest); + mgr.EnsureSentinel(); + + var result = mgr.GetOrCreateEmpty(); + Assert.Same(nest.Plates[^1], result); + Assert.Equal(0, result.Parts.Count); + } + + [Fact] + public void GetOrCreateEmpty_NoEmptyPlate_CreatesNew() + { + var nest = CreateNest(); + var plate1 = nest.CreatePlate(); + plate1.Parts.Add(MakePart()); + using var mgr = new PlateManager(nest); + + var result = mgr.GetOrCreateEmpty(); + Assert.Equal(0, result.Parts.Count); + } + + [Fact] + public void RemoveCurrent_RemovesPlateAndClampsIndex() + { + var nest = CreateNest(); + var plate1 = nest.CreatePlate(); + plate1.Parts.Add(MakePart()); + var plate2 = nest.CreatePlate(); + plate2.Parts.Add(MakePart()); + using var mgr = new PlateManager(nest); + mgr.LoadLast(); + + mgr.RemoveCurrent(); + + Assert.Equal(1, nest.Plates.Count); + Assert.Equal(0, mgr.CurrentIndex); + } + + [Fact] + public void CanRemoveCurrent_OnlyIfMultiplePlatesAndHasParts() + { + var nest = CreateNest(); + var plate1 = nest.CreatePlate(); + using var mgr = new PlateManager(nest); + + Assert.False(mgr.CanRemoveCurrent); + + plate1.Parts.Add(MakePart()); + Assert.False(mgr.CanRemoveCurrent); + + nest.CreatePlate(); + Assert.True(mgr.CanRemoveCurrent); + } }