Files
meeting-assistant/MeetingAssistant.Tests/SpeakerIdentityMergeServiceTests.cs
2026-05-27 12:55:17 +02:00

217 lines
8.9 KiB
C#

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<SpeakerIdentityMergeFixture> 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<SpeakerIdentityDbContext>()
.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<SpeakerIdentityMergeService>.Instance);
}
public async Task<SpeakerIdentity> AddIdentityAsync(
string? canonicalName,
IReadOnlyList<string> aliases,
IReadOnlyList<byte[]> 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<SpeakerIdentity> 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<SpeakerIdentityDbContext>
{
private readonly string dbPath;
public TestSpeakerIdentityDbContextFactory(string dbPath)
{
this.dbPath = dbPath;
}
public SpeakerIdentityDbContext CreateDbContext()
{
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
}
}
private sealed class QueueingSpeakerIdentityMatcher : ISpeakerIdentityMatcher
{
public Queue<int> MatchIdentityIds { get; } = new();
public List<SpeakerIdentityMatchRequest> Requests { get; } = [];
public Task<SpeakerIdentityMatch?> MatchAsync(
SpeakerIdentityMatchRequest request,
CancellationToken cancellationToken)
{
Requests.Add(request);
if (!MatchIdentityIds.TryDequeue(out var identityId))
{
return Task.FromResult<SpeakerIdentityMatch?>(null);
}
return Task.FromResult<SpeakerIdentityMatch?>(new SpeakerIdentityMatch(identityId));
}
}
}