using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.App.Navigation; using Omika.App.ViewModels; using Omika.Core.Contracts.Configuration; using Omika.Core.Contracts.Mobile.License; namespace Omika.Tests.Mobile.Local; /// /// Tests du ViewModel Agenda d'équipe : deux filtres indépendants (équipe + /// collaborateur), exclusion mutuelle, chargement à la demande (API), groupement /// par date et navigation vers les fiches rendez-vous. /// public class AgendaEquipeViewModelTests { private readonly NavigationService _navigation = new(); public AgendaEquipeViewModelTests() { // L'état de session est statique : on le réinitialise avant chaque test // pour éviter les fuites entre scénarios. AgendaEquipeViewModel.ResetSessionState(); } private AgendaEquipeViewModel CreateViewModel( IReadOnlyList equipes, IReadOnlyList collaborateurs, IReadOnlyList agendaItems, DateTime? date = null, IReadOnlyList? parametresMetier = null, StubParametreMetierService? parametreMetierService = null) { var viewModel = new AgendaEquipeViewModel( new FakeAgendaService(agendaItems), new FakeEquipeService(equipes), new FakeCollaborateurService(collaborateurs), _navigation, new FakeSessionService(parametresMetier ?? [MobileTypeRdvParam()]), parametreMetierService ?? new StubParametreMetierService(), new RendezVousStateNotifier()); if (date is not null) { viewModel.SelectedDateOffset = date; } return viewModel; } private static CollaborateurListItem Membre(Guid id, string nom) => new() { CollaborateurGuid = id, DisplayName = nom }; private static AgendaItem Item(Guid id, DateTime debut, params Guid[] collaborateurs) => new() { RendezVousGuid = id, Titre = "RDV", Debut = debut, Fin = debut.AddHours(1), TypeActivite = "Intervention", TypeActiviteKey = "T.RDV.INTER", CollaborateurGuids = collaborateurs }; private static ParametreMetierDto MobileTypeRdvParam() => new() { Id = "T.RDV.INTER", Code = "14", Categorie = "T.RDV", Libelle = "Intervention", Parametre = """{"Envoi_mobile":1}""" }; [Fact] public async Task Initialise_ConstruitLesDeuxFiltres() { // Arrange Guid alice = Guid.NewGuid(); Guid bob = Guid.NewGuid(); Guid solo = Guid.NewGuid(); var equipe = new EquipeListItem { EquipeGuid = Guid.NewGuid(), Nom = "Équipe A", Membres = [Membre(alice, "Alice"), Membre(bob, "Bob")] }; // Act AgendaEquipeViewModel vm = CreateViewModel([equipe], [Membre(solo, "Solo")], []); await vm.InitializeAsync(); // Assert : deux listes distinctes, aucune sélection par défaut. Assert.Single(vm.Equipes); Assert.Equal("👥 Équipe A", vm.Equipes[0].Label); Assert.Equal( new[] { alice, bob }.OrderBy(g => g).ToArray(), vm.Equipes[0].CollaborateurGuids.OrderBy(g => g).ToArray()); Assert.Single(vm.Collaborateurs); Assert.Equal("👤 Solo", vm.Collaborateurs[0].Label); Assert.Null(vm.SelectedEquipe); Assert.Null(vm.SelectedCollaborateur); Assert.False(vm.HasSelection); } [Fact] public async Task FiltreParEquipe_ChargeLesRendezVousDeLEquipe() { // Arrange Guid alice = Guid.NewGuid(); Guid autre = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); var equipe = new EquipeListItem { EquipeGuid = Guid.NewGuid(), Nom = "Équipe A", Membres = [Membre(alice, "Alice")] }; var items = new[] { Item(Guid.NewGuid(), jour.AddHours(9), alice), Item(Guid.NewGuid(), jour.AddHours(11), autre) }; AgendaEquipeViewModel vm = CreateViewModel([equipe], [], items, jour); await vm.InitializeAsync(); // Act : sélection de l'équipe. vm.SelectedEquipe = vm.Equipes[0]; // Assert : seul le rendez-vous de la membre de l'équipe est chargé. Assert.True(vm.HasAppointments); Assert.Single(vm.DayGroups); AgendaItem item = Assert.Single(vm.DayGroups[0].Items); Assert.Equal(alice, item.CollaborateurGuids.Single()); } [Fact] public async Task FiltreParCollaborateur_ChargeSesRendezVous() { // Arrange Guid solo = Guid.NewGuid(); Guid autre = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); var items = new[] { Item(Guid.NewGuid(), jour.AddHours(9), solo), Item(Guid.NewGuid(), jour.AddHours(11), autre) }; AgendaEquipeViewModel vm = CreateViewModel([], [Membre(solo, "Solo")], items, jour); await vm.InitializeAsync(); // Act : sélection du collaborateur. vm.SelectedCollaborateur = vm.Collaborateurs[0]; // Assert Assert.True(vm.HasAppointments); Assert.Single(vm.DayGroups); AgendaItem item = Assert.Single(vm.DayGroups[0].Items); Assert.Equal(solo, item.CollaborateurGuids.Single()); } [Fact] public async Task SelectionEquipe_EffaceLeCollaborateur() { // Arrange Guid alice = Guid.NewGuid(); Guid solo = Guid.NewGuid(); var equipe = new EquipeListItem { EquipeGuid = Guid.NewGuid(), Nom = "Équipe A", Membres = [Membre(alice, "Alice")] }; AgendaEquipeViewModel vm = CreateViewModel([equipe], [Membre(solo, "Solo")], []); await vm.InitializeAsync(); // Act : collaborateur puis équipe. vm.SelectedCollaborateur = vm.Collaborateurs[0]; vm.SelectedEquipe = vm.Equipes[0]; // Assert : l'équipe l'emporte, le collaborateur est effacé. Assert.Equal(vm.Equipes[0], vm.SelectedEquipe); Assert.Null(vm.SelectedCollaborateur); } [Fact] public async Task SelectionCollaborateur_EffaceLEquipe() { // Arrange Guid alice = Guid.NewGuid(); Guid solo = Guid.NewGuid(); var equipe = new EquipeListItem { EquipeGuid = Guid.NewGuid(), Nom = "Équipe A", Membres = [Membre(alice, "Alice")] }; AgendaEquipeViewModel vm = CreateViewModel([equipe], [Membre(solo, "Solo")], []); await vm.InitializeAsync(); // Act : équipe puis collaborateur. vm.SelectedEquipe = vm.Equipes[0]; vm.SelectedCollaborateur = vm.Collaborateurs[0]; // Assert : le collaborateur l'emporte, l'équipe est effacée. Assert.Equal(vm.Collaborateurs[0], vm.SelectedCollaborateur); Assert.Null(vm.SelectedEquipe); } [Fact] public async Task EquipeSelectionnee_ExposeSesMembres() { // Arrange Guid alice = Guid.NewGuid(); Guid bob = Guid.NewGuid(); var equipe = new EquipeListItem { EquipeGuid = Guid.NewGuid(), Nom = "Équipe A", Membres = [Membre(alice, "Alice"), Membre(bob, "Bob")] }; AgendaEquipeViewModel vm = CreateViewModel([equipe], [], []); await vm.InitializeAsync(); // Act vm.SelectedEquipe = vm.Equipes[0]; // Assert : les noms des membres sont exposés pour l'affichage. Assert.True(vm.HasSelectedEquipeMembres); Assert.Equal(["Alice", "Bob"], vm.SelectedEquipeMembres); } [Fact] public async Task EquipeAvecSociete_LibelleInclutLaSociete() { // Arrange var equipe = new EquipeListItem { EquipeGuid = Guid.NewGuid(), Nom = "Pose", SocieteNom = "Groupe GAP", Membres = [Membre(Guid.NewGuid(), "Alice")] }; // Act AgendaEquipeViewModel vm = CreateViewModel([equipe], [], []); await vm.InitializeAsync(); // Assert AgendaScopeOption option = Assert.Single(vm.Equipes); Assert.Equal("👥 Pose (Groupe GAP)", option.Label); } [Fact] public async Task Groupement_ParDate() { // Arrange : deux jours distincts de la même semaine, même collaborateur. Guid solo = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); // mercredi var items = new[] { Item(Guid.NewGuid(), jour.AddHours(9), solo), Item(Guid.NewGuid(), jour.AddDays(1).AddHours(9), solo) }; AgendaEquipeViewModel vm = CreateViewModel([], [Membre(solo, "Solo")], items, jour); await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; // Assert : deux groupes (un par jour), ordonnés chronologiquement. Assert.Equal(2, vm.DayGroups.Count); Assert.Equal(jour, vm.DayGroups[0].Date); Assert.Equal(jour.AddDays(1), vm.DayGroups[1].Date); } [Fact] public async Task SansSelection_EtatVide() { // Act AgendaEquipeViewModel vm = CreateViewModel([], [], [], new DateTime(2026, 8, 12)); await vm.InitializeAsync(); // Assert Assert.False(vm.HasAppointments); Assert.True(vm.IsEmpty); Assert.False(vm.HasSelection); Assert.Equal("Aucun périmètre sélectionné", vm.EmptyStateTitle); } [Fact] public async Task AvecSelectionSansRdv_EtatVide() { // Arrange Guid solo = Guid.NewGuid(); // Act AgendaEquipeViewModel vm = CreateViewModel([], [Membre(solo, "Solo")], [], new DateTime(2026, 8, 12)); await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; // Assert Assert.True(vm.IsEmpty); Assert.True(vm.HasSelection); Assert.Equal("Aucun rendez-vous", vm.EmptyStateTitle); } [Fact] public async Task PreviousNextWeek_ChangeLaSemaine() { // Arrange DateTime jour = new(2026, 8, 12); AgendaEquipeViewModel vm = CreateViewModel([], [Membre(Guid.NewGuid(), "Solo")], [], jour); await vm.InitializeAsync(); // Act await vm.NextWeekCommand.ExecuteAsync(null); // Assert : la date a avancé de 7 jours. Assert.Equal(jour.AddDays(7), vm.SelectedDate); // Act await vm.PreviousWeekCommand.ExecuteAsync(null); // Assert : retour à la semaine initiale. Assert.Equal(jour, vm.SelectedDate); } [Fact] public async Task OpenRendezVous_NavigueVersDetail() { // Arrange Guid rdvId = Guid.NewGuid(); Guid solo = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); AgendaEquipeViewModel vm = CreateViewModel( [], [Membre(solo, "Solo")], [Item(rdvId, jour.AddHours(9), solo)], jour); await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; // Act AgendaItem item = vm.DayGroups[0].Items[0]; vm.OpenRendezVousCommand.Execute(item); // Assert : la fiche reçoit l'élément complet (déjà enrichi) pour ne pas // recharger par GUID (hors snapshot local). Assert.Equal(AppPage.RendezVousDetail, _navigation.CurrentPage); Assert.Same(item, _navigation.CurrentParameter); } [Fact] public async Task OpenRendezVous_NonEnvoyeMobile_BloqueNavigation() { // Arrange : un rendez-vous de type « CP » (non « Envoi_mobile ») de l'équipe. Guid rdvId = Guid.NewGuid(); Guid solo = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); var item = new AgendaItem { RendezVousGuid = rdvId, Titre = "CP", Debut = jour.AddHours(9), Fin = jour.AddHours(10), TypeActivite = "CP", TypeActiviteKey = "CP", CollaborateurGuids = [solo] }; AgendaEquipeViewModel vm = CreateViewModel( [], [Membre(solo, "Solo")], [item], jour); await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; // Act AgendaItem loaded = vm.DayGroups[0].Items[0]; Assert.False(loaded.EstEnvoyeMobile); vm.OpenRendezVousCommand.Execute(loaded); // Assert : aucune navigation vers le détail (le RDV reste consultable uniquement). Assert.Equal(AppPage.Home, _navigation.CurrentPage); } [Fact] public async Task Back_SansHistorique_RetourneAccueil() { // Act AgendaEquipeViewModel vm = CreateViewModel([], [], []); await vm.InitializeAsync(); vm.BackCommand.Execute(null); // Assert Assert.Equal(AppPage.Home, _navigation.CurrentPage); } [Fact] public async Task ShowCalendar_PasseEnModeCalendrier() { // Arrange AgendaEquipeViewModel vm = CreateViewModel([], [Membre(Guid.NewGuid(), "Solo")], []); await vm.InitializeAsync(); // Act vm.ShowCalendarCommand.Execute(null); // Assert Assert.True(vm.IsCalendarMode); Assert.False(vm.IsListMode); } [Fact] public async Task MonthDays_Contient42Jours_EnModeCalendrier() { // Arrange Guid solo = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); AgendaEquipeViewModel vm = CreateViewModel( [], [Membre(solo, "Solo")], [Item(Guid.NewGuid(), jour.AddHours(9), solo)], jour); await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; // Act vm.ShowCalendarCommand.Execute(null); // Assert Assert.True(vm.IsCalendarMode); Assert.Equal(42, vm.MonthDays.Count); Assert.Equal(new DateTime(2026, 7, 27), vm.MonthDays[0].Date); AgendaMonthDayItem cell = vm.MonthDays.Single(d => d.Date == jour); Assert.True(cell.HasAppointments); Assert.Single(cell.Appointments); } [Fact] public async Task SelectCalendarDay_OuvreLaVueJour() { // Arrange Guid solo = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); AgendaEquipeViewModel vm = CreateViewModel([], [Membre(solo, "Solo")], [], jour); await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; vm.ShowCalendarCommand.Execute(null); AgendaMonthDayItem target = vm.MonthDays.Single(d => d.Date == new DateTime(2026, 8, 13)); // Act vm.SelectCalendarDayCommand.Execute(target); // Assert Assert.False(vm.IsCalendarMode); Assert.True(vm.IsDayMode); Assert.Equal(new DateTime(2026, 8, 13), vm.SelectedDate); } private sealed class FakeAgendaService : IAgendaService { private readonly IReadOnlyList _items; public FakeAgendaService(IReadOnlyList items) { _items = items; } public Task> GetRendezVousForCollaborateursAsync( IReadOnlyList collaborateurGuids, DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default) { var set = collaborateurGuids.ToHashSet(); return Task.FromResult>( _items.Where(i => i.CollaborateurGuids.Any(g => set.Contains(g))).ToList()); } public Task> GetRendezVousForCollaborateurAsync(Guid collaborateurGuid, DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetRendezVousForDayAsync(DateTime date, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetRendezVousForRangeAsync(DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetRendezVousByIdAsync(Guid rendezVousGuid, CancellationToken cancellationToken = default, string? affectationId = null) => throw new NotSupportedException(); public Task EnrichSiteAccessAsync(AgendaItem item, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class FakeEquipeService : IEquipeLocalService { private readonly IReadOnlyList _equipes; public FakeEquipeService(IReadOnlyList equipes) { _equipes = equipes; } public Task> GetEquipesAsync(CancellationToken cancellationToken = default) => Task.FromResult(_equipes); public Task> GetAllEquipesAsync(CancellationToken cancellationToken = default) => Task.FromResult(_equipes); } private sealed class FakeCollaborateurService : ICollaborateurLocalService { private readonly IReadOnlyList _collaborateurs; public FakeCollaborateurService(IReadOnlyList collaborateurs) { _collaborateurs = collaborateurs; } public Task> GetCollaborateursAsync(CancellationToken cancellationToken = default) => Task.FromResult(_collaborateurs); public Task GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class FakeSessionService : IMobileSessionService { private readonly IReadOnlyList _parametresMetier; public FakeSessionService(IReadOnlyList parametresMetier) => _parametresMetier = parametresMetier; public Task GetBootstrapAsync(CancellationToken cancellationToken = default) => Task.FromResult(new LocalBootstrapState { ParametresMetier = _parametresMetier }); public Task HasValidSessionAsync(CancellationToken cancellationToken = default) => Task.FromResult(false); public Task HasExpiredSessionAsync(CancellationToken cancellationToken = default) => Task.FromResult(false); public Task ActivateAsync(string codeClient, string numeroLicence, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task ValidateAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task GetAccessTokenAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); public Task ClearSessionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task EnsureDevelopmentSessionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } [Fact] public async Task Load_AnnulationSousIOException_TokenAnnule_MasqueLErreur() { Guid solo = Guid.NewGuid(); var agenda = new FakeThrowingAgendaService(); var vm = new AgendaEquipeViewModel( agenda, new FakeEquipeService([]), new FakeCollaborateurService([Membre(solo, "Solo")]), _navigation, new FakeSessionService([MobileTypeRdvParam()]), new StubParametreMetierService(), new RendezVousStateNotifier()); // Sélection d'un collaborateur sans erreur (le service ne lève pas encore). await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; // Puis annulation remontée sous IOException pendant un refresh annulé. agenda.ExceptionToThrow = new IOException("Canceled"); using var cts = new CancellationTokenSource(); cts.Cancel(); await vm.RefreshAsync(cts.Token); Assert.False(vm.IsLoading); Assert.Null(vm.ErrorMessage); } /// /// Une indisponibilité réseau n'est pas une erreur utilisateur : l'agenda d'équipe /// signale l'état hors ligne (bandeau) sans message bloquant et sans état vide /// trompeur — même langage que l'agenda personnel. /// [Fact] public async Task Load_IOExceptionReelle_TokenNonAnnule_SignaleHorsLigne() { Guid solo = Guid.NewGuid(); var agenda = new FakeThrowingAgendaService { ExceptionToThrow = new IOException("Network unreachable") }; var vm = new AgendaEquipeViewModel( agenda, new FakeEquipeService([]), new FakeCollaborateurService([Membre(solo, "Solo")]), _navigation, new FakeSessionService([MobileTypeRdvParam()]), new StubParametreMetierService(), new RendezVousStateNotifier()); await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; await vm.RefreshAsync(CancellationToken.None); Assert.True(vm.IsOffline); Assert.Null(vm.ErrorMessage); // Hors ligne ≠ planning vide : ne pas afficher « aucun rendez-vous ». Assert.False(vm.IsEmpty); } /// /// Contre-épreuve : une erreur NON réseau reste un incident affiché, avec son /// message (elle n'est pas traduite et n'active pas le bandeau hors ligne). /// [Fact] public async Task Load_ErreurMetier_ConserveLeMessageEtLErreur() { Guid solo = Guid.NewGuid(); var agenda = new FakeThrowingAgendaService { ExceptionToThrow = new InvalidOperationException("panne métier") }; var vm = new AgendaEquipeViewModel( agenda, new FakeEquipeService([]), new FakeCollaborateurService([Membre(solo, "Solo")]), _navigation, new FakeSessionService([MobileTypeRdvParam()]), new StubParametreMetierService(), new RendezVousStateNotifier()); await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; await vm.RefreshAsync(CancellationToken.None); Assert.Equal("panne métier", vm.ErrorMessage); Assert.False(vm.IsOffline); } /// /// Aucune URL d'API configurée (appareil non inscrit) : état actionnable, pas /// « hors ligne » — l'appareil peut être en ligne et l'utilisateur doit savoir /// qu'il lui reste à rescanner le QR. /// [Fact] public async Task Load_SansUrlDApi_SignaleLInscriptionIncompleteSansBandeauHorsLigne() { Guid solo = Guid.NewGuid(); var agenda = new FakeThrowingAgendaService { ExceptionToThrow = new MobileEnrollmentRequiredException() }; var vm = new AgendaEquipeViewModel( agenda, new FakeEquipeService([]), new FakeCollaborateurService([Membre(solo, "Solo")]), _navigation, new FakeSessionService([MobileTypeRdvParam()]), new StubParametreMetierService(), new RendezVousStateNotifier()); await vm.InitializeAsync(); vm.SelectedCollaborateur = vm.Collaborateurs[0]; await vm.RefreshAsync(CancellationToken.None); Assert.False(vm.IsOffline); Assert.Equal( "Inscription incomplète — rescannez le QR code pour reconnecter l'application.", vm.ErrorMessage); } /// Service d'agenda jetable dont la lecture d'équipe peut lever une exception configurée. private sealed class FakeThrowingAgendaService : IAgendaService { public Exception? ExceptionToThrow { get; set; } public Task> GetRendezVousForCollaborateursAsync( IReadOnlyList collaborateurGuids, DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default) { if (ExceptionToThrow is { } ex) { throw ex; } return Task.FromResult>([]); } public Task> GetRendezVousForCollaborateurAsync(Guid collaborateurGuid, DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetRendezVousForDayAsync(DateTime date, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetRendezVousForRangeAsync(DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetRendezVousByIdAsync(Guid rendezVousGuid, CancellationToken cancellationToken = default, string? affectationId = null) => throw new NotSupportedException(); public Task EnrichSiteAccessAsync(AgendaItem item, CancellationToken cancellationToken = default) => Task.CompletedTask; } }