2929 lines
118 KiB
C#
2929 lines
118 KiB
C#
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);
|
||
}
|
||
}
|