From cef9018b6d71ee51a5141d4a6dcd9dbd546337e6 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Thu, 7 May 2026 15:13:42 +0000 Subject: [PATCH] feat(discovery): add ParticipantTracker with rename heuristic --- .../Discovery/ParticipantTracker.cs | 104 ++++++++++++++++++ .../Discovery/ParticipantTrackerTests.cs | 103 +++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/TeamsISO.Engine/Discovery/ParticipantTracker.cs create mode 100644 src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs diff --git a/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs b/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs new file mode 100644 index 0000000..3b94340 --- /dev/null +++ b/src/TeamsISO.Engine/Discovery/ParticipantTracker.cs @@ -0,0 +1,104 @@ +using TeamsISO.Engine.Domain; + +namespace TeamsISO.Engine.Discovery; + +/// +/// Maintains the operator-facing participant list, applying the rename heuristic +/// from the v1 spec: if a participant source disappears and another participant +/// source with the same MachineName appears within , +/// the existing transfers to the new source. +/// +public sealed class ParticipantTracker +{ + private readonly TimeSpan _renameWindow; + private readonly Func _now; + private readonly List _participants = new(); + private readonly List _recentlyRemoved = new(); + + public ParticipantTracker(TimeSpan renameWindow, Func now) + { + _renameWindow = renameWindow; + _now = now; + } + + public IReadOnlyList Participants => + _participants.Select(m => m.ToRecord()).ToList(); + + public void Apply(DiscoveryEvent ev) + { + switch (ev) + { + case DiscoveryEvent.Added a when a.Source.Kind == NdiSourceKind.Participant: + HandleAdded(a.Source); + break; + case DiscoveryEvent.Removed r when r.Source.Kind == NdiSourceKind.Participant: + HandleRemoved(r.Source); + break; + } + } + + private void HandleAdded(NdiSource source) + { + var now = _now(); + PruneRecentlyRemoved(now); + + var match = _recentlyRemoved.FirstOrDefault(rr => rr.MachineName == source.MachineName); + if (match is not null) + { + var existing = _participants.First(p => p.Id == match.Id); + existing.DisplayName = source.DisplayName!; + existing.CurrentSource = source; + existing.LastSeen = now; + _recentlyRemoved.Remove(match); + return; + } + + _participants.Add(new MutableParticipant( + Id: Guid.NewGuid(), + DisplayName: source.DisplayName!, + CurrentSource: source, + FirstSeen: now, + LastSeen: now)); + } + + private void HandleRemoved(NdiSource source) + { + var now = _now(); + var existing = _participants.FirstOrDefault(p => + p.CurrentSource is not null && p.CurrentSource.FullName == source.FullName); + if (existing is null) + return; + + existing.CurrentSource = null; + _recentlyRemoved.Add(new RecentlyRemoved(existing.Id, source.MachineName, now)); + } + + private void PruneRecentlyRemoved(DateTimeOffset now) + { + _recentlyRemoved.RemoveAll(rr => now - rr.RemovedAt > _renameWindow); + } + + private sealed class MutableParticipant + { + public Guid Id { get; init; } + public string DisplayName { get; set; } + public NdiSource? CurrentSource { get; set; } + public DateTimeOffset FirstSeen { get; init; } + public DateTimeOffset LastSeen { get; set; } + + public MutableParticipant(Guid Id, string DisplayName, NdiSource? CurrentSource, + DateTimeOffset FirstSeen, DateTimeOffset LastSeen) + { + this.Id = Id; + this.DisplayName = DisplayName; + this.CurrentSource = CurrentSource; + this.FirstSeen = FirstSeen; + this.LastSeen = LastSeen; + } + + public Participant ToRecord() => + new(Id, DisplayName, CurrentSource, FirstSeen, LastSeen); + } + + private sealed record RecentlyRemoved(Guid Id, string MachineName, DateTimeOffset RemovedAt); +} diff --git a/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs b/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs new file mode 100644 index 0000000..0892fab --- /dev/null +++ b/src/tests/TeamsISO.Engine.Tests/Discovery/ParticipantTrackerTests.cs @@ -0,0 +1,103 @@ +using TeamsISO.Engine.Discovery; +using TeamsISO.Engine.Domain; + +namespace TeamsISO.Engine.Tests.Discovery; + +public class ParticipantTrackerTests +{ + private static NdiSource Source(string machine, string display) => + new($"{machine} (Teams - {display})", machine, NdiSourceKind.Participant, display); + + private static DateTimeOffset T0 => DateTimeOffset.UnixEpoch; + + [Fact] + public void Added_CreatesParticipant() + { + var tracker = new ParticipantTracker(renameWindow: TimeSpan.FromSeconds(5), now: () => T0); + tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); + + tracker.Participants.Should().HaveCount(1); + var p = tracker.Participants[0]; + p.DisplayName.Should().Be("Jane"); + p.CurrentSource!.MachineName.Should().Be("PC1"); + } + + [Fact] + public void Removed_NullsCurrentSourceButKeepsParticipant() + { + var time = T0; + var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time); + tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); + + time = T0.AddSeconds(1); + tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane"))); + + tracker.Participants.Should().HaveCount(1); + tracker.Participants[0].CurrentSource.Should().BeNull(); + } + + [Fact] + public void RenameWithinWindow_TransfersParticipantId() + { + var time = T0; + var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time); + tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); + var originalId = tracker.Participants[0].Id; + + time = T0.AddSeconds(1); + tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane"))); + + time = T0.AddSeconds(3); + tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane (PM)"))); + + tracker.Participants.Should().HaveCount(1); + tracker.Participants[0].Id.Should().Be(originalId); + tracker.Participants[0].DisplayName.Should().Be("Jane (PM)"); + } + + [Fact] + public void RenameAfterWindow_TreatsAsNewParticipant() + { + var time = T0; + var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time); + tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); + var originalId = tracker.Participants[0].Id; + + time = T0.AddSeconds(1); + tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane"))); + + time = T0.AddSeconds(20); + tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Bob"))); + + tracker.Participants.Should().HaveCount(2); + tracker.Participants.Should().Contain(p => p.Id == originalId); + tracker.Participants.Should().Contain(p => p.DisplayName == "Bob" && p.Id != originalId); + } + + [Fact] + public void DifferentMachine_DoesNotInheritIdentity() + { + var time = T0; + var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => time); + tracker.Apply(new DiscoveryEvent.Added(Source("PC1", "Jane"))); + + time = T0.AddSeconds(1); + tracker.Apply(new DiscoveryEvent.Removed(Source("PC1", "Jane"))); + + time = T0.AddSeconds(2); + tracker.Apply(new DiscoveryEvent.Added(Source("PC2", "Jane (PM)"))); + + tracker.Participants.Should().HaveCount(2); + } + + [Fact] + public void NonParticipantSources_AreIgnored() + { + var tracker = new ParticipantTracker(TimeSpan.FromSeconds(5), () => T0); + var screen = new NdiSource("PC1 (Teams Screen Share)", "PC1", NdiSourceKind.ScreenShare, null); + + tracker.Apply(new DiscoveryEvent.Added(screen)); + + tracker.Participants.Should().BeEmpty(); + } +}