Public Access
641 lines
26 KiB
C#
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(";
|
|
}
|
|
|
|
[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(";
|
|
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 .");
|
|
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()
|
|
{
|
|
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
|