Public Access
Archive meeting workflow and screenshot changes
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
@@ -30,6 +31,36 @@ public sealed class RecordingCoordinatorTests
|
||||
throw new TimeoutException("Condition was not met.");
|
||||
}
|
||||
|
||||
private static async Task AppendTextWithRetryAsync(string path, string text)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
await File.AppendAllTextAsync(path, text);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FileContainsText(string path, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(path) &&
|
||||
File.ReadAllText(path).Contains(text, StringComparison.Ordinal);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
|
||||
{
|
||||
@@ -183,6 +214,47 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartSkipsOutlookMeetingAttendeesAboveConfiguredImportLimit()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording =
|
||||
{
|
||||
MaxMetadataAttendeeImportCount = 2
|
||||
}
|
||||
}),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
new FixedMeetingMetadataProvider(new MeetingMetadata(
|
||||
"Presentation Review",
|
||||
["Ada", "Grace", "Linus"],
|
||||
"Large audience agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T14:00:00+02:00"))));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await WaitUntilAsync(() => artifactStore.Agenda == "Large audience agenda");
|
||||
|
||||
Assert.Equal("Presentation Review", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Empty(noteStore.SavedNote?.Frontmatter.Attendees ?? []);
|
||||
Assert.Equal("Presentation Review", artifactStore.ContextMeetingNote?.Frontmatter.Title);
|
||||
Assert.Equal("Large audience agenda", artifactStore.Agenda);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-19T14:00:00+02:00"), artifactStore.ScheduledEnd);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater()
|
||||
{
|
||||
@@ -364,6 +436,94 @@ public sealed class RecordingCoordinatorTests
|
||||
await store.WaitForTextAsync("final:2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbortStopsRecordingDeletesArtifactsAndSkipsSummary()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
BaseFolder = root,
|
||||
TranscriptsFolder = "Transcripts",
|
||||
MeetingNotesFolder = "Notes",
|
||||
AssistantContextFolder = "Context",
|
||||
SummariesFolder = "Summaries"
|
||||
},
|
||||
Recording = new RecordingOptions
|
||||
{
|
||||
StopProcessingTimeout = TimeSpan.FromSeconds(5)
|
||||
}
|
||||
};
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new VaultTranscriptStore(Options.Create(options), NullLogger<VaultTranscriptStore>.Instance),
|
||||
new MarkdownMeetingNoteStore(Options.Create(options), NullLogger<MarkdownMeetingNoteStore>.Instance),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
var started = await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await WaitUntilAsync(() =>
|
||||
FileContainsText(started.AssistantContextPath!, "state: transcribing"));
|
||||
var attachmentPath = Path.Combine(
|
||||
Path.GetDirectoryName(started.AssistantContextPath!)!,
|
||||
"Attachments",
|
||||
"20260527-093135-123-000010-cropped.png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
|
||||
await File.WriteAllTextAsync(attachmentPath, "image");
|
||||
await AppendTextWithRetryAsync(
|
||||
started.AssistantContextPath!,
|
||||
Environment.NewLine + "" + Environment.NewLine);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(started.SummaryPath!)!);
|
||||
await File.WriteAllTextAsync(started.SummaryPath!, "draft summary");
|
||||
|
||||
var aborted = await coordinator.AbortAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(aborted.IsRecording);
|
||||
Assert.Null(aborted.MeetingNotePath);
|
||||
Assert.False(File.Exists(started.MeetingNotePath));
|
||||
Assert.False(File.Exists(started.TranscriptPath));
|
||||
Assert.False(File.Exists(started.AssistantContextPath));
|
||||
Assert.False(File.Exists(started.SummaryPath));
|
||||
Assert.False(File.Exists(attachmentPath));
|
||||
Assert.Null(summaryPipeline.Artifacts);
|
||||
|
||||
await Task.Delay(250);
|
||||
Assert.False(File.Exists(started.AssistantContextPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbortDoesNothingWhenRecordingIsIdle()
|
||||
{
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
new ControlledAudioSource(),
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance);
|
||||
|
||||
var status = await coordinator.AbortAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(status.IsRecording);
|
||||
Assert.Null(status.MeetingNotePath);
|
||||
Assert.Null(status.TranscriptPath);
|
||||
Assert.Null(status.AssistantContextPath);
|
||||
Assert.Null(status.SummaryPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureStartsEvenWhenTranscriptionProviderIsStillWarmingUp()
|
||||
{
|
||||
@@ -950,6 +1110,98 @@ public sealed class RecordingCoordinatorTests
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureScreenshotUsesCurrentMeetingArtifactsAndStartTime()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var screenshotService = new CapturingMeetingScreenshotService();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(CreateOptionsWithoutFinalizer()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
screenshotService: screenshotService);
|
||||
|
||||
var started = await coordinator.StartAsync(CancellationToken.None);
|
||||
|
||||
var result = await coordinator.CaptureScreenshotAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(started.AssistantContextPath, screenshotService.Artifacts?.AssistantContextPath);
|
||||
Assert.NotNull(screenshotService.MeetingStartedAt);
|
||||
Assert.Equal("memory-screenshot.png", result.ScreenshotPath);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureScreenshotFailsAfterRecordingStopHasStarted()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var screenshotService = new CapturingMeetingScreenshotService();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(CreateOptionsWithoutFinalizer()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
screenshotService: screenshotService);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
var stop = coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => coordinator.CaptureScreenshotAsync(CancellationToken.None));
|
||||
|
||||
audioSource.Complete();
|
||||
await stop;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopWaitsForPendingScreenshotOcrBeforeSummarizing()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var screenshotService = new BlockingMeetingScreenshotService();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(CreateOptionsWithoutFinalizer()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
screenshotService: screenshotService);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
var stop = coordinator.StopAsync(CancellationToken.None);
|
||||
await screenshotService.WaitUntilOcrWaitStartedAsync();
|
||||
|
||||
Assert.DoesNotContain(AssistantContextState.Summarizing, artifactStore.States);
|
||||
Assert.Null(summaryPipeline.Artifacts);
|
||||
|
||||
screenshotService.ReleaseOcrWait();
|
||||
await stop;
|
||||
|
||||
Assert.Contains(AssistantContextState.Summarizing, artifactStore.States);
|
||||
Assert.NotNull(summaryPipeline.Artifacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopMarksAssistantContextErrorWhenSummaryFails()
|
||||
{
|
||||
@@ -1396,6 +1648,93 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingScreenshotService : IMeetingScreenshotService
|
||||
{
|
||||
public MeetingSessionArtifacts? Artifacts { get; private set; }
|
||||
|
||||
public DateTimeOffset? MeetingStartedAt { get; private set; }
|
||||
|
||||
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
DateTimeOffset? meetingStartedAt,
|
||||
DateTimeOffset capturedAt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Artifacts = artifacts;
|
||||
MeetingStartedAt = meetingStartedAt;
|
||||
return Task.FromResult(new MeetingScreenshotCaptureResult(
|
||||
"memory-screenshot.png",
|
||||
TimeSpan.FromSeconds(1),
|
||||
OcrStarted: false));
|
||||
}
|
||||
|
||||
public Task WaitForPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CancelPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService
|
||||
{
|
||||
private readonly TaskCompletionSource waitStarted =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource releaseWait =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
DateTimeOffset? meetingStartedAt,
|
||||
DateTimeOffset capturedAt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MeetingScreenshotCaptureResult(
|
||||
"memory-screenshot.png",
|
||||
TimeSpan.Zero,
|
||||
OcrStarted: true));
|
||||
}
|
||||
|
||||
public async Task WaitForPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
waitStarted.TrySetResult();
|
||||
await releaseWait.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task CancelPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
releaseWait.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WaitUntilOcrWaitStartedAsync()
|
||||
{
|
||||
return waitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void ReleaseOcrWait()
|
||||
{
|
||||
releaseWait.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
|
||||
@@ -2140,6 +2479,11 @@ public sealed class RecordingCoordinatorTests
|
||||
{
|
||||
return chunks.Writer.WriteAsync(chunk, cancellationToken);
|
||||
}
|
||||
|
||||
public void Complete()
|
||||
{
|
||||
chunks.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturedChunkThenCancelAudioSource : IMeetingAudioSource
|
||||
|
||||
Reference in New Issue
Block a user