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.Contracts.Common; using Omika.Core.Contracts.Mobile.License; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Contracts.Planning; namespace Omika.Tests.Mobile.Local; /// /// Tests du RootViewModel : décision de démarrage (activation OU Shell), /// bascule après activation réussie et comportement offline. /// public class RootViewModelTests : LocalTestBase { private sealed class FakeSyncEngine : ISyncEngine { public Task SyncAsync(CancellationToken ct = default) => Task.FromResult(new SyncCycleResult { Executed = true }); public Task PushAsync(CancellationToken cancellationToken = default) => Task.FromResult(new SyncCycleResult { Executed = true }); } 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 { public bool HasValid { get; set; } public bool HasExpired { get; set; } public LicenseResponse ActivateToReturn { get; set; } = new(); public SessionRevalidationResult EnsureValidSessionResult { get; set; } = new() { Outcome = SessionRevalidationOutcome.NoSession }; public Task HasValidSessionAsync(CancellationToken ct = default) => Task.FromResult(HasValid); public Task HasExpiredSessionAsync(CancellationToken ct = default) => Task.FromResult(HasExpired); public Task EnsureValidSessionAsync(CancellationToken ct = default) => Task.FromResult(EnsureValidSessionResult); public Task ActivateAsync( string codeClient, string numeroLicence, CancellationToken ct = default) => Task.FromResult(ActivateToReturn); 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; } private sealed class FakeEnrollmentService : IEnrollmentService { public Task LoadAsync(CancellationToken ct = default) => Task.FromResult(null); public Task EnrollAsync(string qrContent, CancellationToken ct = default) => Task.FromResult(EnrollmentResult.Failed("refusée")); public Task ClearAsync(CancellationToken ct = default) => Task.CompletedTask; } private sealed class FakeQrScanner : IEnrollmentQrScanner { public Task ScanAsync(IQrScannerPreviewSink? previewSink, CancellationToken ct = default) => Task.FromResult(null); } private readonly FakeSessionService _session = new(); private readonly RootViewModel _root; public RootViewModelTests() { var preferencesRepository = new UiPreferencesRepository(Database); var themeService = new ThemeService(preferencesRepository); var navigation = new NavigationService(); var syncEngine = new FakeSyncEngine(); 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); var 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, _session, new 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()); var shell = new ShellViewModel(navigation, themeService, syncEngine, pageFactory, _session, new PermissionService(), OutboxService, null!); pageFactory.SetShell(shell); var activation = new LicenseActivationViewModel(new FakeEnrollmentService(), new FakeQrScanner()); _root = new RootViewModel(_session, shell, activation); } [Fact] public void AuDemarrage_AfficheActivation() { // Assert : par défaut, on affiche l'activation (pas de flash du Shell). Assert.IsType(_root.CurrentViewModel); Assert.True(_root.IsActivationRequired); } [Fact] public async Task SessionValide_AuDemarrage_BasculeVersShell() { // Arrange _session.HasValid = true; // Act await _root.EvaluateSessionAsync(); // Assert Assert.IsType(_root.CurrentViewModel); Assert.False(_root.IsActivationRequired); } [Fact] public async Task SessionInvalide_AuDemarrage_ResteSurActivation() { // Arrange _session.HasValid = false; // Act await _root.EvaluateSessionAsync(); // Assert Assert.IsType(_root.CurrentViewModel); Assert.True(_root.IsActivationRequired); } [Fact] public async Task SessionExpiree_DansGrace_BasculeVersShell() { // Arrange : session valide offline (dans la période de grace). _session.HasValid = true; // Act await _root.EvaluateSessionAsync(); // Assert Assert.IsType(_root.CurrentViewModel); Assert.False(_root.IsActivationRequired); } [Fact] public void ActivationReussie_BasculeVersShell() { // Act _root.OnActivationSucceeded(); // Assert Assert.IsType(_root.CurrentViewModel); Assert.False(_root.IsActivationRequired); } [Fact] public async Task Startup_RejetLicenceServeur_AfficheActivationAvecNoticeEtQr() { // Arrange : le serveur confirme une licence terminalement invalide. _session.EnsureValidSessionResult = new SessionRevalidationResult { Outcome = SessionRevalidationOutcome.InvalidLicense, License = new LicenseResponse { IsValid = false, ErrorCode = "E3003", ErrorMessage = "Licence expirée." } }; // Act await _root.EvaluateSessionAsync(); // Assert : activation, QR disponible (seul parcours), notice posée. Assert.IsType(_root.CurrentViewModel); Assert.True(_root.IsActivationRequired); Assert.NotNull(_root.Activation.Notice); Assert.True(_root.Activation.IsEnrollmentEnabled); } [Fact] public async Task Startup_HorsLigne_HorsGrace_AfficheConnexionRequiseSansQr() { // Arrange : token expiré, serveur injoignable, hors période de grace. _session.EnsureValidSessionResult = new SessionRevalidationResult { Outcome = SessionRevalidationOutcome.TransientError }; _session.HasValid = false; _session.HasExpired = true; // Act await _root.EvaluateSessionAsync(); // Assert : pas de QR, message « Connexion requise ». Assert.IsType(_root.CurrentViewModel); Assert.True(_root.IsActivationRequired); Assert.False(_root.Activation.IsEnrollmentEnabled); Assert.Contains("Connexion requise", _root.Activation.Notice); } [Fact] public async Task Startup_RenouvellementReussi_SessionValide_BasculeShell() { // Arrange _session.EnsureValidSessionResult = new SessionRevalidationResult { Outcome = SessionRevalidationOutcome.Success }; _session.HasValid = true; // Act await _root.EvaluateSessionAsync(); // Assert Assert.IsType(_root.CurrentViewModel); Assert.False(_root.IsActivationRequired); } }