Immidiate ui reflection for calendar events and some more error handling.
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.Collections;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using FluentAssertions;
|
||||
using Itenso.TimePeriod;
|
||||
using Moq;
|
||||
@@ -10,9 +11,12 @@ using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Calendar.ViewModels.Interfaces;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Navigation;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests;
|
||||
@@ -155,19 +159,140 @@ public class CalendarPageViewModelTests
|
||||
calendarService.Verify(service => service.GetCalendarEventsAsync(It.Is<IAccountCalendar>(calendar => calendar.Id == hiddenCalendar.Id), It.IsAny<ITimePeriod>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalendarItemAddedMessage_AddsVisibleItemWithoutReloadAndMarksBusy()
|
||||
{
|
||||
var settings = CreateSettings();
|
||||
var preferencesService = CreatePreferencesService(settings);
|
||||
var calendarService = new Mock<ICalendarService>();
|
||||
|
||||
var account = CreateAccount();
|
||||
var calendar = CreateCalendar(account, "Calendar");
|
||||
var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar);
|
||||
var existingItem = CreateCalendarItem(calendar.Id, new DateTime(2026, 3, 20, 9, 0, 0), "Existing");
|
||||
|
||||
calendarService
|
||||
.Setup(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
|
||||
.ReturnsAsync([existingItem]);
|
||||
|
||||
var viewModel = CreateViewModel(
|
||||
calendarService.Object,
|
||||
preferencesService.Object,
|
||||
new DateOnly(2026, 3, 20),
|
||||
new FakeAccountCalendarStateService([accountCalendarViewModel]));
|
||||
|
||||
viewModel.OnNavigatedTo(NavigationMode.New, null!);
|
||||
|
||||
try
|
||||
{
|
||||
await viewModel.ApplyDisplayRequestAsync(new CalendarDisplayRequest(CalendarDisplayType.Day, new DateOnly(2026, 3, 20)));
|
||||
|
||||
var optimisticItem = CreateCalendarItem(calendar.Id, new DateTime(2026, 3, 20, 10, 0, 0), "Optimistic");
|
||||
optimisticItem.AssignedCalendar = accountCalendarViewModel;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(optimisticItem, EntityUpdateSource.ClientUpdated));
|
||||
|
||||
viewModel.CalendarItems.Should().HaveCount(2);
|
||||
viewModel.CalendarItems.Should().Contain(item => item.Id == optimisticItem.Id && item.IsBusy);
|
||||
calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()), Times.Once);
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.OnNavigatedFrom(NavigationMode.Back, null!);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalendarItemDeletedMessage_RemovesVisibleItemWithoutReload()
|
||||
{
|
||||
var settings = CreateSettings();
|
||||
var preferencesService = CreatePreferencesService(settings);
|
||||
var calendarService = new Mock<ICalendarService>();
|
||||
|
||||
var account = CreateAccount();
|
||||
var calendar = CreateCalendar(account, "Calendar");
|
||||
var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar);
|
||||
var existingItem = CreateCalendarItem(calendar.Id, new DateTime(2026, 3, 20, 9, 0, 0), "Existing");
|
||||
|
||||
calendarService
|
||||
.Setup(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
|
||||
.ReturnsAsync([existingItem]);
|
||||
|
||||
var viewModel = CreateViewModel(
|
||||
calendarService.Object,
|
||||
preferencesService.Object,
|
||||
new DateOnly(2026, 3, 20),
|
||||
new FakeAccountCalendarStateService([accountCalendarViewModel]));
|
||||
|
||||
viewModel.OnNavigatedTo(NavigationMode.New, null!);
|
||||
|
||||
try
|
||||
{
|
||||
await viewModel.ApplyDisplayRequestAsync(new CalendarDisplayRequest(CalendarDisplayType.Day, new DateOnly(2026, 3, 20)));
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(existingItem, EntityUpdateSource.ClientUpdated));
|
||||
|
||||
viewModel.CalendarItems.Should().BeEmpty();
|
||||
calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()), Times.Once);
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.OnNavigatedFrom(NavigationMode.Back, null!);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalendarItemAddedMessage_ReconcilesTrackedLocalPreviewInPlace()
|
||||
{
|
||||
var settings = CreateSettings();
|
||||
var preferencesService = CreatePreferencesService(settings);
|
||||
var calendarService = new Mock<ICalendarService>();
|
||||
|
||||
var account = CreateAccount();
|
||||
var calendar = CreateCalendar(account, "Calendar");
|
||||
var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar);
|
||||
var localPreview = CreateCalendarItem(calendar.Id, new DateTime(2026, 3, 20, 9, 0, 0), "Local preview");
|
||||
|
||||
calendarService
|
||||
.Setup(service => service.GetCalendarEventsAsync(It.IsAny<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
|
||||
.ReturnsAsync([localPreview]);
|
||||
|
||||
var viewModel = CreateViewModel(
|
||||
calendarService.Object,
|
||||
preferencesService.Object,
|
||||
new DateOnly(2026, 3, 20),
|
||||
new FakeAccountCalendarStateService([accountCalendarViewModel]));
|
||||
|
||||
viewModel.OnNavigatedTo(NavigationMode.New, null!);
|
||||
|
||||
try
|
||||
{
|
||||
await viewModel.ApplyDisplayRequestAsync(new CalendarDisplayRequest(CalendarDisplayType.Day, new DateOnly(2026, 3, 20)));
|
||||
|
||||
var syncedItem = CreateCalendarItem(calendar.Id, localPreview.StartDate, "Synced");
|
||||
syncedItem.RemoteEventId = "remote-event-id".WithClientTrackingId(localPreview.Id);
|
||||
syncedItem.AssignedCalendar = accountCalendarViewModel;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(syncedItem, EntityUpdateSource.Server));
|
||||
|
||||
viewModel.CalendarItems.Should().ContainSingle();
|
||||
viewModel.CalendarItems[0].Id.Should().Be(syncedItem.Id);
|
||||
viewModel.CalendarItems[0].Title.Should().Be("Synced");
|
||||
viewModel.CalendarItems[0].IsBusy.Should().BeFalse();
|
||||
viewModel.CalendarItems.Should().NotContain(item => item.Id == localPreview.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.OnNavigatedFrom(NavigationMode.Back, null!);
|
||||
}
|
||||
}
|
||||
|
||||
private static CalendarPageViewModel CreateViewModel(
|
||||
ICalendarService calendarService,
|
||||
IPreferencesService preferencesService,
|
||||
DateOnly today)
|
||||
{
|
||||
var account = new MailAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Primary",
|
||||
SenderName = "Primary",
|
||||
Address = "primary@example.com",
|
||||
ProviderType = MailProviderType.Outlook
|
||||
};
|
||||
var account = CreateAccount();
|
||||
|
||||
var calendar = CreateCalendar(account, "Calendar");
|
||||
var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar);
|
||||
@@ -217,6 +342,26 @@ public class CalendarPageViewModelTests
|
||||
IsSynchronizationEnabled = true
|
||||
};
|
||||
|
||||
private static MailAccount CreateAccount()
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Primary",
|
||||
SenderName = "Primary",
|
||||
Address = "primary@example.com",
|
||||
ProviderType = MailProviderType.Outlook
|
||||
};
|
||||
|
||||
private static CalendarItem CreateCalendarItem(Guid calendarId, DateTime startDate, string title)
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarId = calendarId,
|
||||
StartDate = startDate,
|
||||
DurationInSeconds = TimeSpan.FromMinutes(30).TotalSeconds,
|
||||
Title = title
|
||||
};
|
||||
|
||||
private static Mock<IPreferencesService> CreatePreferencesService(CalendarSettings settings)
|
||||
=> CreatePreferencesService(() => settings);
|
||||
|
||||
|
||||
@@ -32,8 +32,10 @@ public sealed class CreateCalendarEventRequestTests
|
||||
|
||||
recipient.Added.Should().ContainSingle();
|
||||
recipient.Deleted.Should().ContainSingle();
|
||||
recipient.Added[0].Id.Should().Be(request.LocalCalendarItemId!.Value);
|
||||
recipient.Deleted[0].Id.Should().Be(request.LocalCalendarItemId!.Value);
|
||||
recipient.Added[0].CalendarItem.Id.Should().Be(request.LocalCalendarItemId!.Value);
|
||||
recipient.Deleted[0].CalendarItem.Id.Should().Be(request.LocalCalendarItemId!.Value);
|
||||
recipient.Added[0].Source.Should().Be(EntityUpdateSource.ClientUpdated);
|
||||
recipient.Deleted[0].Source.Should().Be(EntityUpdateSource.ClientReverted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -117,11 +119,11 @@ public sealed class CreateCalendarEventRequestTests
|
||||
IRecipient<CalendarItemAdded>,
|
||||
IRecipient<CalendarItemDeleted>
|
||||
{
|
||||
public List<CalendarItem> Added { get; } = [];
|
||||
public List<CalendarItem> Deleted { get; } = [];
|
||||
public List<CalendarItemAdded> Added { get; } = [];
|
||||
public List<CalendarItemDeleted> Deleted { get; } = [];
|
||||
|
||||
public void Receive(CalendarItemAdded message) => Added.Add(message.CalendarItem);
|
||||
public void Receive(CalendarItemAdded message) => Added.Add(message);
|
||||
|
||||
public void Receive(CalendarItemDeleted message) => Deleted.Add(message.CalendarItem);
|
||||
public void Receive(CalendarItemDeleted message) => Deleted.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ public sealed class MailRequestStateTests
|
||||
|
||||
mailCopy.IsRead.Should().BeFalse();
|
||||
recipient.Updated.Should().HaveCount(2);
|
||||
recipient.Updated[0].Source.Should().Be(MailUpdateSource.ClientUpdated);
|
||||
recipient.Updated[1].Source.Should().Be(MailUpdateSource.ClientReverted);
|
||||
recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated);
|
||||
recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted);
|
||||
recipient.Updated[1].UpdatedMail.IsRead.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
@@ -56,8 +56,8 @@ public sealed class MailRequestStateTests
|
||||
|
||||
mailCopy.IsFlagged.Should().BeFalse();
|
||||
recipient.Updated.Should().HaveCount(2);
|
||||
recipient.Updated[0].Source.Should().Be(MailUpdateSource.ClientUpdated);
|
||||
recipient.Updated[1].Source.Should().Be(MailUpdateSource.ClientReverted);
|
||||
recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated);
|
||||
recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted);
|
||||
recipient.Updated[1].UpdatedMail.IsFlagged.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using FluentAssertions;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Services;
|
||||
|
||||
public class SynchronizationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Mail_result_merge_issues_should_mark_success_as_partial_and_set_exception()
|
||||
{
|
||||
var result = MailSynchronizationResult.Completed([]);
|
||||
var issues = new[]
|
||||
{
|
||||
new SynchronizationIssue
|
||||
{
|
||||
Message = "Create event failed",
|
||||
OperationType = "RequestExecution",
|
||||
Severity = SynchronizerErrorSeverity.Fatal
|
||||
}
|
||||
};
|
||||
|
||||
result.MergeIssues(issues);
|
||||
|
||||
result.CompletedState.Should().Be(SynchronizationCompletedState.PartiallyCompleted);
|
||||
result.Issues.Should().ContainSingle();
|
||||
result.Exception.Should().NotBeNull();
|
||||
result.Exception!.Message.Should().Be("Create event failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calendar_result_merge_issues_should_mark_success_as_partial_and_preserve_issue()
|
||||
{
|
||||
var result = CalendarSynchronizationResult.Empty;
|
||||
var issues = new[]
|
||||
{
|
||||
new SynchronizationIssue
|
||||
{
|
||||
Message = "Calendar API rate limit",
|
||||
OperationType = "CalendarSync",
|
||||
Severity = SynchronizerErrorSeverity.Transient
|
||||
}
|
||||
};
|
||||
|
||||
result.MergeIssues(issues);
|
||||
|
||||
result.CompletedState.Should().Be(SynchronizationCompletedState.PartiallyCompleted);
|
||||
result.Issues.Should().ContainSingle(issue => issue.Message == "Calendar API rate limit");
|
||||
result.Exception.Should().NotBeNull();
|
||||
result.Exception!.Message.Should().Be("Calendar API rate limit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Error_factory_should_record_handled_metadata_on_context()
|
||||
{
|
||||
var factory = new SynchronizerErrorHandlingFactory();
|
||||
factory.RegisterHandler(new TestErrorHandler());
|
||||
|
||||
var context = new SynchronizerErrorContext
|
||||
{
|
||||
ErrorMessage = "Handled sync error"
|
||||
};
|
||||
|
||||
var handled = await factory.HandleErrorAsync(context);
|
||||
|
||||
handled.Should().BeTrue();
|
||||
context.WasHandled.Should().BeTrue();
|
||||
context.HandledBy.Should().Be(nameof(TestErrorHandler));
|
||||
}
|
||||
|
||||
private sealed class TestErrorHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
public bool CanHandle(SynchronizerErrorContext error) => true;
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using FluentAssertions;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Calendar;
|
||||
using Wino.Core.Synchronizers;
|
||||
using Wino.Messaging.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public sealed class WinoSynchronizerCalendarRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Calendar_request_failure_should_complete_actions_and_reset_state()
|
||||
{
|
||||
var recipient = new SynchronizationActionsCompletedRecipient();
|
||||
WeakReferenceMessenger.Default.RegisterAll(recipient);
|
||||
|
||||
try
|
||||
{
|
||||
var synchronizer = new TestCalendarSynchronizer(throwDuringRequestExecution: true);
|
||||
var calendarItemId = Guid.NewGuid();
|
||||
|
||||
synchronizer.QueueRequest(new DeleteCalendarEventRequest(new CalendarItem { Id = calendarItemId }));
|
||||
|
||||
var result = await synchronizer.SynchronizeCalendarEventsAsync(new CalendarSynchronizationOptions
|
||||
{
|
||||
AccountId = synchronizer.Account.Id,
|
||||
Type = CalendarSynchronizationType.ExecuteRequests
|
||||
});
|
||||
|
||||
result.CompletedState.Should().Be(SynchronizationCompletedState.Failed);
|
||||
synchronizer.State.Should().Be(AccountSynchronizerState.Idle);
|
||||
synchronizer.GetPendingCalendarOperationIds().Should().BeEmpty();
|
||||
recipient.CompletedAccountIds.Should().ContainSingle().Which.Should().Be(synchronizer.Account.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WeakReferenceMessenger.Default.UnregisterAll(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Calendar_request_success_should_complete_actions_and_reset_state()
|
||||
{
|
||||
var recipient = new SynchronizationActionsCompletedRecipient();
|
||||
WeakReferenceMessenger.Default.RegisterAll(recipient);
|
||||
|
||||
try
|
||||
{
|
||||
var synchronizer = new TestCalendarSynchronizer(throwDuringRequestExecution: false);
|
||||
var calendarItemId = Guid.NewGuid();
|
||||
|
||||
synchronizer.QueueRequest(new DeleteCalendarEventRequest(new CalendarItem { Id = calendarItemId }));
|
||||
|
||||
var result = await synchronizer.SynchronizeCalendarEventsAsync(new CalendarSynchronizationOptions
|
||||
{
|
||||
AccountId = synchronizer.Account.Id,
|
||||
Type = CalendarSynchronizationType.ExecuteRequests
|
||||
});
|
||||
|
||||
result.CompletedState.Should().Be(SynchronizationCompletedState.Success);
|
||||
synchronizer.State.Should().Be(AccountSynchronizerState.Idle);
|
||||
synchronizer.GetPendingCalendarOperationIds().Should().BeEmpty();
|
||||
recipient.CompletedAccountIds.Should().ContainSingle().Which.Should().Be(synchronizer.Account.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WeakReferenceMessenger.Default.UnregisterAll(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SynchronizationActionsCompletedRecipient : IRecipient<SynchronizationActionsCompleted>
|
||||
{
|
||||
public List<Guid> CompletedAccountIds { get; } = [];
|
||||
|
||||
public void Receive(SynchronizationActionsCompleted message) => CompletedAccountIds.Add(message.AccountId);
|
||||
}
|
||||
|
||||
private sealed class TestCalendarSynchronizer : WinoSynchronizer<object, object, object>
|
||||
{
|
||||
private readonly bool _throwDuringRequestExecution;
|
||||
|
||||
public TestCalendarSynchronizer(bool throwDuringRequestExecution)
|
||||
: base(new MailAccount { Id = Guid.NewGuid(), Name = "Test account" }, WeakReferenceMessenger.Default)
|
||||
{
|
||||
_throwDuringRequestExecution = throwDuringRequestExecution;
|
||||
}
|
||||
|
||||
public override uint BatchModificationSize => 1;
|
||||
public override uint InitialMessageDownloadCountPerFolder => 0;
|
||||
|
||||
public override Task ExecuteNativeRequestsAsync(List<IRequestBundle<object>> batchedRequests, CancellationToken cancellationToken = default)
|
||||
=> _throwDuringRequestExecution
|
||||
? Task.FromException(new InvalidOperationException("Calendar request execution failed."))
|
||||
: Task.CompletedTask;
|
||||
|
||||
public override List<IRequestBundle<object>> DeleteCalendarEvent(DeleteCalendarEventRequest request)
|
||||
=> [new TestRequestBundle(new object(), request)];
|
||||
|
||||
public override Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(
|
||||
object message,
|
||||
Wino.Core.Domain.Entities.Mail.MailItemFolder assignedFolder,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new List<NewMailItemPackage>());
|
||||
|
||||
protected override Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(MailSynchronizationResult.Empty);
|
||||
|
||||
protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(CalendarSynchronizationResult.Empty);
|
||||
}
|
||||
|
||||
private sealed class TestRequestBundle : IRequestBundle<object>
|
||||
{
|
||||
public TestRequestBundle(object nativeRequest, IRequestBase request)
|
||||
{
|
||||
NativeRequest = nativeRequest;
|
||||
Request = request;
|
||||
}
|
||||
|
||||
public string BundleId { get; set; } = Guid.NewGuid().ToString();
|
||||
public IUIChangeRequest UIChangeRequest => Request;
|
||||
public object NativeRequest { get; }
|
||||
public IRequestBase Request { get; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user