Public Access
Add inactivity safeguard and speaker diagnostics
PR and Push Build/Test / build-and-test (push) Failing after 8m31s
PR and Push Build/Test / build-and-test (push) Failing after 8m31s
This commit is contained in:
@@ -179,6 +179,212 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.Equal(started.SummaryPath, artifactCleaner.DeletedArtifacts?.SummaryPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactivitySafeguardStopsNormallyWhenPromptIsAcceptedAndUsesTranscriptEndTime()
|
||||
{
|
||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T10:00:00+02:00"));
|
||||
var promptService = new CapturingMeetingInactivityPromptService(MeetingInactivityPromptResponse.Stop);
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FixedSegmentStreamingTranscriptionProvider(
|
||||
new TranscriptionSegment(
|
||||
TimeSpan.FromMinutes(3),
|
||||
TimeSpan.FromMinutes(4),
|
||||
"Guest-01",
|
||||
"The latest transcript text before silence."))),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
InactivitySafeguard =
|
||||
{
|
||||
FirstPromptAfter = TimeSpan.FromSeconds(2),
|
||||
ReminderPromptAfter = [],
|
||||
AutoStopAfter = TimeSpan.FromMinutes(30),
|
||||
InferredEndPadding = TimeSpan.FromMinutes(1),
|
||||
CheckInterval = TimeSpan.FromSeconds(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityPromptService: promptService,
|
||||
inactivityClock: clock);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
|
||||
segment.Text.Contains("latest transcript text", StringComparison.Ordinal)));
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
await promptService.WaitForPromptAsync();
|
||||
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
|
||||
|
||||
Assert.Single(promptService.Requests);
|
||||
Assert.False(coordinator.CurrentStatus.IsRecording);
|
||||
Assert.True(summaryPipeline.WasRun);
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2026-06-02T10:05:00+02:00"),
|
||||
noteStore.SavedNote?.Frontmatter.EndTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactivitySafeguardAutoStopsNormallyAndUsesMeetingStartWhenNoTranscriptArrives()
|
||||
{
|
||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T11:00:00+02:00"));
|
||||
var promptService = new CapturingMeetingInactivityPromptService();
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
InactivitySafeguard =
|
||||
{
|
||||
FirstPromptAfter = TimeSpan.Zero,
|
||||
ReminderPromptAfter = [],
|
||||
AutoStopAfter = TimeSpan.FromSeconds(5),
|
||||
InferredEndPadding = TimeSpan.FromMinutes(1),
|
||||
CheckInterval = TimeSpan.FromSeconds(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityPromptService: promptService,
|
||||
inactivityClock: clock);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
|
||||
|
||||
Assert.Empty(promptService.Requests);
|
||||
Assert.False(coordinator.CurrentStatus.IsRecording);
|
||||
Assert.True(summaryPipeline.WasRun);
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2026-06-02T11:01:00+02:00"),
|
||||
noteStore.SavedNote?.Frontmatter.EndTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactivitySafeguardContinuesMonitoringWhenPromptIsIgnored()
|
||||
{
|
||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T11:30:00+02:00"));
|
||||
var promptService = new IgnoringMeetingInactivityPromptService();
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
InactivitySafeguard =
|
||||
{
|
||||
FirstPromptAfter = TimeSpan.FromSeconds(2),
|
||||
ReminderPromptAfter = [],
|
||||
AutoStopAfter = TimeSpan.FromSeconds(5),
|
||||
CheckInterval = TimeSpan.FromSeconds(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityPromptService: promptService,
|
||||
inactivityClock: clock);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
await promptService.WaitForPromptAsync();
|
||||
await WaitUntilAsync(() => clock.PendingDelayCount > 0);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(3));
|
||||
await WaitUntilAsync(() => !coordinator.CurrentStatus.IsRecording);
|
||||
|
||||
Assert.Single(promptService.Requests);
|
||||
Assert.True(summaryPipeline.WasRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InactivitySafeguardResetsPromptScheduleWhenNewTranscriptTextArrives()
|
||||
{
|
||||
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-06-02T12:00:00+02:00"));
|
||||
var promptService = new CapturingMeetingInactivityPromptService(
|
||||
MeetingInactivityPromptResponse.Continue,
|
||||
MeetingInactivityPromptResponse.Continue);
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
InactivitySafeguard =
|
||||
{
|
||||
FirstPromptAfter = TimeSpan.FromSeconds(2),
|
||||
ReminderPromptAfter = [TimeSpan.FromSeconds(5)],
|
||||
AutoStopAfter = TimeSpan.FromMinutes(30),
|
||||
CheckInterval = TimeSpan.FromSeconds(1)
|
||||
}
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
inactivityPromptService: promptService,
|
||||
inactivityClock: clock);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
|
||||
segment.Text.Contains("chunk:2", StringComparison.Ordinal)));
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
await WaitUntilAsync(() => promptService.Requests.Count == 1);
|
||||
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() => transcriptStore.Segments.Any(segment =>
|
||||
segment.Text.Contains("chunk:4", StringComparison.Ordinal)));
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
await WaitUntilAsync(() => promptService.Requests.Count == 2);
|
||||
|
||||
Assert.Equal(
|
||||
[TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)],
|
||||
promptService.Requests.Select(request => request.Threshold).ToArray());
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartUsesCurrentOutlookMeetingMetadataWhenAvailable()
|
||||
{
|
||||
@@ -3225,6 +3431,150 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManualMeetingInactivityClock : IMeetingInactivityClock
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly List<ScheduledDelay> delays = [];
|
||||
|
||||
public ManualMeetingInactivityClock(DateTimeOffset now)
|
||||
{
|
||||
Now = now;
|
||||
}
|
||||
|
||||
public DateTimeOffset Now { get; private set; }
|
||||
|
||||
public int PendingDelayCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return delays.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Task.FromCanceled(cancellationToken);
|
||||
}
|
||||
|
||||
var scheduledDelay = new ScheduledDelay(
|
||||
Now + delay,
|
||||
new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
var registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
var delay = (ScheduledDelay)state!;
|
||||
delay.Completion.TrySetCanceled();
|
||||
},
|
||||
scheduledDelay);
|
||||
scheduledDelay.CancellationRegistration = registration;
|
||||
delays.Add(scheduledDelay);
|
||||
return scheduledDelay.Completion.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
List<ScheduledDelay> due;
|
||||
lock (gate)
|
||||
{
|
||||
Now += duration;
|
||||
due = delays.Where(delay => delay.DueAt <= Now).ToList();
|
||||
foreach (var delay in due)
|
||||
{
|
||||
delays.Remove(delay);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var delay in due)
|
||||
{
|
||||
delay.CancellationRegistration.Dispose();
|
||||
delay.Completion.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ScheduledDelay
|
||||
{
|
||||
public ScheduledDelay(DateTimeOffset dueAt, TaskCompletionSource completion)
|
||||
{
|
||||
DueAt = dueAt;
|
||||
Completion = completion;
|
||||
}
|
||||
|
||||
public DateTimeOffset DueAt { get; }
|
||||
|
||||
public TaskCompletionSource Completion { get; }
|
||||
|
||||
public CancellationTokenRegistration CancellationRegistration { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingInactivityPromptService : IMeetingInactivityPromptService
|
||||
{
|
||||
private readonly Queue<MeetingInactivityPromptResponse> responses;
|
||||
private TaskCompletionSource promptObserved =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public CapturingMeetingInactivityPromptService(params MeetingInactivityPromptResponse[] responses)
|
||||
{
|
||||
this.responses = new Queue<MeetingInactivityPromptResponse>(responses);
|
||||
}
|
||||
|
||||
public List<MeetingInactivityPromptRequest> Requests { get; } = [];
|
||||
|
||||
public async Task ShowStopPromptAsync(
|
||||
MeetingInactivityPromptRequest request,
|
||||
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
promptObserved.TrySetResult();
|
||||
var response = responses.Count > 0
|
||||
? responses.Dequeue()
|
||||
: MeetingInactivityPromptResponse.Continue;
|
||||
await handleResponseAsync(response, cancellationToken);
|
||||
}
|
||||
|
||||
public Task WaitForPromptAsync()
|
||||
{
|
||||
return promptObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class IgnoringMeetingInactivityPromptService : IMeetingInactivityPromptService
|
||||
{
|
||||
private readonly TaskCompletionSource promptObserved =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public List<MeetingInactivityPromptRequest> Requests { get; } = [];
|
||||
|
||||
public Task ShowStopPromptAsync(
|
||||
MeetingInactivityPromptRequest request,
|
||||
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
promptObserved.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WaitForPromptAsync()
|
||||
{
|
||||
return promptObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturedChunkThenCancelAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly AudioChunk chunk;
|
||||
@@ -3257,6 +3607,27 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedSegmentStreamingTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
private readonly TranscriptionSegment segment;
|
||||
|
||||
public FixedSegmentStreamingTranscriptionProvider(TranscriptionSegment segment)
|
||||
{
|
||||
this.segment = segment;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var _ in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
yield return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EchoStreamingTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
public bool FirstChunkWasObservedBeforeSourceCompleted { get; private set; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
@@ -47,6 +48,27 @@ public sealed class SpeakerAudioSampleCollectorTests
|
||||
Assert.DoesNotContain(collector.Snapshot(), sample => sample.Speaker == "Guest01");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectorLogsWhenSampleIsDiscardedBecauseSpeechIsTooShort()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var collector = new SpeakerAudioSampleCollector(
|
||||
TimeSpan.FromMinutes(2),
|
||||
maxSamplesPerSpeaker: 3,
|
||||
minimumUninterruptedSpeechDuration: TimeSpan.FromSeconds(30),
|
||||
maximumSegmentGap: TimeSpan.FromSeconds(1),
|
||||
logger: logger);
|
||||
collector.AppendAudio(CreateAudio(TimeSpan.FromSeconds(35)));
|
||||
|
||||
collector.TryAdd(Segment(0, 12, "Guest01", "one two three four five."));
|
||||
|
||||
var message = Assert.Single(
|
||||
logger.Messages,
|
||||
message => message.Contains("Discarding speaker identity sample for Guest01", StringComparison.Ordinal));
|
||||
Assert.Contains("duration", message);
|
||||
Assert.Contains("minimum duration", message);
|
||||
}
|
||||
|
||||
private static TranscriptionSegment Segment(
|
||||
double start,
|
||||
double end,
|
||||
@@ -73,4 +95,30 @@ public sealed class SpeakerAudioSampleCollectorTests
|
||||
using var reader = new WaveFileReader(new MemoryStream(wavBytes));
|
||||
return reader.TotalTime;
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Messages.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -149,6 +150,28 @@ public sealed class SpeakerIdentityServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectedUnmatchedSpeakerSampleIsLoggedWithSpeakerAndCandidateContext()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
fixture.MatchValidator.SampleIsValid = false;
|
||||
var logger = new CapturingLogger<SpeakerIdentityService>();
|
||||
var service = fixture.CreateService(logger);
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["John", "Mike"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown speaker")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var message = Assert.Single(
|
||||
logger.Messages,
|
||||
message => message.Contains("Skipping speaker identity candidate for Guest01", StringComparison.Ordinal));
|
||||
Assert.Contains("Skipping speaker identity candidate for Guest01", message);
|
||||
Assert.Contains("candidate names John, Mike", message);
|
||||
Assert.Contains("sample bytes 3", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
|
||||
{
|
||||
@@ -648,14 +671,14 @@ public sealed class SpeakerIdentityServiceTests
|
||||
return new SpeakerIdentityFixture(tempDirectory, dbPath, context, options);
|
||||
}
|
||||
|
||||
public SpeakerIdentityService CreateService()
|
||||
public SpeakerIdentityService CreateService(ILogger<SpeakerIdentityService>? logger = null)
|
||||
{
|
||||
return new SpeakerIdentityService(
|
||||
new TestSpeakerIdentityDbContextFactory(dbPath),
|
||||
SnippetExtractor,
|
||||
Matcher,
|
||||
Options.Create(new MeetingAssistantOptions { SpeakerIdentification = options }),
|
||||
NullLogger<SpeakerIdentityService>.Instance,
|
||||
logger ?? NullLogger<SpeakerIdentityService>.Instance,
|
||||
MatchValidator);
|
||||
}
|
||||
|
||||
@@ -836,4 +859,30 @@ public sealed class SpeakerIdentityServiceTests
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Messages.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Taskbar;
|
||||
using System.Drawing;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
@@ -81,6 +83,29 @@ public sealed class TaskbarIconTests
|
||||
Assert.Equal(RecordingProcessState.Recording, menu.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[SupportedOSPlatform("windows")]
|
||||
public void TaskbarIconGlyphsAreVisuallyCentered()
|
||||
{
|
||||
foreach (var state in new[]
|
||||
{
|
||||
RecordingProcessState.Idle,
|
||||
RecordingProcessState.Recording,
|
||||
RecordingProcessState.Summarizing
|
||||
})
|
||||
{
|
||||
using var bitmap = TaskbarIconRenderer.RenderBitmap(state);
|
||||
var bounds = GetVisibleGlyphBounds(bitmap);
|
||||
|
||||
Assert.True(
|
||||
IsCentered(GetCenter(bounds.Left, bounds.Right)),
|
||||
$"{state} glyph is horizontally off-center: {bounds}");
|
||||
Assert.True(
|
||||
IsCentered(GetCenter(bounds.Top, bounds.Bottom)),
|
||||
$"{state} glyph is vertically off-center: {bounds}");
|
||||
}
|
||||
}
|
||||
|
||||
private static LaunchProfile Profile(string name, string hotkey = "")
|
||||
{
|
||||
return new LaunchProfile(name, new MeetingAssistantOptions
|
||||
@@ -106,4 +131,42 @@ public sealed class TaskbarIconTests
|
||||
state,
|
||||
profile);
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static Rectangle GetVisibleGlyphBounds(Bitmap bitmap)
|
||||
{
|
||||
var left = bitmap.Width;
|
||||
var top = bitmap.Height;
|
||||
var right = -1;
|
||||
var bottom = -1;
|
||||
for (var y = 0; y < bitmap.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < bitmap.Width; x++)
|
||||
{
|
||||
var pixel = bitmap.GetPixel(x, y);
|
||||
if (pixel.A < 32 || pixel.GetBrightness() < 0.58f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
left = Math.Min(left, x);
|
||||
top = Math.Min(top, y);
|
||||
right = Math.Max(right, x);
|
||||
bottom = Math.Max(bottom, y);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(right >= left && bottom >= top, "Expected the icon to contain a visible white glyph.");
|
||||
return Rectangle.FromLTRB(left, top, right, bottom);
|
||||
}
|
||||
|
||||
private static double GetCenter(int start, int end)
|
||||
{
|
||||
return (start + end) / 2.0;
|
||||
}
|
||||
|
||||
private static bool IsCentered(double center)
|
||||
{
|
||||
return center is >= 7.25 and <= 8.75;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user