test(engine): cover RebuildFinder failure + success paths
All checks were successful
CI / build-and-test (push) Successful in 27s

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.
This commit is contained in:
Zac Gaetano 2026-06-13 00:24:13 -04:00
parent 886e20e501
commit e36b928c69

View file

@ -67,6 +67,86 @@ public class NdiDiscoveryServiceTests
return list; 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<DiscoveryEvent>();
var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.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<DiscoveryEvent>();
var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.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<DiscoveryEvent>();
var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.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<DiscoveryEvent.Added>()
.Select(a => a.Source.FullName)
.Should().BeEquivalentTo(new[] { "PC1 (Teams - Jane)" });
}
// ============================================================ // ============================================================
// ShouldAutoRebuild — pure function gating the auto-heal path // ShouldAutoRebuild — pure function gating the auto-heal path
// ============================================================ // ============================================================