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