Fixing UI thread issues with bulk operations and request queue refactoring.

This commit is contained in:
Burak Kaan Köse
2026-04-20 02:18:23 +02:00
parent 3bd0b69429
commit 54148716bb
38 changed files with 1644 additions and 206 deletions
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using FluentAssertions;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Mail;
using Wino.Core.Synchronizers;
using Wino.Messaging.UI;
using Xunit;
namespace Wino.Core.Tests.Synchronizers;
public sealed class BaseSynchronizerUiChangeTests
{
[Fact]
public void ApplyOptimisticUiChanges_UsesBundleUiChangeRequest_ForBatchBundle()
{
var folderId = Guid.NewGuid();
var account = new MailAccount { Id = Guid.NewGuid(), Name = "Test account" };
var synchronizer = new TestSynchronizer(account);
var recipient = new UiChangeRecipient();
var request1 = new MarkReadRequest(CreateMailCopy(folderId, isRead: false), IsRead: true);
var request2 = new MarkReadRequest(CreateMailCopy(folderId, isRead: false), IsRead: true);
var batchRequest = new BatchMarkReadRequest([request1, request2]);
var bundle = new HttpRequestBundle<object>(new object(), batchRequest, request1);
WeakReferenceMessenger.Default.Register<MailStateUpdatedMessage>(recipient);
WeakReferenceMessenger.Default.Register<BulkMailStateUpdatedMessage>(recipient);
try
{
synchronizer.ApplyUiChanges([bundle]);
recipient.SingleUpdates.Should().BeEmpty();
recipient.BulkUpdates.Should().ContainSingle();
recipient.BulkUpdates[0].UpdatedStates.Should().HaveCount(2);
request1.Item.IsRead.Should().BeFalse();
request2.Item.IsRead.Should().BeFalse();
}
finally
{
WeakReferenceMessenger.Default.Unregister<MailStateUpdatedMessage>(recipient);
WeakReferenceMessenger.Default.Unregister<BulkMailStateUpdatedMessage>(recipient);
}
}
private static MailCopy CreateMailCopy(Guid folderId, bool isRead) =>
new()
{
UniqueId = Guid.NewGuid(),
Id = Guid.NewGuid().ToString(),
FolderId = folderId,
IsRead = isRead
};
private sealed class TestSynchronizer : BaseSynchronizer<object>
{
public TestSynchronizer(MailAccount account)
: base(account, WeakReferenceMessenger.Default)
{
}
public void ApplyUiChanges(List<IRequestBundle<object>> bundles) => ApplyOptimisticUiChanges(bundles);
public override Task ExecuteNativeRequestsAsync(List<IRequestBundle<object>> batchedRequests, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
internal sealed class UiChangeRecipient : IRecipient<MailStateUpdatedMessage>, IRecipient<BulkMailStateUpdatedMessage>
{
public List<MailStateUpdatedMessage> SingleUpdates { get; } = [];
public List<BulkMailStateUpdatedMessage> BulkUpdates { get; } = [];
public void Receive(MailStateUpdatedMessage message) => SingleUpdates.Add(message);
public void Receive(BulkMailStateUpdatedMessage message) => BulkUpdates.Add(message);
}
}
@@ -2,12 +2,14 @@ using FluentAssertions;
using MailKit;
using MailKit.Net.Imap;
using Moq;
using System.Linq;
using System.Reflection;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Synchronizers.ImapSync;
using Wino.Services.Extensions;
using Xunit;
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
@@ -223,4 +225,61 @@ public class UnifiedImapSynchronizerTests
capturedPackage.Should().NotBeNull();
capturedPackage!.MimeMessage.Should().BeNull();
}
[Fact]
public async Task ProcessSummariesAsync_ShouldBatchStateUpdates_ForExistingMailCopies()
{
var localFolder = new MailItemFolder
{
Id = Guid.NewGuid(),
MailAccountId = Guid.NewGuid(),
FolderName = "Inbox",
RemoteFolderId = "INBOX"
};
var summaryMock = new Mock<IMessageSummary>();
summaryMock.SetupGet(x => x.UniqueId).Returns(new UniqueId(42));
summaryMock.SetupGet(x => x.Flags).Returns(MessageFlags.Seen | MessageFlags.Flagged);
var existingMailCopy = new MailCopy
{
Id = MailkitClientExtensions.CreateUid(localFolder.Id, 42),
IsRead = false,
IsFlagged = false
};
var mailServiceMock = new Mock<IMailService>();
mailServiceMock
.Setup(x => x.GetExistingMailsAsync(localFolder.Id, It.IsAny<IEnumerable<UniqueId>>()))
.ReturnsAsync([existingMailCopy]);
mailServiceMock
.Setup(x => x.ApplyMailStateUpdatesAsync(It.IsAny<IEnumerable<MailCopyStateUpdate>>()))
.Returns(Task.CompletedTask);
var sut = new UnifiedImapSynchronizer(
Mock.Of<IFolderService>(),
mailServiceMock.Object,
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
var imapSynchronizerMock = new Mock<IImapSynchronizer>();
var processMethod = typeof(UnifiedImapSynchronizer).GetMethod("ProcessSummariesAsync", BindingFlags.Instance | BindingFlags.NonPublic);
processMethod.Should().NotBeNull();
var task = (Task<List<string>>)processMethod!.Invoke(
sut,
[imapSynchronizerMock.Object, localFolder, new List<IMessageSummary> { summaryMock.Object }, CancellationToken.None])!;
var result = await task;
result.Should().BeEmpty();
mailServiceMock.Verify(
x => x.ApplyMailStateUpdatesAsync(It.Is<IEnumerable<MailCopyStateUpdate>>(updates =>
updates.Count() == 1
&& updates.First().MailCopyId == existingMailCopy.Id
&& updates.First().IsRead == true
&& updates.First().IsFlagged == true)),
Times.Once);
mailServiceMock.Verify(x => x.CreateMailAsync(It.IsAny<Guid>(), It.IsAny<NewMailItemPackage>()), Times.Never);
}
}
@@ -10,6 +10,7 @@ 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.Mail;
using Wino.Core.Requests.Folder;
using Wino.Core.Synchronizers;
using Xunit;
@@ -49,6 +50,38 @@ public sealed class WinoSynchronizerMailRequestTests
synchronizer.ExecuteNativeRequestsInvocationCount.Should().Be(1);
}
[Fact]
public async Task ExecuteRequests_should_dispatch_grouped_mark_read_requests_with_composite_grouping_key()
{
var synchronizer = new TestMailSynchronizer();
var folderId = Guid.NewGuid();
synchronizer.QueueRequest(new MarkReadRequest(new MailCopy
{
UniqueId = Guid.NewGuid(),
Id = Guid.NewGuid().ToString(),
FolderId = folderId
}, IsRead: true));
synchronizer.QueueRequest(new MarkReadRequest(new MailCopy
{
UniqueId = Guid.NewGuid(),
Id = Guid.NewGuid().ToString(),
FolderId = folderId
}, IsRead: true));
var result = await synchronizer.SynchronizeMailsAsync(new MailSynchronizationOptions
{
AccountId = synchronizer.Account.Id,
Type = MailSynchronizationType.ExecuteRequests
});
result.CompletedState.Should().Be(SynchronizationCompletedState.Success);
synchronizer.MarkReadInvocationCount.Should().Be(1);
synchronizer.LastMarkReadBatchCount.Should().Be(2);
synchronizer.ExecuteNativeRequestsInvocationCount.Should().Be(1);
}
private sealed class TestMailSynchronizer
: WinoSynchronizer<object, object, object>
{
@@ -60,6 +93,8 @@ public sealed class WinoSynchronizerMailRequestTests
public override uint BatchModificationSize => 1;
public override uint InitialMessageDownloadCountPerFolder => 0;
public int CreateRootFolderInvocationCount { get; private set; }
public int MarkReadInvocationCount { get; private set; }
public int LastMarkReadBatchCount { get; private set; }
public int ExecuteNativeRequestsInvocationCount { get; private set; }
public override List<IRequestBundle<object>> CreateRootFolder(CreateRootFolderRequest request)
@@ -68,6 +103,13 @@ public sealed class WinoSynchronizerMailRequestTests
return [new TestRequestBundle(new object(), request)];
}
public override List<IRequestBundle<object>> MarkRead(BatchMarkReadRequest request)
{
MarkReadInvocationCount++;
LastMarkReadBatchCount = request.Count;
return [new TestRequestBundle(new object(), request[0])];
}
public override Task ExecuteNativeRequestsAsync(List<IRequestBundle<object>> batchedRequests, CancellationToken cancellationToken = default)
{
ExecuteNativeRequestsInvocationCount++;