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 sites : chargement des options Type, /// recherche serveur à la demande, restauration de la dernière recherche, /// épinglage et navigation vers la fiche Site. /// /// Couvre aussi la politique d'erreur de la recherche : la recherche à la demande /// atteint l'API sans garde intermédiaire (SiteLocalService.SearchSitesAsync /// → _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 SitesViewModelTests : LocalTestBase { private readonly NavigationService _navigation = new(); private readonly StubParametreMetierService _parametreMetier = new(); private static SiteListItem Site(Guid id, string nom, string? type = null) => new() { SiteGuid = id, Nom = nom, Code = "S001", Type = type, Ville = "Lyon" }; [Fact] public async Task Load_ChargeOptionsType_SansPeuplerLaListe() { _parametreMetier.SiteTypes = [new() { Label = "Entrepôt" }]; var service = new FakeSiteService { Local = [Site(Guid.NewGuid(), "Alpha", "Entrepôt")] }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); await vm.LoadCommand.ExecuteAsync(null); Assert.False(vm.IsLoading); Assert.Contains("Tous", vm.TypeOptions); Assert.Contains("Entrepôt", vm.TypeOptions); Assert.False(vm.HasSites); Assert.True(vm.IsSearchNotStarted); } [Fact] public async Task Load_RestaureLaDerniereRecherche() { var site = Site(Guid.NewGuid(), "Alpha"); var service = new FakeSiteService { LastSearch = [site] }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); await vm.LoadCommand.ExecuteAsync(null); Assert.True(vm.HasSites); Assert.Single(vm.Sites); Assert.Equal("Alpha", vm.Sites[0].Nom); } [Fact] public async Task Search_RetourneLesResultats() { var service = new FakeSiteService { SearchResults = [Site(Guid.NewGuid(), "Alpha")] }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); Assert.True(vm.HasSites); Assert.Single(vm.Sites); Assert.Equal("Alpha", vm.Sites[0].Nom); } [Fact] public async Task Search_SansResultat_ListeVide() { var service = new FakeSiteService { SearchResults = [] }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); Assert.False(vm.HasSites); Assert.True(vm.HasNoResults); } [Fact] public async Task Search_Erreur_Affichee() { var service = new FakeSiteService { ThrowOnSearch = true }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); Assert.NotNull(vm.ErrorMessage); Assert.False(vm.HasSites); } [Fact] public async Task Load_Erreur_Affichee() { var vm = new SitesViewModel(new ThrowingSiteService(), _parametreMetier, _navigation); await vm.LoadCommand.ExecuteAsync(null); Assert.NotNull(vm.ErrorMessage); Assert.False(vm.HasSites); } [Fact] public async Task ResetSearch_VideLaListe() { var service = new FakeSiteService { SearchResults = [Site(Guid.NewGuid(), "Alpha")] }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); Assert.True(vm.HasSites); await vm.ResetSearchCommand.ExecuteAsync(null); Assert.False(vm.HasSites); Assert.Empty(vm.Sites); Assert.False(vm.HasActiveFilters); } [Fact] public async Task TogglePinned_EpingleEtAfficheDansOnglet() { Guid siteId = Guid.NewGuid(); var service = new FakeSiteService { SearchResults = [Site(siteId, "Alpha")] }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); await vm.TogglePinnedCommand.ExecuteAsync(vm.Sites[0]); await vm.ShowPinnedCommand.ExecuteAsync(null); Assert.True(vm.IsShowingPinned); Assert.Single(vm.Sites); Assert.Equal(siteId, vm.Sites[0].SiteGuid); } [Fact] public async Task TogglePinned_AppliqueImmediatement_SansRechargerLaListe() { var service = new FakeSiteService { SearchResults = [Site(Guid.NewGuid(), "Alpha"), Site(Guid.NewGuid(), "Beta")] }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); // La collection affichée sert de témoin : un rechargement la remplacerait. var collection = vm.Sites; var item = vm.Sites[0]; await vm.TogglePinnedCommand.ExecuteAsync(item); Assert.True(item.IsPinned); Assert.Same(collection, vm.Sites); Assert.Equal(2, vm.Sites.Count); } [Fact] public async Task OpenSite_NavigueVersDetail() { Guid siteId = Guid.NewGuid(); var service = new FakeSiteService { SearchResults = [Site(siteId, "Alpha")] }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); await vm.SearchCommand.ExecuteAsync(null); vm.OpenSiteCommand.Execute(vm.Sites[0]); Assert.Equal(AppPage.SiteDetail, _navigation.CurrentPage); Assert.Equal(siteId, _navigation.CurrentParameter); } [Fact] public async Task OpenSite_Null_NeNaviguePas() { var vm = new SitesViewModel(new FakeSiteService(), _parametreMetier, _navigation); vm.OpenSiteCommand.Execute(null); Assert.Equal(AppPage.Home, _navigation.CurrentPage); } /// Fake configurable du service local de sites. private sealed class FakeSiteService : ISiteLocalService { 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"); private readonly List _pinned = []; public Task> GetSitesAsync(CancellationToken ct = default) => Task.FromResult(Local); public Task> SearchSitesAsync( string? nom, string? code, string? type, string? ville, string? codePostal, string? telephone, string? email, 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 Task SetPinnedAsync(Guid siteId, bool isPinned, SiteListItem? item, CancellationToken ct = default) { if (isPinned && item is not null && !_pinned.Any(s => s.SiteGuid == siteId)) { _pinned.Add(item); } else if (!isPinned) { _pinned.RemoveAll(s => s.SiteGuid == siteId); } return Task.CompletedTask; } public Task GetSiteByIdAsync(Guid siteGuid, CancellationToken ct = default) => Task.FromResult(null); } /// Service qui lève une exception pour tester l'état d'erreur. private sealed class ThrowingSiteService : ISiteLocalService { public Task> GetSitesAsync(CancellationToken ct = default) => throw new InvalidOperationException("Erreur SQLite simulée"); public Task GetSiteByIdAsync(Guid siteGuid, 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 FakeSiteService { ThrowOnSearch = true, SearchException = new HttpRequestException("Connection failure") }; var vm = new SitesViewModel(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 = EvenementSites(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 FakeSiteService { ThrowOnSearch = true, SearchException = new InvalidOperationException("échec de recherche") }; var vm = new SitesViewModel(service, _parametreMetier, _navigation); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.SearchCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } DiagnosticEvent evt = EvenementSites(telemetry); Assert.Equal(TelemetryLevel.Error, evt.Level); Assert.Equal("InvalidOperationException", evt.ErrorCode); } /// /// Seul événement de diagnostic attribué au ViewModel : 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 EvenementSites(CapturingTelemetry telemetry) => Assert.Single( telemetry.Events.OfType(), e => e.Component == nameof(SitesViewModel)); /// 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(SitesViewModel) && 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); } }