Public Access
406 lines
14 KiB
C#
406 lines
14 KiB
C#
using MeetingAssistant.Calendar;
|
|
using MeetingAssistant.MeetingNotes;
|
|
using MeetingAssistant.Recording;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace MeetingAssistant.Tests;
|
|
|
|
public sealed class CalendarRecordingPromptSchedulerTests
|
|
{
|
|
[Fact]
|
|
public async Task DueTeamsMeetingPromptStartsRecordingWhenAccepted()
|
|
{
|
|
var harness = CreateHarness(isRecording: false);
|
|
var meeting = CreateMeeting(harness.Clock);
|
|
harness.Provider.Meetings = [meeting];
|
|
|
|
await SyncThenPromptAsync(harness, meeting);
|
|
|
|
Assert.Equal(1, harness.Provider.QueryCount);
|
|
Assert.Collection(harness.PromptService.PromptedMeetings, prompted => Assert.Same(meeting, prompted));
|
|
Assert.Equal(1, harness.Recorder.StartCount);
|
|
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()
|
|
{
|
|
var harness = CreateHarness(isRecording: true);
|
|
var meeting = CreateMeeting(harness.Clock);
|
|
harness.Provider.Meetings = [meeting];
|
|
|
|
await SyncThenPromptAsync(harness, meeting);
|
|
|
|
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()
|
|
{
|
|
var harness = CreateHarness(enabled: false);
|
|
|
|
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(0, harness.Provider.QueryCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DueMeetingPromptsOnlyOncePerDay()
|
|
{
|
|
var harness = CreateHarness();
|
|
var meeting = CreateMeeting(harness.Clock);
|
|
harness.Provider.Meetings = [meeting];
|
|
|
|
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
|
|
harness.Clock.Now = meeting.Start;
|
|
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
|
|
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
|
|
|
|
Assert.Single(harness.PromptService.PromptedMeetings);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedMeetingPromptDoesNotQueryOutlookAgainAtMeetingStart()
|
|
{
|
|
var harness = CreateHarness();
|
|
var meeting = CreateMeeting(harness.Clock);
|
|
harness.Provider.Meetings = [meeting];
|
|
|
|
await SyncThenPromptAsync(harness, meeting);
|
|
|
|
Assert.Equal(1, harness.Provider.QueryCount);
|
|
Assert.Single(harness.PromptService.PromptedMeetings);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedDayMeetingsPromptBackToBackMeetingsWithoutInterveningOutlookSync()
|
|
{
|
|
var harness = CreateHarness(isRecording: false);
|
|
var firstMeeting = CreateMeeting(
|
|
harness.Clock,
|
|
id: "teams-1",
|
|
subject: "First sync",
|
|
startOffset: TimeSpan.FromMinutes(30),
|
|
duration: TimeSpan.FromMinutes(10));
|
|
var secondMeeting = CreateMeeting(
|
|
harness.Clock,
|
|
id: "teams-2",
|
|
subject: "Second sync",
|
|
startOffset: TimeSpan.FromMinutes(40),
|
|
duration: TimeSpan.FromMinutes(10));
|
|
harness.Provider.Meetings = [firstMeeting, secondMeeting];
|
|
|
|
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
|
|
harness.Clock.Now = firstMeeting.Start;
|
|
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
|
|
harness.Clock.Now = secondMeeting.Start;
|
|
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(1, harness.Provider.QueryCount);
|
|
Assert.Equal([firstMeeting, secondMeeting], harness.PromptService.PromptedMeetings);
|
|
Assert.Equal(["start", "stop", "start"], harness.Recorder.Commands);
|
|
}
|
|
|
|
private static async Task SyncThenPromptAsync(
|
|
SchedulerHarness harness,
|
|
CalendarMeeting meeting)
|
|
{
|
|
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
|
|
harness.Clock.Now = meeting.Start;
|
|
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
|
|
}
|
|
|
|
private static CalendarMeeting CreateMeeting(
|
|
ManualCalendarPromptClock clock,
|
|
string id = "teams-1",
|
|
string subject = "Project sync",
|
|
TimeSpan? startOffset = 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)),
|
|
metadata,
|
|
isCanceled);
|
|
}
|
|
|
|
private static SchedulerHarness CreateHarness(
|
|
bool isRecording = false,
|
|
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 CapturingMeetingStartPromptService(autoAcceptPrompts);
|
|
var recorder = new RecordingPromptRecorder(isRecording);
|
|
var scheduler = new CalendarRecordingPromptScheduler(
|
|
Options.Create(new MeetingAssistantOptions
|
|
{
|
|
CalendarRecordingPrompts =
|
|
{
|
|
Enabled = enabled,
|
|
SyncInterval = TimeSpan.FromMinutes(30),
|
|
PromptWindow = TimeSpan.FromMinutes(5)
|
|
}
|
|
}),
|
|
provider,
|
|
promptService,
|
|
recorder,
|
|
clock,
|
|
NullLogger<CalendarRecordingPromptScheduler>.Instance);
|
|
|
|
return new SchedulerHarness(scheduler, provider, promptService, recorder, clock);
|
|
}
|
|
|
|
private sealed record SchedulerHarness(
|
|
CalendarRecordingPromptScheduler Scheduler,
|
|
RecordingCalendarMeetingProvider Provider,
|
|
CapturingMeetingStartPromptService PromptService,
|
|
RecordingPromptRecorder Recorder,
|
|
ManualCalendarPromptClock Clock);
|
|
|
|
private sealed class RecordingCalendarMeetingProvider : ICalendarMeetingProvider
|
|
{
|
|
public RecordingCalendarMeetingProvider(IReadOnlyList<CalendarMeeting> meetings)
|
|
{
|
|
Meetings = meetings;
|
|
}
|
|
|
|
public IReadOnlyList<CalendarMeeting> Meetings { get; set; }
|
|
|
|
public int QueryCount { get; private set; }
|
|
|
|
public Task<IReadOnlyList<CalendarMeeting>> GetRecordingPromptCandidatesForDayAsync(
|
|
DateOnly day,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
QueryCount++;
|
|
return Task.FromResult(Meetings);
|
|
}
|
|
}
|
|
|
|
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(
|
|
MeetingStartPromptRequest request,
|
|
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
PromptedMeetings.Add(request.Meeting);
|
|
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
|
|
{
|
|
public RecordingPromptRecorder(bool isRecording)
|
|
{
|
|
CurrentStatus = Status(isRecording);
|
|
}
|
|
|
|
public RecordingStatus CurrentStatus { get; private set; }
|
|
|
|
public int StartCount { get; private set; }
|
|
|
|
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++;
|
|
Commands.Add("stop");
|
|
CurrentStatus = Status(isRecording: false);
|
|
return Task.FromResult(CurrentStatus);
|
|
}
|
|
|
|
private static RecordingStatus Status(bool isRecording)
|
|
{
|
|
return new RecordingStatus(
|
|
isRecording,
|
|
isRecording ? "transcript.md" : null,
|
|
isRecording ? "meeting.md" : null,
|
|
isRecording ? "context.md" : null,
|
|
isRecording ? "summary.md" : null,
|
|
isRecording ? RecordingProcessState.Recording : RecordingProcessState.Idle,
|
|
isRecording ? "default" : null);
|
|
}
|
|
}
|
|
|
|
private sealed class ManualCalendarPromptClock : ICalendarPromptClock
|
|
{
|
|
public ManualCalendarPromptClock(DateTimeOffset now)
|
|
{
|
|
Now = now;
|
|
}
|
|
|
|
public DateTimeOffset Now { get; set; }
|
|
}
|
|
}
|