Public Access
217 lines
8.9 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|