Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,965 @@
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;
}
}