using System.Net.Http; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Navigation; using Omika.App.Services; using Omika.App.ViewModels; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Mobile.Interventions; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Observability; namespace Omika.Tests.Mobile.Local; /// /// Tests du ViewModel Interventions (modèle offline-first réel) : recherche à /// la demande, dernière recherche, favoris, onglets Récentes/Épinglées, lookup /// humain Client/Site/Collaborateur. /// /// Couvre aussi la politique d'erreur de la recherche : un échec réseau — ticket /// de production du 14/10, « Connection failure » — est un état hors ligne /// journalisé en Warning/P2, jamais un incident Error/P1. /// [Collection("TelemetrieStatique")] public class InterventionsViewModelTests { private static InterventionsViewModel CreateVm(IInterventionLocalService service) => new InterventionsViewModel( service, new FakeParametreMetierService(), new NavigationService(), new PermissionService(), Omika.Core.Observability.NullTelemetryService.Instance); [Fact] public async Task Load_SansRecherche_ListeVide() { var service = new FakeInterventionService(); var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); Assert.False(vm.HasItems); Assert.True(vm.IsEmptyRecent); Assert.Equal("0 résultat", vm.ResultCountLabel); } [Fact] public async Task Search_Reussi_RemplaceDerniereRecherche() { var service = new FakeInterventionService { LastSearch = [Card("INT-1", "Intervention A")] }; var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); Assert.Single(vm.Items); service.SearchResults = [Card("INT-2", "Intervention B"), Card("INT-3", "Intervention C")]; vm.ReferenceSearch = "INT"; await vm.SearchCommand.ExecuteAsync(null); Assert.Equal(2, vm.Items.Count); Assert.False(vm.IsShowingPinned); Assert.Equal("2 résultats", vm.ResultCountLabel); Assert.Equal("INT", service.LastRequest?.Reference); } [Fact] public async Task Search_Echoue_ConservePrecedente() { var service = new FakeInterventionService { LastSearch = [Card("INT-1", "Intervention A")] }; var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); Assert.Single(vm.Items); // Erreur de recherche NEUTRE : ce test porte sur la conservation de la liste // précédente. La politique « indisponibilité réseau → message français stable » // est couverte par les tests d'agenda (Load_IOExceptionReelle_*). service.SearchException = new InvalidOperationException("échec de recherche"); await vm.SearchCommand.ExecuteAsync(null); Assert.Single(vm.Items); Assert.Equal("échec de recherche", vm.ErrorMessage); } /// /// Ticket de production du 14/10 (recherche d'interventions, « Connection failure ») : /// un échec réseau n'est pas un incident. Il est journalisé en Warning/P2, signalé /// par le bandeau hors ligne (aucun message d'erreur), et la liste précédente — /// dernière recherche réussie — reste affichée. /// [Fact] public async Task Search_EchecReseau_SignaleHorsLigneSansIncident() { var service = new FakeInterventionService { LastSearch = [Card("INT-1", "Intervention A")] }; var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { service.SearchException = new HttpRequestException("Connection failure"); await vm.SearchCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.True(vm.IsOffline); Assert.Null(vm.ErrorMessage); Assert.Single(vm.Items); AssertAucunIncident(telemetry); DiagnosticEvent evt = EvenementInterventions(telemetry); Assert.Equal(TelemetryLevel.Warning, evt.Level); Assert.Equal("HttpRequestException", evt.ErrorCode); Assert.StartsWith("Connection failure", evt.Message); } /// /// Aucune URL d'API configurée (appareil non inscrit) : ce n'est pas une coupure /// réseau — l'appareil peut être en ligne. On affiche l'action à faire /// (« rescannez le QR ») et jamais le bandeau hors ligne, sans incident P1. /// [Fact] public async Task Search_SansUrlDApi_MessageActionnableSansBandeauHorsLigne() { var service = new FakeInterventionService { SearchException = new MobileEnrollmentRequiredException() }; var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.SearchCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.False(vm.IsOffline); Assert.Equal( "Inscription incomplète — rescannez le QR code pour reconnecter l'application.", vm.ErrorMessage); AssertAucunIncident(telemetry); DiagnosticEvent evt = EvenementInterventions(telemetry); Assert.Equal(TelemetryLevel.Warning, evt.Level); Assert.Equal("MobileEnrollmentRequiredException", evt.ErrorCode); } /// /// Hors ligne SANS résultat précédent : l'écran ne doit pas annoncer « aucune /// recherche récente » — l'absence n'a pas été constatée par le serveur, c'est /// le bandeau hors ligne qui porte l'information. /// [Fact] public async Task Search_EchecReseau_SansResultatPrecedent_MasqueLEtatVide() { var service = new FakeInterventionService { SearchException = new HttpRequestException("Connection failure") }; var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); await vm.SearchCommand.ExecuteAsync(null); Assert.True(vm.IsOffline); Assert.False(vm.IsEmptyRecent); Assert.Empty(vm.Items); } /// /// Contre-épreuve : une erreur métier (hors réseau) reste un incident Error/P1 /// avec son message, et n'active pas le bandeau hors ligne. /// [Fact] public async Task Search_ErreurMetier_ResteUnIncident() { var service = new FakeInterventionService { SearchException = new InvalidOperationException("échec de recherche") }; var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.SearchCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.False(vm.IsOffline); Assert.Equal("échec de recherche", vm.ErrorMessage); DiagnosticEvent evt = EvenementInterventions(telemetry); Assert.Equal(TelemetryLevel.Error, evt.Level); Assert.Equal("InvalidOperationException", evt.ErrorCode); } /// /// Une recherche réussie après un échec réseau efface l'état hors ligne : le /// bandeau ne doit pas rester affiché une fois le serveur revenu. /// [Fact] public async Task Search_ReussiApresEchecReseau_EffaceLEtatHorsLigne() { var service = new FakeInterventionService { SearchException = new HttpRequestException("Connection failure") }; var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); await vm.SearchCommand.ExecuteAsync(null); Assert.True(vm.IsOffline); service.SearchException = null; service.SearchResults = [Card("INT-2", "Intervention B")]; await vm.SearchCommand.ExecuteAsync(null); Assert.False(vm.IsOffline); Assert.Single(vm.Items); Assert.Null(vm.ErrorMessage); Assert.False(vm.IsEmptyRecent); } [Fact] public async Task ShowPinned_AfficheFavoris() { var service = new FakeInterventionService { Pinned = [Card("INT-9", "Favori", isPinned: true)] }; var vm = CreateVm(service); await vm.ShowPinnedCommand.ExecuteAsync(null); Assert.True(vm.IsShowingPinned); Assert.Single(vm.Items); Assert.Equal("Épinglées", vm.TabLabel); } [Fact] public async Task TogglePinned_Bascule() { var service = new FakeInterventionService { Pinned = [Card("INT-9", "Favori", isPinned: true)] }; var vm = CreateVm(service); await vm.ShowPinnedCommand.ExecuteAsync(null); await vm.TogglePinnedCommand.ExecuteAsync(vm.Items[0]); Assert.Empty(service.Pinned); Assert.Empty(vm.Items); } [Fact] public async Task TogglePinned_AppliqueImmediatement_SansRechargerLaListe() { var service = new FakeInterventionService { LastSearch = [Card("INT-1", "Intervention A"), Card("INT-2", "Intervention B")] }; var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); // La collection affichée sert de témoin : un rechargement la remplacerait. var collection = vm.Items; var item = vm.Items[0]; await vm.TogglePinnedCommand.ExecuteAsync(item); Assert.True(item.IsPinned); Assert.Same(collection, vm.Items); Assert.Equal(2, vm.Items.Count); } [Fact] public async Task Search_ClientSearch_EnvoieLeTexte() { var service = new FakeInterventionService(); var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); vm.ClientSearch = "Alpha"; await vm.SearchCommand.ExecuteAsync(null); Assert.Equal("Alpha", service.LastRequest?.ClientSearch); } [Fact] public async Task Search_Reference105Tous_EnvoieCriteresNeutres() { var service = new FakeInterventionService(); var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); vm.ReferenceSearch = "105"; vm.ClientSearch = string.Empty; vm.SiteSearch = string.Empty; vm.CollaborateurSearch = string.Empty; vm.SelectedEtatIndex = 0; vm.SelectedTypeIndex = 0; vm.RendezVousRelation = 0; vm.RendezVousDate = null; await vm.SearchCommand.ExecuteAsync(null); Assert.Equal("105", service.LastRequest?.Reference); Assert.Null(service.LastRequest?.ClientSearch); Assert.Null(service.LastRequest?.SiteSearch); Assert.Null(service.LastRequest?.CollaborateurSearch); Assert.Null(service.LastRequest?.AvecRendezVous); Assert.Null(service.LastRequest?.RendezVousDate); } [Fact] public async Task Search_SiteSearch_EnvoieLeTexte() { var service = new FakeInterventionService(); var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); vm.SiteSearch = "Nord"; await vm.SearchCommand.ExecuteAsync(null); Assert.Equal("Nord", service.LastRequest?.SiteSearch); } [Fact] public async Task Search_CollaborateurSearch_EnvoieLeTexte() { var service = new FakeInterventionService(); var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); vm.CollaborateurSearch = "Dupont"; await vm.SearchCommand.ExecuteAsync(null); Assert.Equal("Dupont", service.LastRequest?.CollaborateurSearch); } [Fact] public async Task Reset_RemetToutesLesSelectionsAZero() { var service = new FakeInterventionService(); var vm = CreateVm(service); await vm.LoadCommand.ExecuteAsync(null); vm.ReferenceSearch = "INT"; vm.ClientSearch = "Alpha"; vm.SiteSearch = "Nord"; vm.CollaborateurSearch = "Dupont"; vm.SelectedEtatIndex = 1; vm.SelectedTypeIndex = 1; vm.RendezVousRelation = 1; vm.RendezVousDate = DateTime.Now; vm.ResetSearchCommand.Execute(null); Assert.Empty(vm.ReferenceSearch); Assert.Empty(vm.ClientSearch); Assert.Empty(vm.SiteSearch); Assert.Empty(vm.CollaborateurSearch); Assert.Equal(0, vm.SelectedEtatIndex); Assert.Equal(0, vm.SelectedTypeIndex); Assert.Equal(0, vm.RendezVousRelation); Assert.Null(vm.RendezVousDate); } private static InterventionCardItem Card(string reference, string titre, bool isPinned = false) => new InterventionCardItem { InterventionGuid = Guid.NewGuid(), Reference = reference, Titre = titre, IsPinned = isPinned }; private sealed class FakeInterventionService : IInterventionLocalService { public List LastSearch { get; set; } = []; public List Pinned { get; set; } = []; public List SearchResults { get; set; } = []; public Exception? SearchException { get; set; } public MobileInterventionSearchRequest? LastRequest { get; set; } public Task CreateAsync(InterventionSyncPayload payload, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task UpdateAsync(Guid interventionGuid, InterventionSyncPayload payload, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task ArchiveAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetInterventionByIdAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task> GetInterventionsAsync(CancellationToken cancellationToken = default) => Task.FromResult>([]); public async Task> SearchAsync(MobileInterventionSearchRequest request, CancellationToken cancellationToken = default) { LastRequest = request; if (SearchException is not null) { throw SearchException; } await Task.CompletedTask; LastSearch = SearchResults; return LastSearch; } public Task> GetLastSearchAsync(CancellationToken cancellationToken = default) => Task.FromResult>(LastSearch); public Task> GetPinnedAsync(CancellationToken cancellationToken = default) => Task.FromResult>(Pinned); public Task SetPinnedAsync(Guid interventionGuid, bool isPinned, CancellationToken cancellationToken = default) { InterventionCardItem? item = Pinned.FirstOrDefault(i => i.InterventionGuid == interventionGuid); if (item is not null && !isPinned) { Pinned.Remove(item); } return Task.CompletedTask; } public Task RefreshPinnedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task RefreshInterventionAsync(Guid interventionGuid, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class FakeParametreMetierService : IParametreMetierLocalService { public Task> GetTypeTempsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task GetTypeTempsParametresAsync(string typeTempsKey, CancellationToken ct = default) => Task.FromResult(null); public Task GetTypeRdvParametresAsync(string typeRdvKey, CancellationToken ct = default) => Task.FromResult(null); public Task GetStatutRdvParametresAsync(string statutRdvKey, CancellationToken ct = default) => Task.FromResult(null); public Task> GetInterventionTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetInterventionEtatsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDossierTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDossierStatutsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetClientTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetClientStatutsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetSiteTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetTypeSignatairesAsync(CancellationToken cancellationToken = default) => Task.FromResult>([]); } private sealed class FakeClientService : IClientLocalService { public Task> GetClientsAsync(CancellationToken cancellationToken = default) => Task.FromResult>( new List { new() { ClientGuid = Guid.NewGuid(), Nom = "Client Alpha", Code = "C001" } }); public Task GetClientByIdAsync(Guid clientGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class FakeSiteService : ISiteLocalService { public Task> GetSitesAsync(CancellationToken cancellationToken = default) => Task.FromResult>( new List { new() { SiteGuid = Guid.NewGuid(), Nom = "Site Nord", Code = "S001" } }); public Task GetSiteByIdAsync(Guid siteGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class FakeCollaborateurService : ICollaborateurLocalService { public Task> GetCollaborateursAsync(CancellationToken cancellationToken = default) => Task.FromResult>( new List { new() { CollaborateurGuid = Guid.NewGuid(), DisplayName = "Jean Dupont", Code = "COL-1" } }); public Task GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); } /// /// Seul événement de diagnostic attribué au ViewModel Interventions : la télémétrie /// d'usage (fonctionnalités) est un , pas un /// . Le filtre porte sur le composant, la télémétrie /// statique globale étant partagée avec les classes exécutées en parallèle. /// private static DiagnosticEvent EvenementInterventions(CapturingTelemetry telemetry) => Assert.Single( telemetry.Events.OfType(), e => e.Component == nameof(InterventionsViewModel)); /// Aucun événement de diagnostic de cet écran en Error/Critical. private static void AssertAucunIncident(CapturingTelemetry telemetry) => Assert.DoesNotContain( telemetry.Events.OfType(), e => e.Component == nameof(InterventionsViewModel) && e.Level == TelemetryLevel.Error); /// /// Capture en mémoire des événements de la télémétrie statique globale /// (), rétablie à null après /// chaque test — la classe appartient à la collection « TelemetrieStatique ». /// private sealed class CapturingTelemetry : IOmikaTelemetry { public List Events { get; } = []; public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent); public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent); } }