265 lines
9.9 KiB
C#
265 lines
9.9 KiB
C#
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);
|
||
}
|