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);
}
}