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

408 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 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
/// <c>Stretch="Uniform"</c>, 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 <b>2,5 px à gauche</b> de son bouton (les autres icônes étaient
/// décalées de 0 à 3 px selon leur forme).
/// </summary>
/// <remarks>
/// 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
/// <c>Stretch</c> ni sur les styles. Ils rejoignent la collection
/// « ApplicationRuntimeContext » car le localisateur d'Avalonia est global au
/// processus.
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class BarreNavigationIconesTests : IDisposable
{
/// <summary>Largeur d'un petit téléphone : sous 600 px, les libellés sont masqués.</summary>
private const double LargeurTelephoneEtroit = 360;
/// <summary>Largeur desktop : au-dessus de 960 px, c'est le rail latéral qui est affiché.</summary>
private const double LargeurBureau = 1200;
/// <summary>Très petit téléphone : tout doit rester dans l'écran.</summary>
private const double LargeurPetitEcran = 320;
/// <summary>Largeur au-dessus de laquelle les libellés de la barre basse sont affichés.</summary>
private const double LargeurLibelles = 700;
/// <summary>Tolérance (px) : l'encre est comptée en pixels entiers.</summary>
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);
/// <summary>
/// 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).
/// </summary>
[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);
/// <summary>
/// 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.
/// </summary>
[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);
/// <summary>
/// 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.
/// </summary>
[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);
/// <summary>
/// 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.
/// </summary>
[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 =====
/// <summary>Exécute des vérifications sur le thread d'interface, sans ouvrir de vue.</summary>
private void ExecuterSansVue(Action scenario)
=> _session.Dispatch(
() =>
{
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// <summary>Construit le Shell réel et exécute les vérifications sur le rendu.</summary>
private void Executer(Action<Window, IReadOnlyList<NavigationItem>> 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();
}
/// <summary>Boutons visibles de la barre basse (ou du rail latéral).</summary>
private static Button[] Boutons(Window fenetre, string classe)
=> fenetre.GetVisualDescendants()
.OfType<Button>()
.Where(b => b.Classes.Contains(classe) && b.IsVisible && b.Bounds.Width > 0)
.ToArray();
/// <summary>
/// Vérifie, sur le rendu réel, que le tracé est dessiné <b>à sa taille et à sa
/// position natives</b> dans la boîte du <c>Path</c> (c'est-à-dire sans
/// décalage ni mise à l'échelle cachée), puis le placement attendu selon la
/// disposition de l'élément de navigation.
/// </summary>
private static void VerifierIcone(
Button bouton,
IReadOnlyList<NavigationItem> items,
string zone,
Disposition disposition)
{
var page = (AppPage)bouton.CommandParameter!;
NavigationItem item = items.Single(i => i.Page == page);
Path chemin = bouton.GetVisualDescendants().OfType<Path>().Single();
int cote = (int)Math.Round(chemin.Bounds.Width);
int hauteur = (int)Math.Round(chemin.Bounds.Height);
Assert.True(cote > 0 && hauteur > 0, $"L'icône « {item.Icon} » ({zone}) n'a pas été disposée.");
using var cible = new RenderTargetBitmap(new PixelSize(cote, hauteur), new Vector(96, 96));
cible.Render(chemin);
using var flux = new MemoryStream();
cible.Save(flux);
using var bitmap = SKBitmap.Decode(flux.ToArray());
(int x0, int y0, int x1, int y1) = EncreDe(bitmap);
Assert.True(x1 >= x0, $"Aucune encre rendue pour l'icône « {item.Icon} » ({zone}).");
string detail =
$"« {item.Icon} » ({zone}) : encre=[{x0},{y0} .. {x1},{y1}] dans une boîte {cote}x{hauteur}";
// 1. Taille et position natives : l'encre doit occuper exactement les
// limites du tracé, à l'échelle 1:1 depuis l'origine de la boîte. Un
// Stretch="Uniform" (même partiel) ou une boîte mal dimensionnée
// déplace ou rétrécit l'encre : c'est le défaut mesuré à l'origine
// (repère « site » sorti 2,5 px à gauche).
Rect attendu = Geometrie(item.Icon).Bounds;
Assert.True(
Math.Abs(x0 - attendu.X) <= Tolerance
&& Math.Abs(y0 - attendu.Y) <= Tolerance
&& Math.Abs(x1 + 1 - attendu.Right) <= Tolerance
&& Math.Abs(y1 + 1 - attendu.Bottom) <= Tolerance,
$"L'icône « {item.Icon} » ({zone}) n'est pas dessinée à sa place native " +
$"(attendu [{attendu.X:0.#},{attendu.Y:0.#} .. {attendu.Right:0.#},{attendu.Bottom:0.#}]) — {detail}.");
Point? origine = chemin.TranslatePoint(new Point(0, 0), bouton);
Assert.NotNull(origine);
double centreEncreX = origine!.Value.X + (x0 + x1) / 2.0;
double centreEncreY = origine.Value.Y + (y0 + y1) / 2.0;
double centreBoutonX = (bouton.Bounds.Width - 1) / 2.0;
double centreBoutonY = (bouton.Bounds.Height - 1) / 2.0;
if (disposition == Disposition.IconeAvantLeLibelle)
{
// Rail latéral : le libellé suit l'icône sur la même ligne.
TextBlock etiquette = bouton.GetVisualDescendants().OfType<TextBlock>().Single();
Point? etiquetteOrigine = etiquette.TranslatePoint(new Point(0, 0), bouton);
Assert.True(
etiquetteOrigine is not null && etiquetteOrigine.Value.X >= origine.Value.X + cote,
$"Le libellé « {item.Title} » ({zone}) chevauche l'icône — {detail}.");
return;
}
// L'icône doit être centrée horizontalement dans son bouton, dans les deux
// dispositions de la barre basse. Tolérance élargie : le centre optique
// d'un tracé Material n'est pas toujours exactement le centre de la boîte
// (jusqu'à 1 px).
Assert.True(
Math.Abs(centreEncreX - centreBoutonX) <= Tolerance + 0.5,
$"Icône non centrée horizontalement dans son bouton — {detail} " +
$"(centres : icône={centreEncreX:0.#}, bouton={centreBoutonX:0.#}).");
if (disposition == Disposition.IconeAuDessusDuLibelle)
{
// Libellés affichés : c'est le groupe icône + libellé qui est centré
// verticalement dans le bouton (pas l'icône seule).
StackPanel pile = bouton.GetVisualDescendants().OfType<StackPanel>().First();
TextBlock etiquette = bouton.GetVisualDescendants().OfType<TextBlock>().Single();
Point? pileOrigine = pile.TranslatePoint(new Point(0, 0), bouton);
Assert.True(etiquette.IsVisible, $"Le libellé « {item.Title} » ({zone}) devrait être affiché.");
Assert.NotNull(pileOrigine);
Assert.True(
Math.Abs(pileOrigine!.Value.Y + (pile.Bounds.Height - 1) / 2.0 - centreBoutonY) <= Tolerance,
$"Le groupe icône + libellé « {item.Title} » ({zone}) n'est pas centré dans son bouton " +
$"(groupe={pile.Bounds.Height:0.#} px, bouton={bouton.Bounds.Height:0.#} px).");
return;
}
// Icône seule (libellés masqués sur écran étroit) : centrage dans les deux
// axes — c'est le critère visible par l'utilisateur.
Assert.True(
Math.Abs(centreEncreY - centreBoutonY) <= Tolerance + 0.5,
$"Icône non centrée verticalement dans son bouton — {detail} " +
$"(centres : icône={centreEncreY:0.#}, bouton={centreBoutonY:0.#}).");
}
/// <summary>Placement attendu de l'icône selon l'élément de navigation.</summary>
private enum Disposition
{
/// <summary>Barre basse, libellés masqués : l'icône est seule dans le bouton.</summary>
IconeSeuleDansLeBouton,
/// <summary>Barre basse, libellés affichés : icône au-dessus du libellé.</summary>
IconeAuDessusDuLibelle,
/// <summary>Rail latéral : icône puis libellé, sur la même ligne.</summary>
IconeAvantLeLibelle
}
private static Geometry Geometrie(string cle)
=> (Geometry)NavIconGeometryConverter.Instance
.Convert(cle, typeof(Geometry), null, CultureInfo.InvariantCulture)!;
/// <summary>Boîte englobante des pixels non transparents du rendu.</summary>
private static (int X0, int Y0, int X1, int Y1) EncreDe(SKBitmap bitmap)
{
int x0 = int.MaxValue;
int y0 = int.MaxValue;
int x1 = -1;
int y1 = -1;
for (int y = 0; y < bitmap.Height; y++)
{
for (int x = 0; x < bitmap.Width; x++)
{
if (bitmap.GetPixel(x, y).Alpha < 16)
{
continue;
}
x0 = Math.Min(x0, x);
y0 = Math.Min(y0, y);
x1 = Math.Max(x1, x);
y1 = Math.Max(y1, y);
}
}
return (x0, y0, x1, y1);
}
/// <summary>Page vide : la barre de navigation ne dépend d'aucun écran réel.</summary>
private sealed class PageVide : ViewModelBase;
/// <summary>Fabrique de pages neutre : aucune donnée métier n'est nécessaire.</summary>
private sealed class FakePageFactory : IPageViewModelFactory
{
public ViewModelBase Create(AppPage page) => new PageVide();
public ViewModelBase Create(AppPage page, object? parameter) => new PageVide();
}
private sealed class FakeSyncEngine : ISyncEngine
{
public Task<SyncCycleResult> PushAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncCycleResult());
public Task<SyncCycleResult> SyncAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncCycleResult());
}
private sealed class FakeOutboxService : IOutboxService
{
public Task<Guid> EnqueueAsync(
string entityType,
SyncOperationType operationType,
Guid requestedEntityGuid,
string payload,
string? dependencyEntityType = null,
Guid? dependencyRequestedEntityGuid = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(Guid.NewGuid());
public Task<int> CountPendingAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(0);
public Task<int> CountNotYetSentAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(0);
}
}