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