using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Omika.App.Local.Abstractions;
using Omika.App.Navigation;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.App.Views;
using Omika.Core.Constants;
using Omika.Core.Contracts.Mobile.Sync;
using SkiaSharp;
using Xunit;
using Path = Avalonia.Controls.Shapes.Path;
namespace Omika.Tests.Mobile;
///
/// Régression mesurée : dans le menu bas principal, l'icône du bouton « Sites »
/// (repère Material de 14×20) n'était pas centrée. Les tracés sont dessinés dans
/// un gabarit 24×24 et centrés en (12 ; 12) ; rendus en 18×18 avec
/// Stretch="Uniform", Avalonia ramenait les limites du tracé sur le coin
/// haut-gauche de la boîte et laissait les 5 px de jeu à droite : le repère
/// sortait 2,5 px à gauche de son bouton (les autres icônes étaient
/// décalées de 0 à 3 px selon leur forme).
///
///
/// Ces tests mesurent le rendu Skia RÉEL du Shell complet (barre basse et rail
/// latéral), icône par icône : aucune hypothèse sur la géométrie, sur
/// Stretch ni sur les styles. Ils rejoignent la collection
/// « ApplicationRuntimeContext » car le localisateur d'Avalonia est global au
/// processus.
///
[Collection("ApplicationRuntimeContext")]
public sealed class BarreNavigationIconesTests : IDisposable
{
/// Largeur d'un petit téléphone : sous 600 px, les libellés sont masqués.
private const double LargeurTelephoneEtroit = 360;
/// Largeur desktop : au-dessus de 960 px, c'est le rail latéral qui est affiché.
private const double LargeurBureau = 1200;
/// Très petit téléphone : tout doit rester dans l'écran.
private const double LargeurPetitEcran = 320;
/// Largeur au-dessus de laquelle les libellés de la barre basse sont affichés.
private const double LargeurLibelles = 700;
/// Tolérance (px) : l'encre est comptée en pixels entiers.
private const double Tolerance = 1.0;
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
[Fact]
public void BarreBasse_ChaqueIcone_EstCentreeDansSonBouton()
=> Executer((fenetre, items) =>
{
Button[] boutons = Boutons(fenetre, "bottomNavItem");
Assert.Equal(items.Count, boutons.Length);
foreach (Button bouton in boutons)
{
VerifierIcone(bouton, items, "barre basse", Disposition.IconeSeuleDansLeBouton);
}
}, LargeurTelephoneEtroit);
///
/// Au-dessus de 600 px, les libellés apparaissent : le groupe icône + libellé
/// doit rester centré dans le bouton et la barre entière doit tenir dans
/// l'écran (le rembourrage des boutons ne doit pas la faire déborder).
///
[Fact]
public void BarreBasse_LibellesAffiches_LeGroupeResteCentreEtDansLEcran()
=> Executer((fenetre, items) =>
{
Button[] boutons = Boutons(fenetre, "bottomNavItem");
Assert.Equal(items.Count, boutons.Length);
foreach (Button bouton in boutons)
{
VerifierIcone(bouton, items, "barre basse (libellés)", Disposition.IconeAuDessusDuLibelle);
}
double droite = boutons.Max(b => b.TranslatePoint(new Point(b.Bounds.Width, 0), fenetre)!.Value.X);
Assert.True(
droite <= LargeurLibelles + 0.5,
$"La barre basse dépasse l'écran de {droite - LargeurLibelles:0.#} px " +
$"à {LargeurLibelles} px.");
}, LargeurLibelles);
///
/// Rail latéral : l'icône est en tête de ligne, suivie du libellé — elle n'est
/// donc pas centrée dans le bouton (pleine largeur), mais elle doit y être
/// dessinée à sa taille et à sa position natives, comme dans la barre basse.
///
[Fact]
public void RailLateral_ChaqueIcone_EstRendueEnTailleNative()
=> Executer((fenetre, items) =>
{
Button[] boutons = Boutons(fenetre, "navItem");
Assert.Equal(items.Count, boutons.Length);
foreach (Button bouton in boutons)
{
VerifierIcone(bouton, items, "rail latéral", Disposition.IconeAvantLeLibelle);
}
}, LargeurBureau);
///
/// Sur un très petit écran (320 px), les sept boutons de la barre basse
/// (icônes de 24 px, libellés masqués) doivent tous tenir dans la largeur,
/// sans être rognés : le panneau répartit l'espace restant, il ne réduit
/// jamais les boutons.
///
[Fact]
public void BarreBasse_TousLesBoutons_TiennentDansLaLargeur_AEcran320()
=> Executer((fenetre, items) =>
{
Button[] boutons = Boutons(fenetre, "bottomNavItem");
Assert.Equal(items.Count, boutons.Length);
double droite = boutons.Max(b => b.TranslatePoint(new Point(b.Bounds.Width, 0), fenetre)!.Value.X);
string detail = string.Join(
", ",
boutons.Select(b => $"{b.CommandParameter}={b.Bounds.Width:0.#}px"));
Assert.True(
droite <= LargeurPetitEcran + 0.5,
$"La barre basse dépasse l'écran de {droite - LargeurPetitEcran:0.#} px " +
$"à {LargeurPetitEcran} px (boutons : {detail}).");
}, LargeurPetitEcran);
///
/// Le rendu en taille native n'est possible que si chaque tracé tient dans le
/// gabarit carré 24×24 : un tracé plus grand serait rogné (et le centrage
/// perdu). Ce test verrouille la contrainte pour tout futur ajout d'icône.
///
[Fact]
public void IconesDeNavigation_TiennentDansLeGabaritCarre24x24()
=> ExecuterSansVue(() =>
{
foreach (NavigationItem item in new NavigationService().Items)
{
Geometry geo = Geometrie(item.Icon);
Rect limites = geo.Bounds;
Assert.True(
limites.X >= 0 && limites.Y >= 0 && limites.Right <= 24 && limites.Bottom <= 24,
$"L'icône « {item.Icon} » dépasse le gabarit 24×24 : {limites} — " +
"elle serait rognée en taille native (Stretch=\"None\").");
}
});
// ===== Outils =====
/// Exécute des vérifications sur le thread d'interface, sans ouvrir de vue.
private void ExecuterSansVue(Action scenario)
=> _session.Dispatch(
() =>
{
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// Construit le Shell réel et exécute les vérifications sur le rendu.
private void Executer(Action> scenario, double largeur)
{
_session.Dispatch(
() =>
{
var permissions = new PermissionService();
permissions.SetRights(MobilePermissionKeys.All.ToDictionary(k => k, _ => true));
var navigation = new NavigationService(permissions);
var shell = new ShellViewModel(
navigation,
null!,
new FakeSyncEngine(),
new FakePageFactory(),
null!,
permissions,
new FakeOutboxService(),
null!);
Window fenetre = MesureGeometrique.Ouvrir(new ShellView { DataContext = shell }, largeur);
scenario(fenetre, navigation.Items);
fenetre.Close();
// Le Shell déclenche des lectures locales en tâche de fond : on
// vide la file d'attente avant de rendre la main, pour que la
// session headless se termine sans travail en suspens.
Dispatcher.UIThread.RunJobs();
Dispatcher.UIThread.RunJobs();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
}
/// Boutons visibles de la barre basse (ou du rail latéral).
private static Button[] Boutons(Window fenetre, string classe)
=> fenetre.GetVisualDescendants()
.OfType