Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user