Public Access
feat(macos): enable meeting integrations
PR and Push Build/Test / build-and-test (pull_request) Failing after 19m49s
PR and Push Build/Test / build-and-test (pull_request) Failing after 19m49s
This commit is contained in:
@@ -2,8 +2,6 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
@@ -284,15 +282,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
|
||||
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();
|
||||
return PngTestFixture.Solid(width, height);
|
||||
}
|
||||
|
||||
private static string CreateNonStreamingTextResponse(string text)
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
using System.Net.Http.Json;
|
||||
using MeetingAssistant.Calendar;
|
||||
using MeetingAssistant.MacOs;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MacOsMeetingIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void PortableRegistrationUsesRealMacOsProvidersOnlyOnMacOs()
|
||||
{
|
||||
var macServices = new ServiceCollection().AddLogging();
|
||||
var unsupportedServices = new ServiceCollection().AddLogging();
|
||||
|
||||
MacOsMeetingIntegrationRegistration.Add(macServices, isMacOs: true);
|
||||
MacOsMeetingIntegrationRegistration.Add(unsupportedServices, isMacOs: false);
|
||||
|
||||
using var macProvider = macServices.BuildServiceProvider();
|
||||
using var unsupportedProvider = unsupportedServices.BuildServiceProvider();
|
||||
|
||||
Assert.IsType<MacOsMeetingMetadataProvider>(
|
||||
macProvider.GetRequiredService<IMeetingMetadataProvider>());
|
||||
Assert.IsType<MacOsCalendarMeetingProvider>(
|
||||
macProvider.GetRequiredService<ICalendarMeetingProvider>());
|
||||
Assert.IsType<MacOsMeetingStartPromptService>(
|
||||
macProvider.GetRequiredService<IMeetingStartPromptService>());
|
||||
Assert.IsType<MacOsDesktopIntegrationClient>(
|
||||
macProvider.GetRequiredService<IActiveWindowScreenshotCapture>());
|
||||
Assert.IsType<MacOsDesktopIntegrationClient>(
|
||||
macProvider.GetRequiredService<IScreenshotImageCropper>());
|
||||
|
||||
Assert.IsType<NoopMeetingMetadataProvider>(
|
||||
unsupportedProvider.GetRequiredService<IMeetingMetadataProvider>());
|
||||
Assert.IsType<NoopCalendarMeetingProvider>(
|
||||
unsupportedProvider.GetRequiredService<ICalendarMeetingProvider>());
|
||||
Assert.IsType<NoopMeetingStartPromptService>(
|
||||
unsupportedProvider.GetRequiredService<IMeetingStartPromptService>());
|
||||
Assert.IsType<UnavailableActiveWindowScreenshotCapture>(
|
||||
unsupportedProvider.GetRequiredService<IActiveWindowScreenshotCapture>());
|
||||
Assert.IsType<UnavailableScreenshotImageCropper>(
|
||||
unsupportedProvider.GetRequiredService<IScreenshotImageCropper>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MacOsCalendarProvidersReturnTeamsMetadataAndPromptCandidate()
|
||||
{
|
||||
var startedAt = new DateTimeOffset(2026, 7, 25, 10, 5, 0, TimeSpan.FromHours(2));
|
||||
var calendarEvent = new MacOsCalendarEvent(
|
||||
"event-1",
|
||||
"Project sync",
|
||||
startedAt.AddMinutes(-5),
|
||||
startedAt.AddMinutes(55),
|
||||
"Microsoft Teams",
|
||||
"Discuss launch readiness.\nJoin Microsoft Teams Meeting",
|
||||
"https://teams.microsoft.com/l/meetup-join/example",
|
||||
["Ada Lovelace <ada@example.com>", "Grace Hopper <grace@example.com>"],
|
||||
IsCanceled: false);
|
||||
var client = new StubMacOsDesktopIntegrationClient(calendarEvent);
|
||||
IMeetingMetadataProvider metadataProvider = new MacOsMeetingMetadataProvider(
|
||||
client,
|
||||
NullLogger<MacOsMeetingMetadataProvider>.Instance);
|
||||
ICalendarMeetingProvider calendarProvider = new MacOsCalendarMeetingProvider(
|
||||
client,
|
||||
NullLogger<MacOsCalendarMeetingProvider>.Instance);
|
||||
|
||||
var metadata = await metadataProvider.GetCurrentMeetingAsync(
|
||||
startedAt,
|
||||
CancellationToken.None);
|
||||
var candidates = await calendarProvider.GetRecordingPromptCandidatesForDayAsync(
|
||||
DateOnly.FromDateTime(startedAt.Date),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("Project sync", metadata.Title);
|
||||
Assert.Equal(
|
||||
["Ada Lovelace <ada@example.com>", "Grace Hopper <grace@example.com>"],
|
||||
metadata.Attendees);
|
||||
Assert.Equal("Discuss launch readiness.", metadata.Agenda);
|
||||
Assert.Equal(calendarEvent.End, metadata.ScheduledEnd);
|
||||
|
||||
var candidate = Assert.Single(candidates);
|
||||
Assert.Equal("event-1", candidate.Id);
|
||||
Assert.Equal("Project sync", candidate.Subject);
|
||||
Assert.NotNull(candidate.Metadata);
|
||||
Assert.Equal(metadata.Title, candidate.Metadata.Title);
|
||||
Assert.Equal(metadata.Attendees, candidate.Metadata.Attendees);
|
||||
Assert.Equal(metadata.Agenda, candidate.Metadata.Agenda);
|
||||
Assert.Equal(metadata.ScheduledEnd, candidate.Metadata.ScheduledEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MacOsPromptAndScreenshotUseNativeClientResults()
|
||||
{
|
||||
var screenshot = new ActiveWindowScreenshot([137, 80, 78, 71], "Meeting deck");
|
||||
var client = new StubMacOsDesktopIntegrationClient(
|
||||
screenshot: screenshot,
|
||||
promptResponse: MeetingStartPromptResponse.Record);
|
||||
IMeetingStartPromptService prompt = new MacOsMeetingStartPromptService(
|
||||
client,
|
||||
NullLogger<MacOsMeetingStartPromptService>.Instance);
|
||||
IActiveWindowScreenshotCapture capture = client;
|
||||
IScreenshotImageCropper cropper = client;
|
||||
MeetingStartPromptResponse? callbackResponse = null;
|
||||
var request = new MeetingStartPromptRequest(new CalendarMeeting(
|
||||
"event-1",
|
||||
"Project sync",
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now.AddHours(1)));
|
||||
|
||||
await prompt.ShowPromptAsync(
|
||||
request,
|
||||
(response, _) =>
|
||||
{
|
||||
callbackResponse = response;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
CancellationToken.None);
|
||||
var captured = await capture.CaptureAsync(CancellationToken.None);
|
||||
var croppedPath = await cropper.SaveCroppedScreenshotAsync(
|
||||
"meeting.png",
|
||||
new ScreenshotCropCoordinates(1, 2, 3, 4),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MeetingStartPromptResponse.Record, callbackResponse);
|
||||
Assert.Equal(screenshot, captured);
|
||||
Assert.Equal("meeting.png.cropped.png", croppedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MacOsCapabilityEndpointReportsEnabledRealProviders()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var factory = new WebApplicationFactory<Program>();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var report = await client.GetFromJsonAsync<PlatformCapabilityReport>(
|
||||
"/diagnostics/platform-capabilities");
|
||||
|
||||
Assert.NotNull(report);
|
||||
Assert.Equal("macOS", report.Host);
|
||||
Assert.Equal(nameof(MacOsMeetingMetadataProvider), report.MeetingMetadataProvider);
|
||||
Assert.Equal(nameof(MacOsCalendarMeetingProvider), report.CalendarMeetingProvider);
|
||||
Assert.Equal(nameof(MacOsMeetingStartPromptService), report.MeetingStartPromptProvider);
|
||||
Assert.Equal(nameof(MacOsDesktopIntegrationClient), report.ScreenshotProvider);
|
||||
Assert.Equal(nameof(MacOsWorkflowRulesEditorWindowService), report.WorkflowEditorProvider);
|
||||
Assert.True(report.WorkflowEditorAvailable);
|
||||
Assert.True(report.CalendarRecordingPromptsEnabled);
|
||||
Assert.True(report.SpeakerIdentificationEnabled);
|
||||
Assert.True(report.ScreenshotOcrEnabled);
|
||||
Assert.True(report.FunAsrBackendEnabled);
|
||||
Assert.True(report.FunAsrDiarizationEnabled);
|
||||
Assert.True(report.WhisperLocalDiarizationEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeHelperAdvertisesCalendarPromptAndScreenshotFeatures()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var runner = new ProcessCommandRunner(
|
||||
NullLogger<ProcessCommandRunner>.Instance);
|
||||
|
||||
var result = await runner.RunAsync(
|
||||
GetNativeHelperPath(),
|
||||
["self-test"],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
using var json = System.Text.Json.JsonDocument.Parse(result.StandardOutput);
|
||||
Assert.Equal("ok", json.RootElement.GetProperty("status").GetString());
|
||||
Assert.Equal(
|
||||
["calendar", "prompt", "screenshot", "crop"],
|
||||
json.RootElement.GetProperty("features")
|
||||
.EnumerateArray()
|
||||
.Select(feature => feature.GetString()!)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NativeHelperCropsPngUsingOcrPixelCoordinates()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var testRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"meeting-assistant-native-crop-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(testRoot);
|
||||
var bitmapPath = Path.Combine(testRoot, "input.bmp");
|
||||
var inputPath = Path.Combine(testRoot, "input.png");
|
||||
var outputPath = Path.Combine(testRoot, "output.png");
|
||||
var outputBitmapPath = Path.Combine(testRoot, "output.bmp");
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(
|
||||
bitmapPath,
|
||||
CreateTopRedBottomBlueBitmap());
|
||||
var runner = new ProcessCommandRunner(
|
||||
NullLogger<ProcessCommandRunner>.Instance);
|
||||
var inputConversion = await runner.RunAsync(
|
||||
"/usr/bin/sips",
|
||||
["-s", "format", "png", bitmapPath, "--out", inputPath],
|
||||
CancellationToken.None);
|
||||
Assert.Equal(0, inputConversion.ExitCode);
|
||||
|
||||
var result = await runner.RunAsync(
|
||||
GetNativeHelperPath(),
|
||||
["crop", inputPath, outputPath, "0", "0", "2", "1"],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
var png = await File.ReadAllBytesAsync(outputPath);
|
||||
Assert.Equal((2, 1), PngTestFixture.ReadDimensions(png));
|
||||
var outputConversion = await runner.RunAsync(
|
||||
"/usr/bin/sips",
|
||||
["-s", "format", "bmp", outputPath, "--out", outputBitmapPath],
|
||||
CancellationToken.None);
|
||||
Assert.Equal(0, outputConversion.ExitCode);
|
||||
Assert.Equal(
|
||||
(Red: (byte)255, Green: (byte)0, Blue: (byte)0),
|
||||
ReadFirstBitmapPixel(await File.ReadAllBytesAsync(outputBitmapPath)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalendarProviderUsesActualLocalMidnightsAcrossDaylightSavingTransition()
|
||||
{
|
||||
if (TimeZoneInfo.Local.Id != "Europe/Berlin")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var client = new StubMacOsDesktopIntegrationClient();
|
||||
var provider = new MacOsCalendarMeetingProvider(
|
||||
client,
|
||||
NullLogger<MacOsCalendarMeetingProvider>.Instance);
|
||||
|
||||
await provider.GetRecordingPromptCandidatesForDayAsync(
|
||||
new DateOnly(2026, 3, 29),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2026-03-29T00:00:00+01:00"),
|
||||
client.CalendarFrom);
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2026-03-30T00:00:00+02:00"),
|
||||
client.CalendarTo);
|
||||
Assert.Equal(TimeSpan.FromHours(23), client.CalendarTo - client.CalendarFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalendarClientFallsBackToCalendarAutomationWhenEventKitIsDenied()
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var runner = new SequenceCommandRunner(
|
||||
new CommandResult(
|
||||
MacOsIntegrationException.CalendarPermissionDeniedExitCode,
|
||||
"",
|
||||
"Calendar Full Access permission is required."),
|
||||
new CommandResult(
|
||||
0,
|
||||
"""
|
||||
[{"id":"fallback-event","subject":"Project sync","start":"2026-07-25T08:00:00Z","end":"2026-07-25T09:00:00Z","location":"Microsoft Teams","notes":"","url":"https://teams.microsoft.com/example","attendees":[],"isCanceled":false}]
|
||||
""",
|
||||
""));
|
||||
IMacOsCalendarClient client = new MacOsDesktopIntegrationClient(
|
||||
runner,
|
||||
Path.GetDirectoryName(GetNativeHelperPath())!);
|
||||
|
||||
var events = await client.GetCalendarEventsAsync(
|
||||
DateTimeOffset.Parse("2026-07-25T00:00:00Z"),
|
||||
DateTimeOffset.Parse("2026-07-26T00:00:00Z"),
|
||||
CancellationToken.None);
|
||||
|
||||
var calendarEvent = Assert.Single(events);
|
||||
Assert.Equal("fallback-event", calendarEvent.Id);
|
||||
Assert.Collection(
|
||||
runner.Commands,
|
||||
command => Assert.Equal("calendar", command.Arguments[0]),
|
||||
command =>
|
||||
{
|
||||
Assert.Equal("/usr/bin/osascript", command.FileName);
|
||||
Assert.Equal(["-l", "JavaScript"], command.Arguments.Take(2));
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetNativeHelperPath()
|
||||
{
|
||||
var configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent?.Name ?? "Debug";
|
||||
return Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"MeetingAssistant",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0",
|
||||
"Native",
|
||||
"macos-meeting-integrations"));
|
||||
}
|
||||
|
||||
private static byte[] CreateTopRedBottomBlueBitmap()
|
||||
{
|
||||
var bitmap = new byte[70];
|
||||
bitmap[0] = (byte)'B';
|
||||
bitmap[1] = (byte)'M';
|
||||
BitConverter.GetBytes(bitmap.Length).CopyTo(bitmap, 2);
|
||||
BitConverter.GetBytes(54).CopyTo(bitmap, 10);
|
||||
BitConverter.GetBytes(40).CopyTo(bitmap, 14);
|
||||
BitConverter.GetBytes(2).CopyTo(bitmap, 18);
|
||||
BitConverter.GetBytes(2).CopyTo(bitmap, 22);
|
||||
BitConverter.GetBytes((short)1).CopyTo(bitmap, 26);
|
||||
BitConverter.GetBytes((short)24).CopyTo(bitmap, 28);
|
||||
BitConverter.GetBytes(16).CopyTo(bitmap, 34);
|
||||
|
||||
// BMP rows are bottom-up and padded to four-byte boundaries.
|
||||
bitmap[54] = 255;
|
||||
bitmap[57] = 255;
|
||||
bitmap[64] = 255;
|
||||
bitmap[67] = 255;
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static (byte Red, byte Green, byte Blue) ReadFirstBitmapPixel(byte[] bitmap)
|
||||
{
|
||||
var pixelOffset = BitConverter.ToInt32(bitmap, 10);
|
||||
return (
|
||||
Red: bitmap[pixelOffset + 2],
|
||||
Green: bitmap[pixelOffset + 1],
|
||||
Blue: bitmap[pixelOffset]);
|
||||
}
|
||||
|
||||
private sealed class StubMacOsDesktopIntegrationClient :
|
||||
IMacOsCalendarClient,
|
||||
IMacOsMeetingPromptClient,
|
||||
IActiveWindowScreenshotCapture,
|
||||
IScreenshotImageCropper
|
||||
{
|
||||
private readonly IReadOnlyList<MacOsCalendarEvent> events;
|
||||
private readonly ActiveWindowScreenshot screenshot;
|
||||
private readonly MeetingStartPromptResponse promptResponse;
|
||||
|
||||
public StubMacOsDesktopIntegrationClient(
|
||||
MacOsCalendarEvent? calendarEvent = null,
|
||||
ActiveWindowScreenshot? screenshot = null,
|
||||
MeetingStartPromptResponse promptResponse = MeetingStartPromptResponse.Skip)
|
||||
{
|
||||
events = calendarEvent is null ? [] : [calendarEvent];
|
||||
this.screenshot = screenshot ?? new ActiveWindowScreenshot([], null);
|
||||
this.promptResponse = promptResponse;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MacOsCalendarEvent>> GetCalendarEventsAsync(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CalendarFrom = from;
|
||||
CalendarTo = to;
|
||||
return Task.FromResult(events);
|
||||
}
|
||||
|
||||
public DateTimeOffset CalendarFrom { get; private set; }
|
||||
|
||||
public DateTimeOffset CalendarTo { get; private set; }
|
||||
|
||||
public Task<MeetingStartPromptResponse> ShowMeetingStartPromptAsync(
|
||||
MeetingStartPromptRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(promptResponse);
|
||||
}
|
||||
|
||||
public Task<ActiveWindowScreenshot> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(screenshot);
|
||||
}
|
||||
|
||||
public Task<string?> SaveCroppedScreenshotAsync(
|
||||
string screenshotPath,
|
||||
ScreenshotCropCoordinates crop,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<string?>($"{screenshotPath}.cropped.png");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SequenceCommandRunner : ICommandRunner
|
||||
{
|
||||
private readonly Queue<CommandResult> results;
|
||||
|
||||
public SequenceCommandRunner(params CommandResult[] results)
|
||||
{
|
||||
this.results = new Queue<CommandResult>(results);
|
||||
}
|
||||
|
||||
public List<(string FileName, IReadOnlyList<string> Arguments)> Commands { get; } = [];
|
||||
|
||||
public Task<CommandResult> RunAsync(
|
||||
string fileName,
|
||||
IReadOnlyList<string> arguments,
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyDictionary<string, string>? environment = null)
|
||||
{
|
||||
Commands.Add((fileName, arguments));
|
||||
return Task.FromResult(results.Dequeue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ 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
|
||||
|
||||
@@ -117,11 +115,7 @@ public sealed class MeetingScreenshotServiceTests
|
||||
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);
|
||||
}
|
||||
Assert.Equal((2, 2), PngTestFixture.ReadDimensions(await File.ReadAllBytesAsync(croppedPath)));
|
||||
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
var screenshotIndex = context.IndexOf("![Screenshot 00:00:10]", StringComparison.Ordinal);
|
||||
@@ -133,6 +127,38 @@ public sealed class MeetingScreenshotServiceTests
|
||||
Assert.Contains("Shared screen text", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureKeepsSuccessfulOcrTextWhenCropperRejectsCoordinates()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
});
|
||||
var ocr = new CapturingScreenshotOcrClient(
|
||||
"Readable text survives.",
|
||||
new ScreenshotCropCoordinates(99, 99, 10, 10));
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture(CreatePngBytes(4, 4)),
|
||||
ocr,
|
||||
screenshotImageCropper: new RejectingScreenshotImageCropper());
|
||||
|
||||
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);
|
||||
Assert.Contains("Readable text survives.", context);
|
||||
Assert.DoesNotContain("_OCR failed", context);
|
||||
Assert.DoesNotContain("![Cropped screenshot]", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAddsOcrAttendeesToMeetingNoteThroughCanonicalizer()
|
||||
{
|
||||
@@ -454,7 +480,8 @@ public sealed class MeetingScreenshotServiceTests
|
||||
IActiveWindowScreenshotCapture capture,
|
||||
IScreenshotOcrClient ocrClient,
|
||||
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
|
||||
IScreenshotImageCropper? screenshotImageCropper = null)
|
||||
{
|
||||
return new MeetingScreenshotService(
|
||||
capture,
|
||||
@@ -462,12 +489,28 @@ public sealed class MeetingScreenshotServiceTests
|
||||
NoteStore,
|
||||
attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance,
|
||||
ocrClient,
|
||||
screenshotImageCropper ??
|
||||
capture as IScreenshotImageCropper ??
|
||||
new UnavailableScreenshotImageCropper(),
|
||||
NullLogger<MeetingScreenshotService>.Instance,
|
||||
meetingWorkflowEngine);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedScreenshotCapture : IActiveWindowScreenshotCapture
|
||||
private sealed class RejectingScreenshotImageCropper : IScreenshotImageCropper
|
||||
{
|
||||
public Task<string?> SaveCroppedScreenshotAsync(
|
||||
string screenshotPath,
|
||||
ScreenshotCropCoordinates crop,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidDataException("The crop is outside image bounds.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedScreenshotCapture :
|
||||
IActiveWindowScreenshotCapture,
|
||||
IScreenshotImageCropper
|
||||
{
|
||||
private readonly byte[] bytes;
|
||||
|
||||
@@ -480,6 +523,21 @@ public sealed class MeetingScreenshotServiceTests
|
||||
{
|
||||
return Task.FromResult(new ActiveWindowScreenshot(bytes, "Demo Window"));
|
||||
}
|
||||
|
||||
public async Task<string?> SaveCroppedScreenshotAsync(
|
||||
string screenshotPath,
|
||||
ScreenshotCropCoordinates crop,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var croppedPath = Path.Combine(
|
||||
Path.GetDirectoryName(screenshotPath)!,
|
||||
$"{Path.GetFileNameWithoutExtension(screenshotPath)}-cropped.png");
|
||||
await File.WriteAllBytesAsync(
|
||||
croppedPath,
|
||||
PngTestFixture.Solid(2, 2),
|
||||
cancellationToken);
|
||||
return croppedPath;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingScreenshotOcrClient : IScreenshotOcrClient
|
||||
@@ -614,15 +672,7 @@ public sealed class MeetingScreenshotServiceTests
|
||||
|
||||
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();
|
||||
return PngTestFixture.Solid(width, height);
|
||||
}
|
||||
|
||||
private static string ExtractScreenshotOcrId(string context)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
internal static class PngTestFixture
|
||||
{
|
||||
private const string Solid2X2 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAAqADAAQAAAABAAAAAgAAAADO0J6QAAAAEklEQVQIHWP8DwQMQMAEIkAAAD34BACALvQ5AAAAAElFTkSuQmCC";
|
||||
private const string Solid4X4 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAABKADAAQAAAABAAAABAAAAADmpNw4AAAAFUlEQVQIHWP8DwQMSIAJiQ1mEhYAAAZdBAQjjGwcAAAAAElFTkSuQmCC";
|
||||
private const string Solid8X6 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAGCAYAAAD+Bd/7AAAAAXNSR0IArs4c6QAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAACKADAAQAAAABAAAABgAAAAAJfOcJAAAAFklEQVQIHWP8DwQMeAATHjmw1GBQAAAQiQQIVmhjoQAAAABJRU5ErkJggg==";
|
||||
|
||||
public static byte[] Solid(int width, int height)
|
||||
{
|
||||
var encoded = (width, height) switch
|
||||
{
|
||||
(2, 2) => Solid2X2,
|
||||
(4, 4) => Solid4X4,
|
||||
(8, 6) => Solid8X6,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(width),
|
||||
$"No deterministic PNG fixture exists for {width}x{height}.")
|
||||
};
|
||||
return Convert.FromBase64String(encoded);
|
||||
}
|
||||
|
||||
public static (int Width, int Height) ReadDimensions(byte[] png)
|
||||
{
|
||||
if (!png.AsSpan(0, 8).SequenceEqual(
|
||||
new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }))
|
||||
{
|
||||
throw new InvalidDataException("The fixture is not a PNG.");
|
||||
}
|
||||
|
||||
return (
|
||||
System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(16, 4)),
|
||||
System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(20, 4)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user