using MeetingAssistant; using MeetingAssistant.Speakers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace MeetingAssistant.Tests; public sealed class SpeakerIdentityMergeServiceTests { [Fact] public async Task MergeRecentIdentitiesConfirmsMatchTwiceAndMergesAliasesAndSamples() { await using var fixture = await SpeakerIdentityMergeFixture.CreateAsync(); var older = await fixture.AddIdentityAsync( canonicalName: "Manuel", aliases: ["M. Schweigert"], snippets: [[1], [2], [3]], createdAt: DateTimeOffset.UtcNow.AddMonths(-2), transcriptionCount: 5); var recent = await fixture.AddIdentityAsync( canonicalName: "Guest Manuel", aliases: [], snippets: [[4], [5]], createdAt: DateTimeOffset.UtcNow.AddDays(-1), transcriptionCount: 2); fixture.Matcher.MatchIdentityIds.Enqueue(older.Id); fixture.Matcher.MatchIdentityIds.Enqueue(older.Id); var service = fixture.CreateService(); var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None); Assert.Equal(1, result.MergedPairs); Assert.Equal(2, fixture.Matcher.Requests.Count); Assert.Equal([4], fixture.Matcher.Requests[0].UnknownSnippet); Assert.Equal([5], fixture.Matcher.Requests[1].UnknownSnippet); var savedOlder = await fixture.LoadIdentityAsync(older.Id); Assert.Equal("Manuel", savedOlder.CanonicalName); Assert.Equal(7, savedOlder.TranscriptionCount); Assert.True(savedOlder.UpdatedAt > older.UpdatedAt); Assert.Equal(["Guest Manuel", "M. Schweigert"], savedOlder.Aliases.Select(alias => alias.Name).Order()); Assert.Equal(3, savedOlder.Snippets.Count); Assert.False(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == recent.Id)); } [Fact] public async Task MergeRecentIdentitiesDoesNotMergeWithoutSecondValidation() { await using var fixture = await SpeakerIdentityMergeFixture.CreateAsync(); var older = await fixture.AddIdentityAsync("Manuel", [], [[1]], DateTimeOffset.UtcNow.AddMonths(-2), 5); var recent = await fixture.AddIdentityAsync("Guest Manuel", [], [[4], [5]], DateTimeOffset.UtcNow.AddDays(-1), 2); fixture.Matcher.MatchIdentityIds.Enqueue(older.Id); var service = fixture.CreateService(); var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None); Assert.Equal(0, result.MergedPairs); Assert.True(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == recent.Id)); } [Fact] public async Task MergeRecentIdentitiesIgnoresOldSourceIdentities() { await using var fixture = await SpeakerIdentityMergeFixture.CreateAsync(); await fixture.AddIdentityAsync("Older A", [], [[1], [2]], DateTimeOffset.UtcNow.AddMonths(-2), 5); await fixture.AddIdentityAsync("Older B", [], [[4], [5]], DateTimeOffset.UtcNow.AddMonths(-1), 2); var service = fixture.CreateService(); var result = await service.MergeRecentIdentitiesAsync(TimeSpan.FromDays(14), CancellationToken.None); Assert.Equal(0, result.MergedPairs); Assert.Empty(fixture.Matcher.Requests); } private sealed class SpeakerIdentityMergeFixture : IAsyncDisposable { private readonly string dbPath; private SpeakerIdentityMergeFixture(string dbPath, SpeakerIdentityDbContext context) { this.dbPath = dbPath; Context = context; } public SpeakerIdentityDbContext Context { get; } public QueueingSpeakerIdentityMatcher Matcher { get; } = new(); public static async Task CreateAsync() { var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db"); Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None); return new SpeakerIdentityMergeFixture(dbPath, context); } public SpeakerIdentityMergeService CreateService() { return new SpeakerIdentityMergeService( new TestSpeakerIdentityDbContextFactory(dbPath), Matcher, Options.Create(new MeetingAssistantOptions { SpeakerIdentification = new SpeakerIdentificationOptions { MatchBatchSize = 6, MaxSnippetsPerSpeaker = 3 } }), NullLogger.Instance); } public async Task AddIdentityAsync( string? canonicalName, IReadOnlyList aliases, IReadOnlyList snippets, DateTimeOffset createdAt, int transcriptionCount) { var identity = new SpeakerIdentity { CanonicalName = canonicalName, CreatedAt = createdAt, UpdatedAt = createdAt, TranscriptionCount = transcriptionCount, Aliases = aliases.Select(alias => new SpeakerAlias { Name = alias }).ToList(), Snippets = snippets .Select((snippet, index) => new SpeakerSnippet { WavBytes = snippet, CreatedAt = createdAt.AddMinutes(index) }) .ToList() }; Context.SpeakerIdentities.Add(identity); await Context.SaveChangesAsync(); return identity; } public Task LoadIdentityAsync(int id) { Context.ChangeTracker.Clear(); return Context.SpeakerIdentities .Include(identity => identity.Aliases) .Include(identity => identity.Snippets) .SingleAsync(identity => identity.Id == id); } public async ValueTask DisposeAsync() { await Context.DisposeAsync(); if (File.Exists(dbPath)) { File.Delete(dbPath); } } } private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory { private readonly string dbPath; public TestSpeakerIdentityDbContextFactory(string dbPath) { this.dbPath = dbPath; } public SpeakerIdentityDbContext CreateDbContext() { return new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); } } private sealed class QueueingSpeakerIdentityMatcher : ISpeakerIdentityMatcher { public Queue MatchIdentityIds { get; } = new(); public List Requests { get; } = []; public Task MatchAsync( SpeakerIdentityMatchRequest request, CancellationToken cancellationToken) { Requests.Add(request); if (!MatchIdentityIds.TryDequeue(out var identityId)) { return Task.FromResult(null); } return Task.FromResult(new SpeakerIdentityMatch(identityId)); } } }