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.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 meetings) { Meetings = meetings; } public IReadOnlyList Meetings { get; set; } public int QueryCount { get; private set; } public Task> GetRecordingPromptCandidatesForDayAsync( DateOnly day, CancellationToken cancellationToken) { QueryCount++; return Task.FromResult(Meetings); } } private sealed class CapturingMeetingStartPromptService : IMeetingStartPromptService { private readonly bool autoAccept; private readonly List pendingPrompts = []; public CapturingMeetingStartPromptService(bool autoAccept) { this.autoAccept = autoAccept; } public List PromptedMeetings { get; } = []; public async Task ShowPromptAsync( MeetingStartPromptRequest request, Func 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 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 Commands { get; } = []; public List StartMetadata { get; } = []; public Task StartAsync(CancellationToken cancellationToken) { return StartRecordingAsync(null); } private Task StartRecordingAsync( MeetingMetadata? metadata) { StartCount++; Commands.Add("start"); StartMetadata.Add(metadata); CurrentStatus = Status(isRecording: true); return Task.FromResult(CurrentStatus); } public Task StartFromPromptAsync( MeetingMetadata? metadata, CancellationToken cancellationToken) { PromptStartCount++; return StartRecordingAsync(metadata); } public Task 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; } } }