using MeetingAssistant.MeetingNotes; using MeetingAssistant.Screenshots; using Microsoft.Extensions.Logging.Abstractions; 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 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) { Options = options; Artifacts = artifacts; ArtifactStore = artifactStore; } public MeetingAssistantOptions Options { get; } public MeetingSessionArtifacts Artifacts { get; } public MarkdownMeetingArtifactStore ArtifactStore { get; } public static async Task CreateAsync( Action? configure = null) { 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 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.Instance); 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 }; await artifactStore.CreateAssistantContextAsync( artifacts, meeting, "", null, CancellationToken.None); return new ScreenshotFixture(options, artifacts, artifactStore); } public MeetingScreenshotService CreateService( IActiveWindowScreenshotCapture capture, IScreenshotOcrClient ocrClient) { return new MeetingScreenshotService( capture, ArtifactStore, ocrClient, NullLogger.Instance); } } private sealed class FixedScreenshotCapture : IActiveWindowScreenshotCapture { private readonly byte[] bytes; public FixedScreenshotCapture(byte[] bytes) { this.bytes = bytes; } public Task 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) { result = new ScreenshotOcrResult(text, crop); } public int CallCount { get; private set; } public string? Prompt { get; private set; } public Task ExtractAsync( string screenshotPath, string prompt, MeetingAssistantOptions options, CancellationToken cancellationToken) { CallCount++; Prompt = prompt; return Task.FromResult(result); } } private sealed class BlockingScreenshotOcrClient : IScreenshotOcrClient { private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource result = new(TaskCreationOptions.RunContinuationsAsynchronously); public async Task 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 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(); } } #pragma warning restore CA1416