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), referenceCount: 5); var recent = await fixture.AddIdentityAsync( canonicalName: "Guest Manuel", aliases: [], snippets: [[4], [5]], createdAt: DateTimeOffset.UtcNow.AddDays(-1), referenceCount: 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.References.Count); Assert.All(savedOlder.References, reference => Assert.Contains("Manuel and Guest Manuel were merged", File.ReadAllText(reference.TranscriptPath))); 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 tempDirectory; private readonly string dbPath; private SpeakerIdentityMergeFixture(string tempDirectory, string dbPath, SpeakerIdentityDbContext context) { this.tempDirectory = tempDirectory; this.dbPath = dbPath; Context = context; } public SpeakerIdentityDbContext Context { get; } public QueueingSpeakerIdentityMatcher Matcher { get; } = new(); public static async Task CreateAsync() { var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDirectory); var dbPath = Path.Combine(tempDirectory, "speaker-identities.db"); var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None); return new SpeakerIdentityMergeFixture(tempDirectory, 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 referenceCount) { var identity = new SpeakerIdentity { CanonicalName = canonicalName, CreatedAt = createdAt, UpdatedAt = createdAt, Aliases = aliases.Select(alias => new SpeakerAlias { Name = alias }).ToList(), Snippets = snippets .Select((snippet, index) => new SpeakerSnippet { WavBytes = snippet, CreatedAt = createdAt.AddMinutes(index) }) .ToList(), References = Enumerable.Range(0, referenceCount) .Select(index => { var transcriptPath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md"); File.WriteAllText(transcriptPath, "Transcript"); return new SpeakerIdentityReference { MeetingNotePath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md"), TranscriptPath = transcriptPath, 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) .Include(identity => identity.References) .SingleAsync(identity => identity.Id == id); } public async ValueTask DisposeAsync() { await Context.DisposeAsync(); if (Directory.Exists(tempDirectory)) { Directory.Delete(tempDirectory, recursive: true); } } } 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)); } } }