diff --git a/src/TeamsISO.Engine/Discovery/DiscoveryEvent.cs b/src/TeamsISO.Engine/Discovery/DiscoveryEvent.cs
new file mode 100644
index 0000000..eb3fd2b
--- /dev/null
+++ b/src/TeamsISO.Engine/Discovery/DiscoveryEvent.cs
@@ -0,0 +1,9 @@
+using TeamsISO.Engine.Domain;
+
+namespace TeamsISO.Engine.Discovery;
+
+public abstract record DiscoveryEvent
+{
+ public sealed record Added(NdiSource Source) : DiscoveryEvent;
+ public sealed record Removed(NdiSource Source) : DiscoveryEvent;
+}
diff --git a/src/tests/TeamsISO.Engine.Tests/Fakes/FakeFrameClock.cs b/src/tests/TeamsISO.Engine.Tests/Fakes/FakeFrameClock.cs
new file mode 100644
index 0000000..e28c94c
--- /dev/null
+++ b/src/tests/TeamsISO.Engine.Tests/Fakes/FakeFrameClock.cs
@@ -0,0 +1,39 @@
+using TeamsISO.Engine.Pipeline;
+
+namespace TeamsISO.Engine.Tests.Fakes;
+
+///
+/// Manual-tick clock. Tests advance the clock and trigger the awaiter explicitly.
+///
+public sealed class FakeFrameClock : IFrameClock
+{
+ private long _nowTicks;
+ private TaskCompletionSource? _pendingTick;
+ private readonly object _gate = new();
+
+ public long NowTicks => Interlocked.Read(ref _nowTicks);
+
+ public void Advance(TimeSpan by)
+ {
+ Interlocked.Add(ref _nowTicks, by.Ticks);
+ TaskCompletionSource? toSignal;
+ lock (_gate)
+ {
+ toSignal = _pendingTick;
+ _pendingTick = null;
+ }
+ toSignal?.TrySetResult(true);
+ }
+
+ public ValueTask WaitForNextTickAsync(CancellationToken cancellationToken)
+ {
+ TaskCompletionSource tcs;
+ lock (_gate)
+ {
+ _pendingTick ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ tcs = _pendingTick;
+ }
+ cancellationToken.Register(() => tcs.TrySetResult(false));
+ return new ValueTask(tcs.Task);
+ }
+}
diff --git a/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs b/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs
new file mode 100644
index 0000000..35ae09c
--- /dev/null
+++ b/src/tests/TeamsISO.Engine.Tests/Fakes/FakeNdiInterop.cs
@@ -0,0 +1,71 @@
+using System.Collections.Concurrent;
+using TeamsISO.Engine.Interop;
+using TeamsISO.Engine.Pipeline;
+
+namespace TeamsISO.Engine.Tests.Fakes;
+
+///
+/// In-memory test double for . Tests configure source lists and frame
+/// queues; the fake feeds those into engine code as if a real NDI runtime were present.
+///
+public sealed class FakeNdiInterop : INdiInterop
+{
+ public List Sources { get; } = new();
+ public ConcurrentDictionary> ReceiverFrames { get; } = new();
+ public ConcurrentDictionary> SentFrames { get; } = new();
+ public string RuntimeVersion { get; set; } = "6.0.0";
+ public Dictionary ReceiverCreatedCount { get; } = new();
+ public Dictionary SenderCreatedCount { get; } = new();
+
+ public NdiFindHandle CreateFinder() => new FakeFindHandle();
+ public IReadOnlyList GetCurrentSources(NdiFindHandle finder) => Sources.ToArray();
+
+ public NdiReceiverHandle CreateReceiver(string sourceFullName)
+ {
+ ReceiverCreatedCount[sourceFullName] = ReceiverCreatedCount.GetValueOrDefault(sourceFullName) + 1;
+ ReceiverFrames.GetOrAdd(sourceFullName, _ => new ConcurrentQueue());
+ 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 NdiSenderHandle CreateSender(string outputName)
+ {
+ SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1;
+ SentFrames.GetOrAdd(outputName, _ => new List());
+ 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 override void Dispose() { }
+ }
+
+ 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() { }
+ }
+}