using System.Net.Http; using System.Text.Json; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.App.Navigation; using Omika.App.ViewModels; using Omika.Core.Contracts.Clients; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Interventions; using Omika.Core.Observability; namespace Omika.Tests.Mobile.Local; /// /// Tests du ViewModel de la liste des dossiers : chargement, états vide/erreur, /// six critères de recherche (Titre, Client, Statut, Type, Référence externe, /// Référence interne), épinglage local, reset et navigation. La recherche est /// déléguée au service (côté serveur) : le fake filtre les données locales /// comme le ferait la requête SQL LIKE. /// /// Couvre aussi la politique d'erreur de la recherche : elle atteint l'API sans /// garde intermédiaire (DossierLocalService.SearchDossiersAsync → /// _apiClient), donc un échec réseau — ticket de production du 14/10, /// « Connection failure » — doit partir en Warning/P2, jamais en Error/P1. /// [Collection("TelemetrieStatique")] public class DossiersViewModelTests : LocalTestBase { private readonly DossiersViewModel _viewModel; private readonly FakeDossierService _service; private readonly StubParametreMetierService _parametreMetier = new(); private readonly NavigationService _navigation; public DossiersViewModelTests() { var inner = new DossierLocalService(DossierRepository, ClientRepository, DossierStateRepository, DossierInterventionCacheRepository, ApiClient); _service = new FakeDossierService(inner); _navigation = new NavigationService(); _viewModel = new DossiersViewModel(_service, _parametreMetier, _navigation); } private static LocalDossier CreateDossier( Guid id, string reference, string titre, Guid? clientId = null, string? statut = null, string? type = null, string? refInterne = null, string? refExterne = null, int nbInterventions = 0) { var dto = new DossierDetailsDto { Id = id.ToString(), ReferenceMetier = reference, Titre = titre, ClientId = clientId?.ToString(), Statut = statut is null ? null : new ReferenceValueDto { Label = statut }, Type = type is null ? null : new ReferenceValueDto { Label = type }, RefInterne = refInterne, RefExterne = refExterne, NbInterventionsNonArchivees = nbInterventions }; return new LocalDossier { Id = id, ClientId = clientId, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static LocalClient CreateClient(Guid id, string nom) { var dto = new ClientDetailsDto { Id = id.ToString(), Nom = nom }; return new LocalClient { Id = id, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } [Fact] public async Task Load_NAfficheAucunResultatAvantRecherche() { await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A")); await _viewModel.LoadCommand.ExecuteAsync(null); Assert.False(_viewModel.IsLoading); Assert.False(_viewModel.HasDossiers); Assert.Empty(_viewModel.Dossiers); Assert.False(_viewModel.HasSearched); Assert.True(_viewModel.IsSearchNotStarted); } [Fact] public async Task Load_AucunDossier_Empty() { await _viewModel.LoadCommand.ExecuteAsync(null); Assert.False(_viewModel.IsLoading); Assert.False(_viewModel.HasDossiers); Assert.True(_viewModel.IsSearchNotStarted); Assert.Null(_viewModel.ErrorMessage); } [Fact] public async Task FiltreTitre_InsensibleCasse() { await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta")); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.TitreText = "alpha"; await _viewModel.SearchCommand.ExecuteAsync(null); var item = Assert.Single(_viewModel.Dossiers); Assert.Equal("Alpha", item.Titre); Assert.Equal("alpha", _service.LastTitre); } [Fact] public async Task FiltreStatut() { _parametreMetier.DossierStatuts = [new() { Label = "Ouvert" }, new() { Label = "Clôturé" }]; await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", statut: "Ouvert")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", statut: "Clôturé")); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.SelectedStatutIndex = _viewModel.StatutOptions.ToList().IndexOf("Ouvert"); await _viewModel.SearchCommand.ExecuteAsync(null); var item = Assert.Single(_viewModel.Dossiers); Assert.Equal("DOS-001", item.Reference); } [Fact] public async Task FiltreType() { _parametreMetier.DossierTypes = [new() { Label = "Maintenance" }, new() { Label = "Installation" }]; await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", type: "Maintenance")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", type: "Installation")); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.SelectedTypeIndex = _viewModel.TypeOptions.ToList().IndexOf("Installation"); await _viewModel.SearchCommand.ExecuteAsync(null); var item = Assert.Single(_viewModel.Dossiers); Assert.Equal("DOS-002", item.Reference); } [Fact] public async Task FiltreClient_ParNomHumain() { Guid clientAlpha = Guid.NewGuid(); Guid clientBeta = Guid.NewGuid(); await ClientRepository.UpsertAsync(CreateClient(clientAlpha, "Client Alpha")); await ClientRepository.UpsertAsync(CreateClient(clientBeta, "Client Beta")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", clientId: clientAlpha)); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", clientId: clientBeta)); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.ClientText = "Client Alpha"; await _viewModel.SearchCommand.ExecuteAsync(null); var item = Assert.Single(_viewModel.Dossiers); Assert.Equal("DOS-001", item.Reference); } [Fact] public async Task FiltreRefInterne() { await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", refInterne: "INT-42")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", refInterne: "INT-7")); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.RefInterneText = "42"; await _viewModel.SearchCommand.ExecuteAsync(null); var item = Assert.Single(_viewModel.Dossiers); Assert.Equal("DOS-001", item.Reference); Assert.Equal("42", _service.LastRefInterne); } [Fact] public async Task FiltreRefExterne() { await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A", refExterne: "EXT-42")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B", refExterne: "EXT-7")); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.RefExterneText = "42"; await _viewModel.SearchCommand.ExecuteAsync(null); var item = Assert.Single(_viewModel.Dossiers); Assert.Equal("DOS-001", item.Reference); } [Fact] public async Task Reset_RetourneEtatInitialSansResultat() { _parametreMetier.DossierStatuts = [new() { Label = "Ouvert" }, new() { Label = "Clôturé" }]; await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha", statut: "Ouvert")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta", statut: "Clôturé")); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.TitreText = "Alpha"; _viewModel.SelectedStatutIndex = _viewModel.StatutOptions.ToList().IndexOf("Ouvert"); await _viewModel.SearchCommand.ExecuteAsync(null); Assert.Single(_viewModel.Dossiers); Assert.True(_viewModel.HasActiveFilters); _viewModel.ResetFiltersCommand.Execute(null); Assert.Empty(_viewModel.Dossiers); Assert.False(_viewModel.HasSearched); Assert.True(_viewModel.IsSearchNotStarted); Assert.False(_viewModel.HasActiveFilters); Assert.Empty(_viewModel.TitreText); Assert.Equal(0, _viewModel.SelectedStatutIndex); } [Fact] public async Task Pin_Persiste_EtFiltreEpingles() { Guid id = Guid.NewGuid(); await DossierRepository.UpsertAsync(CreateDossier(id, "DOS-001", "A")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "B")); await _viewModel.LoadCommand.ExecuteAsync(null); await _viewModel.SearchCommand.ExecuteAsync(null); // Épingler DOS-001. var item = _viewModel.Dossiers.First(d => d.Reference == "DOS-001"); await _viewModel.TogglePinnedCommand.ExecuteAsync(item); // L'état épinglé est persisté (relecture depuis le repository local). var pinned = await DossierStateRepository.GetPinnedIdsAsync(); Assert.Contains(id, pinned); // Onglet « Épinglés » : seule DOS-001 reste. _viewModel.ShowPinnedCommand.Execute(null); var only = Assert.Single(_viewModel.Dossiers); Assert.Equal("DOS-001", only.Reference); // Retour « Récentes » : les résultats serveur (2) sont restaurés. _viewModel.ShowRecentCommand.Execute(null); Assert.Equal(2, _viewModel.Dossiers.Count); } [Fact] public async Task TogglePinned_AppliqueImmediatement_SansRechargerLaListe() { await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta")); await _viewModel.LoadCommand.ExecuteAsync(null); await _viewModel.SearchCommand.ExecuteAsync(null); // La collection affichée sert de témoin : un rechargement la remplacerait. var collection = _viewModel.Dossiers; var item = _viewModel.Dossiers[0]; await _viewModel.TogglePinnedCommand.ExecuteAsync(item); Assert.True(item.IsPinned); Assert.Same(collection, _viewModel.Dossiers); Assert.Equal(2, _viewModel.Dossiers.Count); // Et l'état est bien persisté (relecture depuis le repository local). Assert.Contains(item.DossierGuid, await DossierStateRepository.GetPinnedIdsAsync()); } [Fact] public async Task EtatVide_SansDonneesEtSansResultat_AfficheAucunResultat() { await _viewModel.LoadCommand.ExecuteAsync(null); Assert.True(_viewModel.IsSearchNotStarted); Assert.False(_viewModel.HasNoResults); await _viewModel.SearchCommand.ExecuteAsync(null); Assert.True(_viewModel.HasSearched); Assert.True(_viewModel.HasNoResults); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "A")); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.TitreText = "introuvable"; await _viewModel.SearchCommand.ExecuteAsync(null); Assert.True(_viewModel.HasSearched); Assert.True(_viewModel.HasNoResults); } [Fact] public async Task ChangementCritereSansRecherche_NeModifiePasLaListe() { await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta")); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.TitreText = "Alpha"; Assert.False(_viewModel.HasSearched); Assert.Empty(_viewModel.Dossiers); Assert.True(_viewModel.IsSearchNotStarted); } [Fact] public async Task RechercheVide_AfficheTousLesNonArchives() { await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-001", "Alpha")); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta")); await _viewModel.LoadCommand.ExecuteAsync(null); await _viewModel.SearchCommand.ExecuteAsync(null); Assert.True(_viewModel.HasSearched); Assert.Equal(2, _viewModel.Dossiers.Count); } [Fact] public async Task EpinglesSansRecherche_AfficheFavorisPuisRetourEtatInitial() { Guid id = Guid.NewGuid(); await DossierRepository.UpsertAsync(CreateDossier(id, "DOS-001", "Alpha")); await DossierStateRepository.SetPinnedAsync( id, true, JsonSerializer.Serialize(new DossierListItem { DossierGuid = id, Reference = "DOS-001", Titre = "Alpha" })); await DossierRepository.UpsertAsync(CreateDossier(Guid.NewGuid(), "DOS-002", "Beta")); await _viewModel.LoadCommand.ExecuteAsync(null); _viewModel.ShowPinnedCommand.Execute(null); var only = Assert.Single(_viewModel.Dossiers); Assert.Equal("DOS-001", only.Reference); _viewModel.ShowRecentCommand.Execute(null); Assert.Empty(_viewModel.Dossiers); Assert.False(_viewModel.HasSearched); Assert.True(_viewModel.IsSearchNotStarted); } [Fact] public async Task CardMapping_TypeAbsentMasqueEtReferencesPropagees() { await DossierRepository.UpsertAsync(CreateDossier( Guid.NewGuid(), "DOS-001", "", type: "Aucun Type de dossier", refInterne: "INT-42", refExterne: "EXT-42")); await _viewModel.LoadCommand.ExecuteAsync(null); await _viewModel.SearchCommand.ExecuteAsync(null); var item = Assert.Single(_viewModel.Dossiers); Assert.Null(item.Type); Assert.Null(item.Titre); Assert.Equal("INT-42", item.RefInterne); Assert.Equal("EXT-42", item.RefExterne); } /// /// Fake de service : délègue au service local réel (lecture SQLite) mais /// implémente par un filtre en mémoire des /// données locales, simulant la requête SQL LIKE côté serveur. /// private sealed class FakeDossierService : IDossierLocalService { private readonly IDossierLocalService _inner; public string? LastRefInterne { get; private set; } public string? LastTitre { get; private set; } public string? LastClient { get; private set; } public string? LastStatut { get; private set; } public string? LastType { get; private set; } public string? LastRefExterne { get; private set; } public FakeDossierService(IDossierLocalService inner) => _inner = inner; public Task> GetDossiersAsync(CancellationToken ct = default) => _inner.GetDossiersAsync(ct); /// Simule un échec de la recherche serveur (réseau ou métier). public Exception? SearchException { get; set; } public async Task> SearchDossiersAsync( string? refInterne, string? titre, string? client, string? statut, string? type, string? refExterne, CancellationToken ct = default) { if (SearchException is not null) { throw SearchException; } LastRefInterne = refInterne; LastTitre = titre; LastClient = client; LastStatut = statut; LastType = type; LastRefExterne = refExterne; IReadOnlyList all = await _inner.GetDossiersAsync(ct); IEnumerable filtered = all; if (!string.IsNullOrWhiteSpace(refInterne)) { filtered = filtered.Where(d => Contains(d.RefInterne, refInterne)); } if (!string.IsNullOrWhiteSpace(titre)) { filtered = filtered.Where(d => Contains(d.Titre, titre)); } if (!string.IsNullOrWhiteSpace(client)) { filtered = filtered.Where(d => Contains(d.ClientNom, client)); } if (!string.IsNullOrWhiteSpace(statut)) { filtered = filtered.Where(d => string.Equals(d.Statut, statut, StringComparison.OrdinalIgnoreCase)); } if (!string.IsNullOrWhiteSpace(type)) { filtered = filtered.Where(d => string.Equals(d.Type, type, StringComparison.OrdinalIgnoreCase)); } if (!string.IsNullOrWhiteSpace(refExterne)) { filtered = filtered.Where(d => Contains(d.RefExterne, refExterne)); } return filtered.ToList(); } public Task> GetLastSearchAsync(CancellationToken ct = default) => _inner.GetLastSearchAsync(ct); public Task ClearLastSearchAsync(CancellationToken ct = default) => _inner.ClearLastSearchAsync(ct); private static bool Contains(string? value, string query) => value is not null && value.Contains(query, StringComparison.OrdinalIgnoreCase); public Task GetDossierByIdAsync(Guid dossierGuid, CancellationToken ct = default) => _inner.GetDossierByIdAsync(dossierGuid, ct); public Task SetPinnedAsync(Guid dossierGuid, bool isPinned, DossierListItem? item, CancellationToken ct = default) => _inner.SetPinnedAsync(dossierGuid, isPinned, item, ct); public Task> GetPinnedAsync(CancellationToken ct = default) => _inner.GetPinnedAsync(ct); public Task> GetDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default) => _inner.GetDossierInterventionsAsync(dossierGuid, ct); public Task> RefreshDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default) => _inner.RefreshDossierInterventionsAsync(dossierGuid, ct); } /// /// 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() { _service.SearchException = new HttpRequestException("Connection failure"); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await _viewModel.SearchCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal("Hors ligne — impossible de contacter le serveur.", _viewModel.ErrorMessage); DiagnosticEvent evt = EvenementDossiers(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() { _service.SearchException = new InvalidOperationException("échec de recherche"); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await _viewModel.SearchCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } DiagnosticEvent evt = EvenementDossiers(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 EvenementDossiers(CapturingTelemetry telemetry) => Assert.Single( telemetry.Events.OfType(), e => e.Component == nameof(DossiersViewModel)); /// 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(DossiersViewModel) && 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); } }