using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Headless; using Avalonia.Threading; using Avalonia.VisualTree; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Navigation; using Omika.App.ViewModels; using Omika.App.Views; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Mobile.Sync; using Xunit; namespace Omika.Tests.Mobile; /// /// Vue de composition d'un courriel : la liste des destinataires affiche l'ORIGINE /// de chaque contact (« Intervention » ou « Téléphone »), afin qu'un contact /// personnel du carnet d'adresses ne soit jamais confondu avec un contact de /// l'intervention. Le texte d'aide annonce les deux sources. /// /// /// Session headless partagée au niveau du processus (localisateur Avalonia global) : /// la classe rejoint la collection « ApplicationRuntimeContext ». /// [Collection("ApplicationRuntimeContext")] public sealed class NouveauEmailViewContactsTests : IDisposable { private readonly HeadlessUnitTestSession _session = HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder)); public void Dispose() => _session.Dispose(); private void Executer(Action scenario) => _session.Dispatch( () => { scenario(); return true; }, CancellationToken.None).GetAwaiter().GetResult(); /// /// Un contact du TÉLÉPHONE affiche son origine : le badge « Téléphone » est /// visible et le badge « Intervention » ne l'est pas. /// [Fact] public void ContactTelephone_AfficheLEBadgeTelephone() => Executer(() => { NouveauEmailView vue = Ouvrir(); Window fenetre = RendreOption(vue, new EmailContactOption("Zoé Martin", "zoe@perso.fr", OrigineContactEmail.Telephone)); try { Assert.True(Badge(fenetre, "Téléphone"), "Le badge d'origine « Téléphone » doit être visible."); Assert.False( Badge(fenetre, "Intervention"), "Le badge « Intervention » ne doit pas être affiché pour un contact du téléphone."); Assert.True( Texte(fenetre, "Zoé Martin — zoe@perso.fr"), "Le nom du contact et son adresse email doivent rester affichés."); } finally { fenetre.Close(); } }); /// Un contact de l'INTERVENTION affiche l'origine « Intervention ». [Fact] public void ContactIntervention_AfficheLEBadgeIntervention() => Executer(() => { NouveauEmailView vue = Ouvrir(); Window fenetre = RendreOption(vue, new EmailContactOption("Alice Dupont", "alice@test.fr", OrigineContactEmail.Intervention)); try { Assert.True(Badge(fenetre, "Intervention"), "Le badge d'origine « Intervention » doit être visible."); Assert.False( Badge(fenetre, "Téléphone"), "Le badge « Téléphone » ne doit pas être affiché pour un contact de l'intervention."); } finally { fenetre.Close(); } }); /// Le texte d'aide de la liste annonce les deux sources et l'origine affichée. [Fact] public void Liste_ExpliqueLesDeuxSourcesEtLOrigine() => Executer(() => { NouveauEmailView vue = Ouvrir(); Window fenetre = Fenetre(vue); try { TextBlock aide = vue.GetVisualDescendants() .OfType() .Single(t => t.Text?.StartsWith("Contacts de l'intervention et contacts du téléphone", StringComparison.Ordinal) == true); Assert.True(aide.IsEffectivelyVisible, "L'explication des deux sources de contacts n'est pas affichée."); } finally { fenetre.Close(); } }); // ===================================================================== // Helpers // ===================================================================== /// Vrai si un texte visible porte exactement ce libellé. private static bool Texte(Window fenetre, string texte) => fenetre.GetVisualDescendants() .OfType() .Any(t => t.Text == texte && t.IsEffectivelyVisible); /// Vrai si un badge visible porte exactement ce libellé d'origine. private static bool Badge(Window fenetre, string libelle) => Texte(fenetre, libelle); /// /// Rend UNE entrée de la liste avec le gabarit réellement utilisé par les /// ComboBox de la vue (« ContactEmailOptionTemplate ») : c'est ce rendu qui /// affiche l'origine à côté du nom. /// private static Window RendreOption(NouveauEmailView vue, EmailContactOption option) { Assert.True( vue.TryFindResource("ContactEmailOptionTemplate", out object? ressource), "Le gabarit d'affichage des contacts est introuvable."); var gabarit = Assert.IsAssignableFrom(ressource); Control rendu = Assert.IsAssignableFrom(gabarit.Build(option)); // Build ne fixe pas le DataContext (c'est le presenter qui le fait) : on le // fixe ici pour que les liaisons de la ligne (libellé + badge d'origine) // résolvent réellement. rendu.DataContext = option; return Fenetre(rendu); } private static NouveauEmailView Ouvrir() { var modele = new NouveauEmailViewModel( new FakeInterventionService(), new FakeContactsService(), new FakeDocumentService(), new NavigationService(), new FakeSyncEngine(), Omika.Core.Observability.NullTelemetryService.Instance); modele.InterventionGuid = Guid.NewGuid(); modele.ContactsDisponibles = new ObservableCollection { new("Alice Dupont", "alice@test.fr", OrigineContactEmail.Intervention), new("Zoé Martin", "zoe@perso.fr", OrigineContactEmail.Telephone) }; // Étape 2 : c'est elle qui contient la liste des destinataires. modele.Etape = 2; Dispatcher.UIThread.RunJobs(); return new NouveauEmailView { DataContext = modele }; } private static Window Fenetre(Control contenu) { var fenetre = new Window { Width = 400, Height = 900, RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Light, Content = contenu }; fenetre.Show(); Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs(); return fenetre; } // ===================================================================== // Fakes // ===================================================================== private sealed class FakeContactsService : IInterventionContactsService { public Task> GetContactsForInterventionAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task GetContactByIdAsync( Guid contactGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetContactsDisponiblesAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); } private sealed class FakeInterventionService : IInterventionLocalService { public Task> GetMailChampsAsync( GetRapportChampsRequest request, CancellationToken ct = default) => Task.FromResult>([]); public Task GetEmailModeleApercuAsync( CreateEmailModeleRequest request, CancellationToken ct = default) => Task.FromResult(null); public Task> GetSmtpConfigurationsAsync( CancellationToken ct = default) => Task.FromResult>([]); public Task AddEmailAsync( Guid interventionGuid, CreateEmailInterventionRequest request, CancellationToken ct = default) => Task.FromResult(new EmailInterventionDto { Id = Guid.NewGuid().ToString() }); public Task CreateAsync(InterventionSyncPayload payload, CancellationToken ct = default) => throw new NotImplementedException(); public Task UpdateAsync(Guid interventionGuid, InterventionSyncPayload payload, CancellationToken ct = default) => throw new NotImplementedException(); public Task ArchiveAsync(Guid interventionGuid, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetInterventionByIdAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetInterventionsAsync(CancellationToken ct = default) => Task.FromResult>([]); } private sealed class FakeDocumentService : IDocumentLocalService { public Task> GetDocumentsForInterventionAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task EnsureDownloadedAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(true); public Task OpenAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(true); public Task GetCachedFilePathAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetNotesForDocumentAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task AddNoteAsync(Guid documentGuid, string contenu, CancellationToken ct = default) => Task.FromResult(new DocumentNoteItem()); public Task AddPhotoAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task AddPhotosAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(new AddPhotosResult()); public Task AddPhotoFromCameraAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task AddDocumentAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task AddAnnotatedPhotoAsync( Guid interventionGuid, Guid sourceDocumentGuid, byte[] pngBytes, string fileName, CancellationToken ct = default) => Task.FromResult(null); } private sealed class FakeSyncEngine : ISyncEngine { public Task SyncAsync(CancellationToken cancellationToken = default) => Task.FromResult(new SyncCycleResult()); public Task PushAsync(CancellationToken cancellationToken = default) => Task.FromResult(new SyncCycleResult()); } }