Files
Omika/tests/Omika.Tests/Mobile/InterventionDetailOfflineActionsTests.cs
T
2026-10-23 18:46:50 +02:00

317 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.VisualTree;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.App.Views;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Fiche intervention : les cartes « Courriels » et « Rapports » passent par le
/// serveur (liste + création pour l'une, catalogue des modèles et champs calculés
/// côté serveur pour l'autre). Hors connexion elles deviennent INACTIVES — sans
/// jamais être masquées : l'action existe, elle est seulement momentanément
/// impossible.
///
/// <para>
/// Vérification faite sur la vue RÉELLE rendue (session Avalonia headless) avec le
/// vrai ViewModel : les liaisons XAML sont compilées, un faux DataContext ne
/// prouverait donc rien.
/// </para>
/// </summary>
[Collection("ApplicationRuntimeContext")]
public sealed class InterventionDetailOfflineActionsTests : IDisposable
{
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
private sealed class FauxReseau : INetworkConnectivity
{
public bool Online { get; set; } = true;
public Task<bool> IsOnlineAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Online);
}
/// <summary>État observé d'une carte, lu sur le thread Avalonia puis comparé hors session.</summary>
private sealed record EtatCarte(
string Libelle,
bool Actif,
bool Visible,
double Largeur,
double Hauteur,
double Opacite,
bool IndiceOuvrir);
/// <summary>
/// ViewModel réel : seules les dépendances non utilisées par le rendu de la
/// fiche restent nulles (aucun chargement, aucun appel réseau n'est déclenché).
/// </summary>
private static InterventionDetailViewModel CreerViewModel()
=> new(
null!, null!, null!, null!, null!, null!, null!, null!,
new PermissionService(), null!, null!, new FauxReseau())
{
Intervention = new InterventionDetail
{
InterventionGuid = Guid.NewGuid(),
Reference = "INT-001",
Titre = "Intervention",
Etat = "En cours"
}
};
/// <summary>Carte cliquable dont le libellé est exactement <paramref name="titre"/>.</summary>
private static Button Carte(Visual racine, string titre)
=> racine.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.GetVisualDescendants()
.OfType<TextBlock>()
.Any(t => t.Text == titre));
private void Executer(Action scenario)
=> _session.Dispatch(
() =>
{
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// <summary>Rend la fiche hors ligne ou en ligne et retourne l'état des deux cartes.</summary>
private EtatCarte[] Rendre(bool horsLigne)
{
EtatCarte[] etats = [];
Executer(() =>
{
InterventionDetailViewModel vm = CreerViewModel();
vm.IsOffline = horsLigne;
var fenetre = new Window
{
Width = 420,
Height = 900,
Content = new InterventionDetailView { DataContext = vm }
};
fenetre.Show();
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
etats = new[] { "Courriels", "Rapports" }
.Select(titre =>
{
Button b = Carte(fenetre, titre);
bool indiceOuvrir = b.GetVisualDescendants()
.OfType<TextBlock>()
.Any(t => t.Text == "Ouvrir ›" && t.IsVisible);
return new EtatCarte(
titre,
b.IsEnabled,
b.IsVisible,
b.Bounds.Width,
b.Bounds.Height,
b.Opacity,
indiceOuvrir);
})
.ToArray();
});
return etats;
}
[Fact]
public void HorsLigne_CourrielsEtRapports_SontInactifsMaisToujoursVisibles()
{
foreach (EtatCarte carte in Rendre(horsLigne: true))
{
// Inactives : aucune navigation possible vers une page qui ne peut pas
// fonctionner sans serveur.
Assert.False(carte.Actif);
// Mais toujours présentes et à leur place (aucun masquage).
Assert.True(carte.Visible);
Assert.True(carte.Largeur > 0);
Assert.True(carte.Hauteur > 0);
// Rendu « désactivé » visible : Button.secondary:disabled atténue la carte.
Assert.Equal(0.45, carte.Opacite, 2);
// Aucun indice d'ouverture trompeur sur une carte inutilisable.
Assert.False(carte.IndiceOuvrir);
}
}
[Fact]
public void EnLigne_CourrielsEtRapports_SontActifs()
{
foreach (EtatCarte carte in Rendre(horsLigne: false))
{
Assert.True(carte.Actif);
Assert.True(carte.Visible);
Assert.Equal(1.0, carte.Opacite, 2);
Assert.True(carte.IndiceOuvrir);
}
}
/// <summary>
/// Le bloc « Client » de la fiche intervention : présent UNIQUEMENT quand un
/// client est lié, placé au-dessus du bloc « Informations », avec le nom du
/// client, ses numéros, son adresse email, les boutons d'appel / courriel et le
/// bouton « Fiche client ».
/// </summary>
[Fact]
public void ClientLie_AfficheLeBlocClientAuDessusDesInformations()
{
double yClient = double.NaN;
double yInformations = double.NaN;
bool boutonVisible = false;
bool boutonActif = false;
bool nomAffiche = false;
bool mobileAffiche = false;
bool fixeAffiche = false;
bool emailAffiche = false;
bool appelVisible = false;
bool emailVisible = false;
Executer(() =>
{
var permissions = new PermissionService();
permissions.SetRights(new Dictionary<string, bool>
{
[Omika.Core.Constants.MobilePermissionKeys.VoirClients] = true,
[Omika.Core.Constants.MobilePermissionKeys.EnvoiEmail] = true
});
Guid clientId = Guid.NewGuid();
InterventionDetailViewModel vm = new(
null!, null!, null!, null!, null!, null!, null!, null!,
permissions, null!, null!, new FauxReseau())
{
Intervention = new InterventionDetail
{
InterventionGuid = Guid.NewGuid(),
Reference = "INT-002",
Titre = "Intervention avec client",
Etat = "En cours",
ClientGuid = clientId,
ClientNom = "Boulangerie Martin"
},
Client = new ClientDetail
{
ClientGuid = clientId,
Nom = "Boulangerie Martin",
Code = "CLI-042",
Mobile = "0612345678",
Phone = "0240000000",
Email = "contact@boulangerie.test"
}
};
var fenetre = new Window
{
Width = 900,
Height = 1200,
Content = new InterventionDetailView { DataContext = vm }
};
fenetre.Show();
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
Border CarteSection(string titre) => fenetre.GetVisualDescendants()
.OfType<Border>()
.Where(b => b.Classes.Contains("card"))
.Single(b => b.GetVisualDescendants()
.OfType<TextBlock>()
.Any(t => t.Text == titre));
Border blocClient = CarteSection("Client");
Border blocInformations = CarteSection("Informations");
Point? pointClient = blocClient.TranslatePoint(new Point(0, 0), fenetre);
Point? pointInformations = blocInformations.TranslatePoint(new Point(0, 0), fenetre);
yClient = pointClient?.Y ?? double.NaN;
yInformations = pointInformations?.Y ?? double.NaN;
Button bouton = blocClient.GetVisualDescendants()
.OfType<Button>()
.Single(b => Equals(b.Content, "Fiche client"));
boutonVisible = bouton.IsVisible && bouton.Bounds.Width > 0;
boutonActif = bouton.IsEnabled;
var textes = blocClient.GetVisualDescendants()
.OfType<TextBlock>()
.Where(t => t.IsVisible)
.Select(t => t.Text)
.ToList();
nomAffiche = textes.Any(t => t == "Boulangerie Martin");
mobileAffiche = textes.Any(t => t == "Mobile 0612345678");
fixeAffiche = textes.Any(t => t == "Tél. 0240000000");
emailAffiche = textes.Any(t => t == "contact@boulangerie.test");
appelVisible = blocClient.GetVisualDescendants()
.OfType<Omika.App.Controls.CallButton>()
.Any(c => c.IsVisible);
emailVisible = blocClient.GetVisualDescendants()
.OfType<Omika.App.Controls.EmailButton>()
.Any(c => c.IsVisible);
});
// Le bloc est placé entre l'en-tête et le bloc « Informations ».
Assert.True(yClient < yInformations, $"bloc client ({yClient}) devrait précéder les informations ({yInformations})");
// Nom, numéros et adresse email sur des lignes distinctes.
Assert.True(nomAffiche);
Assert.True(mobileAffiche);
Assert.True(fixeAffiche);
Assert.True(emailAffiche);
// Actions : appel et courriel comme sur une ligne de contact, plus l'accès
// à la fiche client.
Assert.True(appelVisible);
Assert.True(emailVisible);
Assert.True(boutonVisible);
Assert.True(boutonActif);
}
/// <summary>Sans client lié, aucun bloc « Client » n'est rendu.</summary>
[Fact]
public void SansClient_AucunBlocClient()
{
bool blocPresent = true;
Executer(() =>
{
InterventionDetailViewModel vm = CreerViewModel();
var fenetre = new Window
{
Width = 900,
Height = 1200,
Content = new InterventionDetailView { DataContext = vm }
};
fenetre.Show();
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
blocPresent = fenetre.GetVisualDescendants()
.OfType<Button>()
.Any(b => Equals(b.Content, "Fiche client") && b.IsVisible);
});
Assert.False(blocPresent);
}
}