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:
@@ -19,6 +19,10 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
private readonly Nest nest;
|
private readonly Nest nest;
|
||||||
private bool disposed;
|
private bool disposed;
|
||||||
|
private bool suppressNavigation;
|
||||||
|
private bool batching;
|
||||||
|
private Plate subscribedLast;
|
||||||
|
private Plate subscribedSecondToLast;
|
||||||
|
|
||||||
public event EventHandler<PlateChangedEventArgs> CurrentPlateChanged;
|
public event EventHandler<PlateChangedEventArgs> CurrentPlateChanged;
|
||||||
public event EventHandler PlateListChanged;
|
public event EventHandler PlateListChanged;
|
||||||
@@ -40,6 +44,8 @@ namespace OpenNest
|
|||||||
|
|
||||||
public bool IsLast => CurrentIndex + 1 >= Count;
|
public bool IsLast => CurrentIndex + 1 >= Count;
|
||||||
|
|
||||||
|
public bool CanRemoveCurrent => Count > 1 && CurrentPlate != null && CurrentPlate.Parts.Count > 0;
|
||||||
|
|
||||||
public void LoadFirst()
|
public void LoadFirst()
|
||||||
{
|
{
|
||||||
if (Count == 0)
|
if (Count == 0)
|
||||||
@@ -87,6 +93,109 @@ namespace OpenNest
|
|||||||
FireCurrentPlateChanged();
|
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)
|
private void OnPlateAdded(object sender, ItemAddedEventArgs<Plate> e)
|
||||||
{
|
{
|
||||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||||
@@ -98,6 +207,9 @@ namespace OpenNest
|
|||||||
CurrentIndex = Count - 1;
|
CurrentIndex = Count - 1;
|
||||||
|
|
||||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
if (!suppressNavigation)
|
||||||
|
FireCurrentPlateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FireCurrentPlateChanged()
|
private void FireCurrentPlateChanged()
|
||||||
@@ -111,6 +223,7 @@ namespace OpenNest
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
UnsubscribeFromTailPlates();
|
||||||
nest.Plates.ItemAdded -= OnPlateAdded;
|
nest.Plates.ItemAdded -= OnPlateAdded;
|
||||||
nest.Plates.ItemRemoved -= OnPlateRemoved;
|
nest.Plates.ItemRemoved -= OnPlateRemoved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,4 +205,191 @@ public class PlateManagerTests
|
|||||||
nest.CreatePlate();
|
nest.CreatePlate();
|
||||||
Assert.False(eventFired);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user