966 lines
35 KiB
C#
966 lines
35 KiB
C#
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;
|
||
}
|
||
}
|