Files
2026-10-23 18:46:50 +02:00

265 lines
9.9 KiB
C#
Raw Permalink 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.Globalization;
using System.Linq;
using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Omika.App.Views;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Rangée d'actions de l'écran « Rapport » — dans l'ordre demandé : Annuler,
/// Enregistrer, Créer, Envoyer un email. Sur un téléphone, les quatre boutons
/// doivent rester ATTEIGNABLES et porter le MÊME style que les autres boutons de
/// l'application.
///
/// <para>
/// Deux défauts constatés à l'usage, et corrigés ici :
/// </para>
/// <list type="number">
/// <item>les quatre boutons étaient alignés sur UNE seule ligne, de largeur
/// illimitée : en petit écran les deux derniers sortaient du cadre, deux actions
/// seulement restaient accessibles ;</item>
/// <item>« Annuler » et « Enregistrer » ne portaient que la classe de TAILLE
/// (« compact »), sans aucune variante de couleur : ils conservaient le rendu
/// natif Fluent et ne ressemblaient à aucun autre bouton de l'application.</item>
/// </list>
///
/// <para>
/// Le bouton d'email, lui, ne doit plus afficher d'icône enveloppe : le libellé
/// seul porte le sens, comme sur tous les autres boutons de l'application.
/// </para>
/// </summary>
/// <remarks>
/// La classe rejoint la collection « ApplicationRuntimeContext » : le localisateur
/// d'Avalonia est global au processus, deux sessions headless ne doivent jamais
/// s'exécuter en parallèle. Les mesures sont lues À L'INTÉRIEUR du fil d'interface
/// (une fenêtre n'a de géométrie qu'une fois disposée).
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class RapportActionsTests : IDisposable
{
/// <summary>Libellés attendus des quatre actions, dans l'ordre de la vue.</summary>
private static readonly string[] Libelles =
["Annuler", "Enregistrer", "Créer", "Envoyer un email"];
/// <summary>Variantes de couleur réelles de l'application (styles Controls.axaml).</summary>
private static readonly string[] Variantes = ["primary", "secondary", "ghost", "danger"];
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
private void Executer(Action scenario)
=> _session.Dispatch(
() =>
{
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// <summary>
/// Les quatre actions sont présentes et ENTIÈREMENT dans le cadre, à la largeur
/// d'un téléphone (320 px : le plus étroit courant) comme à 420 px. Sans le
/// retour à la ligne, les derniers boutons étaient disposés au-delà du bord
/// droit (rect.Right very largement supérieur à la largeur de l'écran).
/// </summary>
[Theory]
[InlineData(420)]
[InlineData(360)]
[InlineData(320)]
public void LesQuatreActionsRestentDansLEcran(double largeur)
=> Executer(() =>
{
Window fenetre = OuvrirRapport(largeur);
// Mesure APRÈS disposition : une vue non affichée n'a aucune géométrie.
var boutons = BoutonsDAction(fenetre);
Assert.Equal(Libelles, boutons.Select(b => b.Content as string).ToArray());
foreach (Button bouton in boutons)
{
Rect rect = RectDe(bouton, fenetre);
Assert.True(
rect.Width > 0 && rect.Height > 0,
$"« {bouton.Content} » n'est pas disposé (taille {rect.Width}×{rect.Height}).");
Assert.True(
rect.X >= -0.5,
$"« {bouton.Content} » déborde à gauche : x={Fmt(rect.X)}.");
Assert.True(
rect.Right <= largeur + 0.5,
$"« {bouton.Content} » dépasse l'écran : droite={Fmt(rect.Right)} " +
$"pour une largeur de {Fmt(largeur)} px.");
Assert.True(bouton.IsVisible, $"« {bouton.Content} » n'est pas visible.");
}
fenetre.Close();
});
/// <summary>
/// Les quatre actions sont dans l'ORDRE DEMANDÉ, lu de gauche à droite et de
/// haut en bas dans la page réellement disposée (Annuler, Enregistrer, Créer,
/// Envoyer un email). Ce test est distinct de la recherche par libellé : il
/// mesure les POSITIONS, donc il détecte un ordre inversé dans la vue.
/// </summary>
[Fact]
public void LesActionsSontDansLordreDemande()
=> Executer(() =>
{
Window fenetre = OuvrirRapport(420);
string[] ordre = fenetre.GetVisualDescendants()
.OfType<Button>()
.Where(b => b.Content is string texte && Libelles.Contains(texte))
.Select(b => (Texte: (string)b.Content!, Rect: RectDe(b, fenetre)))
// Ordre de lecture : d'abord la ligne (tolérance de 12 px pour les
// alignements verticaux d'une même rangée), puis la colonne.
.OrderBy(b => Math.Round(b.Rect.Y / 12d))
.ThenBy(b => b.Rect.X)
.Select(b => b.Texte)
.ToArray();
Assert.Equal(Libelles, ordre);
fenetre.Close();
});
/// <summary>
/// Chaque bouton porte une variante de couleur de l'application. Avant le
/// correctif, « Annuler » et « Enregistrer » n'en avaient aucune (classe
/// « compact » seule) : ils gardaient le rendu natif du thème Fluent.
/// </summary>
[Fact]
public void ChaqueBouton_PorteUneVarianteDeLOmika()
=> Executer(() =>
{
Window fenetre = OuvrirRapport(420);
foreach (Button bouton in BoutonsDAction(fenetre))
{
// Boucle explicite : l'analyseur xUnit préfère un message de
// diagnostic portant le libellé du bouton fautif.
bool porteVariante = false;
foreach (string variante in Variantes)
{
if (bouton.Classes.Contains(variante))
{
porteVariante = true;
break;
}
}
Assert.True(
porteVariante,
$"« {bouton.Content} » n'a aucune variante de couleur " +
$"(classes : {string.Join(", ", bouton.Classes)}).");
}
// L'action principale reste identifiable : elle seule est remplie.
Button creer = BoutonsDAction(fenetre).Single(b => b.Content is string texte && texte == "Créer");
Assert.Contains("primary", creer.Classes);
fenetre.Close();
});
/// <summary>
/// Le bouton d'email n'affiche plus d'icône enveloppe : un glyphe décoratif
/// devant un libellé déjà explicite, unique dans l'application.
/// </summary>
[Fact]
public void BoutonEmail_SansIconeDeMessage()
=> Executer(() =>
{
Window fenetre = OuvrirRapport(420);
Button email = BoutonsDAction(fenetre).Single(b => b.Content is string texte && texte == "Envoyer un email");
Assert.Equal("Envoyer un email", email.Content);
Assert.DoesNotContain("✉", (string)email.Content!);
fenetre.Close();
});
/// <summary>
/// Le bouton d'email reste DÉSACTIVÉ hors ligne mais présent : la règle
/// (bouton visible, simplement grisé) ne dépend ni de la largeur ni du style.
/// </summary>
[Fact]
public void BoutonEmail_HorsLigne_ResteVisibleEtGrise()
=> Executer(() =>
{
Window fenetre = OuvrirRapport(360);
Button email = BoutonsDAction(fenetre).Single(b => b.Content is string texte && texte == "Envoyer un email");
email.IsEnabled = false;
Dispatcher.UIThread.RunJobs();
Assert.False(email.IsEnabled);
Assert.True(email.IsVisible);
Assert.True(email.Bounds.Width > 0);
fenetre.Close();
});
/// <summary>Vue « Rapport » réellement affichée à la largeur d'un téléphone.</summary>
private static Window OuvrirRapport(double largeur)
{
var vue = new RapportChampsView();
var fenetre = new Window
{
Width = largeur,
Height = 900,
RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Light,
Content = vue
};
try
{
fenetre.Show();
Dispatcher.UIThread.RunJobs();
Dispatcher.UIThread.RunJobs();
return fenetre;
}
catch
{
fenetre.Close();
throw;
}
}
/// <summary>
/// Les quatre boutons d'action, retrouvés par leur libellé (les autres boutons
/// de la page — « ← Retour », brouillons, rubriques — restent hors périmètre).
/// </summary>
/// <remarks>
/// Le filtre passe par un motif <c>is string</c> : certains boutons de gabarit
/// (liste déroulante) portent une forme, pas un texte — un transtypage direct
/// lèverait une <see cref="InvalidCastException"/>.
/// </remarks>
private static Button[] BoutonsDAction(Window fenetre)
=> Libelles
.Select(libelle => fenetre.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content is string texte && texte == libelle))
.ToArray();
private static Rect RectDe(Visual visuel, Visual racine)
{
Point? origine = visuel.TranslatePoint(new Point(0, 0), racine);
Assert.NotNull(origine);
return new Rect(origine!.Value, visuel.Bounds.Size);
}
private static string Fmt(double valeur) => valeur.ToString("0.##", CultureInfo.InvariantCulture);
}