feat(discovery): add ParticipantTracker with rename heuristic
Some checks failed
CI / build-and-test (push) Failing after 22s
Some checks failed
CI / build-and-test (push) Failing after 22s
This commit is contained in:
parent
c07a668672
commit
cef9018b6d
2 changed files with 207 additions and 0 deletions
104
src/TeamsISO.Engine/Discovery/ParticipantTracker.cs
Normal file
104
src/TeamsISO.Engine/Discovery/ParticipantTracker.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace TeamsISO.Engine.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="_renameWindow"/>,
|
||||
/// the existing <see cref="Participant.Id"/> transfers to the new source.
|
||||
/// </summary>
|
||||
public sealed class ParticipantTracker
|
||||
{
|
||||
private readonly TimeSpan _renameWindow;
|
||||
private readonly Func<DateTimeOffset> _now;
|
||||
private readonly List<MutableParticipant> _participants = new();
|
||||
private readonly List<RecentlyRemoved> _recentlyRemoved = new();
|
||||
|
||||
public ParticipantTracker(TimeSpan renameWindow, Func<DateTimeOffset> now)
|
||||
{
|
||||
_renameWindow = renameWindow;
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Participant> 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue