using System.Net.Http; using System.Text.Json; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; 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.Configuration; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Contracts.Planning; using Omika.Core.Observability; namespace Omika.Tests.Mobile.Local; /// /// Tests du ViewModel Agenda : chargement, navigation jour précédent/suivant, /// état vide, état d'erreur et ouverture du détail. /// [Collection("TelemetrieStatique")] public class AgendaViewModelTests : LocalTestBase { private readonly AgendaViewModel _viewModel; private readonly NavigationService _navigation; private readonly FakeSessionService _sessionService = new(); private readonly FakeSuiviTempsService _suiviTempsService = new(); private readonly StubParametreMetierService _parametreMetierService = new(); public AgendaViewModelTests() { IAgendaService agendaService = new AgendaService( RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, ApiClient, SessionService); _navigation = new NavigationService(); _viewModel = new AgendaViewModel(agendaService, _sessionService, _suiviTempsService, _parametreMetierService, _navigation, new PermissionService(), new RendezVousStateNotifier(), Network); } private static LocalRendezVous CreateRendezVous(Guid id, DateTime debut, DateTime fin, string titre, string? interventionId = null) { var dto = new RendezVousDetailsDto { Id = id.ToString(), Titre = titre, Debut = debut, Fin = fin, InterventionId = interventionId, Statut = new ReferenceValueDto { Label = "Planifié" } }; return new LocalRendezVous { Id = id, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }; } private sealed class FakeSessionService : IMobileSessionService { public Guid? CollaborateurGuid { get; set; } public Task HasValidSessionAsync(CancellationToken ct = default) => Task.FromResult(false); public Task HasExpiredSessionAsync(CancellationToken ct = default) => Task.FromResult(false); public Task ActivateAsync( string codeClient, string numeroLicence, CancellationToken ct = default) => throw new NotImplementedException(); public Task ValidateAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task GetBootstrapAsync(CancellationToken ct = default) => Task.FromResult(CollaborateurGuid is { } guid ? new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = guid.ToString("D") } } : 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 FakeSuiviTempsService : ISuiviTempsLocalService { public IReadOnlyList RendezVousAvecBrouillon { get; set; } = []; 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(RendezVousAvecBrouillon); public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default) => Task.CompletedTask; } [Fact] public async Task Load_JourAvecRendezVous_PeutLaCollection() { // Arrange DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV 1")); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(11), jour.AddHours(12), "RDV 2")); // Date déterministe : indépendante de la date système. _viewModel.SelectedAgendaDateOffset = jour; // Act await _viewModel.LoadCommand.ExecuteAsync(null); // Assert Assert.False(_viewModel.IsLoading); Assert.Null(_viewModel.ErrorMessage); Assert.True(_viewModel.HasRendezVous); Assert.Equal(2, _viewModel.RendezVous.Count); } [Fact] public async Task Load_JourSansRendezVous_EtatVide() { // Act await _viewModel.LoadCommand.ExecuteAsync(null); // Assert Assert.False(_viewModel.HasRendezVous); Assert.Empty(_viewModel.RendezVous); Assert.Null(_viewModel.ErrorMessage); } [Fact] public async Task Load_RendezVousPlusieursJours_VisibleSurChaqueJourDeLIntervalle() { // Arrange : un rendez-vous groupé du 12/08 08:00 au 14/08 17:00. Guid rdvId = Guid.NewGuid(); DateTime premierJour = new(2026, 8, 12); DateTime dernierJour = new(2026, 8, 14); await RendezVousRepository.UpsertAsync( CreateRendezVous(rdvId, premierJour.AddHours(8), dernierJour.AddHours(17), "Chantier 3 jours")); // Assert : présent sur CHAQUE journée couverte… foreach (DateTime jour in new[] { premierJour, premierJour.AddDays(1), dernierJour }) { _viewModel.SelectedAgendaDateOffset = jour; await _viewModel.LoadCommand.ExecuteAsync(null); Assert.True(_viewModel.HasRendezVous); Assert.Contains(_viewModel.RendezVous, r => r.RendezVousGuid == rdvId); } // … et absent de la journée qui précède son début. _viewModel.SelectedAgendaDateOffset = premierJour.AddDays(-1); await _viewModel.LoadCommand.ExecuteAsync(null); Assert.Empty(_viewModel.RendezVous); } [Fact] public async Task Load_HorsFenetre_ConserveLeRendezVousAvecBrouillon() { // Arrange : un RDV conservé (porteur de brouillon non envoyé) sur une date // ancienne, hors snapshot local. Guid rdvId = Guid.NewGuid(); DateTime jour = new(2024, 7, 1); _suiviTempsService.RendezVousAvecBrouillon = [ new AgendaItem { RendezVousGuid = rdvId, Titre = "RDV conservé", Debut = jour.AddHours(9), Fin = jour.AddHours(10) } ]; // Act : ouvre l'agenda sur cette date. _viewModel.SelectedAgendaDateOffset = jour; await _viewModel.LoadCommand.ExecuteAsync(null); // Assert : le RDV conservé est bien ré-affiché. Assert.True(_viewModel.HasRendezVous); Assert.Contains(_viewModel.RendezVous, r => r.RendezVousGuid == rdvId); } [Fact] public async Task Load_DateHistoriqueHorsSnapshot_RepliApiAvecResultat() { // Arrange : un rendez-vous historique n'existe que via l'API (pas en local). Guid collaborateurGuid = Guid.NewGuid(); Guid rdvId = Guid.NewGuid(); DateTime jour = new(2024, 7, 1); var api = new HistoricalRendezVousApiClient(rdvId, jour); IAgendaService agendaService = new AgendaService( RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, api, SessionService); var session = new FakeSessionService { CollaborateurGuid = collaborateurGuid }; var vm = new AgendaViewModel(agendaService, session, _suiviTempsService, _parametreMetierService, _navigation, new PermissionService(), new RendezVousStateNotifier(), Network); // Act vm.SelectedAgendaDateOffset = jour; await vm.LoadCommand.ExecuteAsync(null); // Assert : le repli API a ramené le rendez-vous historique. Assert.True(vm.HasRendezVous); Assert.Single(vm.RendezVous); Assert.Equal(rdvId, vm.RendezVous[0].RendezVousGuid); } [Fact] public async Task Load_DateHistorique_BrouillonNeMasquePasLeRepliApi() { // Arrange : aucun rendez-vous local (hors snapshot), mais : // - une référence de brouillon de saisie de temps (donnée PÉRIMÉE) ; // - l'API renvoie la liste complète et À JOUR pour cette date. Guid collaborateurGuid = Guid.NewGuid(); Guid draftGuid = Guid.NewGuid(); Guid autreGuid = Guid.NewGuid(); DateTime jour = new(2024, 7, 1); _suiviTempsService.RendezVousAvecBrouillon = [ new AgendaItem { RendezVousGuid = draftGuid, Titre = "TITRE PÉRIMÉ", Debut = jour.AddHours(9), Fin = jour.AddHours(10) } ]; var api = new MultiRendezVousApiClient( [ new RendezVousDetailsDto { Id = draftGuid.ToString(), Titre = "TITRE À JOUR", Debut = jour.AddHours(9), Fin = jour.AddHours(10), Statut = new ReferenceValueDto { Label = "Confirmé" } }, new RendezVousDetailsDto { Id = autreGuid.ToString(), Titre = "Autre RDV", Debut = jour.AddHours(11), Fin = jour.AddHours(12), Statut = new ReferenceValueDto { Label = "Confirmé" } } ]); IAgendaService agendaService = new AgendaService( RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, api, SessionService); var session = new FakeSessionService { CollaborateurGuid = collaborateurGuid }; var vm = new AgendaViewModel(agendaService, session, _suiviTempsService, _parametreMetierService, _navigation, new PermissionService(), new RendezVousStateNotifier(), Network); // Act vm.SelectedAgendaDateOffset = jour; await vm.LoadCommand.ExecuteAsync(null); // Assert : la liste complète est affichée (2 RDV) et la donnée fraîche // prime sur l'instantané de brouillon (pas de titre périmé). Assert.Equal(2, vm.RendezVous.Count); AgendaItem draft = Assert.Single(vm.RendezVous, r => r.RendezVousGuid == draftGuid); Assert.Equal("TITRE À JOUR", draft.Titre); Assert.Contains(vm.RendezVous, r => r.RendezVousGuid == autreGuid); } [Fact] public async Task Load_CleMetierAbsente_UtiliseLeLibelleBatik() { // Arrange : schéma Batik — la clé canonique (Key) est vide et la clé // métier est portée par le libellé (PA_Libelle). Le mapping doit retomber // sur le libellé pour que la résolution des couleurs type/statut puisse // retrouver le paramètre métier. Guid rdvId = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); var dto = new RendezVousDetailsDto { Id = rdvId.ToString(), Titre = "RDV Batik", Debut = jour.AddHours(9), Fin = jour.AddHours(10), TypeActivite = new ReferenceValueDto { Key = "", Label = "Intervention" }, Statut = new ReferenceValueDto { Key = "", Label = "Fait" } }; await RendezVousRepository.UpsertAsync(new LocalRendezVous { Id = rdvId, PayloadJson = JsonSerializer.Serialize(dto), UpdatedAtUtc = DateTimeOffset.UtcNow }); _parametreMetierService.TypeRdvParametres = new TypeRdvParametresDto { Couleur = "1393623" }; _parametreMetierService.StatutRdvParametres = new StatutRdvParametresDto { Couleur = "5288012" }; _viewModel.SelectedAgendaDateOffset = jour; // Act await _viewModel.LoadCommand.ExecuteAsync(null); // Assert : la clé métier effective retombe sur le libellé Batik, ce qui // permet ensuite de résoudre les couleurs type/statut. AgendaItem item = Assert.Single(_viewModel.RendezVous); Assert.Equal("Intervention", item.TypeActiviteKey); Assert.Equal("Fait", item.StatutKey); Assert.Equal("#1543D7", item.Couleur); Assert.Equal("#50B04C", item.StatutCouleur); } private sealed class HistoricalRendezVousApiClient : StubMobileApiClient { private readonly Guid _rdvId; private readonly DateTime _debut; public HistoricalRendezVousApiClient(Guid rdvId, DateTime debut) { _rdvId = rdvId; _debut = debut; } public override Task> GetRendezVousByCollaborateurAsync( Guid collaborateurGuid, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default) { return Task.FromResult>( [ new RendezVousDetailsDto { Id = _rdvId.ToString(), Titre = "RDV historique", Debut = _debut.AddHours(9), Fin = _debut.AddHours(10), Statut = new ReferenceValueDto { Label = "Confirmé" } } ]); } } private sealed class MultiRendezVousApiClient : StubMobileApiClient { private readonly IReadOnlyList _rdvs; public MultiRendezVousApiClient(IReadOnlyList rdvs) => _rdvs = rdvs; public override Task> GetRendezVousByCollaborateurAsync( Guid collaborateurGuid, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default) => Task.FromResult(_rdvs); } [Fact] public async Task PreviousDay_ChangeLaDateEtRecharge() { // Arrange DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV")); // Date déterministe : indépendante de la date système. _viewModel.SelectedAgendaDateOffset = jour; // Act await _viewModel.LoadCommand.ExecuteAsync(null); await _viewModel.PreviousDayCommand.ExecuteAsync(null); // Assert : la date a reculé d'un jour et la liste est vide (pas de RDV la veille). Assert.Equal(jour.AddDays(-1), _viewModel.SelectedDate); Assert.False(_viewModel.HasRendezVous); } [Fact] public async Task NextDay_ChangeLaDateEtRecharge() { // Arrange DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV")); // Date déterministe : indépendante de la date système. _viewModel.SelectedAgendaDateOffset = jour; // Act await _viewModel.LoadCommand.ExecuteAsync(null); await _viewModel.NextDayCommand.ExecuteAsync(null); // Assert : la date a avancé d'un jour et la liste est vide. Assert.Equal(jour.AddDays(1), _viewModel.SelectedDate); Assert.False(_viewModel.HasRendezVous); } [Fact] public async Task GoToToday_RevientAAujourdhui() { // Arrange DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV")); // Act await _viewModel.LoadCommand.ExecuteAsync(null); await _viewModel.NextDayCommand.ExecuteAsync(null); await _viewModel.GoToTodayCommand.ExecuteAsync(null); // Assert : retour à aujourd'hui. Assert.Equal(DateTime.Today, _viewModel.SelectedDate); } [Fact] public async Task SelectedDateLabel_FormatFrancais() { // Arrange DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV")); // Date déterministe : indépendante de la date système. _viewModel.SelectedAgendaDateOffset = jour; // Act await _viewModel.LoadCommand.ExecuteAsync(null); // Assert : le libellé contient le jour et le mois. Assert.Contains("12", _viewModel.SelectedDateLabel); Assert.Contains("août", _viewModel.SelectedDateLabel); } [Fact] public async Task OpenRendezVous_NavigueVersDetail() { // Arrange Guid rdvId = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(rdvId, jour.AddHours(9), jour.AddHours(10), "RDV")); // Date déterministe : indépendante de la date système. _viewModel.SelectedAgendaDateOffset = jour; await _viewModel.LoadCommand.ExecuteAsync(null); // Act AgendaItem item = _viewModel.RendezVous[0]; _viewModel.OpenRendezVousCommand.Execute(item); // Assert : la fiche reçoit l'élément complet (déjà enrichi). Assert.Equal(AppPage.RendezVousDetail, _navigation.CurrentPage); Assert.Same(item, _navigation.CurrentParameter); } [Fact] public async Task OpenRendezVous_Null_NeNaviguePas() { // Act _viewModel.OpenRendezVousCommand.Execute(null); // Assert : pas de navigation. Assert.Equal(AppPage.Home, _navigation.CurrentPage); } [Fact] public void Days_Contient7Jours_UnSeulSelectionne() { // Arrange : date déterministe (mercredi). _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); // Assert : 7 jours et un seul sélectionné (la date sélectionnée). Assert.Equal(7, _viewModel.Days.Count); Assert.Single(_viewModel.Days, day => day.IsSelected); Assert.Equal(new DateTime(2026, 8, 12), _viewModel.Days.Single(day => day.IsSelected).Date); } [Fact] public async Task SelectDay_ChangeLaDateEtRecharge() { // Arrange : un rendez-vous le 13 août. DateTime jour = new(2026, 8, 13); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV")); _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); await _viewModel.LoadCommand.ExecuteAsync(null); Assert.False(_viewModel.HasRendezVous); AgendaDayDisplayItem target = _viewModel.Days.Single(day => day.Date == jour); // Act await _viewModel.SelectDayCommand.ExecuteAsync(target); // Assert : date changée et rendez-vous rechargé. Assert.Equal(jour, _viewModel.SelectedDate); Assert.True(_viewModel.HasRendezVous); Assert.Single(_viewModel.RendezVous); } [Fact] public async Task Compteurs_DerivesDesRendezVousReels() { // Arrange : 1 lié + 1 autonome. DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "Lié", Guid.NewGuid().ToString())); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(11), jour.AddHours(12), "Autonome")); _viewModel.SelectedAgendaDateOffset = jour; await _viewModel.LoadCommand.ExecuteAsync(null); // Assert : compteurs dérivés de la collection réelle. Assert.Equal(2, _viewModel.AppointmentCount); Assert.Equal(1, _viewModel.LinkedCount); Assert.Equal(1, _viewModel.StandaloneCount); } [Fact] public async Task OpenFirstRendezVous_NavigueVersDetail() { // Arrange Guid first = Guid.NewGuid(); DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(first, jour.AddHours(9), jour.AddHours(10), "Premier")); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(11), jour.AddHours(12), "Second")); _viewModel.SelectedAgendaDateOffset = jour; await _viewModel.LoadCommand.ExecuteAsync(null); // Act _viewModel.OpenFirstRendezVousCommand.Execute(null); // Assert : navigation vers le détail du premier rendez-vous (élément complet). Assert.Equal(AppPage.RendezVousDetail, _navigation.CurrentPage); Assert.Same(_viewModel.RendezVous[0], _navigation.CurrentParameter); } [Fact] public void VueInitiale_EstJour() { Assert.Equal(AgendaViewMode.Day, _viewModel.SelectedViewMode); Assert.True(_viewModel.IsDayMode); Assert.False(_viewModel.IsWeekMode); } [Fact] public void ShowWeek_PasseEnModeSemaine() { // Act _viewModel.ShowWeekCommand.Execute(null); // Assert Assert.Equal(AgendaViewMode.Week, _viewModel.SelectedViewMode); Assert.True(_viewModel.IsWeekMode); Assert.False(_viewModel.IsDayMode); } [Fact] public void ShowDay_RetourAuModeJour() { // Arrange _viewModel.ShowWeekCommand.Execute(null); // Act _viewModel.ShowDayCommand.Execute(null); // Assert Assert.Equal(AgendaViewMode.Day, _viewModel.SelectedViewMode); Assert.True(_viewModel.IsDayMode); } [Fact] public async Task Previous_EnModeJour_ReculeDUnJour() { // Arrange _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); await _viewModel.LoadCommand.ExecuteAsync(null); // Act await _viewModel.PreviousDayCommand.ExecuteAsync(null); // Assert Assert.Equal(new DateTime(2026, 8, 11), _viewModel.SelectedDate); } [Fact] public async Task Next_EnModeJour_AvanceDUnJour() { // Arrange _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); await _viewModel.LoadCommand.ExecuteAsync(null); // Act await _viewModel.NextDayCommand.ExecuteAsync(null); // Assert Assert.Equal(new DateTime(2026, 8, 13), _viewModel.SelectedDate); } [Fact] public async Task Previous_EnModeSemaine_ReculeDeSeptJours() { // Arrange _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); _viewModel.ShowWeekCommand.Execute(null); await _viewModel.LoadCommand.ExecuteAsync(null); // Act await _viewModel.PreviousDayCommand.ExecuteAsync(null); // Assert Assert.Equal(new DateTime(2026, 8, 5), _viewModel.SelectedDate); } [Fact] public async Task Next_EnModeSemaine_AvanceDeSeptJours() { // Arrange _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); _viewModel.ShowWeekCommand.Execute(null); await _viewModel.LoadCommand.ExecuteAsync(null); // Act await _viewModel.NextDayCommand.ExecuteAsync(null); // Assert Assert.Equal(new DateTime(2026, 8, 19), _viewModel.SelectedDate); } [Fact] public void DatePicker_SelectedAgendaDateOffset_SynchroniseSelectedDate() { // Act : choix d'une date dans le calendrier. _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 13); // Assert : SelectedDate est synchronisée immédiatement. Assert.Equal(new DateTime(2026, 8, 13), _viewModel.SelectedDate); } [Fact] public async Task CompteurParJour_DeriveDeLaSemaineChargee() { // Arrange : 3 rendez-vous le lundi, 1 le mardi. 2026-08-10 est un lundi. DateTime lundi = new(2026, 8, 10); for (int i = 0; i < 3; i++) { await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), lundi.AddHours(8 + i), lundi.AddHours(9 + i), $"Lundi {i}")); } await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), lundi.AddDays(1).AddHours(10), lundi.AddDays(1).AddHours(11), "Mardi")); _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); await _viewModel.LoadCommand.ExecuteAsync(null); // Assert : compteurs réels par jour. AgendaDayDisplayItem lundiItem = _viewModel.Days.Single(d => d.Date == lundi); AgendaDayDisplayItem mardiItem = _viewModel.Days.Single(d => d.Date == lundi.AddDays(1)); Assert.Equal(3, lundiItem.AppointmentCount); Assert.Equal("3 rdv", lundiItem.CountLabel); Assert.True(lundiItem.HasAppointments); Assert.Equal(1, mardiItem.AppointmentCount); Assert.Equal("1 rdv", mardiItem.CountLabel); Assert.Equal("—", _viewModel.Days.Single(d => d.Date == lundi.AddDays(2)).CountLabel); } [Fact] public async Task WeekAppointmentCount_TotalDeLaSemaine() { // Arrange : 2 rendez-vous dans la semaine du 2026-08-10. DateTime lundi = new(2026, 8, 10); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), lundi.AddHours(9), lundi.AddHours(10), "A")); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), lundi.AddDays(2).AddHours(9), lundi.AddDays(2).AddHours(10), "B")); _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); await _viewModel.LoadCommand.ExecuteAsync(null); // Assert : total réel de la semaine chargée. Assert.Equal(2, _viewModel.WeekAppointmentCount); } [Fact] public async Task WeekGroups_SeptSectionsReelles_JourVideCompact() { // Arrange : 1 rendez-vous le mercredi 2026-08-12. DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV")); _viewModel.SelectedAgendaDateOffset = jour; await _viewModel.LoadCommand.ExecuteAsync(null); // Assert : 7 sections, une seule avec des rendez-vous. Assert.Equal(7, _viewModel.WeekGroups.Count); Assert.Single(_viewModel.WeekGroups, g => g.HasAppointments); Assert.Equal(1, _viewModel.WeekGroups.Single(g => g.HasAppointments).Items.Count); Assert.Equal(6, _viewModel.WeekGroups.Count(g => !g.HasAppointments)); Assert.All(_viewModel.WeekGroups.Where(g => !g.HasAppointments), g => Assert.Empty(g.Items)); } [Fact] public void Days_SemaineCommenceLundi_SeTermineDimanche() { // Arrange : mercredi 2026-08-12. _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); // Assert : lundi 2026-08-10 → dimanche 2026-08-16. Assert.Equal(new DateTime(2026, 8, 10), _viewModel.Days[0].Date); Assert.Equal(new DateTime(2026, 8, 16), _viewModel.Days[6].Date); } [Fact] public async Task SelectDay_RechargeLaJourneeSelectionnee() { // Arrange : un rendez-vous le 13 août. DateTime jour = new(2026, 8, 13); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV")); _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); await _viewModel.LoadCommand.ExecuteAsync(null); Assert.False(_viewModel.HasRendezVous); AgendaDayDisplayItem target = _viewModel.Days.Single(day => day.Date == jour); // Act await _viewModel.SelectDayCommand.ExecuteAsync(target); // Assert Assert.Equal(jour, _viewModel.SelectedDate); Assert.True(_viewModel.HasRendezVous); Assert.Single(_viewModel.RendezVous); } [Fact] public async Task ShowCalendar_PasseEnModeCalendrier() { // Act await _viewModel.ShowCalendarCommand.ExecuteAsync(null); // Assert Assert.Equal(AgendaViewMode.Calendar, _viewModel.SelectedViewMode); Assert.True(_viewModel.IsCalendarMode); Assert.False(_viewModel.IsDayMode); Assert.False(_viewModel.IsWeekMode); } [Fact] public async Task MonthDays_Contient42Jours_CommenceLundi() { // Arrange : août 2026 commence un samedi 1er août. _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); // Act await _viewModel.ShowCalendarCommand.ExecuteAsync(null); // Assert : 42 cellules, la première est le lundi 27 juillet 2026. Assert.Equal(42, _viewModel.MonthDays.Count); Assert.Equal(new DateTime(2026, 7, 27), _viewModel.MonthDays[0].Date); Assert.Equal(DayOfWeek.Monday, _viewModel.MonthDays[0].Date.DayOfWeek); } [Fact] public async Task MonthDays_ContientLesRendezVousDuJour() { // Arrange DateTime jour = new(2026, 8, 12); await RendezVousRepository.UpsertAsync(CreateRendezVous(Guid.NewGuid(), jour.AddHours(9), jour.AddHours(10), "RDV")); _viewModel.SelectedAgendaDateOffset = jour; // Act await _viewModel.ShowCalendarCommand.ExecuteAsync(null); // Assert : la cellule du jour porte le rendez-vous. AgendaMonthDayItem cell = _viewModel.MonthDays.Single(d => d.Date == jour); Assert.True(cell.IsCurrentMonth); Assert.True(cell.HasAppointments); Assert.Single(cell.Appointments); } [Fact] public async Task PeriodLabel_EnModeCalendrier_AfficheLeMois() { // Arrange _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); // Act await _viewModel.ShowCalendarCommand.ExecuteAsync(null); // Assert : libellé du mois en français. Assert.Contains("août", _viewModel.PeriodLabel); Assert.Contains("2026", _viewModel.PeriodLabel); } [Fact] public async Task Next_EnModeCalendrier_AvanceDUnMois() { // Arrange _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); await _viewModel.ShowCalendarCommand.ExecuteAsync(null); // Act await _viewModel.NextDayCommand.ExecuteAsync(null); // Assert Assert.Equal(new DateTime(2026, 9, 12), _viewModel.SelectedDate); } [Fact] public async Task SelectCalendarDay_OuvreLaVueJour() { // Arrange _viewModel.SelectedAgendaDateOffset = new DateTime(2026, 8, 12); await _viewModel.ShowCalendarCommand.ExecuteAsync(null); AgendaMonthDayItem target = _viewModel.MonthDays.Single(d => d.Date == new DateTime(2026, 8, 13)); // Act _viewModel.SelectCalendarDayCommand.Execute(target); // Assert : bascule en vue Jour avec la date sélectionnée. Assert.Equal(AgendaViewMode.Day, _viewModel.SelectedViewMode); Assert.Equal(new DateTime(2026, 8, 13), _viewModel.SelectedDate); } private AgendaViewModel CreateViewModelWithFakeAgenda(FakeThrowingAgendaService agenda) => new(agenda, _sessionService, _suiviTempsService, _parametreMetierService, _navigation, new PermissionService(), new RendezVousStateNotifier(), Network); [Fact] public async Task Load_AnnulationNormale_TokenAnnule_MasqueLErreur() { var fake = new FakeThrowingAgendaService(); AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); fake.ExceptionToThrow = new OperationCanceledException(); using var cts = new CancellationTokenSource(); cts.Cancel(); await vm.RefreshAsync(cts.Token); Assert.False(vm.IsLoading); Assert.Null(vm.ErrorMessage); } [Fact] public async Task Load_AnnulationSousIOException_TokenAnnule_MasqueLErreur() { var fake = new FakeThrowingAgendaService(); AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); fake.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 vraie erreur réseau (token NON annulé) est un état hors ligne, pas un /// incident : l'agenda reste consultable, sans message d'erreur bloquant, et /// l'événement est journalisé à sa vraie gravité (Warning → P2), jamais en P1. /// [Fact] public async Task Load_IOExceptionReelle_TokenNonAnnule_SignaleHorsLigne() { var fake = new FakeThrowingAgendaService(); AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); fake.ExceptionToThrow = new IOException("Network unreachable"); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.RefreshAsync(CancellationToken.None); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Null(vm.ErrorMessage); Assert.True(vm.IsOffline); AssertAucunIncident(telemetry); Assert.Equal(TelemetryLevel.Warning, EvenementAgenda(telemetry).Level); Assert.Equal("IOException", EvenementAgenda(telemetry).ErrorCode); } /// /// Le repli API de l'agenda (plage locale vide) qui échoue pour cause de réseau /// reproduit le payload de production « Connection failure » : aucun message /// bloquant, état hors ligne, et un événement en Warning — surtout pas un P1. /// [Fact] public async Task Load_RepliApiEnEchecReseau_SignaleHorsLigneSansIncident() { _sessionService.CollaborateurGuid = Guid.NewGuid(); var fake = new FakeThrowingAgendaService { Items = [], LeverSeulementSurRepliApi = true, ExceptionToThrow = new HttpRequestException("Connection failure") }; AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.RefreshAsync(CancellationToken.None); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.True(vm.IsOffline); Assert.Null(vm.ErrorMessage); // L'événement est bien journalisé (l'indisponibilité reste observable), // mais en Warning : aucune entrée en Error/P1. AssertAucunIncident(telemetry); DiagnosticEvent evt = EvenementAgenda(telemetry); Assert.Equal(TelemetryLevel.Warning, evt.Level); Assert.Equal("HttpRequestException", evt.ErrorCode); Assert.NotNull(evt.Message); Assert.StartsWith("Connection failure", evt.Message); } /// /// Ticket de production du 09/10 : le repli API a révélé qu'aucune URL d'API /// n'était configurée (appareil non inscrit) — l'appareil pouvait être en ligne. /// Cet état est actionnable : message « rescannez le QR » (l'utilisateur sait quoi /// faire), PAS de bandeau « hors ligne » (ce serait faux) et aucun incident P1. /// [Fact] public async Task Load_RepliApiSansUrlDApi_SignaleLInscriptionIncompleteSansIncident() { _sessionService.CollaborateurGuid = Guid.NewGuid(); var fake = new FakeThrowingAgendaService { Items = [], LeverSeulementSurRepliApi = true, ExceptionToThrow = new MobileEnrollmentRequiredException() }; AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.RefreshAsync(CancellationToken.None); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.False(vm.IsOffline); Assert.Equal( "Inscription incomplète — rescannez le QR code pour reconnecter l'application.", vm.ErrorMessage); AssertAucunIncident(telemetry); DiagnosticEvent evt = EvenementAgenda(telemetry); Assert.Equal(TelemetryLevel.Warning, evt.Level); Assert.Equal("MobileEnrollmentRequiredException", evt.ErrorCode); } /// /// Deux déclenchements d'interface rapprochés (cas réel : deux LOAD à 43 ms /// d'intervalle) ne doivent produire qu'UNE requête de repli API : le second /// chargement attend et réutilise l'appel déjà en cours au lieu d'en émettre un /// doublon (double consommation réseau et double latence). /// [Fact] public async Task Load_DeuxChargementsConcurrents_UnSeulAppelAuRepliApi() { _sessionService.CollaborateurGuid = Guid.NewGuid(); var fake = new FakeThrowingAgendaService { Items = [], BlocageRepli = new TaskCompletionSource() }; AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); // Act : deux déclenchements non annulables, en rafale. Le premier suspend le // repli API sur le blocage, le second doit trouver l'appel en cours. vm.Refresh(); vm.Refresh(); Assert.Equal(1, fake.RepliApiCalls); // Libération : les deux chargements s'achèvent, sans appel supplémentaire. fake.BlocageRepli.SetResult(); await AttendreAsync(() => !vm.IsLoading); Assert.Equal(1, fake.RepliApiCalls); // Le repli a réussi (plage vide, aucune exception) : état neutre, pas hors ligne. Assert.Null(vm.ErrorMessage); Assert.False(vm.IsOffline); } /// Attente bornée d'une condition (jamais d'attente infinie en test). private static async Task AttendreAsync(Func condition) { for (int i = 0; i < 200 && !condition(); i++) { await Task.Delay(10); } Assert.True(condition(), "Condition non satisfaite dans le délai imparti."); } /// /// Contre-épreuve : une erreur métier (hors réseau) reste un incident Error/P1 /// avec son message — le classement préalable ne dilue pas les vraies erreurs. /// [Fact] public async Task Load_ErreurMetier_ConserveLeMessageEtLIncident() { var fake = new FakeThrowingAgendaService { ExceptionToThrow = new InvalidOperationException("boom") }; AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); var telemetry = new CapturingTelemetry(); ExceptionTelemetry.Initialize(telemetry, null); try { await vm.RefreshAsync(CancellationToken.None); } finally { ExceptionTelemetry.Initialize(null, null); } Assert.Equal("boom", vm.ErrorMessage); Assert.False(vm.IsOffline); DiagnosticEvent evt = EvenementAgenda(telemetry); Assert.Equal(TelemetryLevel.Error, evt.Level); Assert.Equal("InvalidOperationException", evt.ErrorCode); } /// /// Seul événement de diagnostic attribué au ViewModel Agenda : la télémétrie /// d'usage (features) est un , pas un /// . /// private static DiagnosticEvent EvenementAgenda(CapturingTelemetry telemetry) => Assert.Single( telemetry.Events.OfType(), e => e.Component == nameof(AgendaViewModel)); /// /// Aucun événement de diagnostic de l'agenda en Error/Critical : c'est la garantie /// recherchée (une coupure réseau ne doit plus alimenter les incidents P1). Le /// filtre porte sur le composant, la télémétrie statique globale étant partagée /// avec les classes de tests exécutées en parallèle. /// private static void AssertAucunIncident(CapturingTelemetry telemetry) => Assert.DoesNotContain( telemetry.Events.OfType(), e => e.Component == nameof(AgendaViewModel) && e.Level == TelemetryLevel.Error); [Fact] public async Task Load_TaskCanceledException_TokenNonAnnule_SignaleHorsLigne() { // Un timeout réseau (token NON annulé) est désormais traité comme un état // hors ligne : l'agenda reste consultable, sans message d'erreur bloquant. var fake = new FakeThrowingAgendaService(); AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); fake.ExceptionToThrow = new TaskCanceledException("Timeout"); await vm.RefreshAsync(CancellationToken.None); Assert.Null(vm.ErrorMessage); Assert.True(vm.IsOffline); } [Fact] public async Task Load_Annulation_ConserveLesRendezVousExistants() { Guid rdvId = Guid.NewGuid(); DateTime jour = DateTime.Today; var fake = new FakeThrowingAgendaService { Items = [ new AgendaItem { RendezVousGuid = rdvId, Titre = "RDV", Debut = jour.AddHours(9), Fin = jour.AddHours(10) } ] }; AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); // Premier chargement réussi : le RDV est affiché. await vm.RefreshAsync(CancellationToken.None); Assert.True(vm.HasRendezVous); // Deuxième chargement annulé : les données visibles ne sont pas effacées. fake.ExceptionToThrow = new IOException("Canceled"); using var cts = new CancellationTokenSource(); cts.Cancel(); await vm.RefreshAsync(cts.Token); Assert.True(vm.HasRendezVous); Assert.Contains(vm.RendezVous, r => r.RendezVousGuid == rdvId); Assert.Null(vm.ErrorMessage); } [Fact] public async Task Load_RechargementApresAnnulation_AfficheLeNouveauContenu() { Guid newRdvId = Guid.NewGuid(); DateTime jour = DateTime.Today; var fake = new FakeThrowingAgendaService(); AgendaViewModel vm = CreateViewModelWithFakeAgenda(fake); // Premier chargement annulé. fake.ExceptionToThrow = new IOException("Canceled"); using var cts = new CancellationTokenSource(); cts.Cancel(); await vm.RefreshAsync(cts.Token); Assert.Null(vm.ErrorMessage); // Deuxième chargement réussi. fake.ExceptionToThrow = null; fake.Items = [ new AgendaItem { RendezVousGuid = newRdvId, Titre = "RDV", Debut = jour.AddHours(9), Fin = jour.AddHours(10) } ]; await vm.RefreshAsync(CancellationToken.None); Assert.Null(vm.ErrorMessage); Assert.Contains(vm.RendezVous, r => r.RendezVousGuid == newRdvId); } /// /// Service d'agenda jetable dont la lecture de plage peut lever une exception /// configurée (pour simuler l'annulation Android « IOException("Canceled") », /// un timeout, une vraie erreur réseau…), sinon renvoie une liste configurée. /// private sealed class FakeThrowingAgendaService : IAgendaService { public Exception? ExceptionToThrow { get; set; } public IReadOnlyList Items { get; set; } = []; /// /// Quand vrai, la lecture de plage réussit (et renvoie ) : /// seule la lecture du repli API lève. Reproduit l'échec réseau survenu /// pendant le repli (payload de production « Connection failure »), là où /// l'implémentation par défaut lève dès la première lecture. /// public bool LeverSeulementSurRepliApi { get; set; } /// Nombre d'appels au repli API (détection des requêtes en doublon). public int RepliApiCalls { get; private set; } /// Si posé, le repli API attend cette libération (entrelacement déterministe). public TaskCompletionSource? BlocageRepli { get; set; } public Task> GetRendezVousForRangeAsync( DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default) { if (ExceptionToThrow is { } ex && !LeverSeulementSurRepliApi) { throw ex; } return Task.FromResult(Items); } public async Task> GetRendezVousForCollaborateurAsync( Guid collaborateurGuid, DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default) { RepliApiCalls++; if (BlocageRepli is { } blocage) { await blocage.Task; } if (ExceptionToThrow is { } ex) { throw ex; } return Items; } public Task> GetRendezVousForDayAsync(DateTime date, CancellationToken cancellationToken = default) => Task.FromResult(Items); public Task GetRendezVousByIdAsync(Guid rendezVousGuid, CancellationToken cancellationToken = default, string? affectationId = null) => Task.FromResult(null); public Task EnrichSiteAccessAsync(AgendaItem item, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task> GetRendezVousForCollaborateursAsync( IReadOnlyList collaborateurGuids, DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default) => Task.FromResult>([]); } /// /// Capture en mémoire des événements de la télémétrie statique globale /// (), rétablie à null après /// chaque test — la classe appartient à la collection « TelemetrieStatique ». /// private sealed class CapturingTelemetry : IOmikaTelemetry { public List Events { get; } = []; public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent); public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent); } }