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

346 lines
13 KiB
C#

using System;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Markup.Xaml.Styling;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Themes.Fluent;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Omika.App.Local.Models;
using Omika.App.Navigation;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.App.Views;
using Omika.Core.Constants;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Régression « écran étroit » : dans un en-tête de fiche, les pastilles ne
/// doivent JAMAIS se chevaucher — y compris quand elles passent à la ligne.
/// Un WrapPanel n'ajoute aucun espacement, ni entre ses éléments, ni entre ses
/// lignes : sans marge dédiée, la pastille qui passe à la ligne venait se coller
/// à celle du dessus (constaté à 320 px : ligne 1 de y=109 à y=129, ligne 2
/// reprenant exactement à y=129, avec en plus un décalage de 8 px).
/// Ces tests mesurent la géométrie RÉELLE après disposition de la vue.
/// </summary>
/// <remarks>
/// Ces tests mesurent une disposition réelle : ils rejoignent la collection
/// « ApplicationRuntimeContext » pour qu'aucune autre session Avalonia headless
/// ne tourne en parallèle (le localisateur d'Avalonia est global au processus).
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class PastillesEnTeteTests : IDisposable
{
/// <summary>Largeur d'un petit téléphone (Android compact).</summary>
private const double LargeurEcranEtroit = 320;
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
[Fact]
public void FicheClient_PastillesJamaisCollantes_MemeAEcranEtroit()
{
// La surcharge générique de Dispatch est ATTENDUE (contrairement à
// Dispatch(Action), qui rend la main avant l'exécution de l'action).
_session.Dispatch(
() =>
{
var permissions = new PermissionService();
permissions.SetRights(MobilePermissionKeys.All.ToDictionary(k => k, _ => true));
var vueModele = new ClientDetailViewModel(null!, new NavigationService(), permissions)
{
Client = new ClientDetail
{
ClientGuid = Guid.NewGuid(),
Nom = "REPETTO IMMOBILIER",
Code = "CLI-00123",
Type = "Client final — particulier",
Statut = "Prospect",
EstArchive = false
}
};
Window fenetre = MesureGeometrique.Ouvrir(
new ClientDetailView { DataContext = vueModele },
LargeurEcranEtroit);
MesureGeometrique.VerifierPastillesEspacees(fenetre, LargeurEcranEtroit);
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
}
[Fact]
public void FicheSite_PastillesJamaisCollantes_MemeAEcranEtroit()
{
_session.Dispatch(
() =>
{
var permissions = new PermissionService();
permissions.SetRights(MobilePermissionKeys.All.ToDictionary(k => k, _ => true));
var vueModele = new SiteDetailViewModel(null!, new NavigationService(), permissions, null!)
{
Site = new SiteDetail
{
SiteGuid = Guid.NewGuid(),
Nom = "Siège social Marseille",
Code = "SIT-0001",
Type = "Siège social",
EstArchive = false
}
};
Window fenetre = MesureGeometrique.Ouvrir(
new SiteDetailView { DataContext = vueModele },
LargeurEcranEtroit);
MesureGeometrique.VerifierPastillesEspacees(fenetre, LargeurEcranEtroit);
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
}
}
/// <summary>
/// La carte de site de la liste des sites expose le même fonctionnement que les
/// cartes Client et Dossier : « Ouvrir » (et l'étoile d'épinglage), en plus des
/// informations du site (type, nom, code, client, ville, code postal).
/// </summary>
[Collection("ApplicationRuntimeContext")]
public sealed class CarteSiteListeTests : IDisposable
{
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
[Fact]
public void ListeDesSites_ChaqueCarte_ExposeOuvrirEtEpinglage()
{
_session.Dispatch(
() =>
{
var vueModele = new SitesViewModel(null!, null!, new NavigationService())
{
HasSearched = true,
Sites = new ObservableCollection<SiteListItem>
{
new()
{
SiteGuid = Guid.NewGuid(),
Nom = "Siège social Marseille",
Code = "SIT-0001",
ClientNom = "REPETTO IMMOBILIER",
Ville = "Marseille",
CodePostal = "13001",
Type = "Siège",
IsPinned = true
},
new()
{
SiteGuid = Guid.NewGuid(),
Nom = "Entrepôt Nord",
Code = "SIT-0002",
ClientNom = "REPETTO IMMOBILIER",
Ville = "Lyon",
CodePostal = "69003",
IsPinned = false
}
}
};
Window fenetre = MesureGeometrique.Ouvrir(
new SitesView { DataContext = vueModele },
420);
Control[] descendants = fenetre.GetVisualDescendants().OfType<Control>().ToArray();
// 1. Un bouton « Ouvrir » par site, câblé sur la commande d'ouverture.
Button[] boutonsOuvrir = descendants
.OfType<Button>()
.Where(b => (b.Content as string) == "Ouvrir")
.ToArray();
Assert.Equal(vueModele.Sites.Count, boutonsOuvrir.Length);
Assert.All(boutonsOuvrir, b => Assert.Same(vueModele.OpenSiteCommand, b.Command));
Assert.All(
boutonsOuvrir,
b => Assert.IsType<SiteListItem>(b.CommandParameter));
// 2. Une étoile d'épinglage par site, câblée sur la commande d'épinglage.
Button[] etoiles = descendants
.OfType<Button>()
.Where(b => (ToolTip.GetTip(b) as string) == "Épingler / Retirer")
.ToArray();
Assert.Equal(vueModele.Sites.Count, etoiles.Length);
Assert.All(etoiles, b => Assert.Same(vueModele.TogglePinnedCommand, b.Command));
// 3. L'étoile affichée reflète l'état épinglé du site.
Assert.True(
EtoilePleineVisible(etoiles[0]),
"Le premier site est épinglé : l'étoile pleine doit être affichée.");
Assert.False(
EtoilePleineVisible(etoiles[1]),
"Le second site n'est pas épinglé : l'étoile vide doit être affichée.");
// 4. Les informations du site restent affichées comme avant.
string[] textes = descendants
.OfType<TextBlock>()
.Where(t => t.IsVisible && !string.IsNullOrWhiteSpace(t.Text))
.Select(t => t.Text!)
.ToArray();
Assert.Contains("Siège social Marseille", textes);
Assert.Contains("SIT-0001", textes);
Assert.Contains("REPETTO IMMOBILIER", textes);
Assert.Contains("Marseille", textes);
Assert.Contains("13001", textes);
Assert.Contains("Siège", textes);
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
}
private static bool EtoilePleineVisible(Button etoile)
{
TextBlock[] textes = etoile.GetVisualDescendants().OfType<TextBlock>().ToArray();
TextBlock pleine = textes.Single(t => t.Text == "★");
TextBlock vide = textes.Single(t => t.Text == "☆");
Assert.NotEqual(pleine.IsVisible, vide.IsVisible);
return pleine.IsVisible;
}
}
/// <summary>Mesure de la géométrie réellement disposée (backend Skia headless).</summary>
internal static class MesureGeometrique
{
/// <summary>Espace minimal exigé entre deux pastilles (px).</summary>
private const double EcartMini = 4;
public static Window Ouvrir(Control contenu, double largeur)
{
var fenetre = new Window
{
Width = largeur,
Height = 1200,
RequestedThemeVariant = ThemeVariant.Light,
Background = Brush.Parse("#F3F6FB"),
Content = contenu
};
fenetre.Show();
Dispatcher.UIThread.RunJobs();
Dispatcher.UIThread.RunJobs();
return fenetre;
}
/// <summary>
/// Vérifie qu'aucune paire de pastilles ne se chevauche ni ne se touche :
/// sur une même ligne, l'écart horizontal est au moins <see cref="EcartMini"/>;
/// sur deux lignes, l'écart vertical l'est aussi.
/// </summary>
public static void VerifierPastillesEspacees(Window fenetre, double largeurEcran)
{
var pastilles = fenetre.GetVisualDescendants()
.OfType<Border>()
.Where(b => b.Classes.Contains("badge") && b.Bounds.Width > 0)
.Select(b => (Texte: TexteDe(b), Rect: RectDe(b, fenetre)))
.Where(p => p.Rect is not null)
.Select(p => (p.Texte, Rect: p.Rect!.Value))
.ToList();
Assert.True(
pastilles.Count >= 2,
$"Aucune pastille mesurée ({pastilles.Count}) : la vue n'a pas été disposée.");
foreach (var (texte, rect) in pastilles)
{
Assert.True(
rect.Right <= largeurEcran + 0.5,
$"La pastille « {texte} » dépasse l'écran : droite={Fmt(rect.Right)} > {largeurEcran}.");
}
for (int i = 0; i < pastilles.Count; i++)
{
for (int j = i + 1; j < pastilles.Count; j++)
{
Rect a = pastilles[i].Rect;
Rect b = pastilles[j].Rect;
bool memeLigne = a.Bottom > b.Y + 0.5 && b.Bottom > a.Y + 0.5;
double ecart = memeLigne
? Math.Max(a.X, b.X) - Math.Min(a.Right, b.Right)
: Math.Max(a.Y, b.Y) - Math.Min(a.Bottom, b.Bottom);
Assert.True(
ecart >= EcartMini,
$"Pastilles « {pastilles[i].Texte} » et « {pastilles[j].Texte} » " +
$"{(memeLigne ? "collées sur la même ligne" : "collées d'une ligne à l'autre")} : " +
$"écart={Fmt(ecart)} px (minimum {EcartMini}).");
}
}
}
private static Rect? RectDe(Visual v, Visual racine)
{
Point? p = v.TranslatePoint(new Point(0, 0), racine);
return p is null ? null : new Rect(p.Value, v.Bounds.Size);
}
private static string TexteDe(Border border)
=> border.GetVisualDescendants()
.OfType<TextBlock>()
.Select(t => t.Text)
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "?";
private static string Fmt(double v) => v.ToString("0.##", CultureInfo.InvariantCulture);
}
/// <summary>Application de test : thème Fluent + jetons de design et styles réels de l'application.</summary>
public sealed class LayoutTestApp : Application
{
public override void Initialize()
{
Resources.MergedDictionaries.Add(
new ResourceInclude(new Uri("avares://Omika.App/"))
{
Source = new Uri("avares://Omika.App/Styles/DesignTokens.axaml")
});
Styles.Add(new FluentTheme());
Styles.Add(
new StyleInclude(new Uri("avares://Omika.App/"))
{
Source = new Uri("avares://Omika.App/Styles/Controls.axaml")
});
}
}
/// <summary>Point d'entrée headless (backend Skia + police Inter).</summary>
public static class LayoutTestAppBuilder
{
public static AppBuilder BuildAvaloniaApp() => AppBuilder
.Configure<LayoutTestApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
.WithInterFont();
}