dragon-iso/src/tests/Dragon-ISO.Engine.Tests/Fakes/FakeNdiInterop.cs
ZGaetano 886e20e501
All checks were successful
CI / build-and-test (push) Successful in 30s
test(engine): teach FakeNdiInterop to model finder failure + use-after-dispose
Additive, backwards-compatible test-double extensions so the discovery
rebuild failure path is observable:

  - CreateFinderHook: optional callback invoked on every CreateFinder,
    letting a test throw on the Nth call to simulate a runtime that
    refuses to build a replacement finder.
  - FinderCreatedCount: how many finders were built.
  - The fake find handle now flips a Disposed flag, and GetCurrentSources
    throws ObjectDisposedException if asked to read a disposed finder —
    so a regression that polls a retired finder fails loudly instead of
    silently passing (the fake previously ignored the handle entirely).

Default behavior is unchanged when CreateFinderHook is null.
2026-06-13 00:23:33 -04:00

115 lines
4.8 KiB
C#

using System.Collections.Concurrent;
using DragonISO.Engine.Interop;
using DragonISO.Engine.Pipeline;
namespace DragonISO.Engine.Tests.Fakes;
/// <summary>
/// In-memory test double for <see cref="INdiInterop"/>. Tests configure source lists and frame
/// queues; the fake feeds those into engine code as if a real NDI runtime were present.
/// </summary>
public sealed class FakeNdiInterop : INdiInterop
{
public List<string> Sources { get; } = new();
public ConcurrentDictionary<string, ConcurrentQueue<RawFrame>> ReceiverFrames { get; } = new();
public ConcurrentDictionary<string, List<ProcessedFrame>> SentFrames { get; } = new();
/// <summary>Optional per-source audio peak queue. Each <see cref="CaptureAudioPeak"/> dequeues one entry; null if empty (matches the production "timeout" behavior).</summary>
public ConcurrentDictionary<string, ConcurrentQueue<double>> ReceiverAudioPeaks { get; } = new();
public string RuntimeVersion { get; set; } = "6.0.0";
public Dictionary<string, int> ReceiverCreatedCount { get; } = new();
public Dictionary<string, int> SenderCreatedCount { get; } = new();
/// <summary>Last <c>groups</c> string seen by <see cref="CreateFinder"/>; null = default Public.</summary>
public string? LastFinderGroups { get; private set; }
/// <summary>Per-output <c>groups</c> string seen by <see cref="CreateSender"/>; null = default Public.</summary>
public Dictionary<string, string?> SenderGroups { get; } = new();
/// <summary>
/// Number of finders created so far (initial construction + every rebuild).
/// Lets tests assert how many times the discovery service tried to rebuild.
/// </summary>
public int FinderCreatedCount { get; private set; }
/// <summary>
/// Optional hook invoked at the top of every <see cref="CreateFinder"/> call,
/// receiving the 1-based creation ordinal. Throw from here to simulate a runtime
/// that refuses to build a finder (e.g. the rebuild-failure path); return normally
/// to allow creation. Null (default) = always succeed.
/// </summary>
public Action<int>? CreateFinderHook { get; set; }
public NdiFindHandle CreateFinder(string? groups = null)
{
FinderCreatedCount++;
CreateFinderHook?.Invoke(FinderCreatedCount);
LastFinderGroups = groups;
return new FakeFindHandle();
}
public IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder)
{
if (finder is FakeFindHandle { Disposed: true })
throw new ObjectDisposedException(nameof(FakeFindHandle),
"GetCurrentSources called on a finder that was already disposed.");
return Sources.ToArray();
}
public NdiReceiverHandle CreateReceiver(string sourceFullName)
{
ReceiverCreatedCount[sourceFullName] = ReceiverCreatedCount.GetValueOrDefault(sourceFullName) + 1;
ReceiverFrames.GetOrAdd(sourceFullName, _ => new ConcurrentQueue<RawFrame>());
return new FakeReceiverHandle(sourceFullName);
}
public RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs)
{
var key = ((FakeReceiverHandle)receiver).Source;
if (ReceiverFrames.TryGetValue(key, out var q) && q.TryDequeue(out var frame))
return frame;
return null; // simulate timeout
}
public double? CaptureAudioPeak(NdiReceiverHandle receiver, int timeoutMs)
{
var key = ((FakeReceiverHandle)receiver).Source;
if (ReceiverAudioPeaks.TryGetValue(key, out var q) && q.TryDequeue(out var peak))
return peak;
return null; // no audio queued — simulate timeout
}
public NdiSenderHandle CreateSender(string outputName, string? groups = null)
{
SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1;
SenderGroups[outputName] = groups;
SentFrames.GetOrAdd(outputName, _ => new List<ProcessedFrame>());
return new FakeSenderHandle(outputName);
}
public void SendFrame(NdiSenderHandle sender, ProcessedFrame frame)
{
var key = ((FakeSenderHandle)sender).Output;
SentFrames[key].Add(frame);
}
public string GetRuntimeVersion() => RuntimeVersion;
private sealed class FakeFindHandle : NdiFindHandle
{
public bool Disposed { get; private set; }
public override void Dispose() => Disposed = true;
}
private sealed class FakeReceiverHandle : NdiReceiverHandle
{
public string Source { get; }
public FakeReceiverHandle(string source) => Source = source;
public override void Dispose() { }
}
private sealed class FakeSenderHandle : NdiSenderHandle
{
public string Output { get; }
public FakeSenderHandle(string output) => Output = output;
public override void Dispose() { }
}
}