From e36b928c691d4ad3e7cd4fbd95199635aed679c5 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Sat, 13 Jun 2026 00:24:13 -0400 Subject: [PATCH] test(engine): cover RebuildFinder failure + success paths Three new tests around the finder rebuild: - Failure path: CreateFinder throws on the 2nd call. RebuildFinder returns false, keeps the original (non-disposed) finder, and a follow-up PollOnce still reads sources. Pre-fix this regressed: the incumbent was disposed before the throw, so PollOnce hit a disposed handle (now an ObjectDisposedException from the fake). - Success path: RebuildFinder returns true, builds exactly one replacement, and re-emits all currently-visible sources as Added (seen-set cleared). - Failure path preserves the seen-set: no spurious Added re-fires for sources that were already known. --- .../Discovery/NdiDiscoveryServiceTests.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) 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 // ============================================================