using System.Net.Http; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Navigation; using Omika.App.ViewModels; using Omika.Core.Observability; namespace Omika.Tests.Mobile.Local; /// /// Tests du ViewModel de la liste des clients : chargement des options /// Type/Statut, recherche serveur à la demande, restauration de la dernière /// recherche, épinglage et navigation vers la fiche Client. /// /// Couvre aussi la politique d'erreur de la recherche : la recherche à la demande /// atteint l'API sans garde intermédiaire (ClientLocalService.SearchClientsAsync /// → _apiClient), donc un échec réseau — ticket de production du 14/10, /// « Connection failure » — remonte jusqu'ici et doit être journalisé en Warning/P2, /// jamais en Error/P1. /// [Collection("TelemetrieStatique")] public class ClientsViewModelTests : LocalTestBase { private readonly NavigationService _navigation = new(); private readonly StubParametreMetierService _parametreMetier = new(); private static ClientListItem Client(Guid id, string nom, string? type = null, string? statut = null) => new() { ClientGuid = id, Nom = nom, Code = "C001", Type = type, Statut = statut, Ville = "Lyon" }; [Fact] public async Task Load_ChargeOptionsTypeStatut_SansPeuplerLaListe() { // Arrange : référentiel ParametreMetier (bootstrap), jamais un chargement // en bloc de clients. _parametreMetier.ClientTypes = [new() { Label = "Professionnel" }]; _parametreMetier.ClientStatuts = [new() { Label = "Actif" }]; var service = new FakeClientService { Local = [Client(Guid.NewGuid(), "Alpha", "Professionnel", "Actif")] }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); // Act await vm.LoadCommand.ExecuteAsync(null); // Assert : options issues du référentiel, liste encore vide. Assert.False(vm.IsLoading); Assert.Contains("Tous", vm.TypeOptions); Assert.Contains("Professionnel", vm.TypeOptions); Assert.Contains("Actif", vm.StatutOptions); Assert.False(vm.HasClients); Assert.True(vm.IsSearchNotStarted); } [Fact] public async Task Load_RestaureLaDerniereRecherche() { // Arrange var client = Client(Guid.NewGuid(), "Alpha"); var service = new FakeClientService { LastSearch = [client] }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); // Act await vm.LoadCommand.ExecuteAsync(null); // Assert : la dernière recherche est restaurée. Assert.True(vm.HasClients); Assert.Single(vm.Clients); Assert.Equal("Alpha", vm.Clients[0].Nom); Assert.False(vm.IsShowingPinned); } [Fact] public async Task Search_RetourneLesResultats() { // Arrange var service = new FakeClientService { SearchResults = [Client(Guid.NewGuid(), "Alpha")] }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); // Act await vm.SearchCommand.ExecuteAsync(null); // Assert Assert.False(vm.IsLoading); Assert.True(vm.HasClients); Assert.Single(vm.Clients); Assert.Equal("Alpha", vm.Clients[0].Nom); } [Fact] public async Task Search_SansResultat_ListeVide() { // Arrange var service = new FakeClientService { SearchResults = [] }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); // Act await vm.SearchCommand.ExecuteAsync(null); // Assert Assert.False(vm.HasClients); Assert.True(vm.HasNoResults); } [Fact] public async Task Search_Erreur_Affichee() { // Arrange var service = new FakeClientService { ThrowOnSearch = true }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); // Act await vm.SearchCommand.ExecuteAsync(null); // Assert Assert.NotNull(vm.ErrorMessage); Assert.False(vm.HasClients); } [Fact] public async Task Load_Erreur_Affichee() { // Arrange var vm = new ClientsViewModel(new ThrowingClientService(), _parametreMetier, _navigation); // Act await vm.LoadCommand.ExecuteAsync(null); // Assert Assert.NotNull(vm.ErrorMessage); Assert.False(vm.HasClients); } [Fact] public async Task ResetSearch_VideLaListe() { // Arrange var service = new FakeClientService { SearchResults = [Client(Guid.NewGuid(), "Alpha")] }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); Assert.True(vm.HasClients); // Act await vm.ResetSearchCommand.ExecuteAsync(null); // Assert Assert.False(vm.HasClients); Assert.Empty(vm.Clients); Assert.False(vm.HasActiveFilters); } [Fact] public async Task TogglePinned_EpingleEtAfficheDansOnglet() { // Arrange Guid clientId = Guid.NewGuid(); var client = Client(clientId, "Alpha"); var service = new FakeClientService { SearchResults = [client] }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); // Act : épingle le client. await vm.TogglePinnedCommand.ExecuteAsync(vm.Clients[0]); // Assert : présent dans l'onglet « Épinglés ». await vm.ShowPinnedCommand.ExecuteAsync(null); Assert.True(vm.IsShowingPinned); Assert.Single(vm.Clients); Assert.Equal(clientId, vm.Clients[0].ClientGuid); } [Fact] public async Task TogglePinned_AppliqueImmediatement_SansRechargerLaListe() { // Arrange Guid clientId = Guid.NewGuid(); var service = new FakeClientService { SearchResults = [Client(clientId, "Alpha"), Client(Guid.NewGuid(), "Beta")] }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); // La collection affichée sert de témoin : un rechargement la remplacerait. var collection = vm.Clients; var item = vm.Clients[0]; // Act await vm.TogglePinnedCommand.ExecuteAsync(item); // Assert : étoile basculée immédiatement, aucune reconstruction de la liste. Assert.True(item.IsPinned); Assert.True(vm.Clients[0].IsPinned); Assert.Same(collection, vm.Clients); Assert.Equal(2, vm.Clients.Count); } [Fact] public async Task TogglePinned_EtoileBasculeAvantLaFinDeLEcriture() { // Arrange : persistance volontairement LENTE (base occupée par une // synchronisation en arrière-plan). var service = new FakeClientService { SearchResults = [Client(Guid.NewGuid(), "Alpha")] }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); var item = vm.Clients[0]; service.SetPinnedGate = new TaskCompletionSource(); // Act : la commande n'est PAS attendue, l'écriture est encore en cours. Task commande = vm.TogglePinnedCommand.ExecuteAsync(item); // Assert : l'étoile est déjà basculée, l'utilisateur n'attend pas la base. Assert.True(item.IsPinned); Assert.False(commande.IsCompleted); service.SetPinnedGate.SetResult(); await commande; Assert.True(item.IsPinned); } [Fact] public async Task TogglePinned_EchecDePersistance_RevientALEtaPrecedent() { // Arrange var service = new FakeClientService { SearchResults = [Client(Guid.NewGuid(), "Alpha")], ThrowOnSetPinned = true }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); var item = vm.Clients[0]; // Act await vm.TogglePinnedCommand.ExecuteAsync(item); // Assert : l'interface ne montre jamais un épinglage absent de la base. Assert.False(item.IsPinned); Assert.NotNull(vm.ErrorMessage); } [Fact] public async Task OpenClient_NavigueVersDetail() { // Arrange Guid clientId = Guid.NewGuid(); var service = new FakeClientService { SearchResults = [Client(clientId, "Alpha")] }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); // Act vm.OpenClientCommand.Execute(vm.Clients[0]); // Assert Assert.Equal(AppPage.ClientDetail, _navigation.CurrentPage); Assert.Equal(clientId, _navigation.CurrentParameter); } [Fact] public async Task OpenClient_Null_NeNaviguePas() { // Arrange var vm = new ClientsViewModel(new FakeClientService(), _parametreMetier, _navigation); // Act vm.OpenClientCommand.Execute(null); // Assert Assert.Equal(AppPage.Home, _navigation.CurrentPage); } /// Fake configurable du service local de clients. private sealed class FakeClientService : IClientLocalService { public IReadOnlyList Local { get; set; } = []; public IReadOnlyList SearchResults { get; set; } = []; public IReadOnlyList LastSearch { get; set; } = []; public bool ThrowOnSearch { get; set; } /// Exception levée par la recherche quand est vrai. public Exception SearchException { get; set; } = new InvalidOperationException("Erreur simulée"); /// Fait échouer l'épinglage (test du retour arrière d'affichage). public bool ThrowOnSetPinned { get; set; } /// Retient l'écriture tant qu'il n'est pas libéré (test de réactivité). public TaskCompletionSource? SetPinnedGate { get; set; } private readonly List _pinned = []; public Task> GetClientsAsync(CancellationToken ct = default) => Task.FromResult(Local); public Task> SearchClientsAsync( string? nom, string? code, string? telephone, string? email, string? ville, string? codePostal, string? type, string? statut, CancellationToken ct = default) => ThrowOnSearch ? throw SearchException : Task.FromResult(SearchResults); public Task> GetLastSearchAsync(CancellationToken ct = default) => Task.FromResult(LastSearch); public Task ClearLastSearchAsync(CancellationToken ct = default) { LastSearch = []; return Task.CompletedTask; } public Task> GetPinnedAsync(CancellationToken ct = default) => Task.FromResult>(_pinned); public async Task SetPinnedAsync(Guid clientId, bool isPinned, ClientListItem? item, CancellationToken ct = default) { if (SetPinnedGate is not null) { await SetPinnedGate.Task; } if (ThrowOnSetPinned) { throw new InvalidOperationException("Erreur SQLite simulée"); } if (isPinned && item is not null && !_pinned.Any(c => c.ClientGuid == clientId)) { _pinned.Add(item); } else if (!isPinned) { _pinned.RemoveAll(c => c.ClientGuid == clientId); } } public Task GetClientByIdAsync(Guid clientGuid, CancellationToken ct = default) => Task.FromResult(null); } /// Service qui lève une exception pour tester l'état d'erreur. private sealed class ThrowingClientService : IClientLocalService { public Task> GetClientsAsync(CancellationToken ct = default) => throw new InvalidOperationException("Erreur SQLite simulée"); public Task GetClientByIdAsync(Guid clientGuid, CancellationToken ct = default) => throw new InvalidOperationException("Erreur SQLite simulée"); } /// /// Ticket de production du 14/10 (« Connection failure ») : l'échec réseau de la /// recherche n'est pas un incident. L'événement part en Warning/P2, l'écran n'entre /// dans aucun groupe d'erreurs du Diagnostic Center, et l'utilisateur reçoit le /// libellé français stable plutôt qu'un message technique anglais. /// [Fact] public async Task Search_EchecReseau_SignaleEnWarningSansIncident() { var service = new FakeClientService { ThrowOnSearch = true, SearchException = new HttpRequestException("Connection failure") }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.SearchCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal("Hors ligne — impossible de contacter le serveur.", vm.ErrorMessage); DiagnosticEvent evt = EvenementClients(telemetry); Assert.Equal(TelemetryLevel.Warning, evt.Level); Assert.Equal("HttpRequestException", evt.ErrorCode); AssertAucunIncident(telemetry); } /// /// Contre-épreuve : une erreur métier (hors réseau) reste un incident Error/P1. /// Le classement ne dégrade pas les vraies erreurs. /// [Fact] public async Task Search_ErreurMetier_ResteUnIncident() { var service = new FakeClientService { ThrowOnSearch = true, SearchException = new InvalidOperationException("échec de recherche") }; var vm = new ClientsViewModel(service, _parametreMetier, _navigation); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.SearchCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } DiagnosticEvent evt = EvenementClients(telemetry); Assert.Equal(TelemetryLevel.Error, evt.Level); Assert.Equal("InvalidOperationException", evt.ErrorCode); } /// Seul événement de diagnostic attribué à ce ViewModel (l'usage est un TelemetryEvent). private static DiagnosticEvent EvenementClients(CapturingTelemetry telemetry) => Assert.Single( telemetry.Events.OfType(), e => e.Component == nameof(ClientsViewModel)); /// 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(ClientsViewModel) && e.Level == TelemetryLevel.Error); /// Capture des événements de la télémétrie statique globale. 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); } }