using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Omika.App.Local.Abstractions; using Omika.App.Local.Infrastructure; using Omika.App.Local.Models; 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; namespace Omika.Tests.Mobile.Local; /// /// Tests du RendezVousDetailViewModel (détail d'un rendez-vous). /// Vérifie le chargement du rendez-vous depuis les données locales. /// public class RendezVousDetailViewModelTests { private sealed class FakeAgendaService : IAgendaService { public AgendaItem? ById { get; set; } public Task> GetRendezVousForDayAsync(DateTime date, CancellationToken ct = default) => Task.FromResult>([]); public Task> GetRendezVousForRangeAsync(DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default) => Task.FromResult>([]); public Task GetRendezVousByIdAsync(Guid rendezVousGuid, CancellationToken ct = default, string? affectationId = null) => Task.FromResult(ById); public Task> GetRendezVousForCollaborateursAsync(IReadOnlyList collaborateurGuids, DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default) => Task.FromResult>([]); public Task> GetRendezVousForCollaborateurAsync(Guid collaborateurGuid, DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default) => Task.FromResult>([]); public Task EnrichSiteAccessAsync(AgendaItem item, CancellationToken ct = default) => Task.CompletedTask; } private sealed class FakeSuiviTempsService : ISuiviTempsLocalService { public List DeletedKeys { get; } = []; public List Drafts { get; } = []; /// Brouillons effectivement enregistrés (création ou mise à jour). public List CreatedDrafts { get; } = []; 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>(Drafts); /// Clés de type de temps dont la création de brouillon doit échouer. public HashSet EchecsCreation { get; } = new(StringComparer.OrdinalIgnoreCase); public Task CreateDraftAsync(SuiviTempsDraftEntry draft, CancellationToken ct = default) { if (EchecsCreation.Contains(draft.TypeTempsKey)) { throw new InvalidOperationException("Stockage local indisponible."); } CreatedDrafts.Add(draft); return Task.CompletedTask; } public Task DeleteDraftAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default) { DeletedKeys.Add(typeTempsKey); Drafts.RemoveAll(d => string.Equals(d.TypeTempsKey, typeTempsKey, StringComparison.OrdinalIgnoreCase)); return 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); /// Vrai si a été appelé. public bool ValidateCalled { get; private set; } public Task ValidateRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) { ValidateCalled = true; return Task.CompletedTask; } public List SavedRendezVous { get; } = []; public List DeletedRendezVousIds { get; } = []; public Task SaveRendezVousPourBrouillonAsync(AgendaItem rendezVous, CancellationToken ct = default) { SavedRendezVous.Add(rendezVous); return Task.CompletedTask; } public Task> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default) { DeletedRendezVousIds.Add(rendezVousId); return Task.CompletedTask; } } private sealed class FakeParametreMetierService : IParametreMetierLocalService { /// Référentiel TypeTemps retourné (vide par défaut). public IReadOnlyList TypeTemps { get; set; } = []; /// Origines d'annulation retournées (vide par défaut). public IReadOnlyList OriginesAnnulation { get; set; } = []; /// Motifs d'annulation retournés (vide par défaut). public IReadOnlyList MotifsAnnulation { get; set; } = []; /// Paramètres des types de temps, par clé métier (aucun par défaut). public Dictionary TypeTempsParametres { get; } = new(StringComparer.OrdinalIgnoreCase); /// Paramètres du type de rendez-vous retournés (aucun par défaut). public TypeRdvParametresDto? TypeRdvParametres { get; set; } public Task> GetTypeTempsAsync(CancellationToken ct = default) => Task.FromResult(TypeTemps); public Task> GetOriginesAnnulationAsync(CancellationToken ct = default) => Task.FromResult(OriginesAnnulation); public Task> GetMotifsAnnulationAsync(CancellationToken ct = default) => Task.FromResult(MotifsAnnulation); public Task GetTypeTempsParametresAsync(string typeTempsKey, CancellationToken ct = default) => Task.FromResult(TypeTempsParametres.TryGetValue(typeTempsKey, out TypeTempsParametresDto? parametres) ? parametres : null); public Task GetTypeRdvParametresAsync(string typeRdvKey, CancellationToken ct = default) => Task.FromResult(TypeRdvParametres); public Task GetStatutRdvParametresAsync(string statutRdvKey, CancellationToken ct = default) => Task.FromResult(null); public Task> GetInterventionTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetInterventionEtatsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDossierTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDossierStatutsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetClientTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetClientStatutsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetSiteTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetTypeSignatairesAsync(CancellationToken cancellationToken = default) => Task.FromResult>([]); } private sealed class FakePhoneLauncher : IPhoneLauncher { public Task DialAsync(string phoneNumber) => Task.FromResult(false); } private sealed class FakeEmailLauncher : IEmailLauncher { public int ComposeCalls { get; private set; } public string? LastEmail { get; private set; } public string? LastSubject { get; private set; } public bool Result { get; set; } = true; public Task ComposeAsync(string email) { ComposeCalls++; LastEmail = email; return Task.FromResult(Result); } } /// /// Annuaire local des collaborateurs : le bloc « Rendez-vous groupé » y lit le /// téléphone et l'email des collaborateurs affectés (aucun appel réseau). /// private sealed class FakeCollaborateurService : ICollaborateurLocalService { public List Collaborateurs { get; } = []; public Task> GetCollaborateursAsync(CancellationToken ct = default) => Task.FromResult>(Collaborateurs); public Task GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken ct = default) => Task.FromResult(null); } private sealed class FakeMapLauncher : IMapLauncher { public int OpenItineraireCalls { get; private set; } public string? LastAddress { get; private set; } public bool Result { get; set; } = true; public Task OpenItineraireAsync(string address) { OpenItineraireCalls++; LastAddress = address; return Task.FromResult(Result); } } private sealed class FakePermissionService : IPermissionService { public IReadOnlyDictionary Rights => new Dictionary(); public void SetRights(IReadOnlyDictionary rights) { } public bool HasRight(string key) => true; public bool MessagerieExterne => false; public void SetMessagerieExterne(bool externe) { } public bool PeutRechercherInterventions => true; public bool VoirRdvSansRestriction => true; public bool PeutVoirAgendaEquipe => true; public bool PeutEnvoyerEmail => true; public bool PeutComposerEmail => true; public bool PeutCreerRapport => true; public bool PeutGererLicence => true; public bool PeutSaisirTempsAutrui => true; public bool PeutVoirNotesAdmin => true; public bool PeutVoirClients => true; public bool PeutVoirSites => true; public bool PeutVoirDossiers => true; public bool PeutAnnulerRdv => true; } private sealed class FakeRendezVousAnnulationLocalService : IRendezVousAnnulationLocalService { /// Rendez-vous annulés (GUID → collaborateur → origine/motif/note/nom → libellés). public List<(Guid RendezVousGuid, Guid? CollaborateurGuid, string? OrigineKey, string? MotifKey, string? NoteCollaborateur, string? CollaborateurNom, string? OrigineLabel, string? MotifLabel)> Annules { get; } = []; /// Simule un rendez-vous local absent (KeyNotFoundException). public bool ThrowNotFound { get; set; } public Task AnnulerAsync(Guid rendezVousGuid, Guid? collaborateurGuid, string? origineKey, string? motifKey, string? noteCollaborateur, string? collaborateurNom = null, string? origineLabel = null, string? motifLabel = null, CancellationToken cancellationToken = default) { if (ThrowNotFound) { throw new KeyNotFoundException($"Rendez-vous local introuvable : {rendezVousGuid}."); } Annules.Add((rendezVousGuid, collaborateurGuid, origineKey, motifKey, noteCollaborateur, collaborateurNom, origineLabel, motifLabel)); return Task.CompletedTask; } } private sealed class FakeSyncEngine : ISyncEngine { public Task SyncAsync(CancellationToken cancellationToken = default) => Task.FromResult(new SyncCycleResult { Executed = true }); public Task PushAsync(CancellationToken cancellationToken = default) => Task.FromResult(new SyncCycleResult { Executed = true }); } private sealed class FakeCompteRenduScopeService : ICompteRenduScopeService { /// Compte rendu retourné (null = aucun). public Omika.Core.Contracts.Interventions.CompteRenduDto? CompteRendu { get; set; } /// Vrai si le compte rendu doit être considéré validé. public bool EstValide { get; set; } public CompteRenduScope ResolveScope(AgendaItem rendezVous) => new(rendezVous.RendezVousGuid, DateOnly.FromDateTime(rendezVous.Debut), rendezVous.InterventionGuid); public Task GetCompteRenduAsync(AgendaItem rendezVous, CancellationToken ct = default) => Task.FromResult(CompteRendu); public Task CompteRenduExistsAsync(AgendaItem rendezVous, CancellationToken ct = default) => Task.FromResult(CompteRendu is not null); public Task CompteRenduIsValidatedAsync(AgendaItem rendezVous, CancellationToken ct = default) => Task.FromResult(EstValide); public Task CanCreateCompteRenduAsync(AgendaItem rendezVous, CancellationToken ct = default) => Task.FromResult(CompteRendu is null); } private sealed class FakeFinalisationService : IRendezVousFinalisationLocalService { /// GUID marqués « Fait ». public List MarquesFait { get; } = []; public Task MarquerFaitAsync(Guid rendezVousGuid, CancellationToken ct = default) { MarquesFait.Add(rendezVousGuid); return Task.CompletedTask; } } private static AgendaItem CreateRendezVous(Guid guid, string titre) => new() { RendezVousGuid = guid, Titre = titre }; private readonly FakeAgendaService _agendaService = new(); private readonly FakeSuiviTempsService _suiviTempsService = new(); private readonly FakeParametreMetierService _parametreMetierService = new(); private readonly FakePauseDejeunerPreferenceService _pauseDejeuner = new(); private readonly FakeRendezVousAnnulationLocalService _rendezVousAnnulation = new(); private readonly FakeCompteRenduScopeService _compteRenduScopeService = new(); private readonly FakeFinalisationService _finalisationService = new(); private readonly FakeCollaborateurService _collaborateurService = new(); private readonly StubSessionService _sessionService = new(); private readonly NavigationService _navigation = new(); private readonly FakeMapLauncher _mapLauncher = new(); private readonly FakeEmailLauncher _emailLauncher = new(); private readonly RendezVousDetailViewModel _vm; public RendezVousDetailViewModelTests() { _vm = new RendezVousDetailViewModel( _agendaService, _suiviTempsService, _parametreMetierService, _sessionService, _collaborateurService, new FakePhoneLauncher(), _emailLauncher, _mapLauncher, _navigation, new FakePermissionService(), new FakeSyncEngine(), _pauseDejeuner, _rendezVousAnnulation, _compteRenduScopeService, _finalisationService); } /// /// Fiche ouverte depuis une carte d'agenda : le créneau et le collaborateur /// affichés sont ceux de l'AFFECTATION cliquée (2e jour du groupe), jamais /// ceux du premier rendez-vous du groupe. Le bloc informatif présente ensuite /// les AUTRES collaborateurs (celui de la carte est exclu : c'est sa fiche) et /// uniquement SES journées. /// [Fact] public async Task Load_AvecAffectation_AfficheLeCreneauEtLeCollaborateurDeLaCarte() { // Arrange : carte « Bruno – 2e jour » d'un rendez-vous groupé de 2 jours. Guid rendezVousGuid = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); Guid aliceGuid = Guid.Parse("11111111-1111-1111-1111-111111111111"); Guid brunoGuid = Guid.Parse("22222222-2222-2222-2222-222222222222"); DateTime jour1 = new(2026, 10, 12, 8, 0, 0); DateTime jour2 = new(2026, 10, 13, 13, 0, 0); AgendaItem carte = new() { RendezVousGuid = rendezVousGuid, AffectationId = "affectation-bruno-jour2", Titre = "Chantier groupé", Debut = jour2, Fin = jour2.AddHours(4), CollaborateurNom = "Bruno Petit", CollaborateurOccurrenceGuid = brunoGuid, Collaborateurs = ["Alice Martin", "Bruno Petit"], CollaborateurGuids = [aliceGuid, brunoGuid], JoursDuGroupe = [jour1.Date, jour2.Date], JoursAvecHoraires = [ new JourRendezVous(jour1.Date, jour1, jour1.AddHours(4)), new JourRendezVous(jour2.Date, jour2, jour2.AddHours(4)) ], JoursParCollaborateur = [ new JoursParCollaborateur(aliceGuid, [new JourRendezVous(jour1.Date, jour1, jour1.AddHours(4))]), new JoursParCollaborateur(brunoGuid, [new JourRendezVous(jour2.Date, jour2, jour2.AddHours(4))]) ] }; // Act : ouverture depuis l'agenda (l'élément est préchargé). await _vm.LoadCommand.ExecuteAsync(carte); // Assert : créneau et collaborateur de la carte, pas ceux du 1er jour. Assert.Equal("13/10/2026 13:00 – 17:00", _vm.PeriodeTexte); Assert.Equal("Bruno Petit", _vm.CollaborateurOccurrence); Assert.Contains("13/10/2026", _vm.HeroSubtitle); Assert.DoesNotContain("12/10/2026", _vm.HeroSubtitle); // Bloc informatif : le collaborateur de la carte n'y figure PAS. Assert.True(_vm.AfficheBlocPeriode); Assert.True(_vm.HasCollaborateursRendezVous); Assert.Equal("Alice Martin", Assert.Single(_vm.CollaborateursRendezVous).Nom); // Journées : uniquement celles de Bruno (jour 2), pas l'enveloppe du groupe. Assert.Equal("Mardi 13/10/2026 13h00-17h00", Assert.Single(_vm.JoursRendezVous)); Assert.False(_vm.EstRendezVousPlusieursJours); } /// /// Un collaborateur présent sur DEUX journées du groupe (et pas sur la /// troisième) ne voit que ses deux journées, avec les horaires de SES /// affectations — jamais les heures des autres collaborateurs. /// [Fact] public async Task Load_Groupe_AfficheUniquementLesJoursDuCollaborateurDeLOccurrence() { Guid rendezVousGuid = Guid.Parse("c1c1c1c1-c1c1-c1c1-c1c1-c1c1c1c1c1c1"); Guid aliceGuid = Guid.Parse("11111111-1111-1111-1111-111111111111"); Guid brunoGuid = Guid.Parse("22222222-2222-2222-2222-222222222222"); DateTime jour1 = new(2026, 10, 12, 8, 0, 0); DateTime jour2 = new(2026, 10, 13, 8, 0, 0); DateTime jour3 = new(2026, 10, 14, 13, 0, 0); AgendaItem carte = new() { RendezVousGuid = rendezVousGuid, AffectationId = "affectation-alice-jour2", Titre = "Chantier groupé", Debut = jour2, Fin = jour2.AddHours(4), CollaborateurNom = "Alice Martin", CollaborateurOccurrenceGuid = aliceGuid, Collaborateurs = ["Alice Martin", "Bruno Petit"], CollaborateurGuids = [aliceGuid, brunoGuid], JoursDuGroupe = [jour1.Date, jour2.Date, jour3.Date], JoursAvecHoraires = [ new JourRendezVous(jour1.Date, jour1, jour1.AddHours(4)), new JourRendezVous(jour2.Date, jour2, jour2.AddHours(4)), new JourRendezVous(jour3.Date, jour3, jour3.AddHours(4)) ], JoursParCollaborateur = [ new JoursParCollaborateur( aliceGuid, [ new JourRendezVous(jour1.Date, jour1, jour1.AddHours(4)), new JourRendezVous(jour2.Date, jour2, jour2.AddHours(4)) ]), new JoursParCollaborateur(brunoGuid, [new JourRendezVous(jour3.Date, jour3, jour3.AddHours(4))]) ] }; await _vm.LoadCommand.ExecuteAsync(carte); // Deux journées pour Alice, la journée de Bruno (mercredi 13h-17h) exclue. Assert.Equal( ["Lundi 12/10/2026 08h00-12h00", "Mardi 13/10/2026 08h00-12h00"], _vm.JoursRendezVous); Assert.True(_vm.EstRendezVousPlusieursJours); // Et le bloc ne liste que l'autre collaborateur. Assert.Equal("Bruno Petit", Assert.Single(_vm.CollaborateursRendezVous).Nom); } /// /// Le bloc « Rendez-vous groupé » présente le téléphone et l'email de chaque /// AUTRE collaborateur (lus dans la table locale, aucun appel réseau) et /// l'email est un envoi PERSONNEL : ouverture de la messagerie de l'utilisateur /// (même fonctionnalité que la fiche collaborateur), jamais un courriel envoyé /// par l'application. /// [Fact] public async Task Load_Groupe_AfficheLesCoordonneesDesAutresCollaborateurs() { // Arrange : deux collaborateurs présents dans l'annuaire local. Guid rendezVousGuid = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); Guid aliceGuid = Guid.Parse("11111111-1111-1111-1111-111111111111"); Guid brunoGuid = Guid.Parse("22222222-2222-2222-2222-222222222222"); Guid interventionGuid = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); DateTime jour1 = new(2026, 10, 12, 8, 0, 0); DateTime jour2 = new(2026, 10, 13, 8, 0, 0); _collaborateurService.Collaborateurs.AddRange( [ new CollaborateurListItem { CollaborateurGuid = aliceGuid, DisplayName = "Alice Martin", Telephone = "06 10 20 30 40", Email = "alice.martin@omika.fr" }, new CollaborateurListItem { CollaborateurGuid = brunoGuid, DisplayName = "Bruno Petit", Telephone = "06 50 60 70 80", Email = "bruno.petit@omika.fr" } ]); AgendaItem carte = new() { RendezVousGuid = rendezVousGuid, Titre = "Chantier groupé", Debut = jour1, Fin = jour1.AddHours(4), InterventionGuid = interventionGuid, // La fiche appartient à Alice : elle est exclue du bloc. CollaborateurNom = "Alice Martin", CollaborateurOccurrenceGuid = aliceGuid, Collaborateurs = ["Alice Martin", "Bruno Petit"], CollaborateurGuids = [aliceGuid, brunoGuid], JoursDuGroupe = [jour1.Date, jour2.Date] }; // Act await _vm.LoadCommand.ExecuteAsync(carte); // Assert : seule la ligne de Bruno reste, avec ses coordonnées. RendezVousCollaborateurItem ligne = Assert.Single(_vm.CollaborateursRendezVous); Assert.Equal("Bruno Petit", ligne.Nom); Assert.Equal(brunoGuid, ligne.CollaborateurGuid); Assert.True(ligne.HasPhone); Assert.True(ligne.HasEmail); Assert.Equal("06 50 60 70 80", ligne.Telephone); Assert.Equal("bruno.petit@omika.fr", ligne.Email); // Email = envoi PERSONNEL : la messagerie de l'utilisateur est ouverte, il // n'y a AUCUNE navigation vers la composition interne d'Omika. _vm.EmailCollaborateurCommand.Execute(ligne); Assert.Equal(1, _emailLauncher.ComposeCalls); Assert.Equal("bruno.petit@omika.fr", _emailLauncher.LastEmail); Assert.NotEqual(AppPage.NouveauEmail, _navigation.CurrentPage); } /// /// Sans coordonnées connues localement, la ligne reste affichée avec le nom et /// les boutons d'appel/email sont masqués (aucune donnée inventée). /// [Fact] public async Task Load_Groupe_CollaborateurSansCoordonnees_ResteAfficheAvecSonNom() { Guid rendezVousGuid = Guid.Parse("e2e2e2e2-e2e2-e2e2-e2e2-e2e2e2e2e2e2"); Guid aliceGuid = Guid.Parse("11111111-1111-1111-1111-111111111111"); Guid brunoGuid = Guid.Parse("22222222-2222-2222-2222-222222222222"); DateTime jour = new(2026, 10, 12, 8, 0, 0); AgendaItem carte = new() { RendezVousGuid = rendezVousGuid, Titre = "Chantier groupé", Debut = jour, Fin = jour.AddHours(4), CollaborateurNom = "Alice Martin", CollaborateurOccurrenceGuid = aliceGuid, Collaborateurs = ["Alice Martin", "Bruno Petit"], CollaborateurGuids = [aliceGuid, brunoGuid], JoursDuGroupe = [jour.Date] }; await _vm.LoadCommand.ExecuteAsync(carte); RendezVousCollaborateurItem ligne = Assert.Single(_vm.CollaborateursRendezVous); Assert.Equal("Bruno Petit", ligne.Nom); Assert.False(ligne.HasPhone); Assert.False(ligne.HasEmail); } /// /// Un rendez-vous d'une journée avec un seul collaborateur n'affiche pas le /// bloc informatif « rendez-vous groupé ». /// [Fact] public async Task Load_RendezVousSimple_NAffichePasLeBlocGroupe() { // Arrange Guid rendezVousGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); DateTime jour = new(2026, 10, 12, 9, 0, 0); AgendaItem carte = new() { RendezVousGuid = rendezVousGuid, Titre = "Visite", Debut = jour, Fin = jour.AddHours(1), CollaborateurNom = "Alice Martin", Collaborateurs = ["Alice Martin"], JoursDuGroupe = [jour.Date] }; // Act await _vm.LoadCommand.ExecuteAsync(carte); // Assert Assert.False(_vm.AfficheBlocPeriode); Assert.False(_vm.EstRendezVousPlusieursJours); Assert.Single(_vm.JoursRendezVous); Assert.Equal("Lundi 12/10/2026", _vm.JoursRendezVous[0]); } [Fact] public async Task Load_ChargeLeRendezVous() { // Arrange : un rendez-vous X est chargé. Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _agendaService.ById = CreateRendezVous(rendezVousGuid, "RDV Client"); // Act await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : le rendez-vous est chargé, sans erreur. Assert.NotNull(_vm.RendezVous); Assert.Equal(rendezVousGuid, _vm.RendezVous!.RendezVousGuid); Assert.Null(_vm.ErrorMessage); } /// /// Règle métier : le compte rendu d'un rendez-vous ne se saisit qu'à partir de /// l'heure de début. Sur un rendez-vous futur, la commande reste sans effet — /// même déclenchée directement — et le bouton reste visible mais inactif /// (aucun message d'explication n'est affiché dans la fiche). /// [Fact] public async Task OpenCompteRendu_RendezVousFutur_NaviguePas() { // Arrange : rendez-vous planifié demain. Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); DateTime demain = DateTime.Now.AddDays(1); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV futur", Debut = demain, Fin = demain.AddHours(1) }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : bouton inactif, sans texte d'explication dans la fiche. Assert.False(_vm.PeutSaisirCompteRendu); Assert.True(_vm.EstRendezVousFutur); // Act _vm.OpenCompteRenduCommand.Execute(null); // Assert : aucune navigation vers le formulaire de compte rendu. Assert.NotEqual(AppPage.CompteRenduCreate, _navigation.CurrentPage); } /// /// Contre-épreuve : sur un rendez-vous passé lié à une intervention, le bouton /// ouvre bien le formulaire de compte rendu. /// [Fact] public async Task OpenCompteRendu_RendezVousPasse_OuvreLeFormulaire() { Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); DateTime hier = DateTime.Now.AddDays(-1); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV passé", Debut = hier, Fin = hier.AddHours(1), InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd") }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); Assert.True(_vm.PeutSaisirCompteRendu); // Act _vm.OpenCompteRenduCommand.Execute(null); // Assert : le formulaire est ouvert. Assert.Equal(AppPage.CompteRenduCreate, _navigation.CurrentPage); } /// /// Règle métier : un compte rendu documente une intervention. Un rendez-vous /// sans intervention liée ne peut donc pas en porter un — le bouton n'est pas /// proposé et la commande reste sans effet, même déclenchée directement. /// [Fact] public async Task OpenCompteRendu_SansIntervention_NeNaviguePas() { Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); DateTime hier = DateTime.Now.AddDays(-1); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV sans intervention", Debut = hier, Fin = hier.AddHours(1) }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : aucune action « Compte rendu » proposée (pas même grisée) et // aucune exigence de compte rendu (elle serait impossible à satisfaire). Assert.False(_vm.AfficherCompteRendu); Assert.False(_vm.PeutSaisirCompteRendu); Assert.False(_vm.CompteRenduObligatoire); Assert.False(_vm.CompteRenduBloqueValidationRendezVous); // Act _vm.OpenCompteRenduCommand.Execute(null); // Assert : aucune navigation vers le formulaire de compte rendu. Assert.NotEqual(AppPage.CompteRenduCreate, _navigation.CurrentPage); } // ===== Accès rapide « Images et documents » de l'intervention ===== /// /// Accès rapide : depuis l'en-tête de la fiche rendez-vous, le bouton /// « Images et documents » ouvre directement la page des contenus associés de /// l'intervention liée (onglet Images), sans passer par la fiche intervention. /// [Fact] public async Task OpenDocumentsIntervention_NavigueVersImagesEtDocuments() { // Arrange : rendez-vous lié à une intervention. Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); Guid interventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV Client", Debut = DateTime.Now.AddDays(-1), Fin = DateTime.Now.AddDays(-1).AddHours(1), InterventionGuid = interventionGuid }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : le bouton est proposé sur un rendez-vous lié à une intervention. Assert.True(_vm.PeutOuvrirDocumentsIntervention); // Act _vm.OpenDocumentsInterventionCommand.Execute(null); // Assert : page « Contenus associés », onglet Images (index 0). Assert.Equal(AppPage.InterventionResources, _navigation.CurrentPage); var nav = Assert.IsType(_navigation.CurrentParameter); Assert.Equal(interventionGuid, nav.InterventionGuid); Assert.Equal(0, nav.TabIndex); } /// /// Sans intervention liée, le bouton n'est pas proposé et la commande reste /// sans effet même déclenchée directement. /// [Fact] public async Task OpenDocumentsIntervention_SansIntervention_NeNaviguePas() { // Arrange : rendez-vous sans intervention liée. Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _agendaService.ById = CreateRendezVous(rendezVousGuid, "RDV sans intervention"); await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); Assert.False(_vm.PeutOuvrirDocumentsIntervention); // Act _vm.OpenDocumentsInterventionCommand.Execute(null); // Assert : aucune navigation vers les contenus associés. Assert.NotEqual(AppPage.InterventionResources, _navigation.CurrentPage); } // ===== Ouverture de la saisie dès le DÉBUT du rendez-vous ===== /// /// Règle métier : la saisie de temps et le compte rendu s'ouvrent dès que le /// rendez-vous a COMMENCÉ, sans attendre sa fin (il peut durer moins longtemps /// que prévu). /// [Fact] public async Task RendezVousCommence_SaisieTempsEtCompteRenduOuverts() { // Arrange : rendez-vous commencé il y a une heure, fin prévue dans une heure. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime maintenant = DateTime.Now; _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV en cours", Debut = maintenant.AddHours(-1), Fin = maintenant.AddHours(1), InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), CollaborateurGuids = [collabGuid] }; // Act await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : les deux saisies sont ouvertes, sans attendre la fin du rendez-vous. Assert.False(_vm.EstRendezVousFutur); Assert.True(_vm.PeutSaisirTemps); Assert.True(_vm.PeutSaisirCompteRendu); } /// /// Une fiche ouverte AVANT l'heure du rendez-vous ne reste pas verrouillée : au /// premier passage de la veille après le début, la saisie s'ouvre d'elle-même /// (éligibilité + pré-saisie), sans changer d'écran. /// [Fact] public async Task RafraichirOuvertureSaisie_RendezVousQuiCommence_OuvreLaSaisie() { // Arrange : rendez-vous planifié dans deux secondes, fiche ouverte juste avant. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime debut = DateTime.Now.AddSeconds(2); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV imminent", Debut = debut, Fin = debut.AddHours(1), InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), CollaborateurGuids = [collabGuid] }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); Assert.False(_vm.PeutSaisirTemps); Assert.False(_vm.PeutSaisirCompteRendu); Assert.Empty(_suiviTempsService.CreatedDrafts); // Act : l'heure du rendez-vous arrive, la veille repasse. while (DateTime.Now < debut) { await Task.Delay(50); } await _vm.RafraichirOuvertureSaisieAsync(); // Assert : la saisie est ouverte et pré-remplie, comme à l'ouverture. Assert.False(_vm.EstRendezVousFutur); Assert.True(_vm.PeutSaisirTemps); Assert.True(_vm.PeutSaisirCompteRendu); Assert.Contains( _suiviTempsService.CreatedDrafts, d => string.Equals(d.TypeTempsKey, SuiviTempsConstants.InterventionTypeTempsKey, StringComparison.OrdinalIgnoreCase)); } /// /// Une fiche déjà saisissable n'est pas rechargée par la veille (aucun accès au /// stockage, aucune pré-saisie en double). /// [Fact] public async Task RafraichirOuvertureSaisie_SansChangementNeRechargeRien() { // Arrange : rendez-vous passé, fiche déjà chargée. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime hier = DateTime.Now.AddDays(-1); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV passé", Debut = hier, Fin = hier.AddHours(1), CollaborateurGuids = [collabGuid] }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); int creations = _suiviTempsService.CreatedDrafts.Count; // Act await _vm.RafraichirOuvertureSaisieAsync(); await _vm.RafraichirOuvertureSaisieAsync(); // Assert : aucune nouvelle pré-saisie. Assert.Equal(creations, _suiviTempsService.CreatedDrafts.Count); Assert.True(_vm.PeutSaisirTemps); } // ===== Une saisie ne peut pas se terminer dans le futur ===== /// /// Règle métier : on ne peut pas déclarer une fin postérieure à l'instant /// présent (ex. fin 17:00 alors que la saisie est effectuée à 16:00). La saisie /// reste possible dès le début du rendez-vous, mais elle porte sur du temps /// réellement écoulé. /// [Fact] public async Task Save_HeureFinDansLeFutur_Refuse() { // Arrange : rendez-vous commencé il y a une heure, fin prévue dans une heure. _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); DateTime maintenant = DateTime.Now; DateTime debut = maintenant.AddMinutes(-60); await OuvrirEditeurAsync(debut, maintenant.AddMinutes(60)); // Act : l'utilisateur déclare une fin dans 30 minutes. _vm.HeureDebut = debut.TimeOfDay; _vm.HeureFin = maintenant.AddMinutes(30).TimeOfDay; await _vm.SaveEditorCommand.ExecuteAsync(null); // Assert : la saisie est refusée, avec l'heure actuelle en explication. Assert.NotNull(_vm.ErrorMessage); Assert.Contains("heure actuelle", _vm.ErrorMessage); } /// /// Contre-épreuve : une fin déjà écoulée reste acceptée sur un rendez-vous en /// cours (le rendez-vous a duré moins longtemps que prévu). /// [Fact] public async Task Save_HeureFinDejaEcoulee_SurRendezVousEnCours_Autorise() { // Arrange _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); DateTime maintenant = DateTime.Now; DateTime debut = maintenant.AddMinutes(-90); await OuvrirEditeurAsync(debut, maintenant.AddMinutes(60)); int creations = _suiviTempsService.CreatedDrafts.Count; // Act : le rendez-vous s'est terminé il y a 30 minutes. _vm.HeureDebut = debut.TimeOfDay; _vm.HeureFin = maintenant.AddMinutes(-30).TimeOfDay; await _vm.SaveEditorCommand.ExecuteAsync(null); // Assert : le temps est enregistré, sans message d'erreur. Assert.Null(_vm.ErrorMessage); Assert.Equal(creations + 1, _suiviTempsService.CreatedDrafts.Count); } /// /// Une saisie pré-remplie avec le créneau PLANIFIÉ d'un rendez-vous en cours se /// termine dans le futur : la validation est refusée tant que l'heure de fin /// réelle n'est pas indiquée. /// [Fact] public async Task Valider_SaisieQuiDepasseLInstantPresent_Bloque() { // Arrange : rendez-vous commencé il y a une heure, fin prévue dans une heure. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime maintenant = DateTime.Now; _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV en cours", Debut = maintenant.AddMinutes(-60), Fin = maintenant.AddMinutes(60), CollaborateurGuids = [collabGuid] }; // La saisie pré-remplie reprend le créneau PLANIFIÉ : elle se termine donc // dans une heure alors que le rendez-vous est en cours. _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rendezVousGuid, TypeTempsKey = SuiviTempsConstants.InterventionTypeTempsKey, CollaborateurId = collabGuid, Date = maintenant.AddMinutes(-60), DureeMinutes = 120, EstEnvoye = false }); await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : le blocage est annoncé à l'utilisateur. Assert.NotNull(_vm.MessageBlocageValidationSaisieFuture); Assert.Contains("heure de fin réelle", _vm.MessageBlocageValidationSaisieFuture); Assert.True(_vm.PeutValider); // Act : la validation est tentée malgré tout. await _vm.ValiderCommand.ExecuteAsync(null); // Assert : rien n'est validé (aucun envoi, aucun marquage « Fait »). Assert.False(_suiviTempsService.ValidateCalled); Assert.Empty(_finalisationService.MarquesFait); Assert.False(_vm.EstValide); } /// /// Une fois la saisie corrigée sur du temps écoulé, plus aucun blocage : la /// validation redevient possible. /// [Fact] public async Task Valider_SaisieCorrigeeSurTempsEcoule_NestPlusBloquee() { // Arrange Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime maintenant = DateTime.Now; _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV en cours", Debut = maintenant.AddMinutes(-120), Fin = maintenant.AddMinutes(60), CollaborateurGuids = [collabGuid] }; _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rendezVousGuid, TypeTempsKey = SuiviTempsConstants.InterventionTypeTempsKey, CollaborateurId = collabGuid, Date = maintenant.AddMinutes(-120), DureeMinutes = 110, EstEnvoye = false }); await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : la saisie porte sur du temps écoulé, aucun blocage. Assert.Null(_vm.MessageBlocageValidationSaisieFuture); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert : la validation a bien été effectuée. Assert.True(_suiviTempsService.ValidateCalled); } // ===== Saisie pré-remplie d'un rendez-vous sur plusieurs jours ===== /// /// Un rendez-vous qui court sur plusieurs jours ne peut pas être pré-saisi en /// un seul bloc de 51 heures : le créneau proposé est ramené à une journée de /// saisie (et reste dans la durée maximale acceptée par l'éditeur). /// [Fact] public async Task Load_RendezVousPlusieursJours_SaisiePreRemplieLimiteeAUneJournee() { // Arrange : rendez-vous de trois jours, commencé il y a quatre jours. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime premierJour = DateTime.Now.Date.AddDays(-4).AddHours(12); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV sur trois jours", Debut = premierJour, Fin = premierJour.AddDays(2).AddHours(3), CollaborateurGuids = [collabGuid] }; // Act await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : la pré-saisie ne dépasse pas une journée (12 h ici), pas 51 h. SuiviTempsDraftEntry draft = Assert.Single(_suiviTempsService.CreatedDrafts); Assert.Equal(12 * 60, draft.DureeMinutes); } [Fact] public async Task OpenItineraire_LauncherRetourneFalse_MessageErreur() { // Arrange : RDV avec une adresse, launcher qui échoue (aucune app de nav). Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV Client", SiteAdresse = "12 rue de la Paix 75002 Paris" }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); _mapLauncher.Result = false; // Act await _vm.OpenItineraireCommand.ExecuteAsync(null); // Assert : l'adresse est transmise et l'échec est remonté à l'utilisateur. Assert.Equal(1, _mapLauncher.OpenItineraireCalls); Assert.Equal("12 rue de la Paix 75002 Paris", _mapLauncher.LastAddress); Assert.Equal("Aucune application de navigation disponible.", _vm.ErrorMessage); } [Fact] public async Task OpenItineraire_AdresseVide_NeLancePas() { // Arrange : RDV sans adresse. Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV Client", SiteAdresse = null }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Act await _vm.OpenItineraireCommand.ExecuteAsync(null); // Assert : aucun lancement. Assert.Equal(0, _mapLauncher.OpenItineraireCalls); } [Fact] public async Task DeleteDraft_SupprimeUnBrouillonNonEnvoye() { // Arrange : un rendez-vous passé assigné au collaborateur courant, avec un // brouillon de temps encore en attente de validation (non synchronisé). Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV Client", Debut = DateTime.Now.AddDays(-1), Fin = DateTime.Now.AddDays(-1).AddHours(1), CollaborateurGuids = [collabGuid] }; _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rendezVousGuid, TypeTempsKey = "PAUSE", CollaborateurId = collabGuid, Date = DateTime.Now.AddDays(-1), DureeMinutes = 30, EstEnvoye = false }); await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); SuiviTempsDraftDisplayItem item = Assert.Single(_vm.SuivisTemps); // Act await _vm.DeleteDraftCommand.ExecuteAsync(item); // Assert : le brouillon a été supprimé et l'écran rechargé. Assert.Contains("PAUSE", _suiviTempsService.DeletedKeys); Assert.Empty(_vm.SuivisTemps); } [Fact] public async Task TotalTemps_UnionDesPlages_SansDoubleCompte() { // Arrange : intervention 07:30-19:00 et pause déjeuner 12:30-13:00 (incluse). Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); DateTime jour = new(2026, 8, 14); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV", Debut = jour.AddHours(7).AddMinutes(30), Fin = jour.AddHours(19) }; _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rendezVousGuid, TypeTempsKey = "T.TEMPS.INTER", Date = jour.AddHours(7).AddMinutes(30), DureeMinutes = 690, EstEnvoye = false }); _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rendezVousGuid, TypeTempsKey = "PAUSE", Date = jour.AddHours(12).AddMinutes(30), DureeMinutes = 30, EstEnvoye = false }); await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : 11 h 30 (union), pas 12 h (somme). Assert.Equal(690, _vm.TotalTempsMinutes); } [Fact] public async Task Load_ConserveLeRendezVousAvecBrouillon() { // Arrange : un rendez-vous passé avec un brouillon non envoyé. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV Client", Debut = DateTime.Now.AddDays(-1), Fin = DateTime.Now.AddDays(-1).AddHours(1), CollaborateurGuids = [collabGuid] }; _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rendezVousGuid, TypeTempsKey = "PAUSE", CollaborateurId = collabGuid, Date = DateTime.Now.AddDays(-1), DureeMinutes = 30, EstEnvoye = false }); await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : le rendez-vous est conservé localement pour être ré-affiché. Assert.Contains(_suiviTempsService.SavedRendezVous, r => r.RendezVousGuid == rendezVousGuid); } [Fact] public async Task Load_AutoriseSaisieTempsSurRdvAutrui_AvecDroit() { // Arrange : un rendez-vous passé assigné à UN AUTRE collaborateur (pas à // l'utilisateur courant). Le droit A.TEMP.COLB est présent (fake). Guid collabCourant = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid collabAutre = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabCourant.ToString("D") } }; _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV d'un autre collaborateur", Debut = DateTime.Now.AddDays(-1), Fin = DateTime.Now.AddDays(-1).AddHours(1), CollaborateurGuids = [collabAutre] }; // Act await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : la saisie de temps est autorisée (droit A.TEMP.COLB). Assert.True(_vm.PeutSaisirTemps); } // ===== Préremplissage « pause déjeuner » (préférence locale) ===== /// Référentiel TypeTemps contenant les trois types utilisés par les tests. private static IReadOnlyList TypesAvecPauseDejeuner() => [ new() { Key = "T.TEMPS.INTER", Label = "Temps d'intervention" }, new() { Key = SuiviTempsConstants.PauseDejeunerTypeTempsKey, Label = "Temps pause déjeuner" }, new() { Key = "T.TEMPS.TRAJET", Label = "Temps de trajet" } ]; /// Type « pause déjeuner » du référentiel de test. private ReferenceValueDto TypePauseDejeuner => SuiviTempsConstants.FindPauseDejeunerTypeTemps(_vm.TypesTemps)!; /// Type « trajet » du référentiel de test (autre type de temps). private ReferenceValueDto TypeTrajet => _vm.TypesTemps.First(t => t.Key == "T.TEMPS.TRAJET"); /// /// Charge un rendez-vous passé assigné au collaborateur courant, puis ouvre /// l'éditeur inline de saisie de temps. /// private async Task OuvrirEditeurAsync(DateTime debut, DateTime fin) { Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV Client", Debut = debut, Fin = fin, CollaborateurGuids = [collabGuid] }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); await _vm.AddTimeCommand.ExecuteAsync(null); } [Fact] public async Task AddTime_PauseDejeuner_PrefillLesHorairesHabituelsDeLUtilisateur() { // Arrange : préférence 12:15 → 13:15 et rendez-vous 08:00 → 09:00. _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _pauseDejeuner.Set(new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); DateTime jour = new(2026, 8, 14); await OuvrirEditeurAsync(jour.AddHours(8), jour.AddHours(9)); // Le premier type du référentiel est « intervention » : créneau du RDV. Assert.Equal(new TimeSpan(8, 0, 0), _vm.HeureDebut); Assert.Equal(new TimeSpan(9, 0, 0), _vm.HeureFin); // Act : l'utilisateur choisit « Pause déjeuner ». _vm.SelectedTypeTemps = TypePauseDejeuner; // Assert : les horaires habituels sont proposés. Assert.Equal(new TimeSpan(12, 15, 0), _vm.HeureDebut); Assert.Equal(new TimeSpan(13, 15, 0), _vm.HeureFin); } [Fact] public async Task AddTime_SansPreferenceEnregistree_PrefillLesValeursParDefaut() { // Arrange : aucune préférence enregistrée (le faux retourne le défaut). _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); DateTime jour = new(2026, 8, 14); await OuvrirEditeurAsync(jour.AddHours(8), jour.AddHours(9)); // Act _vm.SelectedTypeTemps = TypePauseDejeuner; // Assert : 12:00 → 13:00 (valeurs par défaut centralisées). Assert.Equal(PauseDejeunerPreference.DebutParDefaut, _vm.HeureDebut); Assert.Equal(PauseDejeunerPreference.FinParDefaut, _vm.HeureFin); } [Fact] public async Task AddTime_AutreTypeDeTemps_ConserveLeCreneauDuRendezVous() { // Arrange : préférence renseignée, mais l'utilisateur choisit un trajet. _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _pauseDejeuner.Set(new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); DateTime jour = new(2026, 8, 14); await OuvrirEditeurAsync(jour.AddHours(8), jour.AddHours(9)); // Act : type « Temps de trajet » (aucun préremplissage attendu). _vm.SelectedTypeTemps = TypeTrajet; // Assert : le créneau du rendez-vous est conservé, sans changement. Assert.Equal(new TimeSpan(8, 0, 0), _vm.HeureDebut); Assert.Equal(new TimeSpan(9, 0, 0), _vm.HeureFin); } [Fact] public async Task AddTime_PauseDejeuner_PuisRetourAutreType_RestaureLeCreneauDuRendezVous() { // Arrange _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _pauseDejeuner.Set(new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); DateTime jour = new(2026, 8, 14); await OuvrirEditeurAsync(jour.AddHours(8), jour.AddHours(9)); // Act : pause déjeuner puis retour à un autre type. _vm.SelectedTypeTemps = TypePauseDejeuner; Assert.Equal(new TimeSpan(12, 15, 0), _vm.HeureDebut); _vm.SelectedTypeTemps = TypeTrajet; // Assert : le créneau du rendez-vous est restauré. Assert.Equal(new TimeSpan(8, 0, 0), _vm.HeureDebut); Assert.Equal(new TimeSpan(9, 0, 0), _vm.HeureFin); } [Fact] public async Task AddTime_HeuresAjusteesParLUtilisateur_NeSontPlusRemplacees() { // Arrange _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _pauseDejeuner.Set(new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); DateTime jour = new(2026, 8, 14); await OuvrirEditeurAsync(jour.AddHours(8), jour.AddHours(9)); // Act : préremplissage puis ajustement exceptionnel par l'utilisateur. _vm.SelectedTypeTemps = TypePauseDejeuner; _vm.HeureDebut = new TimeSpan(12, 30, 0); _vm.HeureFin = new TimeSpan(13, 45, 0); // Un changement de type ultérieur ne doit plus écraser sa saisie. _vm.SelectedTypeTemps = TypeTrajet; _vm.SelectedTypeTemps = TypePauseDejeuner; // Assert : la saisie ajustée est conservée telle quelle. Assert.Equal(new TimeSpan(12, 30, 0), _vm.HeureDebut); Assert.Equal(new TimeSpan(13, 45, 0), _vm.HeureFin); } [Fact] public async Task AddTime_LitLaPreferenceUneSeuleFois_MemeApresPlusieursChangementsDeType() { // Arrange _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _pauseDejeuner.Set(new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); DateTime jour = new(2026, 8, 14); await OuvrirEditeurAsync(jour.AddHours(8), jour.AddHours(9)); // Act : plusieurs changements de type (aucune relecture attendue). _vm.SelectedTypeTemps = TypePauseDejeuner; _vm.SelectedTypeTemps = TypeTrajet; _vm.SelectedTypeTemps = TypePauseDejeuner; // Assert : une seule lecture du stockage local à l'ouverture de l'éditeur. Assert.Equal(1, _pauseDejeuner.GetCallCount); } [Fact] public async Task AddTime_PauseDejeuner_PuisEnregistrement_ConserveLaDateDuRendezVous() { // Arrange : rendez-vous du 14/08/2026 (07:30 → 14:00, la pause est comprise // dans la période), préférence 12:15 → 13:15. _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _pauseDejeuner.Set(new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); DateTime jour = new(2026, 8, 14); await OuvrirEditeurAsync(jour.AddHours(7).AddMinutes(30), jour.AddHours(14)); _vm.SelectedTypeTemps = TypePauseDejeuner; // Act await _vm.SaveEditorCommand.ExecuteAsync(null); // Assert : la date reste celle du contexte de saisie (le rendez-vous), // seule l'heure vient de la préférence. (Un brouillon « intervention » est // créé automatiquement par le chargement : on cible celui de la pause.) SuiviTempsDraftEntry draft = Assert.Single( _suiviTempsService.CreatedDrafts, d => d.TypeTempsKey == SuiviTempsConstants.PauseDejeunerTypeTempsKey); Assert.Equal(jour.AddHours(12).AddMinutes(15), draft.Date); Assert.Equal(60, draft.DureeMinutes); Assert.Null(_vm.ErrorMessage); } [Fact] public async Task AddTime_EchecDeLectureDeLaPreference_NEmpecheJamaisLaSaisie() { // Arrange : le stockage local de la préférence est indisponible. _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _pauseDejeuner.ThrowOnGet = true; DateTime jour = new(2026, 8, 14); // Act : l'éditeur doit s'ouvrir malgré l'échec. await OuvrirEditeurAsync(jour.AddHours(8), jour.AddHours(9)); // Assert : saisie possible, aucun blocage. Assert.True(_vm.IsEditorVisible); Assert.Equal(new TimeSpan(8, 0, 0), _vm.HeureDebut); // Et le repli reste exploitable (valeurs par défaut, sans erreur). _vm.SelectedTypeTemps = TypePauseDejeuner; Assert.Equal(PauseDejeunerPreference.DebutParDefaut, _vm.HeureDebut); Assert.Equal(PauseDejeunerPreference.FinParDefaut, _vm.HeureFin); } [Fact] public async Task EditDraft_PauseDejeuner_ConserveLesHeuresEnregistrees() { // Arrange : brouillon « pause déjeuner » enregistré 12:20 → 13:00, et une // préférence différente (12:15 → 13:15). _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); _pauseDejeuner.Set(new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); DateTime jour = new(2026, 8, 14); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV Client", Debut = jour.AddHours(8), Fin = jour.AddHours(9), CollaborateurGuids = [collabGuid] }; _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rendezVousGuid, TypeTempsKey = SuiviTempsConstants.PauseDejeunerTypeTempsKey, CollaborateurId = collabGuid, Date = jour.AddHours(12).AddMinutes(20), DureeMinutes = 40, EstEnvoye = false }); await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); SuiviTempsDraftDisplayItem item = _vm.SuivisTemps.First(s => s.Draft is not null); // Act : ouverture en MODIFICATION. await _vm.EditDraftCommand.ExecuteAsync(item); // Assert : les heures réellement enregistrées sont utilisées, JAMAIS // remplacées par la préférence. Assert.True(_vm.IsEditingDraft); Assert.Equal(new TimeSpan(12, 20, 0), _vm.HeureDebut); Assert.Equal(new TimeSpan(13, 0, 0), _vm.HeureFin); } // ===== Cohérence temporelle « pause déjeuner » / rendez-vous ===== /// /// Configure le référentiel, ouvre l'éditeur sur le rendez-vous donné, /// sélectionne le type « pause déjeuner », impose le créneau puis enregistre. /// private async Task EnregistrerPauseAsync( DateTime rdvDebut, DateTime rdvFin, TimeSpan pauseDebut, TimeSpan pauseFin) { _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); await OuvrirEditeurAsync(rdvDebut, rdvFin); _vm.SelectedTypeTemps = TypePauseDejeuner; _vm.HeureDebut = pauseDebut; _vm.HeureFin = pauseFin; await _vm.SaveEditorCommand.ExecuteAsync(null); } /// Indique si un brouillon « pause déjeuner » a été enregistré. private bool PauseEnregistree() => _suiviTempsService.CreatedDrafts.Any(d => d.TypeTempsKey == SuiviTempsConstants.PauseDejeunerTypeTempsKey); [Fact] public async Task Save_PauseDejeuner_TropEloigneeApresLeRendezVous_Refuse() { // Arrange : intervention 07:30 → 09:00, pause 12:00 → 13:00 (3 h d'écart). DateTime jour = new(2026, 8, 14); await EnregistrerPauseAsync( jour.AddHours(7).AddMinutes(30), jour.AddHours(9), new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); // Assert : refus, avec un message clair, aucune pause enregistrée. Assert.False(PauseEnregistree()); Assert.NotNull(_vm.ErrorMessage); Assert.Contains("12:00", _vm.ErrorMessage); Assert.Contains("09:00", _vm.ErrorMessage); } [Fact] public async Task Save_PauseDejeuner_ProcheDeLaFinDuRendezVous_Autorise() { // Arrange : intervention 07:30 → 11:40, pause 12:00 → 13:00 (20 min). DateTime jour = new(2026, 8, 14); await EnregistrerPauseAsync( jour.AddHours(7).AddMinutes(30), jour.AddHours(11).AddMinutes(40), new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); Assert.True(PauseEnregistree()); Assert.Null(_vm.ErrorMessage); } [Fact] public async Task Save_PauseDejeuner_DebutEgaleFinDuRendezVous_Autorise() { // Arrange : intervention 07:30 → 12:00, pause 12:00 → 13:00 (contiguë). DateTime jour = new(2026, 8, 14); await EnregistrerPauseAsync( jour.AddHours(7).AddMinutes(30), jour.AddHours(12), new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); Assert.True(PauseEnregistree()); Assert.Null(_vm.ErrorMessage); } [Fact] public async Task Save_PauseDejeuner_CompriseDansLeRendezVous_Autorise() { // Arrange : intervention 07:30 → 14:00, pause 12:00 → 13:00 (incluse). DateTime jour = new(2026, 8, 14); await EnregistrerPauseAsync( jour.AddHours(7).AddMinutes(30), jour.AddHours(14), new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); Assert.True(PauseEnregistree()); Assert.Null(_vm.ErrorMessage); } [Fact] public async Task Save_PauseDejeuner_AExactementLaTolerance_Autorise() { // Arrange : intervention terminée à 11:30, pause 12:00 (30 min exactement). DateTime jour = new(2026, 8, 14); await EnregistrerPauseAsync( jour.AddHours(8), jour.AddHours(11).AddMinutes(30), new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); Assert.True(PauseEnregistree()); Assert.Null(_vm.ErrorMessage); } [Fact] public async Task Save_PauseDejeuner_AuDelaDeLaTolerance_Refuse() { // Arrange : intervention terminée à 11:00, pause 12:00 (60 min d'écart). DateTime jour = new(2026, 8, 14); await EnregistrerPauseAsync( jour.AddHours(8), jour.AddHours(11), new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); Assert.False(PauseEnregistree()); Assert.NotNull(_vm.ErrorMessage); } [Fact] public async Task Save_PauseDejeuner_AvantLeRendezVous_TropEloignee_Refuse() { // Arrange : pause 12:00 → 13:00, rendez-vous 15:00 → 17:00 (2 h d'écart). DateTime jour = new(2026, 8, 14); await EnregistrerPauseAsync( jour.AddHours(15), jour.AddHours(17), new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); Assert.False(PauseEnregistree()); Assert.NotNull(_vm.ErrorMessage); Assert.Contains("15:00", _vm.ErrorMessage); } [Fact] public async Task Save_PauseDejeuner_AvantLeRendezVous_DansLaTolerance_Autorise() { // Arrange : pause 12:00 → 13:00, rendez-vous 13:20 → 15:00 (20 min). DateTime jour = new(2026, 8, 14); await EnregistrerPauseAsync( jour.AddHours(13).AddMinutes(20), jour.AddHours(15), new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); Assert.True(PauseEnregistree()); Assert.Null(_vm.ErrorMessage); } [Fact] public async Task Save_ModificationPauseDejeuner_AppliqueAussiLaCoherence() { // Arrange : brouillon « pause déjeuner » 12:00 → 13:00 valide (rendez-vous // 07:30 → 14:00). L'utilisateur l'ouvre et le décale à 18:00 → 19:00. _parametreMetierService.TypeTemps = TypesAvecPauseDejeuner(); Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); DateTime jour = new(2026, 8, 14); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV Client", Debut = jour.AddHours(7).AddMinutes(30), Fin = jour.AddHours(14), CollaborateurGuids = [collabGuid] }; _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rendezVousGuid, TypeTempsKey = SuiviTempsConstants.PauseDejeunerTypeTempsKey, CollaborateurId = collabGuid, Date = jour.AddHours(12), DureeMinutes = 60, EstEnvoye = false }); await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); SuiviTempsDraftDisplayItem item = _vm.SuivisTemps.First(s => s.Draft is not null); // Act : ouverture en modification puis décalage à un horaire trop éloigné. await _vm.EditDraftCommand.ExecuteAsync(item); _vm.HeureDebut = new TimeSpan(18, 0, 0); _vm.HeureFin = new TimeSpan(19, 0, 0); await _vm.SaveEditorCommand.ExecuteAsync(null); // Assert : la modification est refusée, aucune pause n'est enregistrée. Assert.False(PauseEnregistree()); Assert.NotNull(_vm.ErrorMessage); } // ===== Annulation du rendez-vous ===== /// Référentiels Origine / Motif d'annulation utilisés par les tests. private static IReadOnlyList OriginesAnnulationTest() => [ new() { Key = "O.ANU.RDV.E.CLI", Label = "Externe : Client" }, new() { Key = "O.ANU.RDV.I.CLB", Label = "Interne : Collaborateur" } ]; private static IReadOnlyList MotifsAnnulationTest() => [ new() { Key = "M.ANU.RDV.ABS", Label = "Absent" }, new() { Key = "M.ANU.RDV.EMPC", Label = "Empêchement" } ]; /// Rendez-vous futur, non annulé, sans temps validé ni saisi. private static AgendaItem RendezVousFutur() { DateTime jour = DateTime.Now.Date.AddDays(7); return new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV Client", Debut = jour.AddHours(10), Fin = jour.AddHours(12) }; } private async Task ChargerRdvAsync(AgendaItem rendezVous) { _agendaService.ById = rendezVous; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVous.RendezVousGuid, null)); } [Fact] public async Task PeutAnnulerRdv_RendezVousFuturSansTemps_EstVrai() { await ChargerRdvAsync(RendezVousFutur()); Assert.True(_vm.PeutAnnulerRdv); } [Fact] public async Task PeutAnnulerRdv_RendezVousDejaAnnule_EstFaux() { AgendaItem rdv = RendezVousFutur(); rdv.EstAnnule = true; await ChargerRdvAsync(rdv); Assert.False(_vm.PeutAnnulerRdv); } [Fact] public async Task PeutAnnulerRdv_RendezVousPasse_EstVrai() { // Les rendez-vous passés restent annulables (aucun blocage). DateTime passe = DateTime.Now.AddDays(-1); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV passé", Debut = passe.AddHours(10), Fin = passe.AddHours(12) }); Assert.True(_vm.PeutAnnulerRdv); } [Fact] public async Task PeutAnnulerRdv_RendezVousFait_EstFaux() { // Temps validé ⇒ rendez-vous « fait » (S.RDV.FAIT) ⇒ non annulable. DateTime jour = DateTime.Now.Date.AddDays(7); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV fait", Debut = jour.AddHours(10), Fin = jour.AddHours(12), TempsValide = true }); Assert.False(_vm.PeutAnnulerRdv); } [Fact] public async Task PeutAnnulerRdv_StatutFait_EstFaux() { // Statut métier « Fait » (S.RDV.FAIT) ⇒ non annulable. DateTime jour = DateTime.Now.Date.AddDays(7); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV fait", Debut = jour.AddHours(10), Fin = jour.AddHours(12), StatutKey = "S.RDV.FAIT" }); Assert.False(_vm.PeutAnnulerRdv); } [Fact] public async Task PeutAnnulerRdv_AvecCompteRendu_EstFaux() { DateTime jour = DateTime.Now.Date.AddDays(7); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV avec compte rendu", Debut = jour.AddHours(10), Fin = jour.AddHours(12), InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), AvecCompteRendu = true }); Assert.False(_vm.PeutAnnulerRdv); } [Fact] public async Task PeutAnnulerRdv_TempsDejaSaisis_EstFaux() { AgendaItem rdv = RendezVousFutur(); await ChargerRdvAsync(rdv); // Saisie réelle d'un type autre que l'intervention (ex. trajet). _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rdv.RendezVousGuid, TypeTempsKey = "T.TEMPS.TRAJET", Date = rdv.Debut, DureeMinutes = 60, EstEnvoye = false }); await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rdv.RendezVousGuid, null)); Assert.False(_vm.PeutAnnulerRdv); } [Fact] public async Task PeutAnnulerRdv_PreRemplissageIntervention_EstVrai() { AgendaItem rdv = RendezVousFutur(); // Le préremplissage automatique de l'intervention n'est pas une saisie : // il ne doit pas bloquer l'annulation. _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rdv.RendezVousGuid, TypeTempsKey = SuiviTempsConstants.InterventionTypeTempsKey, Date = rdv.Debut, DureeMinutes = 60, EstEnvoye = false }); await ChargerRdvAsync(rdv); Assert.True(_vm.PeutAnnulerRdv); } [Fact] public async Task PeutSaisirTemps_RendezVousAnnule_EstFaux() { // Arrange : rendez-vous passé annulé, assigné au collaborateur courant. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV annulé", Debut = passe.AddHours(10), Fin = passe.AddHours(12), CollaborateurGuids = [collabGuid], EstAnnule = true }); // Assert : un rendez-vous annulé n'est plus saisissable. Assert.False(_vm.PeutSaisirTemps); } [Fact] public async Task PeutSaisirTemps_CompteRenduExistant_EstVrai() { // Arrange : rendez-vous passé assigné au collaborateur courant, avec un // compte rendu déjà créé (AvecCompteRendu = true). La validation du // compte rendu NE DOIT PAS verrouiller la saisie de temps. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV avec compte rendu validé", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid], InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), AvecCompteRendu = true }); // Assert : le compte rendu (enregistré/validé) ne bloque pas la saisie de temps. Assert.True(_vm.PeutSaisirTemps); } [Fact] public async Task PeutSaisirTemps_TempsValides_EstFaux() { // Arrange : rendez-vous passé assigné dont les temps ont déjà été validés // côté serveur (TempsValide = true). C'est le verrou définitif : la // saisie de temps est interdite. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV temps validés", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid], TempsValide = true }); // Assert : des temps validés verrouillent définitivement la saisie. Assert.False(_vm.PeutSaisirTemps); } [Fact] public async Task PeutSaisirTemps_RdvGroupe_AutreCollaborateurValide_EstVrai() { // Arrange : RDV groupe passé. Le collaborateur courant est assigné, mais // c'est l'AUTRE collaborateur qui a validé ses temps (affectation validée). // La saisie du collaborateur courant ne doit PAS être verrouillée. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid autreGuid = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV groupe", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid, autreGuid], CollaborateursTempsValides = [autreGuid], TempsValide = true }); // Assert : le temps validé d'un AUTRE collaborateur ne verrouille pas la saisie. Assert.False(_vm.EstValide); Assert.True(_vm.PeutSaisirTemps); } [Fact] public async Task PeutSaisirTemps_RdvGroupe_CollaborateurCourantValide_EstFaux() { // Arrange : RDV groupe passé où le collaborateur courant a lui-même validé // ses temps : sa saisie reste verrouillée (comportement inchangé). Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); Guid autreGuid = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV groupe", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid, autreGuid], CollaborateursTempsValides = [collabGuid], TempsValide = true }); // Assert : l'affectation du collaborateur courant est validée → verrou. Assert.True(_vm.EstValide); Assert.False(_vm.PeutSaisirTemps); } [Fact] public async Task DemanderAnnulation_ChargeOriginesEtMotifs_EtOuvreLaConfirmation() { // Arrange : référentiels d'annulation disponibles. _parametreMetierService.OriginesAnnulation = OriginesAnnulationTest(); _parametreMetierService.MotifsAnnulation = MotifsAnnulationTest(); await ChargerRdvAsync(RendezVousFutur()); // Act await _vm.DemanderAnnulationCommand.ExecuteAsync(null); // Assert Assert.True(_vm.IsConfirmingAnnulation); Assert.Equal(2, _vm.OriginesAnnulation.Count); Assert.Equal(2, _vm.MotifsAnnulation.Count); Assert.Null(_vm.SelectedMotifAnnulation); } [Fact] public async Task ConfirmerAnnulation_SansMotif_AfficheUneErreurDeValidation() { _parametreMetierService.MotifsAnnulation = MotifsAnnulationTest(); await ChargerRdvAsync(RendezVousFutur()); await _vm.DemanderAnnulationCommand.ExecuteAsync(null); // Act : confirmation sans sélection de motif. await _vm.ConfirmerAnnulationCommand.ExecuteAsync(null); // Assert : refus métier, aucune annulation envoyée. Assert.NotNull(_vm.ErrorMessage); Assert.Contains("motif", _vm.ErrorMessage, StringComparison.OrdinalIgnoreCase); Assert.Empty(_rendezVousAnnulation.Annules); } [Fact] public async Task ConfirmerAnnulation_AvecMotif_AnnuleEtMetAJourLInterface() { _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", NomAffiche = "Jean DUPONT" } }; _parametreMetierService.OriginesAnnulation = OriginesAnnulationTest(); _parametreMetierService.MotifsAnnulation = MotifsAnnulationTest(); await ChargerRdvAsync(RendezVousFutur()); await _vm.DemanderAnnulationCommand.ExecuteAsync(null); _vm.SelectedOrigineAnnulation = _vm.OriginesAnnulation[0]; _vm.SelectedMotifAnnulation = _vm.MotifsAnnulation[0]; _vm.NoteAnnulation = "Client a annulé par téléphone."; // Act await _vm.ConfirmerAnnulationCommand.ExecuteAsync(null); // Assert : le service local a été appelé avec la note, l'UI reflète l'annulation. (Guid, Guid?, string?, string?, string?, string?, string?, string?) annulation = Assert.Single(_rendezVousAnnulation.Annules); Assert.Equal("O.ANU.RDV.E.CLI", annulation.Item3); Assert.Equal("M.ANU.RDV.ABS", annulation.Item4); Assert.Equal("Client a annulé par téléphone.", annulation.Item5); // Le nom du collaborateur connecté accompagne toujours l'annulation : il est // inscrit entre parenthèses après le mot « Note » dans la description. Assert.Equal("Jean DUPONT", annulation.Item6); // Les LIBELLÉS du référentiel sont transmis au service local afin que la copie // locale conserve le libellé et non le code du paramètre. Assert.Equal("Externe : Client", annulation.Item7); Assert.Equal("Absent", annulation.Item8); Assert.False(_vm.IsConfirmingAnnulation); Assert.True(_vm.RendezVous!.EstAnnule); Assert.Equal("Absent", _vm.RendezVous.MotifAnnulationLabel); Assert.Equal("Externe : Client", _vm.RendezVous.OrigineAnnulationLabel); Assert.Equal("Le rendez-vous a été annulé.", _vm.AnnulationMessage); // La description affichée porte immédiatement la mention d'annulation // « Note (Nom) : message » (aucune attente de synchronisation). Assert.Equal("Note (Jean DUPONT) : Client a annulé par téléphone.", _vm.RendezVous.Description); } [Fact] public async Task ConfirmerAnnulation_DesactiveImmediatementLaSaisieDeTemps() { // Arrange : rendez-vous passé assigné, saisie de temps initialement possible. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; _parametreMetierService.MotifsAnnulation = MotifsAnnulationTest(); DateTime passe = DateTime.Now.AddDays(-1); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV passé", Debut = passe.AddHours(10), Fin = passe.AddHours(12), CollaborateurGuids = [collabGuid] }); Assert.True(_vm.PeutSaisirTemps); await _vm.DemanderAnnulationCommand.ExecuteAsync(null); _vm.SelectedMotifAnnulation = _vm.MotifsAnnulation[0]; // Act await _vm.ConfirmerAnnulationCommand.ExecuteAsync(null); // Assert : la saisie est désactivée immédiatement, sans synchronisation. Assert.False(_vm.PeutSaisirTemps); Assert.True(_vm.RendezVous!.EstAnnule); } [Fact] public async Task AnnulerConfirmationAnnulation_FermeSansAnnuler() { _parametreMetierService.MotifsAnnulation = MotifsAnnulationTest(); await ChargerRdvAsync(RendezVousFutur()); await _vm.DemanderAnnulationCommand.ExecuteAsync(null); // Act _vm.AnnulerConfirmationAnnulationCommand.Execute(null); // Assert : aucune annulation, la confirmation est refermée. Assert.False(_vm.IsConfirmingAnnulation); Assert.Empty(_rendezVousAnnulation.Annules); Assert.False(_vm.RendezVous!.EstAnnule); } // ===== Annulation d'un rendez-vous GROUPÉ ===== [Fact] public async Task DemanderAnnulation_RendezVousGroupe_AfficheLAvertissementDImpactDuGroupe() { // Arrange : rendez-vous groupé (plusieurs collaborateurs sur le même RDV). AgendaItem rdv = RendezVousFutur(); rdv = new AgendaItem { RendezVousGuid = rdv.RendezVousGuid, Titre = rdv.Titre, Debut = rdv.Debut, Fin = rdv.Fin, EstGroupe = true, Collaborateurs = ["Jean DUPONT", "Marie MARTIN"] }; _parametreMetierService.MotifsAnnulation = MotifsAnnulationTest(); await ChargerRdvAsync(rdv); // Act await _vm.DemanderAnnulationCommand.ExecuteAsync(null); // Assert : l'avertissement explique que TOUS les rendez-vous du groupe // seront impactés. Assert.True(_vm.EstRendezVousGroupe); Assert.NotNull(_vm.AvertissementAnnulationGroupe); Assert.Contains("groupé", _vm.AvertissementAnnulationGroupe!, StringComparison.OrdinalIgnoreCase); Assert.Contains( "tous les rendez-vous du groupe", _vm.AvertissementAnnulationGroupe!, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task DemanderAnnulation_RendezVousIndividuel_NAfficheAucunAvertissement() { _parametreMetierService.MotifsAnnulation = MotifsAnnulationTest(); await ChargerRdvAsync(RendezVousFutur()); await _vm.DemanderAnnulationCommand.ExecuteAsync(null); Assert.False(_vm.EstRendezVousGroupe); Assert.Null(_vm.AvertissementAnnulationGroupe); } [Fact] public async Task ConfirmerAnnulation_RendezVousGroupe_MessageDeSuccesUniqueSansRappelDuGroupe() { AgendaItem rdv = RendezVousFutur(); _parametreMetierService.MotifsAnnulation = MotifsAnnulationTest(); await ChargerRdvAsync(new AgendaItem { RendezVousGuid = rdv.RendezVousGuid, Titre = rdv.Titre, Debut = rdv.Debut, Fin = rdv.Fin, EstGroupe = true, Collaborateurs = ["Jean DUPONT", "Marie MARTIN"] }); await _vm.DemanderAnnulationCommand.ExecuteAsync(null); _vm.SelectedMotifAnnulation = _vm.MotifsAnnulation[0]; // Act await _vm.ConfirmerAnnulationCommand.ExecuteAsync(null); // Assert : l'annulation est bien remontée au service local, et le message de // retour est STRICTEMENT le même que pour un rendez-vous individuel : // l'impact du groupe n'est annoncé qu'avant la confirmation. Assert.Single(_rendezVousAnnulation.Annules); Assert.Equal("Le rendez-vous a été annulé.", _vm.AnnulationMessage); Assert.DoesNotContain("groupe", _vm.AnnulationMessage!, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("impact", _vm.AnnulationMessage!, StringComparison.OrdinalIgnoreCase); } // ===== Compte rendu obligatoire & passage à « Fait » ===== /// /// Prépare un rendez-vous passé assigné au collaborateur courant, avec un /// brouillon en attente, pour rendre la commande « Valider » disponible. /// private async Task ChargerRdvValidableAsync(AgendaItem rendezVous) { _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rendezVous.RendezVousGuid, TypeTempsKey = "T.TEMPS.TRAJET", CollaborateurId = rendezVous.CollaborateurGuids.FirstOrDefault(), Date = rendezVous.Debut, DureeMinutes = 60, EstEnvoye = false }); await ChargerRdvAsync(rendezVous); } [Fact] public async Task Valider_CompteRenduObligatoireSansCompteRendu_BloqueEtExposeLeBlocage() { // Arrange : RDV passé assigné, compte rendu obligatoire, AUCUN compte rendu. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); var rdv = new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV compte rendu obligatoire", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid], InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), AvecCompteRendu = true }; _compteRenduScopeService.CompteRendu = null; _compteRenduScopeService.EstValide = false; await ChargerRdvValidableAsync(rdv); Assert.True(_vm.PeutValider); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert : validation bloquée AVANT tout envoi ; le message de blocage est // exposé via les propriétés dédiées (affichées dans la carte « Temps »). Assert.False(_suiviTempsService.ValidateCalled); Assert.Empty(_finalisationService.MarquesFait); Assert.True(_vm.CompteRenduBloqueValidationRendezVous); Assert.NotNull(_vm.MessageBlocageValidationRendezVous); Assert.Contains("Compte rendu obligatoire", _vm.MessageBlocageValidationRendezVous); Assert.Contains("compte rendu validé", _vm.MessageBlocageValidationRendezVous); } [Fact] public async Task Valider_CompteRenduObligatoireAvecCompteRenduValide_PasseAFaitImmediatement() { // Arrange : RDV passé assigné, compte rendu obligatoire et déjà validé. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); var rdv = new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV compte rendu validé", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid], InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), AvecCompteRendu = true }; _compteRenduScopeService.CompteRendu = new Omika.Core.Contracts.Interventions.CompteRenduDto { Id = Guid.NewGuid().ToString(), NomSignataireTech = "Collaborateur A" }; _compteRenduScopeService.EstValide = true; await ChargerRdvValidableAsync(rdv); Assert.True(_vm.PeutValider); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert : validation effectuée + passage à « Fait » immédiat. Assert.True(_suiviTempsService.ValidateCalled); Assert.Contains(rdv.RendezVousGuid, _finalisationService.MarquesFait); Assert.Equal(RendezVousStatut.FaitKey, _vm.RendezVous!.StatutKey); Assert.Equal(RendezVousStatut.FaitLabel, _vm.RendezVous.Statut); Assert.True(_vm.EstValide); Assert.False(_vm.PeutSaisirTemps); } [Fact] public async Task Valider_SansCompteRenduObligatoire_PasseAFaitImmediatement() { // Arrange : RDV passé assigné, SANS obligation de compte rendu. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); var rdv = new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV simple", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid] }; await ChargerRdvValidableAsync(rdv); Assert.True(_vm.PeutValider); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert : « Fait » posé localement, sans exigence de compte rendu. Assert.True(_suiviTempsService.ValidateCalled); Assert.Contains(rdv.RendezVousGuid, _finalisationService.MarquesFait); Assert.Equal(RendezVousStatut.FaitKey, _vm.RendezVous!.StatutKey); } [Fact] public async Task Load_CompteRenduObligatoireSansCompteRendu_ExposeLeBlocage() { // Arrange : RDV à compte rendu obligatoire, AUCUN compte rendu chargé. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); var rdv = new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV compte rendu obligatoire", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid], InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), AvecCompteRendu = true }; _compteRenduScopeService.CompteRendu = null; _compteRenduScopeService.EstValide = false; // Act await ChargerRdvAsync(rdv); // Assert : les propriétés exposées au XAML reflètent la règle métier. Assert.True(_vm.CompteRenduObligatoire); Assert.False(_vm.CompteRenduExiste); Assert.False(_vm.CompteRenduValide); Assert.True(_vm.CompteRenduBloqueValidationRendezVous); Assert.NotNull(_vm.MessageBlocageValidationRendezVous); Assert.Contains("Compte rendu obligatoire", _vm.MessageBlocageValidationRendezVous); } [Fact] public async Task Load_CompteRenduObligatoireValide_NExposePasLeBlocage() { // Arrange : RDV à compte rendu obligatoire, compte rendu déjà validé. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); var rdv = new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV compte rendu validé", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid], InterventionGuid = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd"), AvecCompteRendu = true }; _compteRenduScopeService.CompteRendu = new Omika.Core.Contracts.Interventions.CompteRenduDto { Id = Guid.NewGuid().ToString(), NomSignataireTech = "Collaborateur A" }; _compteRenduScopeService.EstValide = true; // Act await ChargerRdvAsync(rdv); // Assert : aucune exposition d'un message de blocage. Assert.True(_vm.CompteRenduObligatoire); Assert.True(_vm.CompteRenduExiste); Assert.True(_vm.CompteRenduValide); Assert.False(_vm.CompteRenduBloqueValidationRendezVous); Assert.Null(_vm.MessageBlocageValidationRendezVous); } /// /// Sans intervention liée, aucun compte rendu ne peut exister : l'exigence de /// compte rendu est alors sans objet et ne doit jamais bloquer la validation du /// rendez-vous (le rendez-vous resterait définitivement bloqué pour un compte /// rendu impossible à produire). /// [Fact] public async Task Valider_CompteRenduObligatoireSansIntervention_NeBloquePas() { // Arrange : RDV passé assigné, compte rendu obligatoire, AUCUNE intervention. Guid collabGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collabGuid.ToString("D") } }; DateTime passe = DateTime.Now.AddDays(-1); var rdv = new AgendaItem { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV sans intervention", Debut = passe.AddHours(8), Fin = passe.AddHours(16), CollaborateurGuids = [collabGuid], AvecCompteRendu = true }; _compteRenduScopeService.CompteRendu = null; _compteRenduScopeService.EstValide = false; await ChargerRdvValidableAsync(rdv); // Assert : ni badge ni message de blocage — l'exigence n'est pas opposable. Assert.False(_vm.CompteRenduObligatoire); Assert.False(_vm.CompteRenduBloqueValidationRendezVous); Assert.Null(_vm.MessageBlocageValidationRendezVous); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert : la validation passe, sans compte rendu. Assert.True(_suiviTempsService.ValidateCalled); Assert.Contains(rdv.RendezVousGuid, _finalisationService.MarquesFait); } /// /// Un compte rendu déjà existant reste consultable même si le rendez-vous n'est /// plus rattaché à une intervention (donnée historique, intervention dissociée /// depuis) : le bouton reste proposé pour la LECTURE seule. /// [Fact] public async Task Load_CompteRenduExistantSansIntervention_ResteConsultable() { Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); DateTime hier = DateTime.Now.AddDays(-1); _agendaService.ById = new AgendaItem { RendezVousGuid = rendezVousGuid, Titre = "RDV passé sans intervention", Debut = hier, Fin = hier.AddHours(1) }; _compteRenduScopeService.CompteRendu = new Omika.Core.Contracts.Interventions.CompteRenduDto { Id = Guid.NewGuid().ToString(), NomSignataireTech = "Collaborateur A" }; await _vm.LoadCommand.ExecuteAsync(new DetailNavigation(rendezVousGuid, null)); // Assert : bouton proposé, mais la saisie reste fermée (lecture seule). Assert.True(_vm.CompteRenduExiste); Assert.True(_vm.AfficherCompteRendu); Assert.False(_vm.PeutSaisirCompteRendu); // Act _vm.OpenCompteRenduCommand.Execute(null); // Assert : la page de compte rendu s'ouvre sur l'existant (lecture seule). Assert.Equal(AppPage.CompteRenduCreate, _navigation.CurrentPage); } // ===== Saisies de temps obligatoires ===== private static readonly Guid CollaborateurCourantGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); /// /// Configure le référentiel des paramètres métier : type de temps /// « intervention » (autorisé partout), type de temps « pause déjeuner » /// (autorisé dans les rendez-vous d'intervention, plage 12:00 → 15:00, minimum /// 10 minutes, obligatoire au-delà de 30 minutes de rendez-vous) et type de /// temps « trajet » (sans paramètre). /// private void ConfigurerReferentielTemps(bool saisirTempsObligatoire = true) { _parametreMetierService.TypeTemps = [ new ReferenceValueDto { Key = "T.TEMPS.INTER", Label = "Intervention" }, new ReferenceValueDto { Key = SuiviTempsConstants.PauseDejeunerTypeTempsKey, Label = "Temps pause déjeuner" }, new ReferenceValueDto { Key = "T.TEMPS.TRAJET", Label = "Temps de trajet" } ]; _parametreMetierService.TypeRdvParametres = new TypeRdvParametresDto { SaisirTempsObligatoire = saisirTempsObligatoire }; _parametreMetierService.TypeTempsParametres[SuiviTempsConstants.PauseDejeunerTypeTempsKey] = new TypeTempsParametresDto { Obligatoire = "0030", HeureDebut = "120000", HeureFin = "150000", TempsMin = "10", TypeRdv = "T.RDV.INTER" }; } /// Rend le collaborateur courant assigné aux rendez-vous chargés. private void ConnecterCollaborateurCourant() { _sessionService.Bootstrap = new LocalBootstrapState { Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = CollaborateurCourantGuid.ToString("D") } }; } /// Rendez-vous passé, assigné au collaborateur courant, du type demandé. private static AgendaItem RdvPasse( DateTime debut, DateTime fin, string typeActiviteKey, string typeActivite = "Intervention") => new() { RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Titre = "RDV intervention", Debut = debut, Fin = fin, CollaborateurGuids = [CollaborateurCourantGuid], TypeActiviteKey = typeActiviteKey, TypeActivite = typeActivite }; [Fact] public async Task Charger_PauseDejeunerObligatoire_EstPredeclareeAvecLesHorairesHabituels() { // Arrange : RDV 8:00 → 17:00 de type intervention, pause déjeuner autorisée // de 12:00 à 15:00. Le rendez-vous englobe toute la plage : la saisie de la // pause est donc OBLIGATOIRE. Elle doit être PRÉ-DÉCLARÉE à l'ouverture de // la fiche — comme le temps d'intervention — et non réclamée à la validation. ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(); _pauseDejeuner.Set(new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(8), hier.AddHours(17), "T.RDV.INTER"); await ChargerRdvValidableAsync(rdv); // Assert : un brouillon « pause déjeuner » est créé pour le collaborateur // qui saisit, sur ses horaires habituels (préférence locale). // // (Le double de test ne relit pas les brouillons qu'il vient de créer — le // service réel, lui, les écrit en base : l'état « obligation satisfaite » // est donc vérifié par le test du temps DÉJÀ saisi, ci-dessous.) SuiviTempsDraftEntry pause = Assert.Single( _suiviTempsService.CreatedDrafts, d => d.TypeTempsKey == SuiviTempsConstants.PauseDejeunerTypeTempsKey); Assert.Equal(rdv.RendezVousGuid, pause.RendezVousId); Assert.Equal(CollaborateurCourantGuid, pause.CollaborateurId); Assert.Equal(hier.Date.AddHours(12).AddMinutes(15), pause.Date); Assert.Equal(60, pause.DureeMinutes); Assert.False(pause.EstEnvoye); } [Fact] public async Task Charger_PauseDejeunerObligatoire_HorairesHabituelsIncoherents_UtiliseLeCreneauDuRendezVous() { // Arrange : horaires habituels 06:00 → 07:00, à une heure du rendez-vous // (8:00 → 17:00) : au-delà de la tolérance de 30 minutes. Ces horaires ne // peuvent pas être pré-déclarés sur ce rendez-vous, la saisie serait // refusée à l'enregistrement. ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(); _pauseDejeuner.Set(new TimeSpan(6, 0, 0), new TimeSpan(7, 0, 0)); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(8), hier.AddHours(17), "T.RDV.INTER"); await ChargerRdvValidableAsync(rdv); // Assert : repli sur le créneau du rendez-vous (même règle que la saisie // « intervention » pré-remplie), et non un créneau incohérent. SuiviTempsDraftEntry pause = Assert.Single( _suiviTempsService.CreatedDrafts, d => d.TypeTempsKey == SuiviTempsConstants.PauseDejeunerTypeTempsKey); Assert.Equal(hier.AddHours(8), pause.Date); Assert.Equal(540, pause.DureeMinutes); } [Fact] public async Task Charger_TempsObligatoireDejaSaisi_NEstJamaisDuplique() { // Arrange : la pause déjeuner obligatoire est DÉJÀ saisie (brouillon). ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(8), hier.AddHours(17), "T.RDV.INTER"); _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rdv.RendezVousGuid, TypeTempsKey = SuiviTempsConstants.PauseDejeunerTypeTempsKey, CollaborateurId = CollaborateurCourantGuid, Date = hier.AddHours(12), DureeMinutes = 60, EstEnvoye = false }); await ChargerRdvValidableAsync(rdv); // Assert : la pré-déclaration ne crée aucun doublon et ne touche pas à la // saisie existante. Assert.DoesNotContain( _suiviTempsService.CreatedDrafts, d => d.TypeTempsKey == SuiviTempsConstants.PauseDejeunerTypeTempsKey); Assert.False(_vm.TempsObligatoireManquant); } [Fact] public async Task Charger_PreDeclarationImpossible_L_ObligationResteUnFiletDeSecurite() { // Arrange : le stockage local refuse la création du brouillon de pause : // la pré-déclaration échoue en silence, sans casser l'ouverture de la fiche. ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(); _suiviTempsService.EchecsCreation.Add(SuiviTempsConstants.PauseDejeunerTypeTempsKey); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(8), hier.AddHours(17), "T.RDV.INTER"); await ChargerRdvValidableAsync(rdv); // Assert : la fiche est utilisable et le blocage de validation reste actif. Assert.Null(_vm.ErrorMessage); Assert.True(_vm.TempsObligatoireManquant); Assert.Contains("Temps pause déjeuner", _vm.MessageBlocageValidationTempsObligatoire); Assert.Contains("12:00–15:00", _vm.MessageBlocageValidationTempsObligatoire); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert : validation refusée AVANT tout envoi ou passage à « Fait ». Assert.False(_suiviTempsService.ValidateCalled); Assert.Empty(_finalisationService.MarquesFait); Assert.False(_vm.EstValide); } [Fact] public async Task Valider_PauseDejeunerObligatoireSaisie_ValideLeRendezVous() { // Arrange : même rendez-vous, mais la pause déjeuner est déjà saisie. ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(8), hier.AddHours(17), "T.RDV.INTER"); _suiviTempsService.Drafts.Add(new SuiviTempsDraftEntry { RendezVousId = rdv.RendezVousGuid, TypeTempsKey = SuiviTempsConstants.PauseDejeunerTypeTempsKey, CollaborateurId = CollaborateurCourantGuid, Date = hier.AddHours(12), DureeMinutes = 60, EstEnvoye = false }); await ChargerRdvValidableAsync(rdv); // Assert : aucune obligation manquante. Assert.False(_vm.TempsObligatoireManquant); Assert.Null(_vm.MessageBlocageValidationTempsObligatoire); // La pré-déclaration automatique ne double jamais une saisie existante. Assert.DoesNotContain( _suiviTempsService.CreatedDrafts, d => d.TypeTempsKey == SuiviTempsConstants.PauseDejeunerTypeTempsKey); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert : la validation passe. Assert.True(_suiviTempsService.ValidateCalled); Assert.Contains(rdv.RendezVousGuid, _finalisationService.MarquesFait); } [Fact] public async Task Valider_RendezVousLaissantUnCreneauLibreDansLaPlage_NAPasEteBloque() { // Arrange : RDV 13:00 → 15:00 : 12:00 → 13:00 reste libre, soit une heure, // largement de quoi prendre une pause d'au moins 10 minutes : la saisie // n'est pas obligatoire. ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(13), hier.AddHours(15), "T.RDV.INTER"); await ChargerRdvValidableAsync(rdv); Assert.False(_vm.TempsObligatoireManquant); Assert.Null(_vm.MessageBlocageValidationTempsObligatoire); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert Assert.True(_suiviTempsService.ValidateCalled); } [Fact] public async Task Valider_TypeDeRdvSansSaisieObligatoire_NAPasEteBloque() { // Arrange : la porte d'entrée du type de rendez-vous est fermée. ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(saisirTempsObligatoire: false); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(8), hier.AddHours(17), "T.RDV.INTER"); await ChargerRdvValidableAsync(rdv); Assert.False(_vm.TempsObligatoireManquant); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert Assert.True(_suiviTempsService.ValidateCalled); } [Fact] public async Task Valider_PauseDejeunerHorsPerimetreDuTypeDeRdv_NAPasEteBloque() { // Arrange : RDV d'un autre type que celui autorisé pour la pause déjeuner. ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(8), hier.AddHours(17), "T.RDV.SAV", "SAV"); await ChargerRdvValidableAsync(rdv); Assert.False(_vm.TempsObligatoireManquant); // Act await _vm.ValiderCommand.ExecuteAsync(null); // Assert Assert.True(_suiviTempsService.ValidateCalled); } [Fact] public async Task AddTime_TypeDeTempsNonAutorisePourLeRdv_NAPasEtePropose() { // Arrange : RDV de type SAV, alors que la pause déjeuner n'est autorisée que // dans les rendez-vous d'intervention. ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(8), hier.AddHours(17), "T.RDV.SAV", "SAV"); await ChargerRdvAsync(rdv); // Act await _vm.AddTimeCommand.ExecuteAsync(null); // Assert : la pause déjeuner n'est pas proposée, les types sans restriction // le restent. Assert.DoesNotContain(_vm.TypesTemps, t => t.Key == SuiviTempsConstants.PauseDejeunerTypeTempsKey); Assert.Contains(_vm.TypesTemps, t => t.Key == "T.TEMPS.TRAJET"); } [Fact] public async Task AddTime_TypeDeTempsAutorisePourLeRdv_EstPropose() { // Arrange : RDV de type intervention, type autorisé par la pause déjeuner. ConnecterCollaborateurCourant(); ConfigurerReferentielTemps(); DateTime hier = DateTime.Today.AddDays(-1); AgendaItem rdv = RdvPasse(hier.AddHours(8), hier.AddHours(17), "T.RDV.INTER"); await ChargerRdvAsync(rdv); // Act await _vm.AddTimeCommand.ExecuteAsync(null); // Assert Assert.Contains(_vm.TypesTemps, t => t.Key == SuiviTempsConstants.PauseDejeunerTypeTempsKey); } }