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; /// /// Tests du SuiviTempsCreateViewModel (formulaire de création offline-first). /// Vérifie le chargement du référentiel TypeTemps via /// (Key = PA_CLE, aucun libellé /// hardcodé), le préremplissage Intervention depuis le contexte, l'appel /// exclusif à , la protection /// contre le double clic (TaskCompletionSource), l'absence de champ Valide et /// l'absence de toute commande UPDATE / ARCHIVE / DELETE. /// 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 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 GetByGuidAsync(Guid suiviTempsGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetAllAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetByInterventionAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task> GetByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDraftsByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult>([]); 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 HasTypeTempsAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default) => Task.FromResult(false); public Task 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> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default) => Task.CompletedTask; } /// /// Fake local bloquant pour le test double Save : CreateAsync signale son /// démarrage via puis attend la libération via /// . Les deux TaskCompletionSource utilisent /// RunContinuationsAsynchronously pour éviter tout deadlock. /// private sealed class BlockingSuiviTempsLocalService : ISuiviTempsLocalService { public int CallCount { get; private set; } public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Gate { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task CreateAsync(SuiviTempsSyncPayload payload, CancellationToken ct = default) { CallCount++; Started.TrySetResult(true); return AwaitGateAsync(ct); } private async Task AwaitGateAsync(CancellationToken ct) { await Gate.Task.WaitAsync(ct); return Guid.NewGuid(); } public void Release() => Gate.TrySetResult(true); public Task GetByGuidAsync(Guid suiviTempsGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetAllAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetByInterventionAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task> GetByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDraftsByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task CreateDraftAsync(SuiviTempsDraftEntry draft, CancellationToken ct = default) => Task.CompletedTask; public Task DeleteDraftAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default) => Task.CompletedTask; public Task HasTypeTempsAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default) => Task.FromResult(false); public Task IsValideAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.FromResult(false); public Task ValidateRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default) => Task.CompletedTask; public Task SaveRendezVousPourBrouillonAsync(AgendaItem rendezVous, CancellationToken ct = default) => Task.CompletedTask; public Task> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default) => Task.CompletedTask; } private sealed class FakeParametreMetierService : IParametreMetierLocalService { public IReadOnlyList TypeTemps { get; set; } = []; public Task> GetTypeTempsAsync(CancellationToken ct = default) => Task.FromResult(TypeTemps); public Task GetTypeTempsParametresAsync(string typeTempsKey, CancellationToken ct = default) => Task.FromResult(null); public Task GetTypeRdvParametresAsync(string typeRdvKey, CancellationToken ct = default) => Task.FromResult(null); public Task GetStatutRdvParametresAsync(string statutRdvKey, CancellationToken ct = default) => Task.FromResult(null); public Task> GetInterventionTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetInterventionEtatsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDossierTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetDossierStatutsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetClientTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetClientStatutsAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetSiteTypesAsync(CancellationToken ct = default) => Task.FromResult>([]); public Task> GetTypeSignatairesAsync(CancellationToken cancellationToken = default) => Task.FromResult>([]); } private sealed class FakeCollaborateurService : ICollaborateurLocalService { public IReadOnlyList Collaborateurs { get; set; } = []; public Task> GetCollaborateursAsync(CancellationToken ct = default) => Task.FromResult(Collaborateurs); public Task GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken ct = default) => Task.FromResult(null); } private sealed class FakeInterventionService : IInterventionLocalService { public IReadOnlyList Interventions { get; set; } = []; public Task CreateAsync(InterventionSyncPayload payload, CancellationToken ct = default) => Task.FromResult(Guid.NewGuid()); public Task UpdateAsync(Guid interventionGuid, InterventionSyncPayload payload, CancellationToken ct = default) => Task.FromResult(interventionGuid); public Task ArchiveAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(interventionGuid); public Task GetInterventionByIdAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetInterventionsAsync(CancellationToken ct = default) => Task.FromResult(Interventions); } private sealed class FakeAgendaService : IAgendaService { public IReadOnlyList RendezVous { get; set; } = []; public AgendaItem? ById { get; set; } public Task> GetRendezVousForDayAsync(DateTime date, CancellationToken ct = default) => Task.FromResult(RendezVous); public Task> GetRendezVousForRangeAsync(DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default) => Task.FromResult(RendezVous); public Task GetRendezVousByIdAsync(Guid rendezVousGuid, CancellationToken ct = default, string? affectationId = null) => Task.FromResult(ById); public Task> GetRendezVousForCollaborateursAsync(IReadOnlyList collaborateurGuids, DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default) => Task.FromResult>(RendezVous); public Task> GetRendezVousForCollaborateurAsync(Guid collaborateurGuid, DateTime startInclusive, DateTime endExclusive, CancellationToken ct = default) => Task.FromResult>(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); } }