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

570 lines
22 KiB
C#

using System.Net.Http;
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.Interventions;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Observability;
namespace Omika.Tests.Mobile.Local;
/// <summary>
/// Tests du ViewModel Interventions (modèle offline-first réel) : recherche à
/// la demande, dernière recherche, favoris, onglets Récentes/Épinglées, lookup
/// humain Client/Site/Collaborateur.
///
/// Couvre aussi la politique d'erreur de la recherche : un échec réseau — ticket
/// de production du 14/10, « Connection failure » — est un état hors ligne
/// journalisé en Warning/P2, jamais un incident Error/P1.
/// </summary>
[Collection("TelemetrieStatique")]
public class InterventionsViewModelTests
{
private static InterventionsViewModel CreateVm(IInterventionLocalService service)
=> new InterventionsViewModel(
service,
new FakeParametreMetierService(),
new NavigationService(),
new PermissionService(),
Omika.Core.Observability.NullTelemetryService.Instance);
[Fact]
public async Task Load_SansRecherche_ListeVide()
{
var service = new FakeInterventionService();
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
Assert.False(vm.HasItems);
Assert.True(vm.IsEmptyRecent);
Assert.Equal("0 résultat", vm.ResultCountLabel);
}
[Fact]
public async Task Search_Reussi_RemplaceDerniereRecherche()
{
var service = new FakeInterventionService
{
LastSearch = [Card("INT-1", "Intervention A")]
};
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
Assert.Single(vm.Items);
service.SearchResults = [Card("INT-2", "Intervention B"), Card("INT-3", "Intervention C")];
vm.ReferenceSearch = "INT";
await vm.SearchCommand.ExecuteAsync(null);
Assert.Equal(2, vm.Items.Count);
Assert.False(vm.IsShowingPinned);
Assert.Equal("2 résultats", vm.ResultCountLabel);
Assert.Equal("INT", service.LastRequest?.Reference);
}
[Fact]
public async Task Search_Echoue_ConservePrecedente()
{
var service = new FakeInterventionService
{
LastSearch = [Card("INT-1", "Intervention A")]
};
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
Assert.Single(vm.Items);
// Erreur de recherche NEUTRE : ce test porte sur la conservation de la liste
// précédente. La politique « indisponibilité réseau → message français stable »
// est couverte par les tests d'agenda (Load_IOExceptionReelle_*).
service.SearchException = new InvalidOperationException("échec de recherche");
await vm.SearchCommand.ExecuteAsync(null);
Assert.Single(vm.Items);
Assert.Equal("échec de recherche", vm.ErrorMessage);
}
/// <summary>
/// Ticket de production du 14/10 (recherche d'interventions, « Connection failure ») :
/// un échec réseau n'est pas un incident. Il est journalisé en Warning/P2, signalé
/// par le bandeau hors ligne (aucun message d'erreur), et la liste précédente —
/// dernière recherche réussie — reste affichée.
/// </summary>
[Fact]
public async Task Search_EchecReseau_SignaleHorsLigneSansIncident()
{
var service = new FakeInterventionService
{
LastSearch = [Card("INT-1", "Intervention A")]
};
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
service.SearchException = new HttpRequestException("Connection failure");
await vm.SearchCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.True(vm.IsOffline);
Assert.Null(vm.ErrorMessage);
Assert.Single(vm.Items);
AssertAucunIncident(telemetry);
DiagnosticEvent evt = EvenementInterventions(telemetry);
Assert.Equal(TelemetryLevel.Warning, evt.Level);
Assert.Equal("HttpRequestException", evt.ErrorCode);
Assert.StartsWith("Connection failure", evt.Message);
}
/// <summary>
/// Aucune URL d'API configurée (appareil non inscrit) : ce n'est pas une coupure
/// réseau — l'appareil peut être en ligne. On affiche l'action à faire
/// (« rescannez le QR ») et jamais le bandeau hors ligne, sans incident P1.
/// </summary>
[Fact]
public async Task Search_SansUrlDApi_MessageActionnableSansBandeauHorsLigne()
{
var service = new FakeInterventionService
{
SearchException = new MobileEnrollmentRequiredException()
};
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.SearchCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.False(vm.IsOffline);
Assert.Equal(
"Inscription incomplète — rescannez le QR code pour reconnecter l'application.",
vm.ErrorMessage);
AssertAucunIncident(telemetry);
DiagnosticEvent evt = EvenementInterventions(telemetry);
Assert.Equal(TelemetryLevel.Warning, evt.Level);
Assert.Equal("MobileEnrollmentRequiredException", evt.ErrorCode);
}
/// <summary>
/// Hors ligne SANS résultat précédent : l'écran ne doit pas annoncer « aucune
/// recherche récente » — l'absence n'a pas été constatée par le serveur, c'est
/// le bandeau hors ligne qui porte l'information.
/// </summary>
[Fact]
public async Task Search_EchecReseau_SansResultatPrecedent_MasqueLEtatVide()
{
var service = new FakeInterventionService
{
SearchException = new HttpRequestException("Connection failure")
};
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
await vm.SearchCommand.ExecuteAsync(null);
Assert.True(vm.IsOffline);
Assert.False(vm.IsEmptyRecent);
Assert.Empty(vm.Items);
}
/// <summary>
/// Contre-épreuve : une erreur métier (hors réseau) reste un incident Error/P1
/// avec son message, et n'active pas le bandeau hors ligne.
/// </summary>
[Fact]
public async Task Search_ErreurMetier_ResteUnIncident()
{
var service = new FakeInterventionService
{
SearchException = new InvalidOperationException("échec de recherche")
};
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.SearchCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.False(vm.IsOffline);
Assert.Equal("échec de recherche", vm.ErrorMessage);
DiagnosticEvent evt = EvenementInterventions(telemetry);
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("InvalidOperationException", evt.ErrorCode);
}
/// <summary>
/// Une recherche réussie après un échec réseau efface l'état hors ligne : le
/// bandeau ne doit pas rester affiché une fois le serveur revenu.
/// </summary>
[Fact]
public async Task Search_ReussiApresEchecReseau_EffaceLEtatHorsLigne()
{
var service = new FakeInterventionService
{
SearchException = new HttpRequestException("Connection failure")
};
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
await vm.SearchCommand.ExecuteAsync(null);
Assert.True(vm.IsOffline);
service.SearchException = null;
service.SearchResults = [Card("INT-2", "Intervention B")];
await vm.SearchCommand.ExecuteAsync(null);
Assert.False(vm.IsOffline);
Assert.Single(vm.Items);
Assert.Null(vm.ErrorMessage);
Assert.False(vm.IsEmptyRecent);
}
[Fact]
public async Task ShowPinned_AfficheFavoris()
{
var service = new FakeInterventionService
{
Pinned = [Card("INT-9", "Favori", isPinned: true)]
};
var vm = CreateVm(service);
await vm.ShowPinnedCommand.ExecuteAsync(null);
Assert.True(vm.IsShowingPinned);
Assert.Single(vm.Items);
Assert.Equal("Épinglées", vm.TabLabel);
}
[Fact]
public async Task TogglePinned_Bascule()
{
var service = new FakeInterventionService
{
Pinned = [Card("INT-9", "Favori", isPinned: true)]
};
var vm = CreateVm(service);
await vm.ShowPinnedCommand.ExecuteAsync(null);
await vm.TogglePinnedCommand.ExecuteAsync(vm.Items[0]);
Assert.Empty(service.Pinned);
Assert.Empty(vm.Items);
}
[Fact]
public async Task TogglePinned_AppliqueImmediatement_SansRechargerLaListe()
{
var service = new FakeInterventionService
{
LastSearch = [Card("INT-1", "Intervention A"), Card("INT-2", "Intervention B")]
};
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
// La collection affichée sert de témoin : un rechargement la remplacerait.
var collection = vm.Items;
var item = vm.Items[0];
await vm.TogglePinnedCommand.ExecuteAsync(item);
Assert.True(item.IsPinned);
Assert.Same(collection, vm.Items);
Assert.Equal(2, vm.Items.Count);
}
[Fact]
public async Task Search_ClientSearch_EnvoieLeTexte()
{
var service = new FakeInterventionService();
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
vm.ClientSearch = "Alpha";
await vm.SearchCommand.ExecuteAsync(null);
Assert.Equal("Alpha", service.LastRequest?.ClientSearch);
}
[Fact]
public async Task Search_Reference105Tous_EnvoieCriteresNeutres()
{
var service = new FakeInterventionService();
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
vm.ReferenceSearch = "105";
vm.ClientSearch = string.Empty;
vm.SiteSearch = string.Empty;
vm.CollaborateurSearch = string.Empty;
vm.SelectedEtatIndex = 0;
vm.SelectedTypeIndex = 0;
vm.RendezVousRelation = 0;
vm.RendezVousDate = null;
await vm.SearchCommand.ExecuteAsync(null);
Assert.Equal("105", service.LastRequest?.Reference);
Assert.Null(service.LastRequest?.ClientSearch);
Assert.Null(service.LastRequest?.SiteSearch);
Assert.Null(service.LastRequest?.CollaborateurSearch);
Assert.Null(service.LastRequest?.AvecRendezVous);
Assert.Null(service.LastRequest?.RendezVousDate);
}
[Fact]
public async Task Search_SiteSearch_EnvoieLeTexte()
{
var service = new FakeInterventionService();
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
vm.SiteSearch = "Nord";
await vm.SearchCommand.ExecuteAsync(null);
Assert.Equal("Nord", service.LastRequest?.SiteSearch);
}
[Fact]
public async Task Search_CollaborateurSearch_EnvoieLeTexte()
{
var service = new FakeInterventionService();
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
vm.CollaborateurSearch = "Dupont";
await vm.SearchCommand.ExecuteAsync(null);
Assert.Equal("Dupont", service.LastRequest?.CollaborateurSearch);
}
[Fact]
public async Task Reset_RemetToutesLesSelectionsAZero()
{
var service = new FakeInterventionService();
var vm = CreateVm(service);
await vm.LoadCommand.ExecuteAsync(null);
vm.ReferenceSearch = "INT";
vm.ClientSearch = "Alpha";
vm.SiteSearch = "Nord";
vm.CollaborateurSearch = "Dupont";
vm.SelectedEtatIndex = 1;
vm.SelectedTypeIndex = 1;
vm.RendezVousRelation = 1;
vm.RendezVousDate = DateTime.Now;
vm.ResetSearchCommand.Execute(null);
Assert.Empty(vm.ReferenceSearch);
Assert.Empty(vm.ClientSearch);
Assert.Empty(vm.SiteSearch);
Assert.Empty(vm.CollaborateurSearch);
Assert.Equal(0, vm.SelectedEtatIndex);
Assert.Equal(0, vm.SelectedTypeIndex);
Assert.Equal(0, vm.RendezVousRelation);
Assert.Null(vm.RendezVousDate);
}
private static InterventionCardItem Card(string reference, string titre, bool isPinned = false)
=> new InterventionCardItem
{
InterventionGuid = Guid.NewGuid(),
Reference = reference,
Titre = titre,
IsPinned = isPinned
};
private sealed class FakeInterventionService : IInterventionLocalService
{
public List<InterventionCardItem> LastSearch { get; set; } = [];
public List<InterventionCardItem> Pinned { get; set; } = [];
public List<InterventionCardItem> SearchResults { get; set; } = [];
public Exception? SearchException { get; set; }
public MobileInterventionSearchRequest? LastRequest { get; set; }
public Task<Guid> CreateAsync(InterventionSyncPayload payload, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<Guid> UpdateAsync(Guid interventionGuid, InterventionSyncPayload payload, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<Guid> ArchiveAsync(Guid interventionGuid, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<InterventionDetail?> GetInterventionByIdAsync(Guid interventionGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<InterventionDetail?>(null);
public Task<IReadOnlyList<InterventionListItem>> GetInterventionsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<InterventionListItem>>([]);
public async Task<IReadOnlyList<InterventionCardItem>> SearchAsync(MobileInterventionSearchRequest request, CancellationToken cancellationToken = default)
{
LastRequest = request;
if (SearchException is not null)
{
throw SearchException;
}
await Task.CompletedTask;
LastSearch = SearchResults;
return LastSearch;
}
public Task<IReadOnlyList<InterventionCardItem>> GetLastSearchAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<InterventionCardItem>>(LastSearch);
public Task<IReadOnlyList<InterventionCardItem>> GetPinnedAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<InterventionCardItem>>(Pinned);
public Task SetPinnedAsync(Guid interventionGuid, bool isPinned, CancellationToken cancellationToken = default)
{
InterventionCardItem? item = Pinned.FirstOrDefault(i => i.InterventionGuid == interventionGuid);
if (item is not null && !isPinned)
{
Pinned.Remove(item);
}
return Task.CompletedTask;
}
public Task RefreshPinnedAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task RefreshInterventionAsync(Guid interventionGuid, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
private sealed class FakeParametreMetierService : IParametreMetierLocalService
{
public Task<IReadOnlyList<ReferenceValueDto>> GetTypeTempsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
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 FakeClientService : IClientLocalService
{
public Task<IReadOnlyList<ClientListItem>> GetClientsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ClientListItem>>(
new List<ClientListItem>
{
new() { ClientGuid = Guid.NewGuid(), Nom = "Client Alpha", Code = "C001" }
});
public Task<ClientDetail?> GetClientByIdAsync(Guid clientGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<ClientDetail?>(null);
}
private sealed class FakeSiteService : ISiteLocalService
{
public Task<IReadOnlyList<SiteListItem>> GetSitesAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<SiteListItem>>(
new List<SiteListItem>
{
new() { SiteGuid = Guid.NewGuid(), Nom = "Site Nord", Code = "S001" }
});
public Task<SiteDetail?> GetSiteByIdAsync(Guid siteGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<SiteDetail?>(null);
}
private sealed class FakeCollaborateurService : ICollaborateurLocalService
{
public Task<IReadOnlyList<CollaborateurListItem>> GetCollaborateursAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<CollaborateurListItem>>(
new List<CollaborateurListItem>
{
new() { CollaborateurGuid = Guid.NewGuid(), DisplayName = "Jean Dupont", Code = "COL-1" }
});
public Task<CollaborateurDetail?> GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken cancellationToken = default)
=> Task.FromResult<CollaborateurDetail?>(null);
}
/// <summary>
/// Seul événement de diagnostic attribué au ViewModel Interventions : la télémétrie
/// d'usage (fonctionnalités) est un <see cref="TelemetryEvent"/>, pas un
/// <see cref="DiagnosticEvent"/>. Le filtre porte sur le composant, la télémétrie
/// statique globale étant partagée avec les classes exécutées en parallèle.
/// </summary>
private static DiagnosticEvent EvenementInterventions(CapturingTelemetry telemetry) =>
Assert.Single(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(InterventionsViewModel));
/// <summary>Aucun événement de diagnostic de cet écran en Error/Critical.</summary>
private static void AssertAucunIncident(CapturingTelemetry telemetry) =>
Assert.DoesNotContain(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(InterventionsViewModel) && e.Level == TelemetryLevel.Error);
/// <summary>
/// Capture en mémoire des événements de la télémétrie statique globale
/// (<see cref="ExceptionTelemetry.Initialize"/>), rétablie à <c>null</c> après
/// chaque test — la classe appartient à la collection « TelemetrieStatique ».
/// </summary>
private sealed class CapturingTelemetry : IOmikaTelemetry
{
public List<OmikaEvent> Events { get; } = [];
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
}
}