using System; using IvfTl.ControlHost.Debug; using IvfTl.ControlHost.Tests.Fakes; using Xunit; namespace IvfTl.ControlHost.Tests { public class DebugSessionManagerTests { private DateTime _now = new DateTime(2026, 6, 23, 0, 0, 0); private (DebugSessionManager mgr, FakeGate gate) New(bool canAcquire = true) { var serial = new FakeSerial(); var gate = new FakeGate(5, serial) { CanAcquire = canAcquire }; var mgr = new DebugSessionManager(sn => gate, () => _now, ttlMs: 10000, log: _ => { }); return (mgr, gate); } [Fact] public void Acquire_Returns_SessionId_And_Pauses() { var (mgr, gate) = New(); var r = mgr.Acquire(5); Assert.True(r.Ok); Assert.False(string.IsNullOrEmpty((string)r.Result)); Assert.True(gate.IsCapturePaused); } [Fact] public void Acquire_Busy_Returns_BUSY() { var (mgr, _) = New(canAcquire: false); var r = mgr.Acquire(5); Assert.False(r.Ok); Assert.Equal("BUSY", r.Code); } [Fact] public void Release_Disposes_Lease_And_Resumes() { var (mgr, gate) = New(); string sid = (string)mgr.Acquire(5).Result; var r = mgr.Release(sid); Assert.True(r.Ok); Assert.True(gate.LastLease.Disposed); } [Fact] public void Release_Unknown_Session_Is_Idempotent_Ok() { var (mgr, _) = New(); Assert.True(mgr.Release("not-a-session").Ok); } [Fact] public void Sweep_Reclaims_After_Ttl_And_Resumes() { var (mgr, gate) = New(); string sid = (string)mgr.Acquire(5).Result; _now = _now.AddMilliseconds(11000); int reclaimed = mgr.SweepExpired(); Assert.Equal(1, reclaimed); Assert.True(gate.LastLease.Disposed); Assert.False(gate.IsCapturePaused); Assert.Equal("SESSION_EXPIRED", mgr.Heartbeat(sid).Code); } [Fact] public void Heartbeat_Keeps_Session_Alive() { var (mgr, gate) = New(); string sid = (string)mgr.Acquire(5).Result; _now = _now.AddMilliseconds(8000); mgr.Heartbeat(sid); _now = _now.AddMilliseconds(8000); Assert.Equal(0, mgr.SweepExpired()); Assert.False(gate.LastLease.Disposed); } // ── Critical 并发修复:会话关闭(Release/Sweep)在 lease.Dispose 之前先触发 onSessionClosing 回调 ── // 装配时回调接到 calibMgr.StopAndWait(sid),先停标定并等当前孔跑完,之后才 Dispose lease、恢复采集, // 消除"标定后台线程操作已 Dispose 的 lease + 与已恢复的采集线程争同一硬件"的窗口。 // 验证两点:① 回调被调且传入正确 sid;② 回调发生在 lease.Dispose 之前(用 FakeLease.Disposed 在回调里取快照)。 private (DebugSessionManager mgr, FakeGate gate, System.Collections.Generic.List<(string sid, bool disposedAtCallback)> log) NewWithClosing() { var serial = new FakeSerial(); var gate = new FakeGate(5, serial) { CanAcquire = true }; var log = new System.Collections.Generic.List<(string, bool)>(); // 回调里读 LastLease.Disposed:若回调真在 Dispose 之前调,这里应为 false。 var mgr = new DebugSessionManager(sn => gate, () => _now, ttlMs: 10000, log: _ => { }, onSessionClosing: sid => log.Add((sid, gate.LastLease.Disposed))); return (mgr, gate, log); } [Fact] public void Release_Invokes_OnSessionClosing_Before_Dispose() { var (mgr, gate, log) = NewWithClosing(); string sid = (string)mgr.Acquire(5).Result; mgr.Release(sid); Assert.Single(log); Assert.Equal(sid, log[0].sid); Assert.False(log[0].disposedAtCallback); // 回调时 lease 尚未 Dispose Assert.True(gate.LastLease.Disposed); // 之后已 Dispose } [Fact] public void Sweep_Invokes_OnSessionClosing_Before_Dispose() { var (mgr, gate, log) = NewWithClosing(); string sid = (string)mgr.Acquire(5).Result; _now = _now.AddMilliseconds(11000); int n = mgr.SweepExpired(); Assert.Equal(1, n); Assert.Single(log); Assert.Equal(sid, log[0].sid); Assert.False(log[0].disposedAtCallback); // 超时回收路径同样:回调先于 Dispose Assert.True(gate.LastLease.Disposed); } } }