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

966 lines
35 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.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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
[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 =====
/// <summary>
/// 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 <c>Stretch="Uniform"</c> 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).
/// </summary>
[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();
});
}
/// <summary>
/// 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).
/// </summary>
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}");
}
/// <summary>Boîte englobante des pixels non transparents du rendu.</summary>
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<Button>("Micro")!;
private static TextBox Saisie(DictationTextField champ) => champ.FindControl<TextBox>("Saisie")!;
// ===== Dictée traduite (langue secondaire) =====
[Fact]
public void Dictee_SansLangueSecondaire_TransmetLeFrancaisAuMoteur()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
PlatformDicteeTraduite.Current = null;
PlatformDicteeTraduite.Invalider();
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
// Le français est la langue de référence : jamais une langue codée en
// dur ni une culture vide (un moteur natif refuse une langue vide).
Assert.Equal("fr", service.DerniereLangue);
// Aucune langue secondaire : pas de panneau, comportement historique.
Assert.False(PanneauOuvert(champ));
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_BasculeEnLangueSecondaire_RedemarreEtNInsereQueLeFrancais()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite
{
Traduction = "Remplacement de la pièce"
});
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
// Le panneau de dictée s'ouvre et propose le choix de langue.
Assert.True(PanneauOuvert(champ));
Assert.True(PuceSecondaire(champ).IsVisible);
Assert.Equal("fr", service.DerniereLangue);
// Choix de l'espagnol : l'écoute REDÉMARRE dans la nouvelle langue
// (un moteur natif ne change pas de langue en cours de session).
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
Assert.Equal("es", service.DerniereLangue);
Assert.Equal(2, service.NombreDemarrages);
// Le panneau reste ouvert : c'est lui qui porte le choix de langue.
Assert.True(PanneauOuvert(champ));
// Le texte dicté en espagnol n'entre PAS tel quel et n'est PAS
// traduit d'office : la dictée s'arrête en relecture, pour que
// l'utilisateur puisse corriger ce que le moteur a entendu.
service.Session.Final("Sustitución de la pieza");
Assert.Equal(string.Empty, champ.Text ?? string.Empty);
Assert.True(PanneauOuvert(champ));
Assert.True(Valider(champ).IsVisible);
Assert.False(Arreter(champ).IsVisible);
Assert.Equal("Sustitución de la pieza", ZoneReconnu(champ).Text);
// Validation : c'est seulement maintenant que le français traduit
// entre dans le champ, et le panneau se referme.
Cliquer(Valider(champ));
Attendre(() => string.Equals(champ.Text, "Remplacement de la pièce", StringComparison.Ordinal));
Assert.Equal("Remplacement de la pièce", champ.Text);
Assert.False(PanneauOuvert(champ));
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_TexteCorrige_EstCeluiQuiPartEnTraduction()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
var serviceTraduction = new FauxServiceDicteeTraduite
{
Traduction = "Salle 3 du bâtiment B"
};
InstallerDicteeTraduite(serviceTraduction);
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
// Pendant l'écoute, le texte reconnu est en LECTURE seule : chaque
// mot reconnu remplace le précédent.
Assert.True(ZoneReconnu(champ).IsReadOnly);
// La zone éditable ne double pas le cadre : c'est le Border
// « ZoneReconnu » qui dessine le contour, le champ reste transparent.
Assert.Equal(0, ZoneReconnu(champ).BorderThickness.Left);
var fondZone = Assert.IsAssignableFrom<ISolidColorBrush>(ZoneReconnu(champ).Background);
Assert.Equal((byte)0, fondZone.Color.A);
// Le moteur a mal entendu : « schema » au lieu de « sala ».
service.Session.Final("schema tres del edificio B");
// Écoute finie : la zone de texte reconnu devient MODIFIABLE.
Assert.False(ZoneReconnu(champ).IsReadOnly);
ZoneReconnu(champ).Text = "sala tres del edificio B";
Cliquer(Valider(champ));
Attendre(() => string.Equals(champ.Text, "Salle 3 du bâtiment B", StringComparison.Ordinal));
// La correction de l'utilisateur — pas la reconnaissance brute — est
// ce qui a été traduit.
Assert.Equal("sala tres del edificio B", serviceTraduction.DernierTexteRecu);
Assert.Equal("Salle 3 du bâtiment B", champ.Text);
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_AnnulationEnRelecture_NInsereRien()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite
{
Traduction = "Remplacement de la pièce"
});
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
service.Session.Final("Sustitución de la pieza");
Cliquer(Annuler(champ));
Assert.Equal(string.Empty, champ.Text ?? string.Empty);
Assert.False(PanneauOuvert(champ));
Assert.Null(champ.FindControl<TextBox>("TexteReconnu")!.Text);
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_RetourAuFrancais_RedemarreSansFermerLePanneau()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite());
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
Assert.Equal("es", service.DerniereLangue);
// Retour au français : nouvelle écoute, panneau toujours ouvert.
Cliquer(PuceFrancais(champ));
AttendreRelance(service, 3);
Assert.Equal("fr", service.DerniereLangue);
Assert.True(PanneauOuvert(champ));
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_RelanceImpossible_GardeLePanneauOuvertEtExplique()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite());
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
// La nouvelle écoute ne peut pas s'ouvrir (moteur indisponible) :
// la popup ne doit PAS disparaître, sinon l'utilisateur ne peut plus
// revenir au français ni annuler.
service.Demarre = false;
Cliquer(PuceSecondaire(champ));
Attendre(() => string.Equals(
MessagePanneau(champ).Text,
DictationMessages.LangueIndisponible,
StringComparison.Ordinal));
Assert.True(PanneauOuvert(champ));
Assert.True(MessagePanneau(champ).IsVisible);
Assert.Equal(DictationMessages.LangueIndisponible, MessagePanneau(champ).Text);
// Trois ouvertures tentées après la première écoute : le moteur est
// peut-être simplement en train de rendre le micro.
Assert.Equal(1 + 3, service.NombreDemarrages);
fenetre.Close();
});
}
[Fact]
public void DicteeTraduite_TraductionImpossible_ConserveLeTexteDicte()
{
Executer(() =>
{
var service = new FauxServiceDictee();
PlatformDictation.Current = service;
InstallerDicteeTraduite(new FauxServiceDicteeTraduite
{
Echec = new TranslationException("Modèle illisible.")
});
var champ = new DictationTextField();
Window fenetre = MesureGeometrique.Ouvrir(champ, 400);
Cliquer(Micro(champ));
Cliquer(PuceSecondaire(champ));
AttendreRelance(service, 2);
service.Session.Final("Sustitución de la pieza");
// Rien n'est traduit avant validation : la panne ne se manifeste
// qu'une fois le texte relu et validé.
Cliquer(Valider(champ));
Attendre(() => InsererSecours(champ).IsVisible);
// Une panne de traduction ne doit JAMAIS faire perdre la dictée :
// rien n'est inséré, mais le panneau propose l'insertion de secours.
Assert.Equal(string.Empty, champ.Text ?? string.Empty);
Assert.True(PanneauOuvert(champ));
Assert.True(InsererSecours(champ).IsVisible);
fenetre.Close();
});
}
/// <summary>
/// Installe un service de dictée traduite dont la langue secondaire est
/// exploitable (espagnol, moteur présent, modèles installés).
/// </summary>
private static void InstallerDicteeTraduite(FauxServiceDicteeTraduite service)
{
service.Etat = new DicteeTraduiteEtat
{
LangueSecondaireCode = "es",
LangueSecondaireLibelle = "Espagnol",
TraductionDisponible = true,
ModelesInstalles = true
};
PlatformDicteeTraduite.Current = service;
// Le cache d'état est statique : sans invalidation, un test précédent
// imposerait son état au suivant.
PlatformDicteeTraduite.Invalider();
}
private static TextBlock Message(DictationTextField champ) => champ.FindControl<TextBlock>("Message")!;
/// <summary>
/// Vrai si le panneau de dictée est OUVERT. On teste <c>Popup.IsOpen</c> et non
/// <c>IsVisible</c> du panneau : ce dernier reste à sa valeur locale même
/// lorsque la bulle est fermée, donc il ne prouverait rien.
/// </summary>
private static bool PanneauOuvert(DictationTextField champ)
=> champ.FindControl<Popup>("PopupDictee")!.IsOpen;
/// <summary>Puce « langue secondaire » du panneau de dictée.</summary>
private static Button PuceSecondaire(DictationTextField champ)
=> champ.FindControl<Button>("PuceSecondaire")!;
/// <summary>Puce « français » du panneau de dictée.</summary>
private static Button PuceFrancais(DictationTextField champ)
=> champ.FindControl<Button>("PuceFrancais")!;
/// <summary>Message d'état affiché DANS le panneau de dictée.</summary>
private static TextBlock MessagePanneau(DictationTextField champ)
=> champ.FindControl<TextBlock>("MessageDictee")!;
/// <summary>
/// Attend qu'une condition soit vraie en pompant la file d'interface. Un
/// changement de langue impose une pause réelle (le système doit d'abord
/// rendre le micro), donc les assertions ne peuvent pas suivre le clic
/// immédiatement.
/// </summary>
private static void Attendre(Func<bool> condition, int secondesMaximum = 15)
{
DateTime limite = DateTime.UtcNow.AddSeconds(secondesMaximum);
while (!condition() && DateTime.UtcNow < limite)
{
Dispatcher.UIThread.RunJobs();
Thread.Sleep(10);
}
Dispatcher.UIThread.RunJobs();
}
/// <summary>Attend que la relance ait ouvert l'écoute attendue.</summary>
private static void AttendreRelance(FauxServiceDictee service, int demarragesAttendus)
=> Attendre(() => service.NombreDemarrages >= demarragesAttendus);
/// <summary>Bouton d'insertion de secours (traduction impossible).</summary>
private static Button InsererSecours(DictationTextField champ)
=> champ.FindControl<Button>("BoutonInserer")!;
/// <summary>Bouton de validation du texte dicté (écoute terminée).</summary>
private static Button Valider(DictationTextField champ)
=> champ.FindControl<Button>("BoutonValider")!;
/// <summary>Bouton d'arrêt de l'écoute.</summary>
private static Button Arreter(DictationTextField champ)
=> champ.FindControl<Button>("BoutonArreter")!;
/// <summary>Bouton d'abandon de la dictée.</summary>
private static Button Annuler(DictationTextField champ)
=> champ.FindControl<Button>("BoutonAnnuler")!;
/// <summary>Zone du texte reconnu : lecture seule pendant l'écoute, modifiable ensuite.</summary>
private static TextBox ZoneReconnu(DictationTextField champ)
=> champ.FindControl<TextBox>("TexteReconnu")!;
/// <summary>Un clic réel sur le bouton micro (thread d'interface headless).</summary>
private static void Cliquer(Button bouton) => bouton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
private void Executer(Action scenario)
=> _session.Dispatch(
() =>
{
ReinitialiserMemoireLangue();
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// <summary>
/// La dernière langue de dictée est mémorisée dans un champ STATIQUE (confort
/// de saisie, jamais persisté). Sans remise à zéro, un test hériterait du
/// choix du précédent et l'ordre d'exécution changerait le résultat.
/// </summary>
private static void ReinitialiserMemoireLangue()
=> typeof(DictationTextField)
.GetField("derniereLangueDictee", BindingFlags.NonPublic | BindingFlags.Static)
?.SetValue(null, null);
/// <summary>Faux service de dictée : aucune plateforme n'est nécessaire.</summary>
private sealed class FauxServiceDictee : IDictationService
{
public bool IsAvailable { get; set; } = true;
/// <summary>Faux pour simuler une autorisation micro refusée.</summary>
public bool Demarre { get; set; } = true;
public FauxSessionDictee Session { get; } = new();
/// <summary>
/// Dernière langue demandée au moteur : permet de vérifier que le champ
/// transmet bien la langue secondaire choisie (et le français par
/// défaut) au lieu d'une langue codée en dur.
/// </summary>
public string? DerniereLangue { get; private set; }
/// <summary>Nombre d'écoutes démarrées (une bascule en démarre une seconde).</summary>
public int NombreDemarrages { get; private set; }
public Task<IDictationSession?> StartAsync(
string? codeLangue = null,
CancellationToken cancellationToken = default)
{
DerniereLangue = codeLangue;
NombreDemarrages++;
return Task.FromResult<IDictationSession?>(Demarre ? Session : null);
}
}
/// <summary>
/// Faux service de dictée traduite : aucune préférence SQLite ni modèle ONNX
/// n'est nécessaire pour vérifier le comportement du champ de saisie.
/// </summary>
private sealed class FauxServiceDicteeTraduite : IDicteeTraduiteService
{
/// <summary>État renvoyé par <see cref="GetEtatAsync"/>.</summary>
public DicteeTraduiteEtat Etat { get; set; } = new();
/// <summary>Texte français renvoyé par la traduction.</summary>
public string Traduction { get; set; } = string.Empty;
/// <summary>Erreur à lever à la place d'une traduction (cas de panne).</summary>
public Exception? Echec { get; set; }
/// <summary>
/// Dernier texte réellement transmis à la traduction : permet de vérifier
/// que c'est bien le texte RELU (et corrigé) par l'utilisateur qui part,
/// et non la reconnaissance brute du moteur.
/// </summary>
public string? DernierTexteRecu { get; private set; }
public Task<DicteeTraduiteEtat> GetEtatAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Etat);
public Task<string> TraduireVersFrancaisAsync(
string texte,
string codeLangue,
CancellationToken cancellationToken = default)
{
DernierTexteRecu = texte;
return Echec is null ? Task.FromResult(Traduction) : Task.FromException<string>(Echec);
}
public Task<string> TraduireDepuisFrancaisAsync(
string texte,
string codeLangue,
CancellationToken cancellationToken = default)
{
DernierTexteRecu = texte;
return Echec is null ? Task.FromResult(Traduction) : Task.FromException<string>(Echec);
}
}
private sealed class FauxSessionDictee : IDictationSession
{ public event EventHandler<string>? PartialText;
public event EventHandler<string>? FinalText;
public event EventHandler<string>? Failed;
public event EventHandler? Ended;
/// <summary>Texte livré par le moteur au moment de l'arrêt (cas Android).</summary>
public string? TexteSurArret { get; set; }
public bool ArretDemande { get; private set; }
public bool Liberee { get; private set; }
public void Partiel(string texte) => PartialText?.Invoke(this, texte);
public void Final(string texte) => FinalText?.Invoke(this, texte);
public void Echec(string message) => Failed?.Invoke(this, message);
public void Fini() => Ended?.Invoke(this, EventArgs.Empty);
public Task StopAsync()
{
ArretDemande = true;
if (TexteSurArret is { } texte)
{
Final(texte);
}
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
Liberee = true;
return ValueTask.CompletedTask;
}
}
/// <summary>Modèle minimal pour vérifier le lien deux-sens avec le ViewModel.</summary>
private sealed class ModeleTexte : INotifyPropertyChanged
{
private string? _texte;
public string? Texte
{
get => _texte;
set
{
_texte = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Texte)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
}