Files

641 lines
26 KiB
C#

using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Screenshots;
using MeetingAssistant.Speakers;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Drawing;
using System.Drawing.Imaging;
#pragma warning disable CA1416
namespace MeetingAssistant.Tests;
public sealed class MeetingScreenshotServiceTests
{
[Fact]
public async Task CaptureSavesScreenshotAndAppendsTimestampedLink()
{
var fixture = await ScreenshotFixture.CreateAsync();
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
new CapturingScreenshotOcrClient());
var result = await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:03:05+02:00"),
fixture.Options,
CancellationToken.None);
Assert.True(File.Exists(result.ScreenshotPath));
Assert.Equal([1, 2, 3], await File.ReadAllBytesAsync(result.ScreenshotPath));
Assert.StartsWith(
Path.Combine(Path.GetDirectoryName(fixture.Artifacts.AssistantContextPath)!, "Attachments"),
result.ScreenshotPath,
StringComparison.OrdinalIgnoreCase);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("## Screenshot [00:03:05]", context);
Assert.Contains("![Screenshot 00:03:05](Attachments/", context);
}
[Fact]
public async Task CaptureSkipsOcrWhenOcrIsDisabled()
{
var fixture = await ScreenshotFixture.CreateAsync();
var ocr = new CapturingScreenshotOcrClient();
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
await service.CaptureAsync(
fixture.Artifacts,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
DateTimeOffset.Parse("2026-05-26T10:00:05+02:00"),
fixture.Options,
CancellationToken.None);
Assert.Equal(0, ocr.CallCount);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.DoesNotContain("OCR", context);
}
[Fact]
public async Task CaptureAppendsOcrResultAfterScreenshotWhenConfigured()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
options.Screenshots.Ocr.Prompt = "Extract this meeting screenshot.";
});
var ocr = new CapturingScreenshotOcrClient("Slide text\n\n```mermaid\ngraph TD\nA-->B\n```");
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
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);
Assert.Equal(1, ocr.CallCount);
Assert.Equal("Extract this meeting screenshot.", ocr.Prompt);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
var imageIndex = context.IndexOf("![Screenshot 00:00:10]", StringComparison.Ordinal);
var ocrIndex = context.IndexOf("### OCR", StringComparison.Ordinal);
Assert.True(imageIndex >= 0);
Assert.True(ocrIndex > imageIndex);
Assert.Contains("Slide text", context);
Assert.Contains("```mermaid", context);
}
[Fact]
public async Task CaptureSavesCroppedImageAndLinksItBeforeOcrWhenOcrReturnsCropCoordinates()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var ocr = new CapturingScreenshotOcrClient(
"Shared screen text",
new ScreenshotCropCoordinates(1, 1, 2, 2));
var service = fixture.CreateService(
new FixedScreenshotCapture(CreatePngBytes(4, 4)),
ocr);
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 croppedPath = Assert.Single(Directory.GetFiles(
Path.Combine(Path.GetDirectoryName(fixture.Artifacts.AssistantContextPath)!, "Attachments"),
"*-cropped.png"));
using (var cropped = new Bitmap(croppedPath))
{
Assert.Equal(2, cropped.Width);
Assert.Equal(2, cropped.Height);
}
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
var screenshotIndex = context.IndexOf("![Screenshot 00:00:10]", StringComparison.Ordinal);
var cropIndex = context.IndexOf("![Cropped screenshot](Attachments/", StringComparison.Ordinal);
var ocrIndex = context.IndexOf("### OCR", StringComparison.Ordinal);
Assert.True(screenshotIndex >= 0);
Assert.True(cropIndex > screenshotIndex);
Assert.True(ocrIndex > cropIndex);
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 CaptureTransformsOcrAttendeesBeforeWritingMeetingNote()
{
var fixture = await ScreenshotFixture.CreateAsync(
options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace");
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
new CapturingScreenshotOcrClient(
"Visible participant tile: Ada.",
attendees: ["Ada Lovelace (Contoso)"]),
meetingWorkflowEngine: workflowEngine);
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"], meeting.Frontmatter.Attendees);
Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests);
}
[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 ![Diagram](attachments/diagram.png).");
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: `![Diagram](attachments/diagram.png)`", 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()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var ocr = new BlockingScreenshotOcrClient();
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
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 ocr.WaitUntilStartedAsync();
var wait = service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
Assert.False(wait.IsCompleted);
ocr.Release("OCR complete.");
await wait;
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("OCR complete.", context);
}
[Fact]
public async Task CancelPendingOcrCancelsRunningOcrWithoutWritingFailureText()
{
var fixture = await ScreenshotFixture.CreateAsync(options =>
{
options.Screenshots.Ocr.Enabled = true;
});
var ocr = new BlockingScreenshotOcrClient();
var service = fixture.CreateService(
new FixedScreenshotCapture([1, 2, 3]),
ocr);
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 ocr.WaitUntilStartedAsync();
await service.CancelPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.Contains("_OCR pending..._", context);
Assert.DoesNotContain("OCR timed out", context);
Assert.DoesNotContain("OCR failed", context);
}
private sealed class ScreenshotFixture
{
private ScreenshotFixture(
MeetingAssistantOptions options,
MeetingSessionArtifacts artifacts,
MarkdownMeetingArtifactStore artifactStore,
MarkdownMeetingNoteStore noteStore)
{
Options = options;
Artifacts = artifacts;
ArtifactStore = artifactStore;
NoteStore = noteStore;
}
public MeetingAssistantOptions Options { get; }
public MeetingSessionArtifacts Artifacts { get; }
public MarkdownMeetingArtifactStore ArtifactStore { get; }
public MarkdownMeetingNoteStore NoteStore { get; }
public static async Task<ScreenshotFixture> CreateAsync(
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
{
Vault = new VaultOptions
{
BaseFolder = root,
AssistantContextFolder = "Context"
}
};
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"),
Path.Combine(root, "Context", "context.md"),
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"),
transcriptPath: artifacts.TranscriptPath,
assistantContextPath: artifacts.AssistantContextPath,
summaryPath: artifacts.SummaryPath) with
{
Path = artifacts.MeetingNotePath,
UserNotes = userNotes
};
meeting.Frontmatter.Attendees = attendees?.ToList() ?? [];
var savedMeeting = await noteStore.SaveAsync(
meeting,
options,
CancellationToken.None);
await artifactStore.CreateAssistantContextAsync(
artifacts,
savedMeeting,
"",
null,
CancellationToken.None);
return new ScreenshotFixture(options, artifacts, artifactStore, noteStore);
}
public MeetingScreenshotService CreateService(
IActiveWindowScreenshotCapture capture,
IScreenshotOcrClient ocrClient,
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
{
return new MeetingScreenshotService(
capture,
ArtifactStore,
NoteStore,
attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance,
ocrClient,
NullLogger<MeetingScreenshotService>.Instance,
meetingWorkflowEngine);
}
}
private sealed class FixedScreenshotCapture : IActiveWindowScreenshotCapture
{
private readonly byte[] bytes;
public FixedScreenshotCapture(byte[] bytes)
{
this.bytes = bytes;
}
public Task<ActiveWindowScreenshot> CaptureAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new ActiveWindowScreenshot(bytes, "Demo Window"));
}
}
private sealed class CapturingScreenshotOcrClient : IScreenshotOcrClient
{
private readonly ScreenshotOcrResult result;
public CapturingScreenshotOcrClient(
string text = "",
ScreenshotCropCoordinates? crop = null,
IReadOnlyList<string>? attendees = null)
{
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,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
CallCount++;
Prompt = prompt;
ScreenshotPaths.Add(screenshotPath);
return Task.FromResult(result);
}
}
private sealed class BlockingScreenshotOcrClient : IScreenshotOcrClient
{
private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<ScreenshotOcrResult> result = new(TaskCreationOptions.RunContinuationsAsynchronously);
public async Task<ScreenshotOcrResult> ExtractAsync(
string screenshotPath,
string prompt,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
started.TrySetResult();
await using var registration = cancellationToken.Register(() => result.TrySetCanceled(cancellationToken));
return await result.Task;
}
public Task WaitUntilStartedAsync()
{
return started.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void Release(string value)
{
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);
}
}
private static byte[] CreatePngBytes(int width, int height)
{
using var bitmap = new Bitmap(width, height);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.Clear(Color.White);
}
using var stream = new MemoryStream();
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