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

350 lines
12 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 sites : chargement des options Type,
/// recherche serveur à la demande, restauration de la dernière recherche,
/// épinglage et navigation vers la fiche Site.
///
/// Couvre aussi la politique d'erreur de la recherche : la recherche à la demande
/// atteint l'API sans garde intermédiaire (<c>SiteLocalService.SearchSitesAsync</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 SitesViewModelTests : LocalTestBase
{
private readonly NavigationService _navigation = new();
private readonly StubParametreMetierService _parametreMetier = new();
private static SiteListItem Site(Guid id, string nom, string? type = null)
=> new()
{
SiteGuid = id,
Nom = nom,
Code = "S001",
Type = type,
Ville = "Lyon"
};
[Fact]
public async Task Load_ChargeOptionsType_SansPeuplerLaListe()
{
_parametreMetier.SiteTypes = [new() { Label = "Entrepôt" }];
var service = new FakeSiteService
{
Local = [Site(Guid.NewGuid(), "Alpha", "Entrepôt")]
};
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
await vm.LoadCommand.ExecuteAsync(null);
Assert.False(vm.IsLoading);
Assert.Contains("Tous", vm.TypeOptions);
Assert.Contains("Entrepôt", vm.TypeOptions);
Assert.False(vm.HasSites);
Assert.True(vm.IsSearchNotStarted);
}
[Fact]
public async Task Load_RestaureLaDerniereRecherche()
{
var site = Site(Guid.NewGuid(), "Alpha");
var service = new FakeSiteService { LastSearch = [site] };
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
await vm.LoadCommand.ExecuteAsync(null);
Assert.True(vm.HasSites);
Assert.Single(vm.Sites);
Assert.Equal("Alpha", vm.Sites[0].Nom);
}
[Fact]
public async Task Search_RetourneLesResultats()
{
var service = new FakeSiteService { SearchResults = [Site(Guid.NewGuid(), "Alpha")] };
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
Assert.True(vm.HasSites);
Assert.Single(vm.Sites);
Assert.Equal("Alpha", vm.Sites[0].Nom);
}
[Fact]
public async Task Search_SansResultat_ListeVide()
{
var service = new FakeSiteService { SearchResults = [] };
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
Assert.False(vm.HasSites);
Assert.True(vm.HasNoResults);
}
[Fact]
public async Task Search_Erreur_Affichee()
{
var service = new FakeSiteService { ThrowOnSearch = true };
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
Assert.NotNull(vm.ErrorMessage);
Assert.False(vm.HasSites);
}
[Fact]
public async Task Load_Erreur_Affichee()
{
var vm = new SitesViewModel(new ThrowingSiteService(), _parametreMetier, _navigation);
await vm.LoadCommand.ExecuteAsync(null);
Assert.NotNull(vm.ErrorMessage);
Assert.False(vm.HasSites);
}
[Fact]
public async Task ResetSearch_VideLaListe()
{
var service = new FakeSiteService { SearchResults = [Site(Guid.NewGuid(), "Alpha")] };
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
Assert.True(vm.HasSites);
await vm.ResetSearchCommand.ExecuteAsync(null);
Assert.False(vm.HasSites);
Assert.Empty(vm.Sites);
Assert.False(vm.HasActiveFilters);
}
[Fact]
public async Task TogglePinned_EpingleEtAfficheDansOnglet()
{
Guid siteId = Guid.NewGuid();
var service = new FakeSiteService { SearchResults = [Site(siteId, "Alpha")] };
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
await vm.TogglePinnedCommand.ExecuteAsync(vm.Sites[0]);
await vm.ShowPinnedCommand.ExecuteAsync(null);
Assert.True(vm.IsShowingPinned);
Assert.Single(vm.Sites);
Assert.Equal(siteId, vm.Sites[0].SiteGuid);
}
[Fact]
public async Task TogglePinned_AppliqueImmediatement_SansRechargerLaListe()
{
var service = new FakeSiteService
{
SearchResults = [Site(Guid.NewGuid(), "Alpha"), Site(Guid.NewGuid(), "Beta")]
};
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
// La collection affichée sert de témoin : un rechargement la remplacerait.
var collection = vm.Sites;
var item = vm.Sites[0];
await vm.TogglePinnedCommand.ExecuteAsync(item);
Assert.True(item.IsPinned);
Assert.Same(collection, vm.Sites);
Assert.Equal(2, vm.Sites.Count);
}
[Fact]
public async Task OpenSite_NavigueVersDetail()
{
Guid siteId = Guid.NewGuid();
var service = new FakeSiteService { SearchResults = [Site(siteId, "Alpha")] };
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
await vm.SearchCommand.ExecuteAsync(null);
vm.OpenSiteCommand.Execute(vm.Sites[0]);
Assert.Equal(AppPage.SiteDetail, _navigation.CurrentPage);
Assert.Equal(siteId, _navigation.CurrentParameter);
}
[Fact]
public async Task OpenSite_Null_NeNaviguePas()
{
var vm = new SitesViewModel(new FakeSiteService(), _parametreMetier, _navigation);
vm.OpenSiteCommand.Execute(null);
Assert.Equal(AppPage.Home, _navigation.CurrentPage);
}
/// <summary>Fake configurable du service local de sites.</summary>
private sealed class FakeSiteService : ISiteLocalService
{
public IReadOnlyList<SiteListItem> Local { get; set; } = [];
public IReadOnlyList<SiteListItem> SearchResults { get; set; } = [];
public IReadOnlyList<SiteListItem> 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");
private readonly List<SiteListItem> _pinned = [];
public Task<IReadOnlyList<SiteListItem>> GetSitesAsync(CancellationToken ct = default)
=> Task.FromResult(Local);
public Task<IReadOnlyList<SiteListItem>> SearchSitesAsync(
string? nom, string? code, string? type, string? ville,
string? codePostal, string? telephone, string? email,
CancellationToken ct = default)
=> ThrowOnSearch
? throw SearchException
: Task.FromResult(SearchResults);
public Task<IReadOnlyList<SiteListItem>> GetLastSearchAsync(CancellationToken ct = default)
=> Task.FromResult(LastSearch);
public Task ClearLastSearchAsync(CancellationToken ct = default)
{
LastSearch = [];
return Task.CompletedTask;
}
public Task<IReadOnlyList<SiteListItem>> GetPinnedAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SiteListItem>>(_pinned);
public Task SetPinnedAsync(Guid siteId, bool isPinned, SiteListItem? item, CancellationToken ct = default)
{
if (isPinned && item is not null && !_pinned.Any(s => s.SiteGuid == siteId))
{
_pinned.Add(item);
}
else if (!isPinned)
{
_pinned.RemoveAll(s => s.SiteGuid == siteId);
}
return Task.CompletedTask;
}
public Task<SiteDetail?> GetSiteByIdAsync(Guid siteGuid, CancellationToken ct = default)
=> Task.FromResult<SiteDetail?>(null);
}
/// <summary>Service qui lève une exception pour tester l'état d'erreur.</summary>
private sealed class ThrowingSiteService : ISiteLocalService
{
public Task<IReadOnlyList<SiteListItem>> GetSitesAsync(CancellationToken ct = default)
=> throw new InvalidOperationException("Erreur SQLite simulée");
public Task<SiteDetail?> GetSiteByIdAsync(Guid siteGuid, 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 FakeSiteService
{
ThrowOnSearch = true,
SearchException = new HttpRequestException("Connection failure")
};
var vm = new SitesViewModel(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 = EvenementSites(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 FakeSiteService
{
ThrowOnSearch = true,
SearchException = new InvalidOperationException("échec de recherche")
};
var vm = new SitesViewModel(service, _parametreMetier, _navigation);
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await vm.SearchCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
DiagnosticEvent evt = EvenementSites(telemetry);
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("InvalidOperationException", evt.ErrorCode);
}
/// <summary>
/// Seul événement de diagnostic attribué au ViewModel : 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 EvenementSites(CapturingTelemetry telemetry) =>
Assert.Single(
telemetry.Events.OfType<DiagnosticEvent>(),
e => e.Component == nameof(SitesViewModel));
/// <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(SitesViewModel) && 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);
}
}