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