feat: implement PlateManager sentinel, reactive subscriptions, and batch ops (Tasks 3-5)

- EnsureSentinel() maintains exactly one trailing empty plate, suppressing navigation events during mutation
- Reactive tail subscriptions (PartAdded/PartRemoved on last two plates) call EnsureSentinel automatically; re-subscribed after each plate list change
- BeginBatch()/EndBatch() defers sentinel enforcement during bulk operations
- GetOrCreateEmpty() returns or creates an empty plate; RemoveCurrent() removes the current plate with index clamping; CanRemoveCurrent guards deletion
- 13 new tests (30 total PlateManager tests), all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 23:51:25 -04:00
parent a65598615e
commit d93b69c524
2 changed files with 300 additions and 0 deletions

View File

@@ -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<PlateChangedEventArgs> 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<Part> e)
{
if (!batching)
EnsureSentinel();
}
private void OnTailPartRemoved(object sender, ItemRemovedEventArgs<Part> e)
{
if (!batching)
EnsureSentinel();
}
private void OnPlateAdded(object sender, ItemAddedEventArgs<Plate> 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;
}