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

696 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Omika.App.Local.Abstractions;
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.Mobile.Sync;
using Omika.Core.Contracts.Planning;
namespace Omika.Tests.Mobile.Local;
/// <summary>
/// Tests du SuiviTempsCreateViewModel (formulaire de création offline-first).
/// Vérifie le chargement du référentiel TypeTemps via
/// <see cref="IParametreMetierLocalService"/> (Key = PA_CLE, aucun libellé
/// hardcodé), le préremplissage Intervention depuis le contexte, l'appel
/// exclusif à <see cref="ISuiviTempsLocalService.CreateAsync"/>, la protection
/// contre le double clic (TaskCompletionSource), l'absence de champ Valide et
/// l'absence de toute commande UPDATE / ARCHIVE / DELETE.
/// </summary>
public class SuiviTempsCreateViewModelTests
{
private sealed class FakeSuiviTempsService : ISuiviTempsLocalService
{
public int CreateCallCount { get; private set; }
public int CreateDraftCallCount { get; private set; }
public SuiviTempsSyncPayload? LastPayload { get; private set; }
public SuiviTempsDraftEntry? LastDraft { get; private set; }
public bool ThrowOnCreate { get; set; }
public Task<Guid> CreateAsync(SuiviTempsSyncPayload payload, CancellationToken ct = default)
{
CreateCallCount++;
LastPayload = payload;
if (ThrowOnCreate)
{
throw new InvalidOperationException("Erreur locale simulée");
}
return 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>>([]);
public Task CreateDraftAsync(SuiviTempsDraftEntry draft, CancellationToken ct = default)
{
CreateDraftCallCount++;
LastDraft = draft;
return Task.CompletedTask;
}
public Task DeleteDraftAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default)
=> 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);
public Task ValidateRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.CompletedTask;
public Task SaveRendezVousPourBrouillonAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<IReadOnlyList<AgendaItem>> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default)
=> Task.CompletedTask;
}
/// <summary>
/// Fake local bloquant pour le test double Save : CreateAsync signale son
/// démarrage via <see cref="Started"/> puis attend la libération via
/// <see cref="Gate"/>. Les deux TaskCompletionSource utilisent
/// RunContinuationsAsynchronously pour éviter tout deadlock.
/// </summary>
private sealed class BlockingSuiviTempsLocalService : ISuiviTempsLocalService
{
public int CallCount { get; private set; }
public TaskCompletionSource<bool> Started { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> Gate { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<Guid> CreateAsync(SuiviTempsSyncPayload payload, CancellationToken ct = default)
{
CallCount++;
Started.TrySetResult(true);
return AwaitGateAsync(ct);
}
private async Task<Guid> AwaitGateAsync(CancellationToken ct)
{
await Gate.Task.WaitAsync(ct);
return Guid.NewGuid();
}
public void Release() => Gate.TrySetResult(true);
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>>([]);
public Task CreateDraftAsync(SuiviTempsDraftEntry draft, CancellationToken ct = default)
=> Task.CompletedTask;
public Task DeleteDraftAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default)
=> 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);
public Task ValidateRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.CompletedTask;
public Task SaveRendezVousPourBrouillonAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<IReadOnlyList<AgendaItem>> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default)
=> Task.CompletedTask;
}
private sealed class FakeParametreMetierService : IParametreMetierLocalService
{
public IReadOnlyList<ReferenceValueDto> TypeTemps { get; set; } = [];
public Task<IReadOnlyList<ReferenceValueDto>> GetTypeTempsAsync(CancellationToken ct = default)
=> Task.FromResult(TypeTemps);
public Task<Omika.Core.Contracts.Configuration.TypeTempsParametresDto?> GetTypeTempsParametresAsync(string typeTempsKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.TypeTempsParametresDto?>(null);
public Task<Omika.Core.Contracts.Configuration.TypeRdvParametresDto?> GetTypeRdvParametresAsync(string typeRdvKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.TypeRdvParametresDto?>(null);
public Task<Omika.Core.Contracts.Configuration.StatutRdvParametresDto?> GetStatutRdvParametresAsync(string statutRdvKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.StatutRdvParametresDto?>(null);
public Task<IReadOnlyList<ReferenceValueDto>> GetInterventionTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetInterventionEtatsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetDossierTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetDossierStatutsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetClientTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetClientStatutsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetSiteTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetTypeSignatairesAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
}
private sealed class FakeCollaborateurService : ICollaborateurLocalService
{
public IReadOnlyList<CollaborateurListItem> Collaborateurs { get; set; } = [];
public Task<IReadOnlyList<CollaborateurListItem>> GetCollaborateursAsync(CancellationToken ct = default)
=> Task.FromResult(Collaborateurs);
public Task<CollaborateurDetail?> GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken ct = default)
=> Task.FromResult<CollaborateurDetail?>(null);
}
private sealed class FakeInterventionService : IInterventionLocalService
{
public IReadOnlyList<InterventionListItem> Interventions { get; set; } = [];
public Task<Guid> CreateAsync(InterventionSyncPayload payload, CancellationToken ct = default)
=> Task.FromResult(Guid.NewGuid());
public Task<Guid> UpdateAsync(Guid interventionGuid, InterventionSyncPayload payload, CancellationToken ct = default)
=> Task.FromResult(interventionGuid);
public Task<Guid> ArchiveAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult(interventionGuid);
public Task<InterventionDetail?> GetInterventionByIdAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<InterventionDetail?>(null);
public Task<IReadOnlyList<InterventionListItem>> GetInterventionsAsync(CancellationToken ct = default)
=> Task.FromResult(Interventions);
}
private sealed class FakeAgendaService : IAgendaService
{
public IReadOnlyList<AgendaItem> RendezVous { get; set; } = [];
public AgendaItem? ById { get; set; }
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForDayAsync(DateTime date, CancellationToken ct = default)
=> Task.FromResult(RendezVous);
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForRangeAsync(DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default)
=> Task.FromResult(RendezVous);
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>>(RendezVous);
public Task<IReadOnlyList<AgendaItem>> GetRendezVousForCollaborateurAsync(Guid collaborateurGuid, DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>(RendezVous);
public Task EnrichSiteAccessAsync(AgendaItem item, CancellationToken ct = default)
=> Task.CompletedTask;
}
private static ReferenceValueDto TypeTemps(string key, string label)
=> new() { Key = key, Label = label };
private static CollaborateurListItem Collaborateur(Guid guid, string name)
=> new() { CollaborateurGuid = guid, DisplayName = name };
private static InterventionListItem Intervention(Guid guid, string reference)
=> new() { InterventionGuid = guid, Reference = reference };
private static AgendaItem RendezVous(Guid guid, string titre)
=> new() { RendezVousGuid = guid, Titre = titre };
private readonly FakeSuiviTempsService _suiviTempsService = new();
private readonly FakeParametreMetierService _parametreMetierService = new();
private readonly FakeCollaborateurService _collaborateurService = new();
private readonly FakeInterventionService _interventionService = new();
private readonly FakeAgendaService _agendaService = new();
private readonly StubSessionService _sessionService = new();
private readonly NavigationService _navigation = new();
private readonly SuiviTempsCreateViewModel _vm;
public SuiviTempsCreateViewModelTests()
{
_vm = new SuiviTempsCreateViewModel(
_suiviTempsService,
_parametreMetierService,
_collaborateurService,
_interventionService,
_agendaService,
_sessionService,
_navigation,
new PermissionService());
}
private static SuiviTempsCreateNavigation CreateNavigation(Guid? interventionId = null, Guid? rendezVousId = null)
=> new(interventionId, rendezVousId, new NavigationContext(AppPage.Home, null));
[Fact]
public async Task Load_ChargeLesTypeTemps()
{
// Arrange
_parametreMetierService.TypeTemps =
[
TypeTemps("18", "Travail"),
TypeTemps("19", "Trajet")
];
// Act
await _vm.InitializeAsync(CreateNavigation());
// Assert
Assert.Equal(2, _vm.TypesTemps.Count);
Assert.False(_vm.HasNoTypeTemps);
}
[Fact]
public async Task TypeTemps_KeyEstLaCleMetier()
{
// Arrange
_parametreMetierService.TypeTemps = [TypeTemps("18", "Travail")];
// Act
await _vm.InitializeAsync(CreateNavigation());
_vm.SelectedTypeTemps = _vm.TypesTemps[0];
_vm.DureeMinutes = 60;
// Assert : la clé envoyée est la clé métier (PA_CLE), jamais le libellé.
await _vm.SaveCommand.ExecuteAsync(null);
Assert.Equal("18", _suiviTempsService.LastPayload?.TypeTempsKey);
}
[Fact]
public async Task AucunTypeTempsFictif()
{
// Arrange : aucun type de temps hardcodé dans le ViewModel.
_parametreMetierService.TypeTemps = [];
// Act
await _vm.InitializeAsync(CreateNavigation());
// Assert : la liste est vide, aucun libellé inventé.
Assert.Empty(_vm.TypesTemps);
Assert.True(_vm.HasNoTypeTemps);
}
[Fact]
public async Task TypeTempsVide_SaveImpossible()
{
// Arrange : référentiel vide.
_parametreMetierService.TypeTemps = [];
await _vm.InitializeAsync(CreateNavigation());
_vm.DureeMinutes = 60;
// Act
await _vm.SaveCommand.ExecuteAsync(null);
// Assert : aucun CreateAsync, message utilisateur cohérent.
Assert.Equal(0, _suiviTempsService.CreateCallCount);
Assert.NotNull(_vm.ErrorMessage);
Assert.Contains("type de temps", _vm.ErrorMessage);
}
[Fact]
public async Task Collaborateurs_Charges()
{
// Arrange
_collaborateurService.Collaborateurs =
[
Collaborateur(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Jean Dupont")
];
// Act
await _vm.InitializeAsync(CreateNavigation());
// Assert
Assert.Single(_vm.Collaborateurs);
Assert.Equal("Jean Dupont", _vm.Collaborateurs[0].DisplayName);
}
[Fact]
public async Task Interventions_Chargees()
{
// Arrange
_interventionService.Interventions =
[
Intervention(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), "INT-001")
];
// Act
await _vm.InitializeAsync(CreateNavigation());
// Assert
Assert.Single(_vm.Interventions);
Assert.Equal("INT-001", _vm.Interventions[0].Reference);
}
[Fact]
public async Task RendezVous_Charges()
{
// Arrange
_agendaService.RendezVous =
[
RendezVous(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), "RDV Client")
];
// Act
await _vm.InitializeAsync(CreateNavigation());
// Assert
Assert.Single(_vm.RendezVous);
Assert.Equal("RDV Client", _vm.RendezVous[0].Titre);
}
[Fact]
public async Task InterventionId_PrerempliDepuisContexte()
{
// Arrange
Guid interventionGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
_interventionService.Interventions =
[
Intervention(interventionGuid, "INT-001"),
Intervention(Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"), "INT-002")
];
// Act
await _vm.InitializeAsync(CreateNavigation(interventionGuid));
// Assert : le sélecteur Intervention est prérempli (aucune inférence).
Assert.True(_vm.IsFilteredByIntervention);
Assert.NotNull(_vm.SelectedIntervention);
Assert.Equal(interventionGuid, _vm.SelectedIntervention!.InterventionGuid);
}
[Fact]
public async Task Save_AppelleCreateAsync()
{
// Arrange
_parametreMetierService.TypeTemps = [TypeTemps("18", "Travail")];
await _vm.InitializeAsync(CreateNavigation());
_vm.SelectedTypeTemps = _vm.TypesTemps[0];
_vm.DureeMinutes = 45;
// Act
await _vm.SaveCommand.ExecuteAsync(null);
// Assert
Assert.Equal(1, _suiviTempsService.CreateCallCount);
Assert.NotNull(_suiviTempsService.LastPayload);
}
[Fact]
public async Task Save_ValeursExactesPassees()
{
// Arrange
Guid collaborateurGuid = Guid.Parse("11111111-1111-1111-1111-111111111111");
Guid interventionGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
var date = new DateTime(2026, 8, 14, 10, 0, 0);
_parametreMetierService.TypeTemps = [TypeTemps("18", "Travail")];
_collaborateurService.Collaborateurs = [Collaborateur(collaborateurGuid, "Jean Dupont")];
_interventionService.Interventions = [Intervention(interventionGuid, "INT-001")];
_agendaService.RendezVous = [RendezVous(rendezVousGuid, "RDV Client")];
await _vm.InitializeAsync(CreateNavigation());
_vm.SelectedTypeTemps = _vm.TypesTemps[0];
_vm.SelectedCollaborateur = _vm.Collaborateurs[0];
_vm.SelectedIntervention = _vm.Interventions[0];
_vm.SelectedRendezVous = _vm.RendezVous[0];
_vm.Date = date;
_vm.DureeMinutes = 30;
_vm.Description = "Saisie de test";
// Act
await _vm.SaveCommand.ExecuteAsync(null);
// Assert : valeurs exactes transmises au service local.
var payload = _suiviTempsService.LastPayload;
Assert.NotNull(payload);
Assert.Equal(collaborateurGuid.ToString(), payload!.CollaborateurId);
Assert.Equal(interventionGuid.ToString(), payload.InterventionId);
Assert.Equal(rendezVousGuid.ToString(), payload.RendezVousId);
Assert.Equal(date, payload.Date);
Assert.Equal(30, payload.DureeMinutes);
Assert.Equal("18", payload.TypeTempsKey);
Assert.Equal("Saisie de test", payload.Description);
}
[Fact]
public async Task DureeMinutes_SaisieManuelle()
{
// Arrange : aucune durée calculée depuis un RendezVous ou une Intervention.
_parametreMetierService.TypeTemps = [TypeTemps("18", "Travail")];
_agendaService.RendezVous =
[
new AgendaItem
{
RendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
Titre = "RDV 09:00-11:00",
Debut = new DateTime(2026, 8, 14, 9, 0, 0),
Fin = new DateTime(2026, 8, 14, 11, 0, 0)
}
];
await _vm.InitializeAsync(CreateNavigation());
_vm.SelectedTypeTemps = _vm.TypesTemps[0];
_vm.SelectedRendezVous = _vm.RendezVous[0];
_vm.DureeMinutes = 15; // saisie manuelle, pas 120 (différence RDV)
// Act
await _vm.SaveCommand.ExecuteAsync(null);
// Assert : la durée saisie manuellement est transmise telle quelle.
Assert.Equal(15, _suiviTempsService.LastPayload?.DureeMinutes);
}
[Fact]
public void Valide_NonExpose()
{
// Assert : le champ Valide n'est pas exposé par le formulaire.
Assert.Null(_vm.GetType().GetProperty("Valide"));
Assert.Null(_vm.GetType().GetProperty("IsValide"));
}
[Fact]
public async Task Cancel_ZeroCreateAsync()
{
// Arrange
_parametreMetierService.TypeTemps = [TypeTemps("18", "Travail")];
await _vm.InitializeAsync(CreateNavigation());
// Act
_vm.CancelCommand.Execute(null);
// Assert : aucune mutation locale ni Outbox.
Assert.Equal(0, _suiviTempsService.CreateCallCount);
}
[Fact]
public async Task SaveCommand_PendantExecution_CanExecuteFalse_EtUneSeuleMutation()
{
// Arrange : fake local bloquant basé sur le comportement réel de la
// commande/UI. AsyncRelayCommand (AllowConcurrentExecutions=false) fait
// que CanExecute retourne false pendant que ExecutionTask est en cours,
// ce qui désactive le bouton Avalonia : aucun second Execute n'est possible.
var fake = new BlockingSuiviTempsLocalService();
var vm = new SuiviTempsCreateViewModel(
fake,
_parametreMetierService,
_collaborateurService,
_interventionService,
_agendaService,
_sessionService,
_navigation,
new PermissionService());
_parametreMetierService.TypeTemps = [TypeTemps("18", "Travail")];
await vm.InitializeAsync(CreateNavigation());
vm.SelectedTypeTemps = vm.TypesTemps[0];
vm.DureeMinutes = 30;
try
{
// Act : premier Save reste en cours (CreateAsync bloqué sur le gate).
var firstSave = vm.SaveCommand.ExecuteAsync(null);
// Attendre que le premier CreateAsync soit réellement démarré.
await fake.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(1, fake.CallCount);
Assert.True(vm.IsBusy);
// Comportement commande : le bouton Avalonia est désactivé pendant
// l'exécution (CanExecute == false).
Assert.False(vm.SaveCommand.CanExecute(null));
// Simulation d'un command source UI normal : le bloc n'est PAS exécuté
// puisque CanExecute == false. Aucun second Execute n'est déclenché.
if (vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
}
Assert.Equal(1, fake.CallCount);
// Fin de la première exécution : libération du gate.
fake.Release();
await firstSave.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(1, fake.CallCount);
Assert.False(vm.IsBusy);
Assert.True(vm.SaveCommand.CanExecute(null));
}
finally
{
// Le gate DOIT toujours être libéré, même si une assertion échoue,
// afin qu'aucun firstSave ne reste suspendu.
fake.Release();
}
}
[Fact]
public async Task ErreurService_ErrorMessageEtPasDeNavigation()
{
// Arrange
_suiviTempsService.ThrowOnCreate = true;
_parametreMetierService.TypeTemps = [TypeTemps("18", "Travail")];
await _vm.InitializeAsync(CreateNavigation());
_vm.SelectedTypeTemps = _vm.TypesTemps[0];
_vm.DureeMinutes = 30;
// Act
await _vm.SaveCommand.ExecuteAsync(null);
// Assert : IsBusy revient false, ErrorMessage renseigné, pas d'exception brute.
Assert.False(_vm.IsBusy);
Assert.NotNull(_vm.ErrorMessage);
Assert.Contains("Erreur locale simulée", _vm.ErrorMessage);
}
[Fact]
public void AucuneCommandeDeMutation()
{
// Assert : le formulaire CREATE n'expose aucune commande UPDATE / ARCHIVE / DELETE.
Assert.Null(_vm.GetType().GetProperty("EditCommand"));
Assert.Null(_vm.GetType().GetProperty("ArchiveCommand"));
Assert.Null(_vm.GetType().GetProperty("DeleteCommand"));
Assert.Null(_vm.GetType().GetProperty("UpdateCommand"));
}
[Fact]
public async Task ContexteRendezVous_RendezVousIdPrerempli()
{
// Arrange
Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
_parametreMetierService.TypeTemps = [TypeTemps("18", "Travail")];
_agendaService.ById = new AgendaItem
{
RendezVousGuid = rendezVousGuid,
Titre = "RDV Client",
Debut = new DateTime(2026, 8, 14, 9, 0, 0),
Fin = new DateTime(2026, 8, 14, 10, 0, 0)
};
// Act : ouverture CREATE depuis un contexte RendezVous X.
await _vm.InitializeAsync(CreateNavigation(rendezVousId: rendezVousGuid));
// Assert : le sélecteur RendezVous est prérempli (aucune inférence).
Assert.True(_vm.IsRendezVousCentric);
Assert.True(_vm.IsFilteredByRendezVous);
Assert.NotNull(_vm.SelectedRendezVous);
Assert.Equal(rendezVousGuid, _vm.SelectedRendezVous!.RendezVousGuid);
}
[Fact]
public async Task ContexteRendezVous_PrefilleDateMaisPasInterventionNiDuree()
{
// Arrange : RendezVous X possède InterventionId=I, DateDebut=D1, DateFin=D2.
Guid rendezVousGuid = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
Guid interventionGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var dateDebut = new DateTime(2026, 8, 14, 9, 0, 0);
var dateFin = new DateTime(2026, 8, 14, 11, 0, 0);
_parametreMetierService.TypeTemps = [TypeTemps("18", "Travail")];
_interventionService.Interventions = [Intervention(interventionGuid, "INT-001")];
_agendaService.ById = new AgendaItem
{
RendezVousGuid = rendezVousGuid,
Titre = "RDV Client",
InterventionGuid = interventionGuid,
Debut = dateDebut,
Fin = dateFin
};
// Act : ouverture CREATE depuis un contexte RendezVous X.
await _vm.InitializeAsync(CreateNavigation(rendezVousId: rendezVousGuid));
// Assert : RendezVousId prérempli.
Assert.True(_vm.IsRendezVousCentric);
Assert.NotNull(_vm.SelectedRendezVous);
Assert.Equal(rendezVousGuid, _vm.SelectedRendezVous!.RendezVousGuid);
// Assert : la date est préremplie sur le début du rendez-vous.
Assert.Equal(new DateTimeOffset(dateDebut), _vm.Date);
// Assert : l'intervention n'est pas un sélecteur déduit (portée par le contexte).
Assert.Null(_vm.SelectedIntervention);
// Assert : la durée reste à saisir (non déduite de D1/D2 pour un type ajouté).
Assert.Equal(0, _vm.DureeMinutes);
}
}