346 lines
13 KiB
C#
346 lines
13 KiB
C#
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();
|
|
}
|