diff --git a/src/tests/Dragon-ISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs b/src/tests/Dragon-ISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs index fb13c11..d45ae36 100644 --- a/src/tests/Dragon-ISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs +++ b/src/tests/Dragon-ISO.Engine.Tests/Discovery/NdiDiscoveryServiceTests.cs @@ -67,6 +67,86 @@ public class NdiDiscoveryServiceTests return list; } + // ============================================================ + // RebuildFinder — swap-then-dispose, failure keeps the old finder + // ============================================================ + + [Fact] + public void RebuildFinder_WhenCreateFails_KeepsExistingFinder_AndKeepsPolling() + { + // First finder (construction) succeeds; the rebuild's CreateFinder throws. + var interop = new FakeNdiInterop + { + CreateFinderHook = ordinal => + { + if (ordinal >= 2) throw new InvalidOperationException("runtime refused a new finder"); + }, + }; + interop.Sources.Add("PC1 (Teams - Jane)"); + var channel = Channel.CreateUnbounded(); + var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger.Instance); + + // Prime the seen-set. + svc.PollOnce(); + DrainChannel(channel.Reader); + + // Rebuild fails — must return false and must NOT dispose the live finder. + svc.RebuildFinder("test: simulated failure").Should().BeFalse(); + interop.FinderCreatedCount.Should().Be(2); // initial + the failed attempt + + // The incumbent finder is still alive: PollOnce reads it without throwing. + // Pre-fix this threw ObjectDisposedException because the finder had been + // disposed before the failing CreateFinder call. + var poll = () => svc.PollOnce(); + poll.Should().NotThrow(); + } + + [Fact] + public void RebuildFinder_FailureKeepsSeenSet_NoSpuriousReAdd() + { + var interop = new FakeNdiInterop + { + CreateFinderHook = ordinal => + { + if (ordinal >= 2) throw new InvalidOperationException("boom"); + }, + }; + interop.Sources.Add("PC1 (Teams - Jane)"); + var channel = Channel.CreateUnbounded(); + var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger.Instance); + + svc.PollOnce(); + DrainChannel(channel.Reader); // consume the initial Added + + svc.RebuildFinder("test: simulated failure").Should().BeFalse(); + svc.PollOnce(); + + // Seen-set was preserved across the failed rebuild, so the already-known + // source must NOT re-fire as Added. + DrainChannel(channel.Reader).Should().BeEmpty(); + } + + [Fact] + public void RebuildFinder_OnSuccess_SwapsFinder_AndReEmitsKnownSources() + { + var interop = new FakeNdiInterop(); + interop.Sources.Add("PC1 (Teams - Jane)"); + var channel = Channel.CreateUnbounded(); + var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger.Instance); + + svc.PollOnce(); + DrainChannel(channel.Reader); // consume initial Added + + svc.RebuildFinder("test: success").Should().BeTrue(); + interop.FinderCreatedCount.Should().Be(2); + + // Seen-set was cleared on success, so the still-visible source re-fires. + svc.PollOnce(); + DrainChannel(channel.Reader).OfType() + .Select(a => a.Source.FullName) + .Should().BeEquivalentTo(new[] { "PC1 (Teams - Jane)" }); + } + // ============================================================ // ShouldAutoRebuild — pure function gating the auto-heal path // ============================================================