451 lines
16 KiB
C#
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);
|
|
}
|
|
}
|