Files
meeting-assistant/MeetingAssistant.Tests/DesktopUserRequestTests.cs
T
dh 728e66dd72
PR and Push Build/Test / windows-build-and-test (push) Failing after 38m25s
PR and Push Build/Test / portable-build-and-test (push) Successful in 8m9s
PR and Push Build/Test / macos-native-full (push) Skipped
ci: consolidate platform pipelines and test prerequisites on macOS support
2026-10-06 09:02:19 +02:00

199 lines
9.2 KiB
C#

using MeetingAssistant.Notifications;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeetingAssistant.Tests;
public sealed class DesktopUserRequestTests
{
[Fact]
public async Task OfferedDecisionIsListedAndDeliveredOnce()
{
using var lifetime = new TestApplicationLifetime();
using var store = CreateStore(lifetime);
var registration = store.Add(Approval(), CancellationToken.None);
var request = Assert.Single(store.List());
Assert.Equal(registration.Id, request.Id);
Assert.Equal("Associate Alpha?", request.Title);
Assert.Equal(["yes", "no"], request.Actions.Select(action => action.Id));
Assert.False(registration.Decision.IsCompleted);
Assert.Equal(DesktopUserRequestRespondResult.Accepted, await store.RespondAsync(request.Id, "yes"));
Assert.Equal("yes", await registration.Decision);
Assert.Empty(store.List());
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(request.Id, "yes"));
}
[Fact]
public async Task UnsupportedActionLeavesDecisionPending()
{
using var lifetime = new TestApplicationLifetime();
using var store = CreateStore(lifetime);
var registration = store.Add(Approval(), CancellationToken.None);
Assert.Equal(DesktopUserRequestRespondResult.InvalidAction, await store.RespondAsync(registration.Id, "delete"));
Assert.Single(store.List());
Assert.False(registration.Decision.IsCompleted);
Assert.Equal(DesktopUserRequestRespondResult.Accepted, await store.RespondAsync(registration.Id, "no"));
Assert.Equal("no", await registration.Decision);
}
[Fact]
public async Task CanceledOriginRemovesDecisionAndRejectsLateResponse()
{
using var lifetime = new TestApplicationLifetime();
using var store = CreateStore(lifetime);
using var origin = new CancellationTokenSource();
var invoked = false;
var registration = store.Add(Approval(), origin.Token, (_, _) =>
{
invoked = true;
return Task.CompletedTask;
});
await origin.CancelAsync();
Assert.Empty(store.List());
Assert.Null(await registration.Decision.WaitAsync(TimeSpan.FromSeconds(2)));
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes"));
Assert.False(invoked);
}
[Fact]
public async Task DeadlineResolvesUnansweredDecisionWithoutPolling()
{
using var lifetime = new TestApplicationLifetime();
var clock = new ManualDesktopRequestTimeProvider();
using var store = new DesktopUserRequestStore(clock, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
var registration = store.Add(Approval(), CancellationToken.None);
Assert.Equal(DateTimeOffset.Parse("2026-10-03T10:10:00Z"), Assert.Single(store.List()).ExpiresAt);
clock.Advance(TimeSpan.FromMinutes(10));
Assert.True(registration.Decision.IsCompleted);
Assert.Null(await registration.Decision);
Assert.Empty(store.List());
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes"));
}
[Fact]
public async Task KindWithdrawalPreservesOtherQuestionsAndShutdownRemovesThem()
{
using var lifetime = new TestApplicationLifetime();
using var store = CreateStore(lifetime);
var approval = store.Add(Approval(), CancellationToken.None);
var warning = store.Add(Approval() with { Kind = "meeting-inactivity" }, CancellationToken.None);
store.DismissKind("meeting-inactivity");
Assert.Equal(approval.Id, Assert.Single(store.List()).Id);
Assert.Null(await warning.Decision.WaitAsync(TimeSpan.FromSeconds(2)));
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(warning.Id, "yes"));
lifetime.StopApplication();
Assert.Empty(store.List());
Assert.Null(await approval.Decision.WaitAsync(TimeSpan.FromSeconds(2)));
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(approval.Id, "yes"));
}
[Fact]
public async Task FailedActionIsConsumedAndReportedWithoutReplayingWork()
{
using var lifetime = new TestApplicationLifetime();
using var store = CreateStore(lifetime);
var registration = store.Add(Approval(), CancellationToken.None,
(_, _) => throw new InvalidOperationException("Test action could not finish."));
Assert.Equal(DesktopUserRequestRespondResult.Failed, await store.RespondAsync(registration.Id, "yes"));
Assert.Null(await registration.Decision);
Assert.Empty(store.List());
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes"));
}
[Fact]
public async Task PendingListExcludesDeadlinesBeforeTimerDispatch()
{
using var lifetime = new TestApplicationLifetime();
var clock = new ManualDesktopRequestTimeProvider();
using var store = new DesktopUserRequestStore(clock, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
var registration = store.Add(Approval(), CancellationToken.None);
clock.Advance(TimeSpan.FromMinutes(10), fireTimers: false);
Assert.Empty(store.List());
Assert.Null(await registration.Decision);
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "yes"));
}
[Fact]
public async Task SimultaneousPresentationsUseTheOriginLifetimeAndPerformOneAction()
{
using var lifetime = new TestApplicationLifetime();
using var origin = new CancellationTokenSource();
using var store = CreateStore(lifetime);
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var actionCount = 0;
CancellationToken observedToken = default;
var registration = store.Add(Approval(), origin.Token, async (_, token) =>
{
Interlocked.Increment(ref actionCount);
observedToken = token;
started.TrySetResult();
await finish.Task.WaitAsync(token);
});
var firstResponse = store.RespondAsync(registration.Id, "yes");
await started.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(DesktopUserRequestRespondResult.NoLongerPending, await store.RespondAsync(registration.Id, "no"));
Assert.Equal(origin.Token, observedToken);
finish.SetResult();
Assert.Equal(DesktopUserRequestRespondResult.Accepted, await firstResponse);
Assert.Equal("yes", await registration.Decision);
Assert.Equal(1, actionCount);
}
private static DesktopUserRequestSpec Approval() => new(
"agent-approval", "Associate Alpha?", "The meeting discusses Alpha.", TimeSpan.FromMinutes(10),
[new("yes", "Yes"), new("no", "No")]);
private static DesktopUserRequestStore CreateStore(TestApplicationLifetime lifetime) => new(
TimeProvider.System, lifetime, NullLogger<DesktopUserRequestStore>.Instance);
}
internal sealed class TestApplicationLifetime : IHostApplicationLifetime, IDisposable
{
private readonly CancellationTokenSource stopping = new();
public CancellationToken ApplicationStarted => CancellationToken.None;
public CancellationToken ApplicationStopping => stopping.Token;
public CancellationToken ApplicationStopped => stopping.Token;
public void StopApplication() => stopping.Cancel();
public void Dispose() => stopping.Dispose();
}
internal sealed class ManualDesktopRequestTimeProvider : TimeProvider
{
private DateTimeOffset now = DateTimeOffset.Parse("2026-10-03T10:00:00Z");
private readonly List<ManualTimer> timers = [];
public override DateTimeOffset GetUtcNow() => now;
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new ManualTimer(this, callback, state);
timers.Add(timer);
timer.Change(dueTime, period);
return timer;
}
public void Advance(TimeSpan elapsed, bool fireTimers = true)
{
now += elapsed;
if (!fireTimers) return;
foreach (var timer in timers.ToArray()) timer.FireIfDue();
}
private sealed class ManualTimer(ManualDesktopRequestTimeProvider owner, TimerCallback callback, object? state) : ITimer
{
private DateTimeOffset due;
private bool disposed;
public bool Change(TimeSpan dueTime, TimeSpan period)
{
due = dueTime == Timeout.InfiniteTimeSpan ? DateTimeOffset.MaxValue : owner.now + dueTime;
return !disposed;
}
public void FireIfDue()
{
if (disposed || due > owner.now) return;
due = DateTimeOffset.MaxValue;
callback(state);
}
public void Dispose() => disposed = true;
public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
}
}