using MeetingAssistant.Calendar; 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 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 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) { var start = clock.Now.Add(startOffset ?? TimeSpan.FromMinutes(30)); return new CalendarMeeting( id, subject, start, start.Add(duration ?? TimeSpan.FromMinutes(30))); } private static SchedulerHarness CreateHarness( bool isRecording = false, bool enabled = true) { var clock = new ManualCalendarPromptClock(new DateTimeOffset(2026, 6, 3, 9, 30, 0, TimeSpan.Zero)); var provider = new RecordingCalendarMeetingProvider([]); var promptService = new AcceptingMeetingStartPromptService(); 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, AcceptingMeetingStartPromptService 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 AcceptingMeetingStartPromptService : IMeetingStartPromptService { public List PromptedMeetings { get; } = []; public async Task ShowPromptAsync( MeetingStartPromptRequest request, Func handleResponseAsync, CancellationToken cancellationToken) { PromptedMeetings.Add(request.Meeting); await handleResponseAsync(MeetingStartPromptResponse.Record, cancellationToken); } } 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 List Commands { get; } = []; public Task StartAsync(CancellationToken cancellationToken) { StartCount++; Commands.Add("start"); CurrentStatus = Status(isRecording: true); return Task.FromResult(CurrentStatus); } 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; } } }