Files
meeting-assistant/MeetingAssistant.Tests/CalendarRecordingPromptSchedulerTests.cs
T
codex aecef30627
PR and Push Build/Test / build-and-test (push) Successful in 9m19s
Archive completed meeting assistant changes
2026-06-26 13:15:47 +02:00

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; }
}
}