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