Public Access
Archive completed meeting assistant changes
PR and Push Build/Test / build-and-test (push) Successful in 9m19s
PR and Push Build/Test / build-and-test (push) Successful in 9m19s
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.Calendar;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Recording;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -22,6 +23,94 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
Assert.Equal(0, harness.Recorder.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedPromptStartsRecordingWithPromptedMeetingMetadata()
|
||||
{
|
||||
var harness = CreateHarness(isRecording: false);
|
||||
var selectedMetadata = new MeetingMetadata(
|
||||
"Selected planning",
|
||||
["Ada"],
|
||||
"Selected agenda",
|
||||
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
|
||||
var otherMetadata = new MeetingMetadata(
|
||||
"Other planning",
|
||||
["Grace"],
|
||||
"Other agenda",
|
||||
DateTimeOffset.Parse("2026-06-03T10:40:00+00:00"));
|
||||
var selectedMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-selected",
|
||||
subject: "Selected planning",
|
||||
metadata: selectedMetadata);
|
||||
var otherMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-other",
|
||||
subject: "Other planning",
|
||||
startOffset: TimeSpan.FromMinutes(40),
|
||||
metadata: otherMetadata);
|
||||
harness.Provider.Meetings = [selectedMeeting, otherMeeting];
|
||||
|
||||
await SyncThenPromptAsync(harness, selectedMeeting);
|
||||
|
||||
Assert.Equal(1, harness.Recorder.StartCount);
|
||||
Assert.Same(selectedMetadata, harness.Recorder.StartMetadata.Single());
|
||||
Assert.Equal(1, harness.Recorder.PromptStartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedPromptWithoutMetadataStillUsesPromptStartPath()
|
||||
{
|
||||
var harness = CreateHarness(isRecording: false);
|
||||
var meeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-without-metadata",
|
||||
subject: "No metadata",
|
||||
metadata: null);
|
||||
harness.Provider.Meetings = [meeting];
|
||||
|
||||
await SyncThenPromptAsync(harness, meeting);
|
||||
|
||||
Assert.Equal(1, harness.Recorder.StartCount);
|
||||
Assert.Null(harness.Recorder.StartMetadata.Single());
|
||||
Assert.Equal(1, harness.Recorder.PromptStartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptingDisplayedPromptsOutOfOrderUsesAcceptedPromptMetadata()
|
||||
{
|
||||
var harness = CreateHarness(isRecording: false, autoAcceptPrompts: false);
|
||||
var firstMetadata = new MeetingMetadata(
|
||||
"First planning",
|
||||
["Ada"],
|
||||
"First agenda",
|
||||
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
|
||||
var secondMetadata = new MeetingMetadata(
|
||||
"Second planning",
|
||||
["Grace"],
|
||||
"Second agenda",
|
||||
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
|
||||
var firstMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-first",
|
||||
subject: "First planning",
|
||||
metadata: firstMetadata);
|
||||
var secondMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-second",
|
||||
subject: "Second planning",
|
||||
metadata: secondMetadata);
|
||||
harness.Provider.Meetings = [firstMeeting, secondMeeting];
|
||||
|
||||
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
|
||||
harness.Clock.Now = firstMeeting.Start;
|
||||
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
|
||||
await harness.PromptService.RespondAsync(secondMeeting, MeetingStartPromptResponse.Record);
|
||||
|
||||
Assert.Equal([firstMeeting, secondMeeting], harness.PromptService.PromptedMeetings);
|
||||
Assert.Equal(1, harness.Recorder.StartCount);
|
||||
Assert.Same(secondMetadata, harness.Recorder.StartMetadata.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedPromptStopsActiveRecordingBeforeStartingNewRecording()
|
||||
{
|
||||
@@ -34,6 +123,25 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
Assert.Equal(["stop", "start"], harness.Recorder.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledCachedMeetingDoesNotPromptRecording()
|
||||
{
|
||||
var harness = CreateHarness(isRecording: false);
|
||||
var canceledMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-canceled",
|
||||
subject: "Canceled project sync",
|
||||
isCanceled: true);
|
||||
harness.Provider.Meetings = [canceledMeeting];
|
||||
|
||||
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
|
||||
harness.Clock.Now = canceledMeeting.Start;
|
||||
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(harness.PromptService.PromptedMeetings);
|
||||
Assert.Equal(0, harness.Recorder.StartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledCalendarPromptsDoNotQueryCalendar()
|
||||
{
|
||||
@@ -115,23 +223,28 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
string id = "teams-1",
|
||||
string subject = "Project sync",
|
||||
TimeSpan? startOffset = null,
|
||||
TimeSpan? duration = null)
|
||||
TimeSpan? duration = null,
|
||||
MeetingMetadata? metadata = null,
|
||||
bool isCanceled = false)
|
||||
{
|
||||
var start = clock.Now.Add(startOffset ?? TimeSpan.FromMinutes(30));
|
||||
return new CalendarMeeting(
|
||||
id,
|
||||
subject,
|
||||
start,
|
||||
start.Add(duration ?? TimeSpan.FromMinutes(30)));
|
||||
start.Add(duration ?? TimeSpan.FromMinutes(30)),
|
||||
metadata,
|
||||
isCanceled);
|
||||
}
|
||||
|
||||
private static SchedulerHarness CreateHarness(
|
||||
bool isRecording = false,
|
||||
bool enabled = true)
|
||||
bool enabled = true,
|
||||
bool autoAcceptPrompts = true)
|
||||
{
|
||||
var clock = new ManualCalendarPromptClock(new DateTimeOffset(2026, 6, 3, 9, 30, 0, TimeSpan.Zero));
|
||||
var provider = new RecordingCalendarMeetingProvider([]);
|
||||
var promptService = new AcceptingMeetingStartPromptService();
|
||||
var promptService = new CapturingMeetingStartPromptService(autoAcceptPrompts);
|
||||
var recorder = new RecordingPromptRecorder(isRecording);
|
||||
var scheduler = new CalendarRecordingPromptScheduler(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
@@ -155,7 +268,7 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
private sealed record SchedulerHarness(
|
||||
CalendarRecordingPromptScheduler Scheduler,
|
||||
RecordingCalendarMeetingProvider Provider,
|
||||
AcceptingMeetingStartPromptService PromptService,
|
||||
CapturingMeetingStartPromptService PromptService,
|
||||
RecordingPromptRecorder Recorder,
|
||||
ManualCalendarPromptClock Clock);
|
||||
|
||||
@@ -179,8 +292,16 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AcceptingMeetingStartPromptService : IMeetingStartPromptService
|
||||
private sealed class CapturingMeetingStartPromptService : IMeetingStartPromptService
|
||||
{
|
||||
private readonly bool autoAccept;
|
||||
private readonly List<PendingPrompt> pendingPrompts = [];
|
||||
|
||||
public CapturingMeetingStartPromptService(bool autoAccept)
|
||||
{
|
||||
this.autoAccept = autoAccept;
|
||||
}
|
||||
|
||||
public List<CalendarMeeting> PromptedMeetings { get; } = [];
|
||||
|
||||
public async Task ShowPromptAsync(
|
||||
@@ -189,8 +310,24 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PromptedMeetings.Add(request.Meeting);
|
||||
await handleResponseAsync(MeetingStartPromptResponse.Record, cancellationToken);
|
||||
pendingPrompts.Add(new PendingPrompt(request.Meeting, handleResponseAsync));
|
||||
if (autoAccept)
|
||||
{
|
||||
await handleResponseAsync(MeetingStartPromptResponse.Record, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public Task RespondAsync(
|
||||
CalendarMeeting meeting,
|
||||
MeetingStartPromptResponse response)
|
||||
{
|
||||
var prompt = pendingPrompts.Single(pending => ReferenceEquals(pending.Meeting, meeting));
|
||||
return prompt.HandleResponseAsync(response, CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed record PendingPrompt(
|
||||
CalendarMeeting Meeting,
|
||||
Func<MeetingStartPromptResponse, CancellationToken, Task> HandleResponseAsync);
|
||||
}
|
||||
|
||||
private sealed class RecordingPromptRecorder : IMeetingPromptRecordingController
|
||||
@@ -206,16 +343,35 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
|
||||
public int StopCount { get; private set; }
|
||||
|
||||
public int PromptStartCount { get; private set; }
|
||||
|
||||
public List<string> Commands { get; } = [];
|
||||
|
||||
public List<MeetingMetadata?> StartMetadata { get; } = [];
|
||||
|
||||
public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return StartRecordingAsync(null);
|
||||
}
|
||||
|
||||
private Task<RecordingStatus> StartRecordingAsync(
|
||||
MeetingMetadata? metadata)
|
||||
{
|
||||
StartCount++;
|
||||
Commands.Add("start");
|
||||
StartMetadata.Add(metadata);
|
||||
CurrentStatus = Status(isRecording: true);
|
||||
return Task.FromResult(CurrentStatus);
|
||||
}
|
||||
|
||||
public Task<RecordingStatus> StartFromPromptAsync(
|
||||
MeetingMetadata? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PromptStartCount++;
|
||||
return StartRecordingAsync(metadata);
|
||||
}
|
||||
|
||||
public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopCount++;
|
||||
|
||||
@@ -16,9 +16,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
[Fact]
|
||||
public async Task ExtractUsesAgentEndpointAndModelWhenOcrEndpointAndModelAreBlank()
|
||||
{
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, [1, 2, 3]);
|
||||
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output": [
|
||||
@@ -75,9 +73,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
[Fact]
|
||||
public async Task ExtractUsesScreenshotOcrEndpointAndModelWhenConfigured()
|
||||
{
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, [4, 5, 6]);
|
||||
var screenshotPath = await CreateScreenshotAsync([4, 5, 6]);
|
||||
var handler = new RecordingHandler("""{ "output_text": "OCR result" }""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
@@ -116,9 +112,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
[Fact]
|
||||
public async Task ExtractParsesCropMetadataAndOmitsMetadataFromReturnedText()
|
||||
{
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, CreatePngBytes(8, 6));
|
||||
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 } }\n```"
|
||||
@@ -150,6 +144,67 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractParsesAttendeeMetadataAndOmitsMetadataFromReturnedText()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output_text": "Visible participant tiles: Ada and Grace.\n\n```json\n{ \"crop\": null, \"attendees\": [\"Ada Lovelace\", \"Grace Hopper\"] }\n```"
|
||||
}
|
||||
""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Key = "agent-key"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.ExtractAsync(
|
||||
screenshotPath,
|
||||
"Extract screenshot.",
|
||||
options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Visible participant tiles: Ada and Grace.", result.Text);
|
||||
Assert.Equal(["Ada Lovelace", "Grace Hopper"], result.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractIgnoresMalformedAttendeesMetadataAndStillParsesCrop()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 }, \"attendees\": \"Ada\" }\n```"
|
||||
}
|
||||
""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Key = "agent-key"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.ExtractAsync(
|
||||
screenshotPath,
|
||||
"Extract screenshot.",
|
||||
options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Slide text", result.Text);
|
||||
Assert.Equal(new ScreenshotCropCoordinates(1, 2, 3, 4), result.Crop);
|
||||
Assert.Empty(result.Attendees);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string responseBody;
|
||||
@@ -193,6 +248,14 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<string> CreateScreenshotAsync(byte[] bytes)
|
||||
{
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, bytes);
|
||||
return screenshotPath;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore CA1416
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
|
||||
@@ -130,6 +132,165 @@ public sealed class MeetingScreenshotServiceTests
|
||||
Assert.Contains("Shared screen text", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAddsOcrAttendeesToMeetingNoteThroughCanonicalizer()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(
|
||||
options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
},
|
||||
attendees: ["Ada Lovelace"]);
|
||||
var ocr = new CapturingScreenshotOcrClient(
|
||||
"Visible participant tiles: Ada, Grace, and Ada again.",
|
||||
attendees: ["Ada L.", "Grace Hopper", "Ada Lovelace"]);
|
||||
var canonicalizer = new MappingAttendeeCanonicalizer(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Ada L."] = "Ada Lovelace"
|
||||
});
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr,
|
||||
canonicalizer);
|
||||
|
||||
await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Equal(["Ada Lovelace", "Grace Hopper"], meeting.Frontmatter.Attendees);
|
||||
Assert.Contains(canonicalizer.Requests, request =>
|
||||
request.SequenceEqual(["Ada Lovelace", "Ada L.", "Grace Hopper", "Ada Lovelace"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureWritesRetryLinkWhenOcrFails()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(options =>
|
||||
{
|
||||
options.Api.PublicBaseUrl = "http://localhost:5090";
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
});
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
new ThrowingScreenshotOcrClient("vision offline"));
|
||||
|
||||
var result = await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
var screenshotId = ExtractScreenshotOcrId(context);
|
||||
Assert.Contains("_OCR failed: vision offline_", context);
|
||||
Assert.Contains("<!-- screenshot-ocr:", context);
|
||||
Assert.Contains("<!-- /screenshot-ocr:", context);
|
||||
Assert.Contains("[Retry screenshot OCR](http://localhost:5090/meetings/screenshot-ocr/retry?", context);
|
||||
Assert.Contains($"screenshotId={screenshotId}", context);
|
||||
Assert.Contains($"screenshotPath={Uri.EscapeDataString(result.ScreenshotPath)}", context);
|
||||
Assert.Contains($"assistantContextPath={Uri.EscapeDataString(fixture.Artifacts.AssistantContextPath)}", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryOcrReplacesFailureForSameScreenshot()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
});
|
||||
var ocr = new SequencedScreenshotOcrClient(
|
||||
new InvalidOperationException("vision offline"),
|
||||
new ScreenshotOcrResult("Retried OCR text", null, ["Grace Hopper"]));
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr);
|
||||
|
||||
var result = await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
var failedContext = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
var screenshotId = ExtractScreenshotOcrId(failedContext);
|
||||
|
||||
var retry = await service.TriggerOcrRetryAsync(
|
||||
fixture.Artifacts,
|
||||
result.ScreenshotPath,
|
||||
screenshotId,
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
Assert.NotNull(retry);
|
||||
Assert.Equal(result.ScreenshotPath, retry.ScreenshotPath);
|
||||
Assert.Equal(screenshotId, retry.ScreenshotId);
|
||||
Assert.Equal([result.ScreenshotPath, result.ScreenshotPath], ocr.ScreenshotPaths);
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
Assert.Contains("### OCR", context);
|
||||
Assert.Contains("Retried OCR text", context);
|
||||
Assert.DoesNotContain("vision offline", context);
|
||||
Assert.DoesNotContain("Retry screenshot OCR", context);
|
||||
Assert.DoesNotContain("<!-- screenshot-ocr:", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMeetingNoteImageEmbedsAppendsContextAndRunsOcrWithoutCropOrAttendees()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(
|
||||
options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
},
|
||||
attendees: ["Ada Lovelace"],
|
||||
userNotes: "Discussed ![[whiteboard.png]] and .");
|
||||
var noteFolder = Path.GetDirectoryName(fixture.Artifacts.MeetingNotePath)!;
|
||||
var attachmentsFolder = Path.Combine(noteFolder, "attachments");
|
||||
Directory.CreateDirectory(attachmentsFolder);
|
||||
var whiteboardPath = Path.Combine(noteFolder, "whiteboard.png");
|
||||
var diagramPath = Path.Combine(attachmentsFolder, "diagram.png");
|
||||
await File.WriteAllBytesAsync(whiteboardPath, [1, 2, 3]);
|
||||
await File.WriteAllBytesAsync(diagramPath, [4, 5, 6]);
|
||||
var ocr = new CapturingScreenshotOcrClient(
|
||||
"Manual image OCR",
|
||||
new ScreenshotCropCoordinates(1, 1, 2, 2),
|
||||
["Grace Hopper"]);
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr);
|
||||
var originalMeetingNote = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
|
||||
|
||||
var result = await service.ProcessMeetingNoteImageEmbedsAsync(
|
||||
fixture.Artifacts,
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.QueuedCount);
|
||||
Assert.Equal(2, ocr.CallCount);
|
||||
Assert.Equal([whiteboardPath, diagramPath], ocr.ScreenshotPaths);
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
Assert.Contains("## Meeting Note Image", context);
|
||||
Assert.Contains("Image from meeting note.", context);
|
||||
Assert.Contains("Original embed: `![[whiteboard.png]]`", context);
|
||||
Assert.Contains("Original embed: ``", context);
|
||||
Assert.Contains("whiteboard.png", context);
|
||||
Assert.Contains("diagram.png", context);
|
||||
Assert.Contains("Manual image OCR", context);
|
||||
Assert.DoesNotContain("Cropped screenshot", context);
|
||||
Assert.Equal(originalMeetingNote, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath));
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForPendingOcrWaitsForRunningScreenshotOcr()
|
||||
{
|
||||
@@ -192,11 +353,13 @@ public sealed class MeetingScreenshotServiceTests
|
||||
private ScreenshotFixture(
|
||||
MeetingAssistantOptions options,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MarkdownMeetingArtifactStore artifactStore)
|
||||
MarkdownMeetingArtifactStore artifactStore,
|
||||
MarkdownMeetingNoteStore noteStore)
|
||||
{
|
||||
Options = options;
|
||||
Artifacts = artifacts;
|
||||
ArtifactStore = artifactStore;
|
||||
NoteStore = noteStore;
|
||||
}
|
||||
|
||||
public MeetingAssistantOptions Options { get; }
|
||||
@@ -205,8 +368,12 @@ public sealed class MeetingScreenshotServiceTests
|
||||
|
||||
public MarkdownMeetingArtifactStore ArtifactStore { get; }
|
||||
|
||||
public MarkdownMeetingNoteStore NoteStore { get; }
|
||||
|
||||
public static async Task<ScreenshotFixture> CreateAsync(
|
||||
Action<MeetingAssistantOptions>? configure = null)
|
||||
Action<MeetingAssistantOptions>? configure = null,
|
||||
IReadOnlyList<string>? attendees = null,
|
||||
string userNotes = "")
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var options = new MeetingAssistantOptions
|
||||
@@ -218,6 +385,9 @@ public sealed class MeetingScreenshotServiceTests
|
||||
}
|
||||
};
|
||||
configure?.Invoke(options);
|
||||
var noteStore = new MarkdownMeetingNoteStore(
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
NullLogger<MarkdownMeetingNoteStore>.Instance);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
Path.Combine(root, "Notes", "meeting.md"),
|
||||
Path.Combine(root, "Transcripts", "transcript.md"),
|
||||
@@ -225,6 +395,7 @@ public sealed class MeetingScreenshotServiceTests
|
||||
Path.Combine(root, "Summaries", "summary.md"));
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(
|
||||
NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
var meeting = MeetingNoteTemplate.Create(
|
||||
"Planning",
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
@@ -232,24 +403,33 @@ public sealed class MeetingScreenshotServiceTests
|
||||
assistantContextPath: artifacts.AssistantContextPath,
|
||||
summaryPath: artifacts.SummaryPath) with
|
||||
{
|
||||
Path = artifacts.MeetingNotePath
|
||||
Path = artifacts.MeetingNotePath,
|
||||
UserNotes = userNotes
|
||||
};
|
||||
meeting.Frontmatter.Attendees = attendees?.ToList() ?? [];
|
||||
var savedMeeting = await noteStore.SaveAsync(
|
||||
meeting,
|
||||
options,
|
||||
CancellationToken.None);
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
meeting,
|
||||
savedMeeting,
|
||||
"",
|
||||
null,
|
||||
CancellationToken.None);
|
||||
return new ScreenshotFixture(options, artifacts, artifactStore);
|
||||
return new ScreenshotFixture(options, artifacts, artifactStore, noteStore);
|
||||
}
|
||||
|
||||
public MeetingScreenshotService CreateService(
|
||||
IActiveWindowScreenshotCapture capture,
|
||||
IScreenshotOcrClient ocrClient)
|
||||
IScreenshotOcrClient ocrClient,
|
||||
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null)
|
||||
{
|
||||
return new MeetingScreenshotService(
|
||||
capture,
|
||||
ArtifactStore,
|
||||
NoteStore,
|
||||
attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance,
|
||||
ocrClient,
|
||||
NullLogger<MeetingScreenshotService>.Instance);
|
||||
}
|
||||
@@ -276,15 +456,18 @@ public sealed class MeetingScreenshotServiceTests
|
||||
|
||||
public CapturingScreenshotOcrClient(
|
||||
string text = "",
|
||||
ScreenshotCropCoordinates? crop = null)
|
||||
ScreenshotCropCoordinates? crop = null,
|
||||
IReadOnlyList<string>? attendees = null)
|
||||
{
|
||||
result = new ScreenshotOcrResult(text, crop);
|
||||
result = new ScreenshotOcrResult(text, crop, attendees ?? []);
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public string? Prompt { get; private set; }
|
||||
|
||||
public List<string> ScreenshotPaths { get; } = [];
|
||||
|
||||
public Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
@@ -293,6 +476,7 @@ public sealed class MeetingScreenshotServiceTests
|
||||
{
|
||||
CallCount++;
|
||||
Prompt = prompt;
|
||||
ScreenshotPaths.Add(screenshotPath);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -320,7 +504,79 @@ public sealed class MeetingScreenshotServiceTests
|
||||
|
||||
public void Release(string value)
|
||||
{
|
||||
result.TrySetResult(new ScreenshotOcrResult(value, null));
|
||||
result.TrySetResult(new ScreenshotOcrResult(value, null, []));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingScreenshotOcrClient : IScreenshotOcrClient
|
||||
{
|
||||
private readonly string message;
|
||||
|
||||
public ThrowingScreenshotOcrClient(string message)
|
||||
{
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SequencedScreenshotOcrClient : IScreenshotOcrClient
|
||||
{
|
||||
private readonly Queue<object> results;
|
||||
|
||||
public SequencedScreenshotOcrClient(params object[] results)
|
||||
{
|
||||
this.results = new Queue<object>(results);
|
||||
}
|
||||
|
||||
public List<string> ScreenshotPaths { get; } = [];
|
||||
|
||||
public Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ScreenshotPaths.Add(screenshotPath);
|
||||
var result = results.Dequeue();
|
||||
if (result is Exception exception)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
|
||||
return Task.FromResult((ScreenshotOcrResult)result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MappingAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string> aliases;
|
||||
|
||||
public MappingAttendeeCanonicalizer(IReadOnlyDictionary<string, string> aliases)
|
||||
{
|
||||
this.aliases = aliases;
|
||||
}
|
||||
|
||||
public List<IReadOnlyList<string>> Requests { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(attendees.ToList());
|
||||
var result = attendees
|
||||
.Select(attendee => aliases.TryGetValue(attendee, out var canonical) ? canonical : attendee)
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<string>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +592,17 @@ public sealed class MeetingScreenshotServiceTests
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static string ExtractScreenshotOcrId(string context)
|
||||
{
|
||||
const string prefix = "<!-- screenshot-ocr:";
|
||||
var start = context.IndexOf(prefix, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0);
|
||||
start += prefix.Length;
|
||||
var end = context.IndexOf(" -->", start, StringComparison.Ordinal);
|
||||
Assert.True(end > start);
|
||||
return context[start..end];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore CA1416
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MicrophoneSelectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void BlankConfiguredMicrophoneUsesDefaultDevice()
|
||||
{
|
||||
var selection = new MicrophoneDeviceSelection();
|
||||
|
||||
var selected = selection.Resolve(
|
||||
configuredDeviceId: null,
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
[
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
new MicrophoneDevice("other-id", "other microphone")
|
||||
]);
|
||||
|
||||
Assert.Equal("default-id", selected?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfiguredMicrophoneUsesMatchingDevice()
|
||||
{
|
||||
var selection = new MicrophoneDeviceSelection();
|
||||
var selected = selection.Resolve(
|
||||
"other-id",
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
[
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
new MicrophoneDevice("other-id", "other microphone")
|
||||
]);
|
||||
|
||||
Assert.Equal("other-id", selected?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeMicrophoneSelectionOverridesConfiguredDevice()
|
||||
{
|
||||
var selection = new MicrophoneDeviceSelection();
|
||||
selection.Select("runtime-id");
|
||||
|
||||
var selected = selection.Resolve(
|
||||
"configured-id",
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
[
|
||||
new MicrophoneDevice("configured-id", "configured microphone"),
|
||||
new MicrophoneDevice("runtime-id", "runtime microphone")
|
||||
]);
|
||||
|
||||
Assert.Equal("runtime-id", selected?.Id);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
|
||||
[Fact]
|
||||
public void ExtractAgendaStopsBeforeTeamsJoinInformation()
|
||||
{
|
||||
var agenda = OutlookClassicMeetingMetadataProvider.ExtractAgenda(
|
||||
var agenda = OutlookClassicAppointmentMetadata.ExtractAgenda(
|
||||
"""
|
||||
Review current prototype
|
||||
Decide next backend
|
||||
@@ -25,7 +25,7 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
|
||||
[Fact]
|
||||
public void NormalizeAttendeesDeduplicatesOrganizerAndRecipientEmail()
|
||||
{
|
||||
var attendees = OutlookClassicMeetingMetadataProvider.NormalizeAttendees([
|
||||
var attendees = OutlookClassicAppointmentMetadata.NormalizeAttendees([
|
||||
"Marcus.Altmann@sew-eurodrive.de",
|
||||
"Marcus.Altmann@sew-eurodrive.de <Marcus.Altmann@sew-eurodrive.de>",
|
||||
"Schweigert, Manuel",
|
||||
@@ -37,5 +37,14 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
|
||||
"Schweigert, Manuel"
|
||||
], attendees);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Canceled: Project sync")]
|
||||
[InlineData("Cancelled: Project sync")]
|
||||
[InlineData("Abgesagt: Project sync")]
|
||||
public void SubjectIndicatesCancellationRecognizesOutlookCanceledPrefixes(string subject)
|
||||
{
|
||||
Assert.True(OutlookClassicCom.SubjectIndicatesCancellation(subject));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -784,6 +784,88 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartWithPromptedMetadataBypassesLookupAndRunsWorkflowWithMetadata()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\prompted-metadata-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var metadataProvider = new CountingMeetingMetadataProvider();
|
||||
var workflowEngine = new MetadataObservingWorkflowEngine(() => noteStore.SavedNote);
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: metadataProvider,
|
||||
meetingWorkflowEngine: workflowEngine);
|
||||
var promptedMetadata = new MeetingMetadata(
|
||||
"Prompted Architecture Sync",
|
||||
["Ada <ada@example.com>"],
|
||||
"Prompted agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"));
|
||||
|
||||
await coordinator.StartFromPromptAsync(promptedMetadata, CancellationToken.None);
|
||||
|
||||
await WaitUntilAsync(() => workflowEngine.ObservedEvents.Any(entry =>
|
||||
entry.Type == MeetingWorkflowEventType.StateTransition &&
|
||||
entry.Title == "Prompted Architecture Sync"));
|
||||
Assert.Equal(0, metadataProvider.CallCount);
|
||||
Assert.Equal("Prompted Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(["Ada <ada@example.com>"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal("Prompted agenda", artifactStore.Agenda);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
|
||||
Assert.Collection(
|
||||
workflowEngine.ObservedEvents.Where(entry =>
|
||||
entry.Type is MeetingWorkflowEventType.Created or MeetingWorkflowEventType.StateTransition),
|
||||
created =>
|
||||
{
|
||||
Assert.Equal(MeetingWorkflowEventType.Created, created.Type);
|
||||
Assert.StartsWith("Meeting ", created.Title, StringComparison.Ordinal);
|
||||
},
|
||||
transition =>
|
||||
{
|
||||
Assert.Equal(MeetingWorkflowEventType.StateTransition, transition.Type);
|
||||
Assert.Equal("Prompted Architecture Sync", transition.Title);
|
||||
Assert.Equal(AssistantContextState.CollectingMetadata, transition.FromState);
|
||||
Assert.Equal(AssistantContextState.Transcribing, transition.ToState);
|
||||
});
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartFromPromptWithoutMetadataBypassesLookup()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var metadataProvider = new CountingMeetingMetadataProvider();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: metadataProvider);
|
||||
|
||||
await coordinator.StartFromPromptAsync(null, CancellationToken.None);
|
||||
|
||||
await Task.Delay(100);
|
||||
Assert.Equal(0, metadataProvider.CallCount);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartCanonicalizesOutlookMeetingAttendeesBeforeWritingNote()
|
||||
{
|
||||
@@ -1353,7 +1435,9 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
|
||||
Assert.Contains(transcriptStore.Segments, segment =>
|
||||
segment.Speaker == "System" &&
|
||||
segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal));
|
||||
segment.Text.Contains(
|
||||
"Transcription profile changed to english. Speaker recognition and identities reset.",
|
||||
StringComparison.Ordinal));
|
||||
Assert.Null(summaryPipeline.Artifacts);
|
||||
await WaitUntilAsync(() => metadataProvider.CallCount == 1);
|
||||
|
||||
@@ -2121,6 +2205,44 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.NotNull(summaryPipeline.Artifacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopProcessesMeetingNoteImageEmbedsBeforeSummarizing()
|
||||
{
|
||||
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,
|
||||
meetingNoteImageOcrService: screenshotService);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
var stop = coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
await screenshotService.WaitUntilMeetingNoteImageProcessingStartedAsync();
|
||||
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()
|
||||
{
|
||||
@@ -2866,6 +2988,46 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MetadataObservingWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
private readonly Func<MeetingNote?> getMeetingNote;
|
||||
|
||||
public MetadataObservingWorkflowEngine(Func<MeetingNote?> getMeetingNote)
|
||||
{
|
||||
this.getMeetingNote = getMeetingNote;
|
||||
}
|
||||
|
||||
public List<ObservedWorkflowEvent> ObservedEvents { get; } = [];
|
||||
|
||||
public Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var note = getMeetingNote();
|
||||
ObservedEvents.Add(new ObservedWorkflowEvent(
|
||||
workflowEvent.Type,
|
||||
note?.Frontmatter.Title,
|
||||
workflowEvent.FromState,
|
||||
workflowEvent.ToState));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> TransformTranscriptLineAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
|
||||
}
|
||||
|
||||
public sealed record ObservedWorkflowEvent(
|
||||
MeetingWorkflowEventType Type,
|
||||
string? Title,
|
||||
AssistantContextState? FromState,
|
||||
AssistantContextState? ToState);
|
||||
}
|
||||
|
||||
private sealed class FailingFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
public Task RunAsync(
|
||||
@@ -3003,12 +3165,14 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService
|
||||
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService, IMeetingNoteImageOcrService
|
||||
{
|
||||
private readonly TaskCompletionSource waitStarted =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource releaseWait =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource meetingNoteImageProcessingStarted =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
@@ -3041,6 +3205,20 @@ public sealed class RecordingCoordinatorTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<MeetingNoteImageOcrQueueResult> ProcessMeetingNoteImageEmbedsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
meetingNoteImageProcessingStarted.TrySetResult();
|
||||
return Task.FromResult(new MeetingNoteImageOcrQueueResult(0));
|
||||
}
|
||||
|
||||
public Task WaitUntilMeetingNoteImageProcessingStartedAsync()
|
||||
{
|
||||
return meetingNoteImageProcessingStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public Task WaitUntilOcrWaitStartedAsync()
|
||||
{
|
||||
return waitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
@@ -3683,9 +3861,9 @@ public sealed class RecordingCoordinatorTests
|
||||
return Task.FromResult<IReadOnlyList<OfflineTranscriptionBacklogItem>>(Items.ToList());
|
||||
}
|
||||
|
||||
public Task CompleteAsync(string id, CancellationToken cancellationToken)
|
||||
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
Items.RemoveAll(item => string.Equals(item.Id, id, StringComparison.Ordinal));
|
||||
Items.RemoveAll(existing => string.Equals(existing.Id, item.Id, StringComparison.Ordinal));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ public sealed class ScreenshotOcrOptionsTests
|
||||
{
|
||||
Assert.Contains("exactly who is in the meeting", ScreenshotOcrOptions.DefaultPrompt);
|
||||
Assert.Contains("partial", ScreenshotOcrOptions.DefaultPrompt);
|
||||
Assert.Contains("\"attendees\"", ScreenshotOcrOptions.DefaultPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,34 @@ public sealed class TaskbarIconTests
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MenuOffersMicrophoneSubmenuWithEffectiveDeviceChecked()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(),
|
||||
[Profile("default")],
|
||||
[
|
||||
new MicrophoneDevice("integrated", "integrated microphone"),
|
||||
new MicrophoneDevice("other", "other microphone")
|
||||
],
|
||||
"integrated");
|
||||
|
||||
var microphoneMenu = Assert.Single(menu.Items, item => item.Text == "Microphone");
|
||||
|
||||
Assert.Equal(MeetingTaskbarAction.OpenSubmenu, microphoneMenu.Action);
|
||||
Assert.NotNull(microphoneMenu.Items);
|
||||
Assert.Contains(microphoneMenu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SelectMicrophone &&
|
||||
item.MicrophoneDeviceId == "integrated" &&
|
||||
item.Text == "integrated microphone" &&
|
||||
item.IsChecked);
|
||||
Assert.Contains(microphoneMenu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SelectMicrophone &&
|
||||
item.MicrophoneDeviceId == "other" &&
|
||||
item.Text == "other microphone" &&
|
||||
!item.IsChecked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordingMenuOffersStopAbortAndOtherProfileSwitches()
|
||||
{
|
||||
@@ -108,7 +136,7 @@ public sealed class TaskbarIconTests
|
||||
{
|
||||
Assert.Equal(
|
||||
requiresConfirmation,
|
||||
MeetingTaskbarExitPolicy.RequiresConfirmation(Status(state: state)));
|
||||
MeetingTaskbarExitPolicy.RequiresConfirmation(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -442,7 +442,8 @@ public sealed class WorkflowRulesEditorTests
|
||||
|
||||
Assert.Equal("summary body", await File.ReadAllTextAsync(Path.Combine(summariesRoot, "daily-summary.md")));
|
||||
Assert.Equal("one\ntwo\nthree", await File.ReadAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md")));
|
||||
var note = await File.ReadAllTextAsync(Path.Combine(notesRoot, "daily.md"));
|
||||
var note = (await File.ReadAllTextAsync(Path.Combine(notesRoot, "daily.md")))
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
Assert.Contains("title: Fixed", note);
|
||||
Assert.Contains("projects:\n- Alpha", note);
|
||||
Assert.Contains("Existing body.", note);
|
||||
@@ -721,6 +722,16 @@ public sealed class WorkflowRulesEditorTests
|
||||
Assert.Contains("read_logs", instructions);
|
||||
Assert.Contains("read_spec_file", instructions);
|
||||
Assert.Contains("list_recent_summaries", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstructionBuilderIncludesProjectSyncGuidanceForCorrectedMeetingNotes()
|
||||
{
|
||||
var builder = new WorkflowRulesEditorInstructionBuilder(
|
||||
NullLogger<WorkflowRulesEditorInstructionBuilder>.Instance);
|
||||
|
||||
var instructions = await builder.BuildAsync(new MeetingAssistantOptions(), CancellationToken.None);
|
||||
|
||||
Assert.Contains("When correcting a meeting note that has project references", instructions);
|
||||
Assert.Contains("read that project's AGENTS.md", instructions);
|
||||
Assert.Contains("new project reference", instructions);
|
||||
|
||||
Reference in New Issue
Block a user