using System.Linq; using Omika.App.Local.Abstractions; using Omika.Core.Contracts.Documents; using Omika.App.Local.Models; using Omika.App.Local.Repositories; using Omika.App.Local.Services; using Omika.App.Navigation; using Omika.App.Services; using Omika.App.ViewModels; using Omika.Core.Constants; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Contracts.Planning; using Omika.Core.Observability; namespace Omika.Tests.Mobile.Local; /// /// Tests du ShellViewModel : navigation, page courante, thème, état de /// synchronisation et protection contre les doubles clics. /// [Collection("TelemetrieStatique")] public class ShellViewModelTests : LocalTestBase { private sealed class FakeSyncEngine : ISyncEngine { public int SyncCalls { get; private set; } public int PushCalls { get; private set; } public SyncCycleResult ResultToReturn { get; set; } = new() { Executed = true }; public SyncCycleResult PushResultToReturn { get; set; } = new() { Executed = true }; public TimeSpan Delay { get; set; } = TimeSpan.Zero; public bool ThrowOnSync { get; set; } public bool ThrowOnPush { get; set; } public async Task SyncAsync(CancellationToken cancellationToken = default) { SyncCalls++; if (Delay > TimeSpan.Zero) { await Task.Delay(Delay, cancellationToken); } if (ThrowOnSync) { throw new InvalidOperationException("Erreur réseau simulée"); } return ResultToReturn; } public async Task PushAsync(CancellationToken cancellationToken = default) { PushCalls++; if (Delay > TimeSpan.Zero) { await Task.Delay(Delay, cancellationToken); } if (ThrowOnPush) { throw new InvalidOperationException("Erreur réseau simulée (envoi)"); } return PushResultToReturn; } } 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(false); public Task OpenAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(false); 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 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 FakeDossierService : IDossierLocalService { public Task> GetDossiersAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> SearchDossiersAsync(string? refInterne, string? titre, string? client, string? statut, string? type, string? refExterne, CancellationToken ct = default) => Task.FromResult>([]); public Task> GetLastSearchAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task ClearLastSearchAsync(CancellationToken ct = default) => Task.CompletedTask; public Task GetDossierByIdAsync(Guid dossierGuid, CancellationToken ct = default) => Task.FromResult(null); public Task SetPinnedAsync(Guid dossierGuid, bool isPinned, DossierListItem? item, CancellationToken ct = default) => Task.CompletedTask; public Task> GetPinnedAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task> RefreshDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default) => Task.FromResult>([]); } private sealed class FakeClientService : IClientLocalService { public Task> GetClientsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task GetClientByIdAsync(Guid clientGuid, CancellationToken ct = default) => Task.FromResult(null); } private sealed class FakeSiteService : ISiteLocalService { public Task> GetSitesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task GetSiteByIdAsync(Guid siteGuid, CancellationToken ct = default) => Task.FromResult(null); } private sealed class FakeCollaborateurService : ICollaborateurLocalService { public Task> GetCollaborateursAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken ct = default) => Task.FromResult(null); } private sealed class FakeEquipeService : IEquipeLocalService { public Task> GetEquipesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetAllEquipesAsync(CancellationToken ct = default) => Task.FromResult>([]); } private sealed class FakeSuiviTempsService : ISuiviTempsLocalService { public Task CreateAsync(SuiviTempsSyncPayload payload, CancellationToken ct = default) => Task.FromResult(Guid.NewGuid()); public Task GetByGuidAsync(Guid suiviTempsGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetAllAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetByInterventionAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task> GetByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDraftsByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task CreateDraftAsync(SuiviTempsDraftEntry draft, CancellationToken ct = default) => Task.CompletedTask; public Task DeleteDraftAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default) => Task.CompletedTask; public Task HasTypeTempsAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default) => Task.FromResult(false); public Task IsValideAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult(false); public Task ValidateRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.CompletedTask; public Task SaveRendezVousPourBrouillonAsync(AgendaItem rendezVous, CancellationToken ct = default) => Task.CompletedTask; public Task> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = 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 FakePhoneLauncher : IPhoneLauncher { public Task DialAsync(string phoneNumber) => Task.FromResult(true); } private sealed class FakeEmailLauncher : IEmailLauncher { public Task ComposeAsync(string email) => Task.FromResult(true); } private sealed class FakeMapLauncher : IMapLauncher { public Task OpenItineraireAsync(string address) => Task.FromResult(true); } private sealed class FakeSessionService : IMobileSessionService { /// Nombre d'appels au rafraîchissement des référentiels (garde hors ligne). public int RefreshBootstrapCalls { get; private set; } /// Exception à lever par le rafraîchissement des référentiels. public Exception? RefreshBootstrapThrows { get; set; } public Task HasValidSessionAsync(CancellationToken ct = default) => Task.FromResult(false); public Task HasExpiredSessionAsync(CancellationToken ct = default) => Task.FromResult(false); public Task ActivateAsync( string codeClient, string numeroLicence, CancellationToken ct = default) => throw new NotImplementedException(); public Task ValidateAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task GetBootstrapAsync(CancellationToken ct = default) => Task.FromResult(null); public Task GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult(null); public Task ClearSessionAsync(CancellationToken ct = default) => Task.CompletedTask; public Task EnsureDevelopmentSessionAsync(CancellationToken ct = default) => Task.CompletedTask; public Task RefreshBootstrapAsync(CancellationToken ct = default) { RefreshBootstrapCalls++; return RefreshBootstrapThrows is { } ex ? Task.FromException(ex) : Task.CompletedTask; } } private readonly UiPreferencesRepository _preferencesRepository; private readonly ThemeService _themeService; private readonly NavigationService _navigation; private readonly FakeSyncEngine _syncEngine; private readonly FakeSessionService _sessionService = new(); private readonly PageViewModelFactory _pageFactory; private readonly ShellViewModel _shell; private readonly PermissionService _permissionService = new(); public ShellViewModelTests() { _preferencesRepository = new UiPreferencesRepository(Database); _themeService = new ThemeService(_preferencesRepository); _navigation = new NavigationService(); _syncEngine = new FakeSyncEngine(); // Droit nécessaire à la navigation vers la page Interventions // (PermissionGuard), requis par Navigate_ChangePageEtViewModel. _permissionService.SetRights(new Dictionary { [MobilePermissionKeys.RechercheRdvExistant] = true }); IAgendaService agendaService = new AgendaService( RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, ApiClient, SessionService); IInterventionLocalService interventionService = new InterventionLocalService( InterventionRepository, ClientRepository, SiteRepository, DossierRepository, RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository, OutboxService, ReconciliationService, ApiClient, InterventionCacheRepository, ContextInterventionCacheRepository, Database); _pageFactory = new PageViewModelFactory( _themeService, agendaService, interventionService, new FakeDocumentService(), new FakeContactsService(), new FakeDossierService(), new FakeClientService(), new FakeSiteService(), new FakeCollaborateurService(), new FakeEquipeService(), new FakeSuiviTempsService(), new FakeParametreMetierService(), InterventionRepository, OutboxRepository, null!, new FakePhoneLauncher(), new FakeEmailLauncher(), new FakeMapLauncher(), null!, _navigation, new FakeSessionService(), _permissionService, _syncEngine, null!, new FakePhotoPolicyProvider(), new PauseDejeunerPreferenceService(PreferencesUtilisateurRepository, SessionService), new RendezVousAnnulationLocalService(RendezVousRepository, OutboxService, new RendezVousStateNotifier()), new RendezVousFinalisationLocalService(RendezVousRepository, new RendezVousStateNotifier()), new CompteRenduScopeService(interventionService), new RendezVousStateNotifier(), null!, Omika.Core.Observability.NullTelemetryService.Instance, Network, new FakeLangueSecondairePreferenceService(), new FakeLanguagePackService(), new FakeTranslationService()); _shell = new ShellViewModel(_navigation, _themeService, _syncEngine, _pageFactory, _sessionService, _permissionService, OutboxService, null!); _pageFactory.SetShell(_shell); } [Fact] public void PageInitiale_Home() { // Assert Assert.Equal(AppPage.Home, _shell.CurrentPage); Assert.IsType(_shell.CurrentPageViewModel); } [Fact] public void Navigate_ChangePageEtViewModel() { // Act _shell.NavigateCommand.Execute(AppPage.Interventions); // Assert Assert.Equal(AppPage.Interventions, _shell.CurrentPage); Assert.IsType(_shell.CurrentPageViewModel); } [Fact] public void Navigate_PageActiveMiseAJour() { // Act _shell.NavigateCommand.Execute(AppPage.Clients); // Assert : l'élément Clients est actif. Assert.True(_navigation.Items.Single(i => i.Page == AppPage.Clients).IsSelected); Assert.False(_navigation.Items.Single(i => i.Page == AppPage.Home).IsSelected); } [Fact] public void NavigationDetail_ConserveSectionOrigine() { // Arrange : partir de l'agenda (Home) vers un rendez-vous, puis une intervention. _navigation.NavigateTo(AppPage.RendezVousDetail, Guid.NewGuid()); _navigation.NavigateTo(AppPage.InterventionDetail, Guid.NewGuid()); // Assert : la section active reste l'agenda (Home), pas Interventions. Assert.True(_navigation.Items.Single(i => i.Page == AppPage.Home).IsSelected); Assert.False(_navigation.Items.Single(i => i.Page == AppPage.Interventions).IsSelected); } [Fact] public void NavigationSectionPuisDetail_ConserveLaSection() { // Arrange : sélection de la section Interventions, puis descente dans un détail. _shell.NavigateCommand.Execute(AppPage.Interventions); _navigation.NavigateTo(AppPage.InterventionDetail, Guid.NewGuid()); // Assert : la section active reste Interventions. Assert.True(_navigation.Items.Single(i => i.Page == AppPage.Interventions).IsSelected); Assert.False(_navigation.Items.Single(i => i.Page == AppPage.Home).IsSelected); } [Fact] public void Navigate_Parametres_RetourneParametresViewModel() { // Act _shell.NavigateCommand.Execute(AppPage.Parametres); // Assert Assert.IsType(_shell.CurrentPageViewModel); } [Fact] public void Navigate_NeDeclencheAucunAppelSync() { // Act : naviguer vers plusieurs pages placeholder. _shell.NavigateCommand.Execute(AppPage.Interventions); _shell.NavigateCommand.Execute(AppPage.Dossiers); _shell.NavigateCommand.Execute(AppPage.Collaborateurs); // Assert : aucun appel au SyncEngine. Assert.Equal(0, _syncEngine.SyncCalls); } [Fact] public void CurrentPageTitle_RefleteLaPageCourante() { // Arrange : le titre initial correspond au libellé réel de la page courante. string expected = _navigation.Items.First(i => i.Page == _shell.CurrentPage).Title; // Act / Assert Assert.Equal(expected, _shell.CurrentPageTitle); } [Fact] public void Navigate_MetAJourLeTitreDePage() { // Act : naviguer vers une autre page. _shell.NavigateCommand.Execute(AppPage.Clients); // Assert : le titre reflète la vraie page sélectionnée. string expected = _navigation.Items.First(i => i.Page == AppPage.Clients).Title; Assert.Equal(expected, _shell.CurrentPageTitle); } [Fact] public async Task SyncNow_AppelleSyncEngineUneFois() { _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, SnapshotApplied = true }; await _shell.SyncNowCommand.ExecuteAsync(null); Assert.Equal(1, _syncEngine.SyncCalls); Assert.Equal(SyncUiState.Synced, _shell.SyncState); // Succès : aucune erreur technique affichée. Assert.Equal("Synchronisé", _shell.SyncStatusText); Assert.Null(_shell.SyncErrorMessage); } [Fact] public async Task SyncNow_BoutonDesactivePendantSync() { // Arrange : sync lente. _syncEngine.Delay = TimeSpan.FromMilliseconds(200); // Act : lancer la sync sans attendre. Task? task = _shell.SyncNowCommand.ExecuteAsync(null); // Assert : pendant la sync, IsSyncing est vrai. Assert.True(_shell.IsSyncing); await task; // Assert : après la sync, IsSyncing est faux. Assert.False(_shell.IsSyncing); } [Fact] public async Task DeuxClicsRapides_UnSeulCycleUI() { // Arrange : sync lente pour laisser le temps au second clic. _syncEngine.Delay = TimeSpan.FromMilliseconds(200); // Act : deux clics rapides. Task t1 = _shell.SyncNowCommand.ExecuteAsync(null); Task t2 = _shell.SyncNowCommand.ExecuteAsync(null); await Task.WhenAll(t1, t2); // Assert : un seul cycle UI (le second est ignoré par le verrou). Assert.Equal(1, _syncEngine.SyncCalls); } [Fact] public async Task SyncNow_Erreur_AfficheeProprement() { // Arrange : le moteur échoue sur une erreur réseau. _syncEngine.ThrowOnSync = true; // Act await _shell.SyncNowCommand.ExecuteAsync(null); // Assert : message utilisateur FRANÇAIS stable (jamais le libellé technique // anglais de la plateforme), état d'erreur, et aucune exception qui remonte. Assert.Equal(SyncUiState.Error, _shell.SyncState); Assert.NotNull(_shell.SyncErrorMessage); Assert.Equal("Hors ligne — impossible de contacter le serveur.", _shell.SyncErrorMessage); } [Fact] public async Task SyncNow_Offline_AfficheSansCrash() { // Arrange : le moteur détecte l'appareil hors ligne AVANT tout appel réseau. _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, Offline = true, Message = "Hors ligne." }; // Act await _shell.SyncNowCommand.ExecuteAsync(null); // Assert : état hors ligne, pas d'exception. Assert.Equal(SyncUiState.Offline, _shell.SyncState); Assert.Equal("Hors ligne", _shell.SyncStatusText); } [Fact] public async Task SyncNow_HorsLigne_NeTentePasLeRafraichissementDesReferentiels() { // Arrange : cycle détecté hors ligne par le moteur. _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, Offline = true, Message = "Hors ligne." }; // Act await _shell.SyncNowCommand.ExecuteAsync(null); // Assert : aucun appel réseau de référentiel n'est tenté (il échouerait à coup // sûr et ne produirait qu'un incident de diagnostic inutile). Assert.Equal(0, _sessionService.RefreshBootstrapCalls); } [Fact] public async Task SyncNow_EchecReseauDuRafraichissement_NEstPasUnIncident() { // Arrange : le cycle métier réussit, mais le réseau tombe pendant le // rafraîchissement des référentiels (payload réel « Connection failure »). _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, SnapshotApplied = true }; _sessionService.RefreshBootstrapThrows = new HttpRequestException("Connection failure"); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { // Act await _shell.SyncNowCommand.ExecuteAsync(null); } finally { ExceptionTelemetry.Initialize(null, null); } // Assert : échec explicitement NON bloquant → la synchronisation reste un succès // et l'indisponibilité réseau est journalisée en Warning, jamais en Error/P1. Assert.Equal(1, _sessionService.RefreshBootstrapCalls); Assert.Equal(SyncUiState.Synced, _shell.SyncState); Assert.Contains( telemetry.Events.OfType(), e => e.Component == nameof(ShellViewModel) && e.Level == TelemetryLevel.Warning && e.ErrorCode == "HttpRequestException"); Assert.DoesNotContain( telemetry.Events.OfType(), e => e.Component == nameof(ShellViewModel) && e.Level == TelemetryLevel.Error); } [Fact] public async Task SyncNow_Rejet_AfficheErreur() { // Arrange _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, OperationsRejected = 2, OperationsAnomaly = 1 }; // Act await _shell.SyncNowCommand.ExecuteAsync(null); // Assert Assert.Equal(SyncUiState.Error, _shell.SyncState); Assert.Contains("2 opération(s) rejetée(s)", _shell.SyncErrorMessage); } [Fact] public async Task SyncNow_SnapshotEchoue_NePresentePasCommeSucces() { // Arrange : le moteur retourne un résultat avec SnapshotApplied=false // (échec du snapshot) mais sans opérations rejetées/anomalies. _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, SnapshotApplied = false, Message = "Snapshot non appliqué : InvalidOperationException - Snapshot invalide : GeneratedAtUtc manquant." }; // Act await _shell.SyncNowCommand.ExecuteAsync(null); // Assert : l'échec du snapshot est présenté comme une erreur, pas comme // un succès. Le message technique est affiché. Assert.Equal(SyncUiState.Error, _shell.SyncState); Assert.Equal("Synchronisation échouée", _shell.SyncStatusText); Assert.NotNull(_shell.SyncErrorMessage); Assert.Contains("Snapshot non appliqué", _shell.SyncErrorMessage); Assert.Contains("InvalidOperationException", _shell.SyncErrorMessage); } [Fact] public async Task SyncNow_SnapshotEchoue_SansMessage_MessageParDefaut() { // Arrange : SnapshotApplied=false sans Message renseigné. _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, SnapshotApplied = false }; // Act await _shell.SyncNowCommand.ExecuteAsync(null); // Assert : un message par défaut est affiché, jamais un succès. Assert.Equal(SyncUiState.Error, _shell.SyncState); Assert.Equal("Synchronisation échouée", _shell.SyncStatusText); Assert.Equal("Snapshot non appliqué.", _shell.SyncErrorMessage); } [Fact] public async Task SyncNow_EchecDownload_StatusTextContientStageEtException() { // Arrange : échec au téléchargement du snapshot, cause structurée conservée. _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, SnapshotApplied = false, FailureStage = "download", ExceptionType = "HttpRequestException", ErrorMessage = "Snapshot error", Message = "Snapshot non appliqué : HttpRequestException - Snapshot error" }; // Act await _shell.SyncNowCommand.ExecuteAsync(null); // Assert : erreur affichée, cause technique concise (stage + type) en // DEBUG, sans le message technique long dans la sidebar. Assert.Equal(SyncUiState.Error, _shell.SyncState); Assert.StartsWith("Synchronisation échouée", _shell.SyncStatusText); Assert.Contains("download", _shell.SyncStatusText); Assert.Contains("HttpRequestException", _shell.SyncStatusText); Assert.DoesNotContain("Snapshot error", _shell.SyncStatusText); Assert.Contains("HttpRequestException", _shell.SyncErrorMessage); Assert.Contains("Snapshot error", _shell.SyncErrorMessage); } [Fact] public async Task SyncNow_EchecApply_StatusTextContientStageEtException() { // Arrange : échec à l'application SQLite du snapshot, cause structurée conservée. _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, SnapshotApplied = false, FailureStage = "apply", ExceptionType = "FormatException", ErrorMessage = "Unrecognized Guid format.", Message = "Snapshot non appliqué : FormatException - Unrecognized Guid format." }; // Act await _shell.SyncNowCommand.ExecuteAsync(null); // Assert : erreur affichée, cause technique concise (stage + type) en // DEBUG, sans le message technique long dans la sidebar. Assert.Equal(SyncUiState.Error, _shell.SyncState); Assert.StartsWith("Synchronisation échouée", _shell.SyncStatusText); Assert.Contains("apply", _shell.SyncStatusText); Assert.Contains("FormatException", _shell.SyncStatusText); Assert.DoesNotContain("Unrecognized Guid format.", _shell.SyncStatusText); Assert.Contains("FormatException", _shell.SyncErrorMessage); Assert.Contains("Unrecognized Guid format.", _shell.SyncErrorMessage); } [Fact] public async Task SetThemeMode_ChangeEtPersiste() { // Act await _shell.SetThemeModeCommand.ExecuteAsync(ThemeMode.Dark); // Assert Assert.Equal(ThemeMode.Dark, _shell.CurrentThemeMode); Assert.Equal(ThemeMode.Dark, _themeService.CurrentMode); } [Fact] public async Task SetPrimaryColor_ChangeEtPersiste() { // Act await _shell.SetPrimaryColorCommand.ExecuteAsync("#0d9488"); // Assert Assert.Equal("#0d9488", _shell.CurrentPrimaryColor); Assert.Equal("#0d9488", _themeService.PrimaryColor); } [Fact] public void AucuneIdentiteFictive() { // Assert : pas d'identité utilisateur fictive. Assert.False(_shell.HasIdentity); Assert.Null(_shell.UserDisplayName); Assert.Null(_shell.UserInitials); } // --------------------------------------------------------------------- // Envoi de fond (SendPendingDataAsync) : il envoie les données locales mais // ne doit JAMAIS interférer avec l'écran en cours ni lancer de // synchronisation globale (celle-ci reste réservée au lancement, au bouton // « Synchroniser » et à l'ouverture de chaque fiche). // --------------------------------------------------------------------- [Fact] public async Task SendPendingData_EnvoieSansToucherALEcranNiALaSyncGlobale() { // Arrange : état de synchronisation globale déjà affiché (ex. lancement). _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, SnapshotApplied = true }; await _shell.SyncNowCommand.ExecuteAsync(null); string statusAvant = _shell.SyncStatusText; SyncUiState etatAvant = _shell.SyncState; var pageAvant = _shell.CurrentPageViewModel; _syncEngine.PushResultToReturn = new SyncCycleResult { Executed = true, OperationsSent = 1, OperationsSucceeded = 1 }; // Act : envoi de fond déclenché par la surveillance automatique. await _shell.SendPendingDataAsync(); // Assert : l'envoi a eu lieu (et pas une synchronisation globale)… Assert.Equal(1, _syncEngine.PushCalls); Assert.Equal(1, _syncEngine.SyncCalls); // …sans aucune interférence avec l'écran en cours. Assert.Equal(etatAvant, _shell.SyncState); Assert.Equal(statusAvant, _shell.SyncStatusText); Assert.Null(_shell.SyncErrorMessage); Assert.False(_shell.IsSyncing); Assert.Same(pageAvant, _shell.CurrentPageViewModel); } [Fact] public async Task SendPendingData_MetAJourLeBadgeEnAttente() { // Arrange : une saisie locale en attente d'envoi. await OutboxService.EnqueueAsync( "Client", SyncOperationType.Create, Guid.NewGuid(), "{}"); // Act await _shell.SendPendingDataAsync(); // Assert : la file est visible pour l'utilisateur (badge « à synchroniser »), // ce qui lui permet de relancer l'envoi manuellement en cas de besoin. Assert.Equal(1, _shell.PendingSyncCount); Assert.True(_shell.HasPendingSync); } [Fact] public async Task SendPendingData_PendantUneSyncGlobale_EstIgnore() { // Arrange : synchronisation globale en cours. _syncEngine.Delay = TimeSpan.FromMilliseconds(200); Task? globale = _shell.SyncNowCommand.ExecuteAsync(null); Assert.True(_shell.IsSyncing); // Act : la surveillance de fond se manifeste pendant ce temps. await _shell.SendPendingDataAsync(); // Assert : aucun cycle d'envoi concurrent (la sync globale envoie déjà tout). Assert.Equal(0, _syncEngine.PushCalls); await globale; } [Fact] public async Task SyncNow_PendantUnEnvoiDeFond_AttendPuisExecuteLaSyncGlobale() { // Arrange : un envoi de fond est en cours (la surveillance automatique // vient de détecter une saisie à envoyer). _syncEngine.Delay = TimeSpan.FromMilliseconds(150); Task envoi = _shell.SendPendingDataAsync(); // Act : pendant ce temps, l'utilisateur demande une synchronisation // complète (bouton « Synchroniser »). _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, SnapshotApplied = true }; await _shell.SyncNowCommand.ExecuteAsync(null); await envoi; // Assert : le clic n'est jamais perdu — attente de la fin de l'envoi de // fond, puis synchronisation globale réellement exécutée. Assert.Equal(1, _syncEngine.PushCalls); Assert.Equal(1, _syncEngine.SyncCalls); Assert.Equal(SyncUiState.Synced, _shell.SyncState); Assert.Equal("Synchronisé", _shell.SyncStatusText); Assert.False(_shell.IsSyncing); } [Fact] public async Task SendPendingData_ErreurDEnvoi_RemonteSansToucherALEcran() { // Arrange : état affiché avant l'envoi. string statusAvant = _shell.SyncStatusText; SyncUiState etatAvant = _shell.SyncState; _syncEngine.ThrowOnPush = true; // Act / Assert : l'échec remonte au surveillant (qui le journalise sans // arrêter la boucle), sans jamais rien afficher ni modifier à l'écran. await Assert.ThrowsAsync(() => _shell.SendPendingDataAsync()); Assert.Equal(etatAvant, _shell.SyncState); Assert.Equal(statusAvant, _shell.SyncStatusText); Assert.Null(_shell.SyncErrorMessage); Assert.False(_shell.IsSyncing); // Le verrou est libéré : la synchronisation globale reste possible. _syncEngine.ResultToReturn = new SyncCycleResult { Executed = true, SnapshotApplied = true }; await _shell.SyncNowCommand.ExecuteAsync(null); Assert.Equal(1, _syncEngine.SyncCalls); } /// /// 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); } }