using System;
using System.ComponentModel;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Headless;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Omika.App.Controls;
using Omika.App.Local.Abstractions;
using Omika.App.Services;
using SkiaSharp;
using Xunit;
namespace Omika.Tests.Mobile;
///
/// Champ de saisie avec dictée vocale : le micro n'apparaît que si un moteur de
/// dictée existe (jamais sur Desktop), l'écoute écrit dans le champ lié, le texte
/// dicté s'ajoute à la fin, et aucun échec ne fait perdre la saisie.
///
///
/// Session Avalonia headless : la classe rejoint la collection
/// « ApplicationRuntimeContext » pour qu'aucune autre session Avalonia ne tourne
/// en parallèle (le localisateur d'Avalonia est global au processus).
///
[Collection("ApplicationRuntimeContext")]
public sealed class DictationTextFieldTests : IDisposable
{
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose()
{
// Le point d'injection est statique : on le remet à zéro entre les classes.
PlatformDictation.Current = null;
PlatformDicteeTraduite.Current = null;
PlatformDicteeTraduite.Invalider();
_session.Dispose();
}
// ===== Visibilité du micro =====
[Fact]
public void Micro_MasqueSansMoteurDeDictee_LeChampResteUnChampNormal()
{
Executer(() =>
{
PlatformDictation.Current = null; // Desktop/Windows : aucune dictée
var champ = new DictationTextField { PlaceholderText = "Ville" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Assert.False(Micro(champ).IsVisible);
Assert.Equal("Ville", Saisie(champ).PlaceholderText);
Assert.Contains("field", Saisie(champ).Classes);
// La hauteur minimale du style « field » n'est pas écrasée par le contrôle.
Assert.True(Saisie(champ).MinHeight > 0);
fenetre.Close();
});
}
[Fact]
public void Micro_AfficheQuandLeMoteurExiste()
{
Executer(() =>
{
PlatformDictation.Current = new FauxServiceDictee();
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Assert.True(Micro(champ).IsVisible);
fenetre.Close();
});
}
[Fact]
public void Micro_MasqueQuandLeChampEstEnLectureSeule()
{
Executer(() =>
{
PlatformDictation.Current = new FauxServiceDictee();
var champ = new DictationTextField { IsReadOnly = true };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Assert.False(Micro(champ).IsVisible);
Assert.True(Saisie(champ).IsReadOnly);
fenetre.Close();
});
}
// ===== Dictée =====
[Fact]
public void ClicMicro_AjouteLeTexteDicteALaFinDuChamp()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Travaux réalisés." };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
// L'écoute est en cours : le bouton passe en mode « arrêter ».
Assert.Contains("ecoute", Micro(champ).Classes);
service.Session.Partiel("Remplacement de la pièce");
Assert.Equal("Travaux réalisés. Remplacement de la pièce", champ.Text);
service.Session.Final("Remplacement de la pièce.");
Assert.Equal("Travaux réalisés. Remplacement de la pièce.", champ.Text);
Assert.DoesNotContain("ecoute", Micro(champ).Classes);
Assert.True(service.Session.Liberee);
fenetre.Close();
});
}
[Fact]
public void ClicMicro_LeDernierTextePartielRemplaceLePrecedent_SansDoublon()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Note :" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
service.Session.Partiel("Bonjour");
service.Session.Partiel("Bonjour le monde");
// Chaque partiel repart du texte présent AVANT la dictée.
Assert.Equal("Note : Bonjour le monde", champ.Text);
service.Session.Final("Bonjour le monde.");
Assert.Equal("Note : Bonjour le monde.", champ.Text);
fenetre.Close();
});
}
[Fact]
public void ClicMicro_EcritDansLaProprieteLieeDuModele()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var modele = new ModeleTexte { Texte = "Départ" };
var champ = new DictationTextField();
champ.Bind(
DictationTextField.TextProperty,
new Binding(nameof(ModeleTexte.Texte)) { Source = modele, Mode = BindingMode.TwoWay });
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Assert.Equal("Départ", champ.Text);
Cliquer(Micro(champ));
service.Session.Final("Remplacement du filtre.");
// Le texte dicté arrive bien dans la propriété du ViewModel.
Assert.Equal("Départ Remplacement du filtre.", modele.Texte);
fenetre.Close();
});
}
[Fact]
public void DeuxiemeClic_ArreteLaDicteeEtConserveLeDernierTexteReconnu()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
service.Session.TexteSurArret = "pièce commandée."; // comportement Android
var champ = new DictationTextField { Text = "Note :" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
service.Session.Partiel("pièce commandée");
Cliquer(Micro(champ)); // arrêt demandé par l'utilisateur
Assert.True(service.Session.ArretDemande);
Assert.Equal("Note : pièce commandée.", champ.Text);
Assert.DoesNotContain("ecoute", Micro(champ).Classes);
fenetre.Close();
});
}
// ===== Échecs : la saisie n'est jamais perdue =====
[Fact]
public void ClicMicro_MicroRefuse_AfficheLeMessageSansToucherAuChamp()
{
Executer(() =>
{
var service = new FauxServiceDictee { Demarre = false }; // autorisation refusée
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Travaux" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Assert.True(Message(champ).IsVisible);
Assert.Equal(DictationMessages.MicroRefuse, Message(champ).Text);
Assert.Contains("erreur", Message(champ).Classes);
Assert.Equal("Travaux", champ.Text);
fenetre.Close();
});
}
[Fact]
public void ClicMicro_EchecEnCoursDEcoute_ConserveLeTexteDejaReconnu()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Constat :" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
service.Session.Partiel("fuite sous l'évier");
service.Session.Echec(DictationMessages.Interrompue);
Assert.Equal(DictationMessages.Interrompue, Message(champ).Text);
Assert.Equal("Constat : fuite sous l'évier", champ.Text);
fenetre.Close();
});
}
[Fact]
public void ClicMicro_Silence_AfficheRienEntenduSansToucherAuChamp()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField { Text = "Travaux" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
service.Session.Fini(); // rien entendu
Assert.True(Message(champ).IsVisible);
Assert.Equal(DictationMessages.RienEntendu, Message(champ).Text);
Assert.Contains("info", Message(champ).Classes);
Assert.Equal("Travaux", champ.Text);
fenetre.Close();
});
}
// ===== Sortie d'écran =====
[Fact]
public void QuitterLEcran_ArreteLaDicteeEtLibereLeMoteur()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Assert.False(service.Session.Liberee);
// La page quitte l'arbre visuel (navigation) : le micro doit se fermer.
fenetre.Content = new TextBlock { Text = "Autre écran" };
Dispatcher.UIThread.RunJobs();
Assert.True(service.Session.ArretDemande);
Assert.True(service.Session.Liberee);
fenetre.Close();
});
}
// ===== Présentation du champ =====
[Fact]
public void ChampMultiligne_RetoursALaLigneEtHauteurMinimale()
{
Executer(() =>
{
PlatformDictation.Current = null;
var champ = new DictationTextField { Multiline = true, InputMinHeight = 90 };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
TextBox saisie = Saisie(champ);
Assert.True(saisie.AcceptsReturn);
Assert.Equal(TextWrapping.Wrap, saisie.TextWrapping);
Assert.Equal(90, saisie.MinHeight);
fenetre.Close();
});
}
[Fact]
public void TexteIndicatif_WatermarkEtPlaceholderText_SontReportesSurLeChamp()
{
Executer(() =>
{
PlatformDictation.Current = null;
var avecWatermark = new DictationTextField { Watermark = "Valeur" };
var avecPlaceholder = new DictationTextField { PlaceholderText = "Ville" };
var pile = new StackPanel { Children = { avecWatermark, avecPlaceholder } };
Window fenetre = MesureGeometrique.Ouvrir(pile, 400);
Assert.Equal("Valeur", Saisie(avecWatermark).PlaceholderText);
Assert.Equal("Ville", Saisie(avecPlaceholder).PlaceholderText);
fenetre.Close();
});
}
[Fact]
public void ClasseDeSaisie_ParDefautField_EtRetirablePourLesChampsSansClasse()
{
Executer(() =>
{
PlatformDictation.Current = null;
var parDefaut = new DictationTextField();
var sansClasse = new DictationTextField { InputClass = string.Empty };
var pile = new StackPanel { Children = { parDefaut, sansClasse } };
Window fenetre = MesureGeometrique.Ouvrir(pile, 400);
Assert.Contains("field", Saisie(parDefaut).Classes);
// Classes contient aussi les pseudo-classes (":empty", ":focus"…) :
// on vérifie l'absence de la classe de style, pas la vacuité.
Assert.DoesNotContain("field", Saisie(sansClasse).Classes);
fenetre.Close();
});
}
// ===== Centrage de l'icône =====
///
/// Régression mesurée : l'icône du micro était décalée de 2,5 px vers la
/// GAUCHE dans son bouton. La géométrie Material mesure 14×19 ; mise à
/// l'échelle par Stretch="Uniform" dans une boîte de 20×20, la
/// transformation part de l'origine du contrôle et laisse les 2,6 px de jeu à
/// droite. L'icône est désormais dessinée en taille native dans une boîte
/// carrée 24×24 : le centrage est vérifié sur le rendu Skia RÉEL, dans les
/// deux états du bouton (aucune hypothèse sur la géométrie ni sur Stretch).
///
[Fact]
public void Micro_IconeCentreDansLeBouton_DansLesDeuxEtats()
{
Executer(() =>
{
PlatformDictation.Current = new FauxServiceDictee();
var champ = new DictationTextField { PlaceholderText = "Test" };
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Button micro = Micro(champ);
VerifierIconeCentree(micro, "dicter", largeurEncre: 14, hauteurEncre: 19);
Cliquer(micro);
Dispatcher.UIThread.RunJobs();
VerifierIconeCentree(micro, "écoute", largeurEncre: 16, hauteurEncre: 16);
fenetre.Close();
});
}
///
/// Mesure le rendu réel du bouton : le centre de l'encre doit coïncider avec
/// le centre du bouton (tolérance 1 px : l'encre est comptée en pixels
/// entiers), et l'icône rendue doit être celle de l'état attendu (le micro et
/// le carré d'arrêt n'ont pas la même empreinte).
///
private static void VerifierIconeCentree(Button bouton, string etat, int largeurEncre, int hauteurEncre)
{
int largeur = (int)Math.Round(bouton.Bounds.Width);
int hauteur = (int)Math.Round(bouton.Bounds.Height);
Assert.True(largeur > 0 && hauteur > 0, "Le bouton micro n'a pas été disposé.");
using var cible = new RenderTargetBitmap(new PixelSize(largeur, hauteur), new Vector(96, 96));
cible.Render(bouton);
using var flux = new MemoryStream();
cible.Save(flux);
using var bitmap = SKBitmap.Decode(flux.ToArray());
(int x0, int y0, int x1, int y1) = EncreDe(bitmap);
Assert.True(x1 >= x0, $"Aucune encre rendue dans le bouton micro (état « {etat} »).");
double centreEncreX = (x0 + x1) / 2.0;
double centreEncreY = (y0 + y1) / 2.0;
double centreBoutonX = (largeur - 1) / 2.0;
double centreBoutonY = (hauteur - 1) / 2.0;
string detail =
$"état « {etat} » : encre=[{x0},{y0} .. {x1},{y1}] " +
$"centre=({centreEncreX};{centreEncreY}) bouton={largeur}x{hauteur} " +
$"centre=({centreBoutonX};{centreBoutonY})";
Assert.True(
Math.Abs(centreEncreX - centreBoutonX) <= 1.0 && Math.Abs(centreEncreY - centreBoutonY) <= 1.0,
$"Icône non centrée dans le bouton — {detail}");
Assert.True(
Math.Abs((x1 - x0 + 1) - largeurEncre) <= 1 && Math.Abs((y1 - y0 + 1) - hauteurEncre) <= 1,
$"Icône inattendue (empreinte {x1 - x0 + 1}x{y1 - y0 + 1}, " +
$"attendu {largeurEncre}x{hauteurEncre}) — {detail}");
}
/// Boîte englobante des pixels non transparents du rendu.
private static (int X0, int Y0, int X1, int Y1) EncreDe(SKBitmap bitmap)
{
int x0 = int.MaxValue;
int y0 = int.MaxValue;
int x1 = -1;
int y1 = -1;
for (int y = 0; y < bitmap.Height; y++)
{
for (int x = 0; x < bitmap.Width; x++)
{
if (bitmap.GetPixel(x, y).Alpha < 16)
{
continue;
}
x0 = Math.Min(x0, x);
y0 = Math.Min(y0, y);
x1 = Math.Max(x1, x);
y1 = Math.Max(y1, y);
}
}
return (x0, y0, x1, y1);
}
// ===== Outils =====
private static Button Micro(DictationTextField champ) => champ.FindControl