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

451 lines
16 KiB
C#

using System.Net.Http;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Navigation;
using Omika.App.ViewModels;
using Omika.Core.Observability;
namespace Omika.Tests.Mobile.Local;
/// <summary>
/// Tests du ViewModel de la liste des clients : chargement des options
/// Type/Statut, recherche serveur à la demande, restauration de la dernière
/// recherche, épinglage et navigation vers la fiche Client.
///
/// Couvre aussi la politique d'erreur de la recherche : la recherche à la demande
/// atteint l'API sans garde intermédiaire (<c>ClientLocalService.SearchClientsAsync</c>
/// → <c>_apiClient</c>), donc un échec réseau — ticket de production du 14/10,
/// « Connection failure » — remonte jusqu'ici et doit être journalisé en Warning/P2,
/// jamais en Error/P1.
/// </summary>
[Collection("TelemetrieStatique")]
public class ClientsViewModelTests : LocalTestBase
{
private readonly NavigationService _navigation = new();
private readonly StubParametreMetierService _parametreMetier = new();
private static ClientListItem Client(Guid id, string nom, string? type = null, string? statut = null)
=> new()
{
ClientGuid = id,
Nom = nom,
Code = "C001",
Type = type,
Statut = statut,
Ville = "Lyon"
};
[Fact]
public async Task Load_ChargeOptionsTypeStatut_SansPeuplerLaListe()
{
// Arrange : référentiel ParametreMetier (bootstrap), jamais un chargement
// en bloc de clients.
_parametreMetier.ClientTypes = [new() { Label = "Professionnel" }];
_parametreMetier.ClientStatuts = [new() { Label = "Actif" }];
var service = new FakeClientService
{
Local = [Client(Guid.NewGuid(), "Alpha", "Professionnel", "Actif")]
};
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
// Act
await vm.LoadCommand.ExecuteAsync(null);
// Assert : options issues du référentiel, liste encore vide.
Assert.False(vm.IsLoading);
Assert.Contains("Tous", vm.TypeOptions);
Assert.Contains("Professionnel", vm.TypeOptions);
Assert.Contains("Actif", vm.StatutOptions);
Assert.False(vm.HasClients);
Assert.True(vm.IsSearchNotStarted);
}
[Fact]
public async Task Load_RestaureLaDerniereRecherche()
{
// Arrange
var client = Client(Guid.NewGuid(), "Alpha");
var service = new FakeClientService { LastSearch = [client] };
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
// Act
await vm.LoadCommand.ExecuteAsync(null);
// Assert : la dernière recherche est restaurée.
Assert.True(vm.HasClients);
Assert.Single(vm.Clients);
Assert.Equal("Alpha", vm.Clients[0].Nom);
Assert.False(vm.IsShowingPinned);
}
[Fact]
public async Task Search_RetourneLesResultats()
{
// Arrange
var service = new FakeClientService
{
SearchResults = [Client(Guid.NewGuid(), "Alpha")]
};
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
// Act
await vm.SearchCommand.ExecuteAsync(null);
// Assert
Assert.False(vm.IsLoading);
Assert.True(vm.HasClients);
Assert.Single(vm.Clients);
Assert.Equal("Alpha", vm.Clients[0].Nom);
}
[Fact]
public async Task Search_SansResultat_ListeVide()
{
// Arrange
var service = new FakeClientService { SearchResults = [] };
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
// Act
await vm.SearchCommand.ExecuteAsync(null);
// Assert
Assert.False(vm.HasClients);
Assert.True(vm.HasNoResults);
}
[Fact]
public async Task Search_Erreur_Affichee()
{
// Arrange
var service = new FakeClientService { ThrowOnSearch = true };
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
// Act
await vm.SearchCommand.ExecuteAsync(null);
// Assert
Assert.NotNull(vm.ErrorMessage);
Assert.False(vm.HasClients);
}
[Fact]
public async Task Load_Erreur_Affichee()
{
// Arrange
var vm = new ClientsViewModel(new ThrowingClientService(), _parametreMetier, _navigation);
// Act
await vm.LoadCommand.ExecuteAsync(null);
// Assert
Assert.NotNull(vm.ErrorMessage);
Assert.False(vm.HasClients);
}
[Fact]
public async Task ResetSearch_VideLaListe()
{
// Arrange
var service = new FakeClientService
{
SearchResults = [Client(Guid.NewGuid(), "Alpha")]
};
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
Assert.True(vm.HasClients);
// Act
await vm.ResetSearchCommand.ExecuteAsync(null);
// Assert
Assert.False(vm.HasClients);
Assert.Empty(vm.Clients);
Assert.False(vm.HasActiveFilters);
}
[Fact]
public async Task TogglePinned_EpingleEtAfficheDansOnglet()
{
// Arrange
Guid clientId = Guid.NewGuid();
var client = Client(clientId, "Alpha");
var service = new FakeClientService { SearchResults = [client] };
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
// Act : épingle le client.
await vm.TogglePinnedCommand.ExecuteAsync(vm.Clients[0]);
// Assert : présent dans l'onglet « Épinglés ».
await vm.ShowPinnedCommand.ExecuteAsync(null);
Assert.True(vm.IsShowingPinned);
Assert.Single(vm.Clients);
Assert.Equal(clientId, vm.Clients[0].ClientGuid);
}
[Fact]
public async Task TogglePinned_AppliqueImmediatement_SansRechargerLaListe()
{
// Arrange
Guid clientId = Guid.NewGuid();
var service = new FakeClientService { SearchResults = [Client(clientId, "Alpha"), Client(Guid.NewGuid(), "Beta")] };
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
// La collection affichée sert de témoin : un rechargement la remplacerait.
var collection = vm.Clients;
var item = vm.Clients[0];
// Act
await vm.TogglePinnedCommand.ExecuteAsync(item);
// Assert : étoile basculée immédiatement, aucune reconstruction de la liste.
Assert.True(item.IsPinned);
Assert.True(vm.Clients[0].IsPinned);
Assert.Same(collection, vm.Clients);
Assert.Equal(2, vm.Clients.Count);
}
[Fact]
public async Task TogglePinned_EtoileBasculeAvantLaFinDeLEcriture()
{
// Arrange : persistance volontairement LENTE (base occupée par une
// synchronisation en arrière-plan).
var service = new FakeClientService { SearchResults = [Client(Guid.NewGuid(), "Alpha")] };
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
var item = vm.Clients[0];
service.SetPinnedGate = new TaskCompletionSource();
// Act : la commande n'est PAS attendue, l'écriture est encore en cours.
Task commande = vm.TogglePinnedCommand.ExecuteAsync(item);
// Assert : l'étoile est déjà basculée, l'utilisateur n'attend pas la base.
Assert.True(item.IsPinned);
Assert.False(commande.IsCompleted);
service.SetPinnedGate.SetResult();
await commande;
Assert.True(item.IsPinned);
}
[Fact]
public async Task TogglePinned_EchecDePersistance_RevientALEtaPrecedent()
{
// Arrange
var service = new FakeClientService
{
SearchResults = [Client(Guid.NewGuid(), "Alpha")],
ThrowOnSetPinned = true
};
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
var item = vm.Clients[0];
// Act
await vm.TogglePinnedCommand.ExecuteAsync(item);
// Assert : l'interface ne montre jamais un épinglage absent de la base.
Assert.False(item.IsPinned);
Assert.NotNull(vm.ErrorMessage);
}
[Fact]
public async Task OpenClient_NavigueVersDetail()
{
// Arrange
Guid clientId = Guid.NewGuid();
var service = new FakeClientService
{
SearchResults = [Client(clientId, "Alpha")]
};
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
// Act
vm.OpenClientCommand.Execute(vm.Clients[0]);
// Assert
Assert.Equal(AppPage.ClientDetail, _navigation.CurrentPage);
Assert.Equal(clientId, _navigation.CurrentParameter);
}
[Fact]
public async Task OpenClient_Null_NeNaviguePas()
{
// Arrange
var vm = new ClientsViewModel(new FakeClientService(), _parametreMetier, _navigation);
// Act
vm.OpenClientCommand.Execute(null);
// Assert
Assert.Equal(AppPage.Home, _navigation.CurrentPage);
}
/// <summary>Fake configurable du service local de clients.</summary>
private sealed class FakeClientService : IClientLocalService
{
public IReadOnlyList<ClientListItem> Local { get; set; } = [];
public IReadOnlyList<ClientListItem> SearchResults { get; set; } = [];
public IReadOnlyList<ClientListItem> LastSearch { get; set; } = [];
public bool ThrowOnSearch { get; set; }
/// <summary>Exception levée par la recherche quand <see cref="ThrowOnSearch"/> est vrai.</summary>
public Exception SearchException { get; set; } = new InvalidOperationException("Erreur simulée");
/// <summary>Fait échouer l'épinglage (test du retour arrière d'affichage).</summary>
public bool ThrowOnSetPinned { get; set; }
/// <summary>Retient l'écriture tant qu'il n'est pas libéré (test de réactivité).</summary>
public TaskCompletionSource? SetPinnedGate { get; set; }
private readonly List<ClientListItem> _pinned = [];
public Task<IReadOnlyList<ClientListItem>> GetClientsAsync(CancellationToken ct = default)
=> Task.FromResult(Local);
public Task<IReadOnlyList<ClientListItem>> SearchClientsAsync(
string? nom, string? code, string? telephone, string? email,
string? ville, string? codePostal, string? type, string? statut,
CancellationToken ct = default)
=> ThrowOnSearch
? throw SearchException
: Task.FromResult(SearchResults);
public Task<IReadOnlyList<ClientListItem>> GetLastSearchAsync(CancellationToken ct = default)
=> Task.FromResult(LastSearch);
public Task ClearLastSearchAsync(CancellationToken ct = default)
{
LastSearch = [];
return Task.CompletedTask;
}
public Task<IReadOnlyList<ClientListItem>> GetPinnedAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ClientListItem>>(_pinned);
public async Task SetPinnedAsync(Guid clientId, bool isPinned, ClientListItem? item, CancellationToken ct = default)
{
if (SetPinnedGate is not null)
{
await SetPinnedGate.Task;
}
if (ThrowOnSetPinned)
{
throw new InvalidOperationException("Erreur SQLite simulée");
}
if (isPinned && item is not null && !_pinned.Any(c => c.ClientGuid == clientId))
{
_pinned.Add(item);
}
else if (!isPinned)
{
_pinned.RemoveAll(c => c.ClientGuid == clientId);
}
}
public Task<ClientDetail?> GetClientByIdAsync(Guid clientGuid, CancellationToken ct = default)
=> Task.FromResult<ClientDetail?>(null);
}
/// <summary>Service qui lève une exception pour tester l'état d'erreur.</summary>
private sealed class ThrowingClientService : IClientLocalService
{
public Task<IReadOnlyList<ClientListItem>> GetClientsAsync(CancellationToken ct = default)
=> throw new InvalidOperationException("Erreur SQLite simulée");
public Task<ClientDetail?> GetClientByIdAsync(Guid clientGuid, CancellationToken ct = default)
=> throw new InvalidOperationException("Erreur SQLite simulée");
}
/// <summary>
/// Ticket de production du 14/10 (« Connection failure ») : l'échec réseau de la
/// recherche n'est pas un incident. L'événement part en Warning/P2, l'écran n'entre
/// dans aucun groupe d'erreurs du Diagnostic Center, et l'utilisateur reçoit le
/// libellé français stable plutôt qu'un message technique anglais.
/// </summary>
[Fact]
public async Task Search_EchecReseau_SignaleEnWarningSansIncident()
{
var service = new FakeClientService
{
ThrowOnSearch = true,
SearchException = new HttpRequestException("Connection failure")
};
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.SearchCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.Equal("Hors ligne — impossible de contacter le serveur.", vm.ErrorMessage);
DiagnosticEvent evt = EvenementClients(telemetry);
Assert.Equal(TelemetryLevel.Warning, evt.Level);
Assert.Equal("HttpRequestException", evt.ErrorCode);
AssertAucunIncident(telemetry);
}
/// <summary>
/// Contre-épreuve : une erreur métier (hors réseau) reste un incident Error/P1.
/// Le classement ne dégrade pas les vraies erreurs.
/// </summary>
[Fact]
public async Task Search_ErreurMetier_ResteUnIncident()
{
var service = new FakeClientService
{
ThrowOnSearch = true,
SearchException = new InvalidOperationException("échec de recherche")
};
var vm = new ClientsViewModel(service, _parametreMetier, _navigation);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.SearchCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
DiagnosticEvent evt = EvenementClients(telemetry);
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("InvalidOperationException", evt.ErrorCode);
}
/// <summary>Seul événement de diagnostic attribué à ce ViewModel (l'usage est un TelemetryEvent).</summary>
private static DiagnosticEvent EvenementClients(CapturingTelemetry telemetry) =>
Assert.Single(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(ClientsViewModel));
/// <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(ClientsViewModel) && e.Level == TelemetryLevel.Error);
/// <summary>Capture des événements de la télémétrie statique globale.</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);
}
}