Files
Omika/tests/Omika.Tests/Mobile/Local/ShellViewModelTests.cs
T
2026-10-23 18:46:50 +02:00

887 lines
37 KiB
C#

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;
/// <summary>
/// Tests du ShellViewModel : navigation, page courante, thème, état de
/// synchronisation et protection contre les doubles clics.
/// </summary>
[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<SyncCycleResult> 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<SyncCycleResult> 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<IReadOnlyList<InterventionDocumentItem>> GetDocumentsForInterventionAsync(
Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<InterventionDocumentItem>>([]);
public Task<bool> EnsureDownloadedAsync(Guid documentGuid, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<bool> OpenAsync(Guid documentGuid, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<string?> GetCachedFilePathAsync(Guid documentGuid, CancellationToken ct = default)
=> Task.FromResult<string?>(null);
public Task<IReadOnlyList<DocumentNoteItem>> GetNotesForDocumentAsync(Guid documentGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DocumentNoteItem>>([]);
public Task<DocumentNoteItem> AddNoteAsync(Guid documentGuid, string contenu, CancellationToken ct = default)
=> Task.FromResult(new DocumentNoteItem());
public Task<InterventionDocumentItem?> AddPhotoAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<InterventionDocumentItem?>(null);
public Task<AddPhotosResult> AddPhotosAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult(new AddPhotosResult());
public Task<InterventionDocumentItem?> AddPhotoFromCameraAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<InterventionDocumentItem?>(null);
public Task<InterventionDocumentItem?> AddDocumentAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<InterventionDocumentItem?>(null);
public Task<InterventionDocumentItem?> AddAnnotatedPhotoAsync(Guid interventionGuid, Guid sourceDocumentGuid, byte[] pngBytes, string fileName, CancellationToken ct = default)
=> Task.FromResult<InterventionDocumentItem?>(null);
}
private sealed class FakeContactsService : IInterventionContactsService
{
public Task<IReadOnlyList<InterventionContact>> GetContactsForInterventionAsync(
Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<InterventionContact>>([]);
public Task<InterventionContact?> GetContactByIdAsync(Guid contactGuid, CancellationToken ct = default)
=> Task.FromResult<InterventionContact?>(null);
public Task<IReadOnlyList<InterventionContact>> GetContactsDisponiblesAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<InterventionContact>>([]);
}
private sealed class FakeDossierService : IDossierLocalService
{
public Task<IReadOnlyList<DossierListItem>> GetDossiersAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierListItem>>([]);
public Task<IReadOnlyList<DossierListItem>> SearchDossiersAsync(string? refInterne, string? titre, string? client, string? statut, string? type, string? refExterne, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierListItem>>([]);
public Task<IReadOnlyList<DossierListItem>> GetLastSearchAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierListItem>>([]);
public Task ClearLastSearchAsync(CancellationToken ct = default)
=> Task.CompletedTask;
public Task<DossierDetail?> GetDossierByIdAsync(Guid dossierGuid, CancellationToken ct = default)
=> Task.FromResult<DossierDetail?>(null);
public Task SetPinnedAsync(Guid dossierGuid, bool isPinned, DossierListItem? item, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<IReadOnlyList<DossierListItem>> GetPinnedAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierListItem>>([]);
public Task<IReadOnlyList<DossierIntervention>> GetDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierIntervention>>([]);
public Task<IReadOnlyList<DossierIntervention>> RefreshDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierIntervention>>([]);
}
private sealed class FakeClientService : IClientLocalService
{
public Task<IReadOnlyList<ClientListItem>> GetClientsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ClientListItem>>([]);
public Task<ClientDetail?> GetClientByIdAsync(Guid clientGuid, CancellationToken ct = default)
=> Task.FromResult<ClientDetail?>(null);
}
private sealed class FakeSiteService : ISiteLocalService
{
public Task<IReadOnlyList<SiteListItem>> GetSitesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SiteListItem>>([]);
public Task<SiteDetail?> GetSiteByIdAsync(Guid siteGuid, CancellationToken ct = default)
=> Task.FromResult<SiteDetail?>(null);
}
private sealed class FakeCollaborateurService : ICollaborateurLocalService
{
public Task<IReadOnlyList<CollaborateurListItem>> GetCollaborateursAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<CollaborateurListItem>>([]);
public Task<CollaborateurDetail?> GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken ct = default)
=> Task.FromResult<CollaborateurDetail?>(null);
}
private sealed class FakeEquipeService : IEquipeLocalService
{
public Task<IReadOnlyList<EquipeListItem>> GetEquipesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<EquipeListItem>>([]);
public Task<IReadOnlyList<EquipeListItem>> GetAllEquipesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<EquipeListItem>>([]);
}
private sealed class FakeSuiviTempsService : ISuiviTempsLocalService
{
public Task<Guid> CreateAsync(SuiviTempsSyncPayload payload, CancellationToken ct = default)
=> Task.FromResult(Guid.NewGuid());
public Task<SuiviTempsDto?> GetByGuidAsync(Guid suiviTempsGuid, CancellationToken ct = default)
=> Task.FromResult<SuiviTempsDto?>(null);
public Task<IReadOnlyList<SuiviTempsDto>> GetAllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDto>> GetByInterventionAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDto>> GetByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDraftEntry>> GetDraftsByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDraftEntry>>([]);
public Task CreateDraftAsync(SuiviTempsDraftEntry draft, CancellationToken ct = default)
=> Task.CompletedTask;
public Task DeleteDraftAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<bool> HasTypeTempsAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<bool> 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<IReadOnlyList<AgendaItem>> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default)
=> Task.CompletedTask;
}
private sealed class FakeParametreMetierService : IParametreMetierLocalService
{
public Task<IReadOnlyList<ReferenceValueDto>> GetTypeTempsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<Omika.Core.Contracts.Configuration.TypeTempsParametresDto?> GetTypeTempsParametresAsync(string typeTempsKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.TypeTempsParametresDto?>(null);
public Task<Omika.Core.Contracts.Configuration.TypeRdvParametresDto?> GetTypeRdvParametresAsync(string typeRdvKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.TypeRdvParametresDto?>(null);
public Task<Omika.Core.Contracts.Configuration.StatutRdvParametresDto?> GetStatutRdvParametresAsync(string statutRdvKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.StatutRdvParametresDto?>(null);
public Task<IReadOnlyList<ReferenceValueDto>> GetInterventionTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetInterventionEtatsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetDossierTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetDossierStatutsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetClientTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetClientStatutsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetSiteTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetTypeSignatairesAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
}
private sealed class FakePhoneLauncher : IPhoneLauncher
{
public Task<bool> DialAsync(string phoneNumber) => Task.FromResult(true);
}
private sealed class FakeEmailLauncher : IEmailLauncher
{
public Task<bool> ComposeAsync(string email) => Task.FromResult(true);
}
private sealed class FakeMapLauncher : IMapLauncher
{
public Task<bool> OpenItineraireAsync(string address) => Task.FromResult(true);
}
private sealed class FakeSessionService : IMobileSessionService
{
/// <summary>Nombre d'appels au rafraîchissement des référentiels (garde hors ligne).</summary>
public int RefreshBootstrapCalls { get; private set; }
/// <summary>Exception à lever par le rafraîchissement des référentiels.</summary>
public Exception? RefreshBootstrapThrows { get; set; }
public Task<bool> HasValidSessionAsync(CancellationToken ct = default) => Task.FromResult(false);
public Task<bool> HasExpiredSessionAsync(CancellationToken ct = default) => Task.FromResult(false);
public Task<Omika.Core.Contracts.Mobile.License.LicenseResponse> ActivateAsync(
string codeClient, string numeroLicence, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<Omika.Core.Contracts.Mobile.License.LicenseResponse> ValidateAsync(CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<LocalBootstrapState?> GetBootstrapAsync(CancellationToken ct = default)
=> Task.FromResult<LocalBootstrapState?>(null);
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
=> Task.FromResult<string?>(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<string, bool>
{
[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<AgendaViewModel>(_shell.CurrentPageViewModel);
}
[Fact]
public void Navigate_ChangePageEtViewModel()
{
// Act
_shell.NavigateCommand.Execute(AppPage.Interventions);
// Assert
Assert.Equal(AppPage.Interventions, _shell.CurrentPage);
Assert.IsType<InterventionsViewModel>(_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<ParametresViewModel>(_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<DiagnosticEvent>(),
e => e.Component == nameof(ShellViewModel)
&& e.Level == TelemetryLevel.Warning
&& e.ErrorCode == "HttpRequestException");
Assert.DoesNotContain(
telemetry.Events.OfType<DiagnosticEvent>(),
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<InvalidOperationException>(() => _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);
}
/// <summary>
/// Capture en mémoire des événements de la télémétrie statique globale
/// (<see cref="ExceptionTelemetry.Initialize"/>), rétablie à <c>null</c> après
/// chaque test — la classe appartient à la collection « TelemetrieStatique ».
/// </summary>
private sealed class CapturingTelemetry : IOmikaTelemetry
{
public List<OmikaEvent> Events { get; } = [];
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
}
}