887 lines
37 KiB
C#
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);
|
|
}
|
|
}
|