Files
meeting-assistant/MeetingAssistant.Tests/SpeakerIdentityMergeServiceTests.cs
T

199 lines
7.8 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),
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<SpeakerIdentityMergeFixture> 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<SpeakerIdentityDbContext>()
.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<SpeakerIdentityMergeService>.Instance);
}
public async Task<SpeakerIdentity> AddIdentityAsync(
string? canonicalName,
IReadOnlyList<string> aliases,
IReadOnlyList<byte[]> 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<SpeakerIdentity> 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<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));
}
}
}