Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -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);
}
}