Files
Omika/tests/Omika.Tests/Mobile/Local/RendezVousDetailViewModelTests.cs
2026-10-23 18:46:50 +02:00

2929 lines
118 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Tests du RendezVousDetailViewModel (détail d'un rendez-vous).
/// Vérifie le chargement du rendez-vous depuis les données locales.
/// </summary>
public class RendezVousDetailViewModelTests
{
private sealed class FakeAgendaService : IAgendaService
{
public AgendaItem? ById { get; set; }
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForDayAsync(DateTime date, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForRangeAsync(DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
public Task<AgendaItem?> GetRendezVousByIdAsync(Guid rendezVousGuid, CancellationToken ct = default, string? affectationId = null)
=> Task.FromResult(ById);
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForCollaborateursAsync(IReadOnlyList<Guid> collaborateurGuids, DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForCollaborateurAsync(Guid collaborateurGuid, DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
public Task EnrichSiteAccessAsync(AgendaItem item, CancellationToken ct = default)
=> Task.CompletedTask;
}
private sealed class FakeSuiviTempsService : ISuiviTempsLocalService
{
public List<string> DeletedKeys { get; } = [];
public List<SuiviTempsDraftEntry> Drafts { get; } = [];
/// <summary>Brouillons effectivement enregistrés (création ou mise à jour).</summary>
public List<SuiviTempsDraftEntry> CreatedDrafts { get; } = [];
public Task<Guid> CreateAsync(SuiviTempsSyncPayload payload, CancellationToken ct = default)
=> Task.FromResult(Guid.NewGuid());
public Task<SuiviTempsDto?> GetByGuidAsync(Guid suiviTempsGuid, CancellationToken ct = default)
=> Task.FromResult<SuiviTempsDto?>(null);
public Task<IReadOnlyList<SuiviTempsDto>> GetAllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDto>> GetByInterventionAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDto>> GetByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDraftEntry>> GetDraftsByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDraftEntry>>(Drafts);
/// <summary>Clés de type de temps dont la création de brouillon doit échouer.</summary>
public HashSet<string> 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<bool> HasTypeTempsAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<bool> IsValideAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.FromResult(false);
/// <summary>Vrai si <see cref="ValidateRendezVousAsync"/> a été appelé.</summary>
public bool ValidateCalled { get; private set; }
public Task ValidateRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
{
ValidateCalled = true;
return Task.CompletedTask;
}
public List<AgendaItem> SavedRendezVous { get; } = [];
public List<Guid> DeletedRendezVousIds { get; } = [];
public Task SaveRendezVousPourBrouillonAsync(AgendaItem rendezVous, CancellationToken ct = default)
{
SavedRendezVous.Add(rendezVous);
return Task.CompletedTask;
}
public Task<IReadOnlyList<AgendaItem>> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default)
{
DeletedRendezVousIds.Add(rendezVousId);
return Task.CompletedTask;
}
}
private sealed class FakeParametreMetierService : IParametreMetierLocalService
{
/// <summary>Référentiel TypeTemps retourné (vide par défaut).</summary>
public IReadOnlyList<ReferenceValueDto> TypeTemps { get; set; } = [];
/// <summary>Origines d'annulation retournées (vide par défaut).</summary>
public IReadOnlyList<ReferenceValueDto> OriginesAnnulation { get; set; } = [];
/// <summary>Motifs d'annulation retournés (vide par défaut).</summary>
public IReadOnlyList<ReferenceValueDto> MotifsAnnulation { get; set; } = [];
/// <summary>Paramètres des types de temps, par clé métier (aucun par défaut).</summary>
public Dictionary<string, TypeTempsParametresDto> TypeTempsParametres { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Paramètres du type de rendez-vous retournés (aucun par défaut).</summary>
public TypeRdvParametresDto? TypeRdvParametres { get; set; }
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetTypeTempsAsync(CancellationToken ct = default)
=> Task.FromResult(TypeTemps);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetOriginesAnnulationAsync(CancellationToken ct = default)
=> Task.FromResult(OriginesAnnulation);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetMotifsAnnulationAsync(CancellationToken ct = default)
=> Task.FromResult(MotifsAnnulation);
public Task<Omika.Core.Contracts.Configuration.TypeTempsParametresDto?> GetTypeTempsParametresAsync(string typeTempsKey, CancellationToken ct = default)
=> Task.FromResult(TypeTempsParametres.TryGetValue(typeTempsKey, out TypeTempsParametresDto? parametres)
? parametres
: null);
public Task<Omika.Core.Contracts.Configuration.TypeRdvParametresDto?> GetTypeRdvParametresAsync(string typeRdvKey, CancellationToken ct = default)
=> Task.FromResult(TypeRdvParametres);
public Task<Omika.Core.Contracts.Configuration.StatutRdvParametresDto?> GetStatutRdvParametresAsync(string statutRdvKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.StatutRdvParametresDto?>(null);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetInterventionTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>>([]);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetInterventionEtatsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>>([]);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetDossierTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>>([]);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetDossierStatutsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>>([]);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetClientTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>>([]);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetClientStatutsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>>([]);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetSiteTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>>([]);
public Task<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>> GetTypeSignatairesAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Omika.Core.Contracts.Common.ReferenceValueDto>>([]);
}
private sealed class FakePhoneLauncher : IPhoneLauncher
{
public Task<bool> 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<bool> ComposeAsync(string email)
{
ComposeCalls++;
LastEmail = email;
return Task.FromResult(Result);
}
}
/// <summary>
/// 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).
/// </summary>
private sealed class FakeCollaborateurService : ICollaborateurLocalService
{
public List<CollaborateurListItem> Collaborateurs { get; } = [];
public Task<IReadOnlyList<CollaborateurListItem>> GetCollaborateursAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<CollaborateurListItem>>(Collaborateurs);
public Task<CollaborateurDetail?> GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken ct = default)
=> Task.FromResult<CollaborateurDetail?>(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<bool> OpenItineraireAsync(string address)
{
OpenItineraireCalls++;
LastAddress = address;
return Task.FromResult(Result);
}
}
private sealed class FakePermissionService : IPermissionService
{
public IReadOnlyDictionary<string, bool> Rights => new Dictionary<string, bool>();
public void SetRights(IReadOnlyDictionary<string, bool> 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
{
/// <summary>Rendez-vous annulés (GUID → collaborateur → origine/motif/note/nom → libellés).</summary>
public List<(Guid RendezVousGuid, Guid? CollaborateurGuid, string? OrigineKey, string? MotifKey, string? NoteCollaborateur, string? CollaborateurNom, string? OrigineLabel, string? MotifLabel)> Annules { get; } = [];
/// <summary>Simule un rendez-vous local absent (KeyNotFoundException).</summary>
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<SyncCycleResult> SyncAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncCycleResult { Executed = true });
public Task<SyncCycleResult> PushAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncCycleResult { Executed = true });
}
private sealed class FakeCompteRenduScopeService : ICompteRenduScopeService
{
/// <summary>Compte rendu retourné (null = aucun).</summary>
public Omika.Core.Contracts.Interventions.CompteRenduDto? CompteRendu { get; set; }
/// <summary>Vrai si le compte rendu doit être considéré validé.</summary>
public bool EstValide { get; set; }
public CompteRenduScope ResolveScope(AgendaItem rendezVous)
=> new(rendezVous.RendezVousGuid, DateOnly.FromDateTime(rendezVous.Debut), rendezVous.InterventionGuid);
public Task<Omika.Core.Contracts.Interventions.CompteRenduDto?> GetCompteRenduAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> Task.FromResult(CompteRendu);
public Task<bool> CompteRenduExistsAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> Task.FromResult(CompteRendu is not null);
public Task<bool> CompteRenduIsValidatedAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> Task.FromResult(EstValide);
public Task<bool> CanCreateCompteRenduAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> Task.FromResult(CompteRendu is null);
}
private sealed class FakeFinalisationService : IRendezVousFinalisationLocalService
{
/// <summary>GUID marqués « Fait ».</summary>
public List<Guid> 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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>
/// Un rendez-vous d'une journée avec un seul collaborateur n'affiche pas le
/// bloc informatif « rendez-vous groupé ».
/// </summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>
/// Contre-épreuve : sur un rendez-vous passé lié à une intervention, le bouton
/// ouvre bien le formulaire de compte rendu.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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 =====
/// <summary>
/// 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.
/// </summary>
[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<InterventionResourceNavigation>(_navigation.CurrentParameter);
Assert.Equal(interventionGuid, nav.InterventionGuid);
Assert.Equal(0, nav.TabIndex);
}
/// <summary>
/// Sans intervention liée, le bouton n'est pas proposé et la commande reste
/// sans effet même déclenchée directement.
/// </summary>
[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 =====
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// Une fiche déjà saisissable n'est pas rechargée par la veille (aucun accès au
/// stockage, aucune pré-saisie en double).
/// </summary>
[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 =====
/// <summary>
/// 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é.
/// </summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Une fois la saisie corrigée sur du temps écoulé, plus aucun blocage : la
/// validation redevient possible.
/// </summary>
[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 =====
/// <summary>
/// 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).
/// </summary>
[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) =====
/// <summary>Référentiel TypeTemps contenant les trois types utilisés par les tests.</summary>
private static IReadOnlyList<ReferenceValueDto> 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" }
];
/// <summary>Type « pause déjeuner » du référentiel de test.</summary>
private ReferenceValueDto TypePauseDejeuner =>
SuiviTempsConstants.FindPauseDejeunerTypeTemps(_vm.TypesTemps)!;
/// <summary>Type « trajet » du référentiel de test (autre type de temps).</summary>
private ReferenceValueDto TypeTrajet =>
_vm.TypesTemps.First(t => t.Key == "T.TEMPS.TRAJET");
/// <summary>
/// Charge un rendez-vous passé assigné au collaborateur courant, puis ouvre
/// l'éditeur inline de saisie de temps.
/// </summary>
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 =====
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Indique si un brouillon « pause déjeuner » a été enregistré.</summary>
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 =====
/// <summary>Référentiels Origine / Motif d'annulation utilisés par les tests.</summary>
private static IReadOnlyList<ReferenceValueDto> OriginesAnnulationTest() =>
[
new() { Key = "O.ANU.RDV.E.CLI", Label = "Externe : Client" },
new() { Key = "O.ANU.RDV.I.CLB", Label = "Interne : Collaborateur" }
];
private static IReadOnlyList<ReferenceValueDto> MotifsAnnulationTest() =>
[
new() { Key = "M.ANU.RDV.ABS", Label = "Absent" },
new() { Key = "M.ANU.RDV.EMPC", Label = "Empêchement" }
];
/// <summary>Rendez-vous futur, non annulé, sans temps validé ni saisi.</summary>
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 » =====
/// <summary>
/// Prépare un rendez-vous passé assigné au collaborateur courant, avec un
/// brouillon en attente, pour rendre la commande « Valider » disponible.
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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");
/// <summary>
/// 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).
/// </summary>
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"
};
}
/// <summary>Rend le collaborateur courant assigné aux rendez-vous chargés.</summary>
private void ConnecterCollaborateurCourant()
{
_sessionService.Bootstrap = new LocalBootstrapState
{
Collaborateur = new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto
{
Id = CollaborateurCourantGuid.ToString("D")
}
};
}
/// <summary>Rendez-vous passé, assigné au collaborateur courant, du type demandé.</summary>
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);
}
}