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,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);
}