350 lines
12 KiB
C#
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);
|
|
}
|
|
}
|