Files
Omika/tools/Omika.Verif.Dictee/Program.cs
T
2026-10-23 18:46:50 +02:00

1516 lines
58 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.Reflection;
using System.Runtime.InteropServices;
using System.Xml.Linq;
using Avalonia.Layout;
using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Templates;
using Avalonia.Headless;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Omika.App.Controls;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Local.Services;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.App.Views;
using Omika.Core.Contracts.Documents;
using Omika.Tests.Mobile;
// ============================================================================
// Vérification, dans un processus console x64 :
//
// 1. rejoue les tests [Fact] du champ de dictée déclarés dans Omika.Tests ;
// 2. vérifie que les écrans de notes (internes, administratives, photo)
// contiennent bien un champ de dictée vocale ;
// 3. vérifie la lecture traduite : puce de langue À CÔTÉ du libellé (et non
// « ⇄ Traduire »), texte traduit affiché SEUL — sans aucun titre ;
// 4. vérifie que chaque écran où un texte peut être traduit porte bien ce
// contrôle ;
// 5. vérifie que le cadre de signature garde son bouton « Effacer » ;
// 6. vérifie que le libellé de chaque bouton est centré dans son cadre ;
// 7. vérifie la carte « Langues » : « Actualiser la liste » en tête de carte
// et « Annuler » uniquement dans la ligne de la langue téléchargée ;
// 8. vérifie que le bloc « Suivi par » a la même charpente qu'une ligne de
// contact, boutons téléphone et courriel compris ;
// 9. vérifie la préparation de traduction : nombre masqué en même longueur,
// fin de phrase ancrée, valeurs réelles restituées (doublon supprimé).
//
// Aucun test n'est réécrit ici : les méthodes sont découvertes par réflexion et
// invoquées sur une instance neuve (comme le fait xunit).
//
// Sortie : une ligne [OK] / [ECHEC] par contrôle + un bilan, code 1 si un
// contrôle échoue.
// ============================================================================
int total = 0;
int echecs = 0;
// ---- 1. Tests du champ de dictée, rejoués depuis le projet de tests --------
const string TypeChampDictee = "Omika.Tests.Mobile.DictationTextFieldTests";
Assembly assemblyTests = typeof(DictationTextFieldTests).Assembly;
Type? typeChamp = assemblyTests.GetType(TypeChampDictee);
Console.WriteLine("=== Champ de dictée vocale (tests du projet Omika.Tests) ===");
if (typeChamp is null)
{
Console.WriteLine($"[ECHEC] type de test introuvable : {TypeChampDictee}");
echecs++;
}
else
{
MethodInfo[] faits = [.. typeChamp
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(m => m.GetCustomAttributes()
.Any(a => string.Equals(a.GetType().Name, "FactAttribute", StringComparison.Ordinal)))
.OrderBy(m => m.Name, StringComparer.Ordinal)];
Console.WriteLine($"{faits.Length} test(s)");
foreach (MethodInfo fait in faits)
{
total++;
LancerTest(typeChamp, fait);
}
}
// ---- 2. Écrans de notes : le micro de dictée doit être présent -------------
Console.WriteLine();
Console.WriteLine("=== Écrans de notes : champ de dictée vocale ===");
(string Nom, Type Vue)[] ecrans =
[
("Notes internes", typeof(InterventionNotesView)),
("Notes administratives", typeof(InterventionNotesAdministrativesView)),
("Notes d'une photo", typeof(PhotoViewerView))
];
using (var session = HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder)))
{
foreach ((string nom, Type vue) in ecrans)
{
total++;
try
{
// Moteur de dictée factice « disponible » : c'est la condition dans
// laquelle le micro doit apparaître sur le téléphone.
PlatformDictation.Current = new MoteurDicteePresent();
session.Dispatch(
() =>
{
var ecran = (UserControl)Activator.CreateInstance(vue)!;
var fenetre = new Window { Width = 420, Height = 800, Content = ecran };
fenetre.Show();
Dispatcher.UIThread.RunJobs();
DictationTextField[] champs = [.. ecran
.GetVisualDescendants()
.OfType<DictationTextField>()];
if (champs.Length == 0)
{
throw new InvalidOperationException(
"aucun champ de dictée trouvé : la note ne peut pas être dictée.");
}
Button? micro = champs[0].FindControl<Button>("Micro");
if (micro is null)
{
throw new InvalidOperationException("le bouton micro est absent du champ.");
}
if (!micro.IsVisible)
{
throw new InvalidOperationException(
"le bouton micro existe mais reste invisible alors qu'un moteur de dictée est disponible.");
}
if (!champs[0].Multiline)
{
throw new InvalidOperationException(
"le champ de note n'est pas multiligne : les retours à la ligne seraient perdus.");
}
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
Console.WriteLine($" [OK] {nom}");
}
catch (Exception ex)
{
echecs++;
Console.WriteLine($" [ECHEC] {nom}");
Console.WriteLine($" {ex.GetType().Name} : {ex.Message}");
}
finally
{
PlatformDictation.Current = null;
}
}
}
Console.WriteLine();
Console.WriteLine("=== Lecture traduite : puce de langue + texte traduit seul ===");
total++;
PlatformDicteeTraduite.Current = new TraductionDisponible();
PlatformDicteeTraduite.Invalider();
using (var session = HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder)))
{
try
{
session.Dispatch(
() =>
{
var bouton = new TranslateButton
{
Libelle = "Note",
SourceText = "Bonjour le monde"
};
var fenetre = new Window { Width = 420, Height = 600, Content = bouton };
fenetre.Show();
Dispatcher.UIThread.RunJobs();
Button puce = bouton.FindControl<Button>("Puce")
?? throw new InvalidOperationException("puce de langue absente du contrôle.");
TextBlock libelle = bouton.FindControl<TextBlock>("LibelleInformation")
?? throw new InvalidOperationException("libellé de l'information absent.");
TextBlock source = bouton.FindControl<TextBlock>("TexteSource")
?? throw new InvalidOperationException("texte d'origine absent.");
TextBlock traduit = bouton.FindControl<TextBlock>("TexteTraduit")
?? throw new InvalidOperationException("texte traduit absent.");
// ---- Le bouton affiche LA LANGUE, pas « ⇄ Traduire » ----
if (!puce.IsVisible)
{
throw new InvalidOperationException(
"la puce reste invisible alors qu'une langue secondaire exploitable est installée.");
}
string entete = puce.Content?.ToString() ?? string.Empty;
if (!string.Equals(entete, "Espagnol", StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"la puce n'affiche pas la langue choisie (contenu : « {entete} », attendu : « Espagnol »).");
}
if (entete.Contains("Traduire", StringComparison.OrdinalIgnoreCase) || entete.Contains('⇄'))
{
throw new InvalidOperationException(
$"la puce affiche encore l'ancien libellé « {entete} ».");
}
// ---- Le bouton est SUR LA LIGNE du libellé ----
if (!string.Equals(libelle.Text, "Note", StringComparison.Ordinal) || !libelle.IsVisible)
{
throw new InvalidOperationException(
$"le libellé de l'information n'est pas affiché (« {libelle.Text} »).");
}
if (!source.IsVisible || traduit.IsVisible)
{
throw new InvalidOperationException(
"à l'ouverture, seul le texte d'origine doit être visible.");
}
Point? positionLibelle = libelle.TranslatePoint(default, fenetre);
Point? positionPuce = puce.TranslatePoint(default, fenetre);
Point? positionSource = source.TranslatePoint(default, fenetre);
if (positionLibelle is null || positionPuce is null || positionSource is null)
{
throw new InvalidOperationException("mise en page impossible dans la fenêtre de test.");
}
// Même LIGNE que le libellé : les deux rectangles se recouvrent
// verticalement (une puce plus haute que la légende reste
// centrée sur la même ligne), et la puce est à sa droite.
Rect zoneLibelle = new(positionLibelle.Value, libelle.Bounds.Size);
Rect zonePuce = new(positionPuce.Value, puce.Bounds.Size);
if (zonePuce.Bottom <= zoneLibelle.Top || zonePuce.Top >= zoneLibelle.Bottom)
{
throw new InvalidOperationException(
$"la puce n'est pas sur la ligne du libellé "
+ $"(libellé {zoneLibelle}, puce {zonePuce}) : le bouton n'est plus à côté de l'information.");
}
if (positionPuce.Value.X <= positionLibelle.Value.X)
{
throw new InvalidOperationException("la puce n'est pas placée à droite du libellé.");
}
if (positionPuce.Value.Y >= positionSource.Value.Y)
{
throw new InvalidOperationException(
"la puce est placée sous le texte : le bouton doit rester à côté du libellé.");
}
// ---- Clic : le texte traduit remplace le français, sans titre ----
puce.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
Dispatcher.UIThread.RunJobs();
if (!traduit.IsVisible || string.IsNullOrWhiteSpace(traduit.Text))
{
throw new InvalidOperationException("le texte traduit ne s'affiche pas après le clic sur la puce.");
}
if (!string.Equals(traduit.Text, TraductionDisponible.Reponse, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"le texte affiché n'est pas la traduction attendue (« {traduit.Text} »).");
}
if (source.IsVisible)
{
throw new InvalidOperationException(
"le texte français reste affiché en plus de la traduction : seul le texte traduit doit apparaître.");
}
if (!puce.Classes.Contains("chipActif"))
{
throw new InvalidOperationException("la puce n'indique pas qu'une traduction est affichée.");
}
Capturer(fenetre, "traduction");
// Aucun titre au-dessus du texte traduit : le contrôle n'affiche
// que le libellé de l'information et le texte traduit lui-même
// (le texte de la puce est écarté : c'est le nom de la langue).
string[] textesAffiches =
[
.. bouton.GetVisualDescendants()
.OfType<TextBlock>()
.Where(t => t.IsVisible
&& !string.IsNullOrWhiteSpace(t.Text)
&& t.FindAncestorOfType<Button>() is null)
.Select(t => t.Text!)
];
if (textesAffiches.Length != 2)
{
throw new InvalidOperationException(
$"le contrôle affiche {textesAffiches.Length} texte(s) au lieu de 2 (libellé + texte traduit) : "
+ string.Join(" | ", textesAffiches));
}
string[] interdits = ["traduite", "hors ligne", "affichage seul", "Traduit"];
foreach (string texte in textesAffiches)
{
if (interdits.Any(mot => texte.Contains(mot, StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException(
$"un titre accompagne encore le texte traduit : « {texte} ».");
}
}
Console.WriteLine(
$" disposition : libellé {Decrire(libelle, fenetre)}"
+ $" · puce {Decrire(puce, fenetre)}"
+ $" · texte traduit {Decrire(traduit, fenetre)}");
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
Console.WriteLine(" [OK] Puce de langue à côté du libellé, texte traduit seul");
}
catch (Exception ex)
{
echecs++;
Console.WriteLine(" [ECHEC] Lecture traduite");
Console.WriteLine($" {ex.GetType().Name} : {ex.Message}");
}
finally
{
PlatformDicteeTraduite.Current = null;
PlatformDicteeTraduite.Invalider();
}
}
// ---- 4. Écrans : tout contenu traduisible porte le contrôle de traduction --
Console.WriteLine();
Console.WriteLine("=== Écrans : contenu traduisible équipé de la puce de langue ===");
(string Nom, Type Vue)[] ecransTraduction =
[
("Description d'intervention", typeof(InterventionDetailView)),
("Description d'un rendez-vous", typeof(RendezVousDetailView)),
("Notes internes", typeof(InterventionNotesView)),
("Notes administratives", typeof(InterventionNotesAdministrativesView)),
("Notes d'une photo", typeof(PhotoViewerView))
];
using (var session = HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder)))
{
foreach ((string nom, Type vue) in ecransTraduction)
{
total++;
try
{
session.Dispatch(
() =>
{
var ecran = (UserControl)Activator.CreateInstance(vue)!;
var fenetre = new Window { Width = 420, Height = 800, Content = ecran };
fenetre.Show();
Dispatcher.UIThread.RunJobs();
List<TranslateButton> controles = [.. TrouverControles(ecran)];
// Les notes vivent dans un DataTemplate : sans élément, le
// contrôle n'existe pas encore dans l'arbre visuel. On
// instancie donc le gabarit pour vérifier qu'il en contient un.
if (controles.Count == 0)
{
foreach (ItemsControl liste in ecran.GetVisualDescendants().OfType<ItemsControl>())
{
if (liste.ItemTemplate is not IDataTemplate gabarit)
{
continue;
}
Control? contenu = gabarit.Build(new NoteInterneDto { Contenu = "Note de test" });
if (contenu is null)
{
continue;
}
controles.AddRange(TrouverControles(contenu));
}
}
if (controles.Count == 0)
{
throw new InvalidOperationException(
"aucun contrôle de traduction : le texte de cet écran ne peut pas être traduit.");
}
TranslateButton principal = controles[0];
if (string.IsNullOrWhiteSpace(principal.Libelle))
{
throw new InvalidOperationException(
"le contrôle de traduction n'a pas de libellé : la puce ne serait pas placée à côté de l'information.");
}
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
Console.WriteLine($" [OK] {nom}");
}
catch (Exception ex)
{
echecs++;
Console.WriteLine($" [ECHEC] {nom}");
Console.WriteLine($" {ex.GetType().Name} : {ex.Message}");
}
}
}
// ---- 5. Cadre de signature : le bouton « Effacer » doit rester visible -----
Console.WriteLine();
Console.WriteLine("=== Cadre de signature : bouton « Effacer » ===");
using (var session = HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder)))
{
foreach ((string nom, ThemeVariant variante, string suffixe) in new[]
{
("thème clair", ThemeVariant.Light, "clair"),
("thème sombre", ThemeVariant.Dark, "sombre")
})
{
total++;
try
{
session.Dispatch(
() =>
{
var signature = new SignaturePad();
var carte = new Border { Padding = new Thickness(16) };
carte.Classes.Add("card");
carte.Child = signature;
var fenetre = new Window
{
Width = 420,
Height = 800,
Content = carte,
RequestedThemeVariant = variante
};
fenetre.Show();
Dispatcher.UIThread.RunJobs();
Button? effacer = fenetre.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => b.Content as string == "Effacer");
if (effacer is null)
{
throw new InvalidOperationException(
"aucun bouton « Effacer » dans le cadre de signature.");
}
if (!effacer.IsVisible)
{
throw new InvalidOperationException(
"le bouton « Effacer » est masqué alors que la signature est modifiable.");
}
Size taille = effacer.Bounds.Size;
if (taille.Width <= 0 || taille.Height <= 0)
{
throw new InvalidOperationException(
$"le bouton « Effacer » n'a aucune surface cliquable ({taille.Width}×{taille.Height}).");
}
Point? coin = effacer.TranslatePoint(default, fenetre);
if (coin is null
|| coin.Value.X < 0
|| coin.Value.Y < 0
|| coin.Value.X + taille.Width > fenetre.Width
|| coin.Value.Y + taille.Height > fenetre.Height)
{
throw new InvalidOperationException(
$"le bouton « Effacer » sort de l'écran : {Decrire(effacer, fenetre)}.");
}
// Rien ne doit être dessiné par-dessus : le bouton doit être le
// dernier enfant de la grille de signature (ordre d'affichage).
if (effacer.Parent is Control conteneur
&& conteneur.GetVisualChildren().LastOrDefault() != effacer)
{
throw new InvalidOperationException(
"le bouton « Effacer » est recouvert par un autre élément du cadre.");
}
// Le cadre de signature a un fond BLANC quel que soit le thème :
// le libellé doit rester lisible dessus (un texte clair sur fond
// blanc équivaut à un bouton absent). Un bouton qui porte son
// propre fond opaque est jugé sur ce fond-là.
Color texte = (effacer.Foreground as ISolidColorBrush)?.Color ?? default;
Color fond = effacer.Background is ISolidColorBrush { Color.A: > 200 } propre
? propre.Color
: Colors.White;
double contraste = Contraste(texte, fond);
if (contraste < 3.0)
{
throw new InvalidOperationException(
$"bouton « Effacer » illisible : texte {Hex(texte)} sur fond {Hex(fond)} " +
$"(contraste {contraste:0.0}:1, minimum 3,0:1).");
}
// En lecture seule (compte rendu déjà existant), le bouton est
// volontairement masqué : dessiner n'aurait aucun sens.
var lecture = new SignaturePad { IsReadOnly = true };
var fenetreLecture = new Window
{
Width = 420,
Height = 400,
Content = lecture,
RequestedThemeVariant = variante
};
fenetreLecture.Show();
Dispatcher.UIThread.RunJobs();
Button? effacerLecture = fenetreLecture.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => b.Content as string == "Effacer");
if (effacerLecture is { IsVisible: true })
{
throw new InvalidOperationException(
"le bouton « Effacer » reste affiché sur une signature en lecture seule.");
}
Console.WriteLine(
$" [OK] {nom} : bouton « Effacer » {Decrire(effacer, fenetre)}, " +
$"texte {Hex(texte)} sur fond {Hex(fond)}, contraste {contraste:0.0}:1");
Capturer(fenetre, $"signature-{suffixe}");
fenetreLecture.Close();
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
echecs++;
Console.WriteLine($" [ECHEC] bouton « Effacer » du cadre de signature ({nom})");
Console.WriteLine($" {ex.GetType().Name} : {ex.Message}");
}
}
}
// ---- 6. Boutons : le libellé doit être centré dans son cadre --------------
Console.WriteLine();
Console.WriteLine("=== Boutons : libellé centré verticalement et horizontalement ===");
(string Nom, string Classes, string Libelle, bool Centre)[] boutons =
[
("Télécharger (langue)", "primary compact", "Télécharger", true),
("Supprimer (langue)", "danger compact", "Supprimer", true),
("Actualiser la liste", "secondary", "Actualiser la liste", true),
("Puce de langue", "chip", "Espagnol", true),
("Bouton sans classe", string.Empty, "Valider", true),
("Retirer la langue", "ghost compact", "Retirer la langue", true),
("Bouton icône", "icon", "✎", true),
("Nav latéral (texte à gauche)", "navItem", "Interventions", false)
];
using (var session = HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder)))
{
foreach ((string nom, string classes, string libelle, bool centre) in boutons)
{
total++;
try
{
session.Dispatch(
() =>
{
var bouton = new Button { Content = libelle };
foreach (string classe in classes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
bouton.Classes.Add(classe);
}
var pile = new StackPanel { Spacing = 12, Margin = new Thickness(16) };
pile.Children.Add(bouton);
var fenetre = new Window
{
Width = 420,
Height = 260,
Content = pile,
Background = Brushes.Transparent,
RequestedThemeVariant = ThemeVariant.Light
};
fenetre.Show();
Dispatcher.UIThread.RunJobs();
ContentPresenter? presentateur = bouton.GetVisualDescendants()
.OfType<ContentPresenter>()
.FirstOrDefault();
if (presentateur is null)
{
throw new InvalidOperationException("le bouton n'a pas rendu son contenu.");
}
TextBlock? texte = presentateur.GetVisualDescendants()
.OfType<TextBlock>()
.FirstOrDefault();
if (texte is null)
{
throw new InvalidOperationException(
"le libellé du bouton n'est pas rendu comme du texte.");
}
Point? origine = texte.TranslatePoint(default, bouton);
if (origine is null)
{
throw new InvalidOperationException(
"le libellé du bouton n'est pas positionné dans son cadre.");
}
Size cadre = bouton.Bounds.Size;
Size tailleLibelle = texte.Bounds.Size;
// Écart entre le libellé et les quatre bords du bouton : un
// texte centré laisse le MÊME espace au-dessus et au-dessous.
double dessus = origine.Value.Y;
double dessous = cadre.Height - origine.Value.Y - tailleLibelle.Height;
double gauche = origine.Value.X;
double droite = cadre.Width - origine.Value.X - tailleLibelle.Width;
string constat =
$"{cadre.Width:0.#}×{cadre.Height:0.#}, " +
$"dessus {dessus:0.#} px / dessous {dessous:0.#} px, " +
$"gauche {gauche:0.#} px / droite {droite:0.#} px";
// Ce que l'œil voit : l'encre du libellé, mesurée sur un rendu
// réel. Une boîte de ligne centrée peut laisser le texte
// visiblement posé trop haut (ou trop bas).
(double encreDessus, double encreDessous, double hauteurNaturelle) =
EncreLibelle(texte, libelle);
double dessusEncre = dessus + encreDessus;
double dessousEncre =
cadre.Height - dessus - hauteurNaturelle + encreDessous;
string constatEncre =
$"encre : dessus {dessusEncre:0.#} px / dessous {dessousEncre:0.#} px " +
$"(cadre de ligne {tailleLibelle.Height:0.#} px pour un texte de {hauteurNaturelle:0.#} px)";
if (Math.Abs(dessus - dessous) > 1.0)
{
throw new InvalidOperationException(
$"libellé « {libelle} » pas centré verticalement ({constat}).");
}
// Le libellé ne doit plus occuper une boîte de ligne étirée : c'est
// cet étirement qui dessinait le texte en haut du cadre.
if (tailleLibelle.Height - hauteurNaturelle > 1.0)
{
throw new InvalidOperationException(
$"libellé « {libelle} » étiré verticalement : cadre de ligne " +
$"{tailleLibelle.Height:0.#} px pour un texte de {hauteurNaturelle:0.#} px.");
}
if (centre && Math.Abs(gauche - droite) > 1.0)
{
throw new InvalidOperationException(
$"libellé « {libelle} » pas centré horizontalement ({constat}).");
}
// Une règle plus précise (rail de navigation) garde la main : le
// centrage global ne doit pas écraser ce qui a été demandé.
if (!centre && Math.Abs(gauche - droite) <= 1.0)
{
throw new InvalidOperationException(
$"libellé « {libelle} » centré alors que ce bouton demande un texte à gauche ({constat}).");
}
// L'encre du texte suit la ligne de base de la police : elle ne
// tombe pas exactement au milieu de la boîte de ligne (Inter
// réserve plus d'espace au-dessus des capitales qu'en dessous
// des jambages, soit 1 à 1,5 px d'écart résiduel une fois la
// boîte centrée). Au-delà de 3 px, ce n'est plus la métrique de
// la police mais un vrai défaut de placement : le défaut
// d'origine atteignait 11 px sur un bouton icône.
if (Math.Abs(dessusEncre - dessousEncre) > 3.0)
{
throw new InvalidOperationException(
$"libellé « {libelle} » visiblement décalé verticalement ({constatEncre}).");
}
Console.WriteLine($" [OK] {nom} : {constat} ; {constatEncre}");
Capturer(fenetre, $"boutons-{nom}");
fenetre.Close();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
echecs++;
Console.WriteLine($" [ECHEC] {nom}");
Console.WriteLine($" {ex.GetType().Name} : {ex.Message}");
}
}
}
// ---- 7. Langues : « Actualiser » en tête, « Annuler » dans la ligne ---------
Console.WriteLine();
Console.WriteLine("=== Langues : « Actualiser la liste » en tête, « Annuler » dans la ligne ===");
Controle("Carte « Langues » : structure du fichier XAML", () =>
{
string? chemin = ChercherFichier(Path.Combine("src", "Omika.App", "Views", "ParametresView.axaml"));
if (chemin is null)
{
throw new InvalidOperationException("ParametresView.axaml introuvable depuis le répertoire courant.");
}
List<XElement> ordre = [.. XDocument.Load(chemin).Descendants()];
static int IndexDe(List<XElement> ordre, Func<XElement, bool> predicat, int depuis = 0)
{
for (int i = depuis; i < ordre.Count; i++)
{
if (predicat(ordre[i]))
{
return i;
}
}
return -1;
}
static bool BoutonAvecContent(XElement e, string contenu) =>
e.Name.LocalName == "Button" && (string?)e.Attribute("Content") == contenu;
List<XElement> actualiser = [.. ordre.Where(e => BoutonAvecContent(e, "Actualiser la liste"))];
List<XElement> annulerGlobaux = [.. ordre.Where(e => BoutonAvecContent(e, "Annuler le téléchargement"))];
List<XElement> annuler = [.. ordre.Where(e => BoutonAvecContent(e, "Annuler"))];
List<XElement> retirer = [.. ordre.Where(e => BoutonAvecContent(e, "Retirer la langue"))];
int titre = IndexDe(ordre, e => e.Name.LocalName == "TextBlock"
&& (string?)e.Attribute("Text") == "Langue secondaire");
int liste = IndexDe(ordre, e => e.Name.LocalName == "ItemsControl"
&& (string?)e.Attribute("ItemsSource") == "{Binding Langues}");
// 1. Le bouton d'annulation ne doit plus traîner en bas de page : pendant un
// téléchargement il fallait faire défiler la liste pour l'atteindre.
if (annulerGlobaux.Count > 0)
{
throw new InvalidOperationException(
"« Annuler le téléchargement » est encore présent hors de la ligne de la langue.");
}
// 2. Un seul « Annuler », dans le modèle de ligne de la langue.
if (annuler.Count != 1)
{
throw new InvalidOperationException($"{annuler.Count} bouton(s) « Annuler » au lieu d'un seul.");
}
XElement modele = annuler[0].AncestorsAndSelf()
.FirstOrDefault(e => e.Name.LocalName == "DataTemplate")
?? throw new InvalidOperationException("le bouton « Annuler » n'est pas dans une ligne de langue.");
if (!modele.Descendants().Any(e => (string?)e.Attribute("Content") == "{Binding ActionTexte}"))
{
throw new InvalidOperationException("le bouton « Annuler » n'est pas dans la ligne qui porte le bouton de téléchargement.");
}
if ((string?)annuler[0].Attribute("IsVisible") != "{Binding PeutAnnuler}")
{
throw new InvalidOperationException(
"le bouton « Annuler » n'est pas conditionné par PeutAnnuler : il apparaîtrait sur toutes les langues.");
}
if ((string?)annuler[0].Attribute("Command") is not { } commande
|| !commande.Contains("AnnulerTelechargementCommand", StringComparison.Ordinal))
{
throw new InvalidOperationException("le bouton « Annuler » n'appelle pas AnnulerTelechargementCommand.");
}
// 3. « Actualiser la liste » : un seul bouton, AVANT le titre et la liste.
if (actualiser.Count != 1)
{
throw new InvalidOperationException($"{actualiser.Count} bouton(s) « Actualiser la liste » au lieu d'un seul.");
}
int positionActualiser = ordre.IndexOf(actualiser[0]);
if (titre < 0 || liste < 0)
{
throw new InvalidOperationException("titre « Langue secondaire » ou liste des langues introuvable.");
}
if (positionActualiser > titre || positionActualiser > liste)
{
throw new InvalidOperationException(
"« Actualiser la liste » n'est pas en tête de carte : il reste en dessous du titre ou de la liste.");
}
// 4. La note explicative suit le titre, dans le même bloc de tête. La
// recherche part du bouton « Actualiser » : la même phrase ouvre aussi la
// carte des horaires, plus haut dans la page.
int note = IndexDe(
ordre,
e => e.Name.LocalName == "TextBlock"
&& ((string?)e.Attribute("Text"))?.StartsWith("Réglage propre à cet utilisateur", StringComparison.Ordinal) == true,
depuis: positionActualiser);
if (note < 0 || note > liste)
{
throw new InvalidOperationException("la note « Réglage propre à cet utilisateur… » n'est pas remontée en tête de carte.");
}
// 5. « Retirer la langue » : après les textes, AVANT la liste.
if (retirer.Count != 1)
{
throw new InvalidOperationException($"{retirer.Count} bouton(s) « Retirer la langue » au lieu d'un seul.");
}
int positionRetirer = ordre.IndexOf(retirer[0]);
int langueActuelle = IndexDe(ordre, e => e.Name.LocalName == "TextBlock"
&& (string?)e.Attribute("Text") == "Langue actuelle :");
if (langueActuelle < 0)
{
throw new InvalidOperationException("ligne « Langue actuelle : » introuvable.");
}
if (positionRetirer < langueActuelle || positionRetirer > liste)
{
throw new InvalidOperationException(
"« Retirer la langue » n'est pas entre les textes et la liste des langues.");
}
return $"actualiser {positionActualiser} · titre {titre} · note {note} · retirer {positionRetirer} · liste {liste}";
});
// ---- 8. Fiche intervention : « Suivi par » présenté comme un contact -------
Console.WriteLine();
Console.WriteLine("=== Fiche intervention : bloc « Suivi par » ===");
Controle("« Suivi par » : même ligne qu'un contact (avatar, textes, boutons à droite)", () =>
{
string? chemin = ChercherFichier(Path.Combine("src", "Omika.App", "Views", "InterventionDetailView.axaml"));
if (chemin is null)
{
throw new InvalidOperationException("InterventionDetailView.axaml introuvable depuis le répertoire courant.");
}
List<XElement> ordre = [.. XDocument.Load(chemin).Descendants()];
XElement titre = ordre.FirstOrDefault(e => e.Name.LocalName == "TextBlock"
&& (string?)e.Attribute("Text") == "Suivi par")
?? throw new InvalidOperationException("titre « Suivi par » introuvable.");
XElement carte = titre.Ancestors().FirstOrDefault(e => e.Name.LocalName == "Border")
?? throw new InvalidOperationException("la carte « Suivi par » est introuvable.");
List<XElement> cartesContacts = [.. ordre.Where(e => e.Name.LocalName == "TextBlock"
&& (string?)e.Attribute("Text") == "Contacts")];
if (cartesContacts.Count != 1)
{
throw new InvalidOperationException($"{cartesContacts.Count} bloc(s) « Contacts » au lieu d'un seul.");
}
XElement ligneContact = cartesContacts[0].Ancestors()
.FirstOrDefault(e => e.Name.LocalName == "Border")
?? throw new InvalidOperationException("la carte « Contacts » est introuvable.");
// Les deux lignes doivent avoir la même charpente : avatar · textes · boutons.
string DecrireLigne(XElement carteLigne, string nom, string commandeAppel, string commandeMail)
{
XElement grille = carteLigne.Descendants().FirstOrDefault(e => e.Name.LocalName == "Grid"
&& (string?)e.Attribute("ColumnDefinitions") == "Auto,*,Auto")
?? throw new InvalidOperationException(
$"{nom} : pas de ligne avatar / textes / boutons (grille « Auto,*,Auto » absente).");
bool avatar = grille.Elements().Any(e => e.Name.LocalName == "Border"
&& (string?)e.Attribute("Width") == "32"
&& (string?)e.Attribute("CornerRadius") == "16");
if (!avatar)
{
throw new InvalidOperationException($"{nom} : avatar à initiales absent (cercle de 32 px).");
}
XElement? Bouton(string commande) => grille.Descendants().FirstOrDefault(e =>
((string?)e.Attribute("Command"))?.Contains(commande, StringComparison.Ordinal) == true);
XElement appel = Bouton(commandeAppel)
?? throw new InvalidOperationException($"{nom} : bouton d'appel ({commandeAppel}) absent.");
XElement mail = Bouton(commandeMail)
?? throw new InvalidOperationException($"{nom} : bouton courriel ({commandeMail}) absent.");
// Boutons posés dans la 3e colonne (à droite), groupés dans le même
// conteneur : c'est la disposition demandée pour le suivi.
XElement groupe = appel.Parent
?? throw new InvalidOperationException($"{nom} : bouton d'appel sans conteneur.");
if ((string?)groupe.Attribute("Grid.Column") != "2")
{
throw new InvalidOperationException($"{nom} : les boutons ne sont pas dans la colonne de droite.");
}
if (!ReferenceEquals(mail.Parent, groupe))
{
throw new InvalidOperationException($"{nom} : téléphone et courriel ne sont pas groupés.");
}
// Les textes sont bien dans la colonne du milieu (jamais sous l'avatar).
bool textesAuMilieu = grille.Elements().Any(e => e.Name.LocalName == "StackPanel"
&& (string?)e.Attribute("Grid.Column") == "1");
if (!textesAuMilieu)
{
throw new InvalidOperationException($"{nom} : les coordonnées ne sont pas dans la colonne du milieu.");
}
return "grille Auto,*,Auto · avatar 32 px · textes colonne 1 · boutons colonne 2";
}
string suivi = DecrireLigne(carte, "Suivi par", "CallSuiviParCommand", "EmailSuiviParCommand");
string contact = DecrireLigne(ligneContact, "Contacts", "CallContactCommand", "EmailContactCommand");
if (!string.Equals(suivi, contact, StringComparison.Ordinal))
{
throw new InvalidOperationException($"« Suivi par » ({suivi}) diffère d'un contact ({contact}).");
}
return suivi;
});
Controle("Ligne de langue : « Annuler » seulement pendant le téléchargement", () =>
{
var ligne = new LangueOptionViewModel(new LangueDisponible { Code = "es", Libelle = "Espagnol" });
bool notifie = false;
ligne.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(LangueOptionViewModel.PeutAnnuler))
{
notifie = true;
}
};
if (ligne.PeutAnnuler)
{
throw new InvalidOperationException("« Annuler » proposé avant tout téléchargement.");
}
ligne.Occupee = true;
if (!ligne.PeutAnnuler)
{
throw new InvalidOperationException("« Annuler » absent pendant le téléchargement.");
}
if (!notifie)
{
throw new InvalidOperationException(
"l'écran n'est pas prévenu du changement de PeutAnnuler : le bouton n'apparaîtrait pas.");
}
ligne.SuppressionEnCours = true;
if (ligne.PeutAnnuler)
{
throw new InvalidOperationException("« Annuler » proposé pendant une suppression : rien à annuler à ce moment-là.");
}
ligne.Terminer();
if (ligne.PeutAnnuler)
{
throw new InvalidOperationException("« Annuler » encore proposé après la fin de l'opération.");
}
return "invisible → visible pendant le téléchargement → invisible après";
});
Console.WriteLine("=== Traduction : préparation du texte et réparation des nombres ===");
Controle("Préparation : la phrase est ancrée, le texte envoyé reste celui saisi", () =>
{
PreparationTraduction preparation = PreparationTraduction.Depuis(
"remplacement chauffe-eau electrique selon devis 640330");
if (preparation.TexteModele != "remplacement chauffe-eau electrique selon devis 640330.")
{
throw new InvalidOperationException($"texte envoyé au modèle inattendu : « {preparation.TexteModele} »");
}
if (preparation.Nombres.Count != 1 || preparation.Nombres[0] != "640330")
{
throw new InvalidOperationException(
$"nombres retenus inattendus : [{string.Join(", ", preparation.Nombres)}]");
}
if (!preparation.PointAjoute)
{
throw new InvalidOperationException("aucun point ajouté : la phrase resterait non ancrée.");
}
return "seul le point est ajouté · 640330 identifié";
});
Controle("Préparation : une phrase déjà ponctuée n'est pas retouchée", () =>
{
PreparationTraduction dejaPonctuee = PreparationTraduction.Depuis("Il est parti.");
if (dejaPonctuee.TexteModele != "Il est parti." || dejaPonctuee.PointAjoute)
{
throw new InvalidOperationException($"texte modifié : « {dejaPonctuee.TexteModele} »");
}
// Guillemet fermant après le point : la phrase est terminée elle aussi.
PreparationTraduction entreGuillemets = PreparationTraduction.Depuis("Il a dit « fini. »");
if (entreGuillemets.TexteModele != "Il a dit « fini. »" || entreGuillemets.PointAjoute)
{
throw new InvalidOperationException(
$"un point a été ajouté après un guillemet fermant : « {entreGuillemets.TexteModele} »");
}
return "point final et guillemet fermant reconnus";
});
Controle("Préparation : un nombre déformé par le décodeur est remplacé par la valeur saisie", () =>
{
PreparationTraduction preparation = PreparationTraduction.Depuis(
"remplacement chauffe-eau electrique selon devis 640330");
// Sortie réellement mesurée sur le pack arabe : le décodeur rend 64033030 et
// le point produit par l'ancrage.
string rendu = preparation.Restituer(
"استبدال سخانات المياه الكهربائية حسب التقديرات 64033030.");
if (!rendu.Contains("640330", StringComparison.Ordinal) || rendu.Contains("64033030", StringComparison.Ordinal))
{
throw new InvalidOperationException($"valeur déformée laissée en place : « {rendu} »");
}
if (rendu.EndsWith('.'))
{
throw new InvalidOperationException($"le point ajouté pour l'inférence est resté : « {rendu} »");
}
return rendu;
});
Controle("Préparation : un numéro rejoué par le décodeur est supprimé", () =>
{
PreparationTraduction preparation = PreparationTraduction.Depuis("devis 640330");
// Sortie mesurée sur le pack réel : le décodeur rejoue la fin de la source.
string rendu = preparation.Restituer("التقديرات 640330 640330");
if (rendu.Split("640330").Length != 2)
{
throw new InvalidOperationException($"le numéro apparaît plusieurs fois : « {rendu} »");
}
if (rendu.Contains(" ", StringComparison.Ordinal))
{
throw new InvalidOperationException($"double espace laissé par la suppression : « {rendu} »");
}
return rendu;
});
Controle("Préparation : les chiffres arabes de la dictée reviennent en chiffres latins", () =>
{
PreparationTraduction preparation = PreparationTraduction.Depuis("تم استبدال المضخة رقم ٦٤٠٣٣٠");
if (preparation.Nombres.Count != 1 || preparation.Nombres[0] != "640330")
{
throw new InvalidOperationException(
$"valeur non normalisée : [{string.Join(", ", preparation.Nombres)}]");
}
if (!preparation.TexteModele.Contains("٦٤٠٣٣٠", StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"le texte saisi a été retouché avant l'inférence : « {preparation.TexteModele} »");
}
// Le décodeur rend ici une autre valeur : c'est celle de la source qui gagne.
string rendu = preparation.Restituer("Remplacement de la pompe numero ٤٥٠٠.");
if (!rendu.Contains("640330", StringComparison.Ordinal) || rendu.Contains("٤٥٠٠", StringComparison.Ordinal))
{
throw new InvalidOperationException($"valeur non restituée : « {rendu} »");
}
return "٦٤٠٣٣٠ identifié, ٤٥٠٠ remplacé par 640330";
});
Controle("Préparation : plusieurs nombres et des comptes différents restent intacts", () =>
{
PreparationTraduction preparation = PreparationTraduction.Depuis("facture 12 et devis 34");
// Le décodeur n'a rendu qu'un nombre sur les deux : sans correspondance sûre,
// la sortie n'est pas retouchée (remplacer au hasard ferait pire).
const string sortie = "Facture 12";
string rendu = preparation.Restituer(sortie);
if (rendu != sortie)
{
throw new InvalidOperationException($"sortie modifiée sans correspondance sûre : « {rendu} »");
}
return "aucune réparation hasardeuse";
});
Controle("Préparation : un nombre unique perdu par le décodeur est remis en fin de texte", () =>
{
PreparationTraduction preparation = PreparationTraduction.Depuis("devis 640330");
string rendu = preparation.Restituer("التقديرات");
if (!rendu.EndsWith("640330", StringComparison.Ordinal))
{
throw new InvalidOperationException($"la valeur saisie a disparu : « {rendu} »");
}
return rendu;
});
Controle("Préparation : la variante qui garde les nombres est identifiable", () =>
{
PreparationTraduction preparation = PreparationTraduction.Depuis(
"Merci de rappeler le 06 12 34 56 78 avant 18h");
// Sortie mesurée : le point ajouté pour l'ancrage transforme l'heure 18 en 6.
int alteres = preparation.NombresRetrouves("Por favor llame al 06 12 34 56 78 antes de las 6:00");
int intacts = preparation.NombresRetrouves("Por favor llame al 06 12 34 56 78 antes de las 18 h");
if (alteres != 5 || intacts != 6)
{
throw new InvalidOperationException(
$"décompte inattendu : {alteres} valeur(s) retrouvée(s) sur la sortie altérée, {intacts} sur la sortie intacte.");
}
return $"{alteres} valeur(s) retrouvée(s) sur la sortie altérée, {intacts} sur 6 sur la sortie intacte";
});
Controle("Préparation : texte vide ou absent", () =>
{
PreparationTraduction vide = PreparationTraduction.Depuis(" ");
if (vide.TexteModele.Length != 0 || vide.Nombres.Count != 0 || vide.PointAjoute)
{
throw new InvalidOperationException("une source vide a été transformée en texte non vide.");
}
if (vide.Restituer(null).Length != 0 || vide.Restituer(string.Empty).Length != 0)
{
throw new InvalidOperationException("une sortie vide a produit du texte.");
}
return "aucune transformation, aucune exception";
});
Console.WriteLine();
Console.WriteLine($"{total} contrôle(s) exécuté(s), {echecs} échec(s).");
if (echecs > 0)
{
Console.WriteLine("RÉSULTAT : au moins un contrôle échoue.");
return 1;
}
Console.WriteLine("RÉSULTAT : tous les contrôles sont conformes.");
return 0;
// ============================================================================
// Outils
// ============================================================================
/// <summary>Invoque un test xunit et libère la session Avalonia qu'il a ouverte.</summary>
void LancerTest(Type type, MethodInfo fait)
{
object? instance = null;
try
{
instance = Activator.CreateInstance(type);
fait.Invoke(instance, null);
Console.WriteLine($" [OK] {fait.Name}");
}
catch (TargetInvocationException ex)
{
echecs++;
Exception cause = ex.InnerException ?? ex;
Console.WriteLine($" [ECHEC] {fait.Name}");
Console.WriteLine($" {cause.GetType().Name} : {cause.Message}");
}
catch (Exception ex)
{
echecs++;
Console.WriteLine($" [ECHEC] {fait.Name} (préparation)");
Console.WriteLine($" {ex.GetType().Name} : {ex.Message}");
}
finally
{
// Un échec ici ne concerne que l'infrastructure de test (arrêt du
// dispatcher headless), jamais le code applicatif.
try
{
(instance as IDisposable)?.Dispose();
}
catch (Exception ex)
{
Console.WriteLine($" (fermeture de la session Avalonia : {ex.GetType().Name})");
}
}
}
/// <summary>
/// Exécute un contrôle indépendant d'Avalonia : succès affiché avec son détail,
/// échec compté avec son message.
/// </summary>
void Controle(string nom, Func<string> corps)
{
total++;
try
{
Console.WriteLine($" [OK] {nom} : {corps()}");
}
catch (Exception ex)
{
echecs++;
Console.WriteLine($" [ECHEC] {nom}");
Console.WriteLine($" {ex.GetType().Name} : {ex.Message}");
}
}
/// <summary>
/// Retrouve un fichier du dépôt en remontant depuis le répertoire courant, puis
/// depuis celui de l'exécutable (le harnais peut être lancé des deux endroits).
/// </summary>
string? ChercherFichier(string cheminRelatif)
{
foreach (string depart in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
{
DirectoryInfo? dossier = new(depart);
while (dossier is not null)
{
string candidat = Path.Combine(dossier.FullName, cheminRelatif);
if (File.Exists(candidat))
{
return candidat;
}
dossier = dossier.Parent;
}
}
return null;
}
/// <summary>
/// Position d'un contrôle dans la fenêtre, en clair : « (x=0, y=8, 34×17) ».
/// </summary>
static string Decrire(Control controle, Window fenetre)
{
Point? position = controle.TranslatePoint(default, fenetre);
Size taille = controle.Bounds.Size;
return position is null
? "(hors fenêtre)"
: $"({position.Value.X:0.#}, {position.Value.Y:0.#}, {taille.Width:0.#}×{taille.Height:0.#})";
}
/// <summary>
/// Contraste WCAG entre deux couleurs (1:1 = invisible, 21:1 = noir sur blanc).
/// Le cadre de signature reste blanc dans tous les thèmes : c'est le fond de
/// référence pour juger si le libellé d'un bouton posé dessus est lisible.
/// </summary>
static double Contraste(Color a, Color b)
{
static double Canal(double composante)
{
double v = composante / 255.0;
return v <= 0.03928 ? v / 12.92 : Math.Pow((v + 0.055) / 1.055, 2.4);
}
static double Luminance(Color c)
=> (0.2126 * Canal(c.R)) + (0.7152 * Canal(c.G)) + (0.0722 * Canal(c.B));
double premiere = Luminance(a);
double seconde = Luminance(b);
return (Math.Max(premiere, seconde) + 0.05) / (Math.Min(premiere, seconde) + 0.05);
}
/// <summary>Couleur en clair : « #172033 ».</summary>
static string Hex(Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
/// <summary>
/// Encre d'un libellé : les pixels que l'œil voit, et non la boîte de ligne que
/// le moteur de mise en page centre. Un texte dont la boîte est centrée peut
/// encore paraître posé trop haut : c'est cet écart qu'on mesure ici.
/// Le libellé est rendu seul, sur une fenêtre ordinaire, et tout pixel différent
/// du fond de cette fenêtre est compté comme encre du texte.
/// </summary>
static (double Dessus, double Dessous, double HauteurNaturelle) EncreLibelle(TextBlock modele, string libelle)
{
const int Largeur = 400;
const int Hauteur = 120;
var bloc = new TextBlock
{
Text = libelle,
FontFamily = modele.FontFamily,
FontSize = modele.FontSize,
FontWeight = modele.FontWeight,
FontStyle = modele.FontStyle,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top
};
var fenetre = new Window
{
Width = Largeur,
Height = Hauteur,
Content = bloc,
RequestedThemeVariant = ThemeVariant.Light
};
fenetre.Show();
Dispatcher.UIThread.RunJobs();
using var cible = new Avalonia.Media.Imaging.RenderTargetBitmap(
new PixelSize(Largeur, Hauteur), new Vector(96, 96));
cible.Render(fenetre);
int tailleOctets = Largeur * Hauteur * 4;
IntPtr memoire = Marshal.AllocHGlobal(tailleOctets);
try
{
cible.CopyPixels(new PixelRect(0, 0, Largeur, Hauteur), memoire, tailleOctets, Largeur * 4);
byte[] octets = new byte[tailleOctets];
Marshal.Copy(memoire, octets, 0, tailleOctets);
// Le fond de la fenêtre sert de référence : inutile de savoir s'il est
// blanc, gris ou translucide.
int reference = (Largeur + 5) * 4;
static bool Different(byte[] image, int i, int reference)
{
for (int canal = 0; canal < 3; canal++)
{
if (Math.Abs(image[i + canal] - image[reference + canal]) > 40)
{
return true;
}
}
return false;
}
int premier = -1;
int dernier = -1;
for (int y = 0; y < Hauteur; y++)
{
for (int x = 0; x < Largeur; x++)
{
if (Different(octets, (((y * Largeur) + x) * 4), reference))
{
if (premier < 0)
{
premier = y;
}
dernier = y;
break;
}
}
}
fenetre.Close();
if (premier < 0)
{
throw new InvalidOperationException("le libellé n'a laissé aucune encre à l'écran.");
}
double bas = Math.Ceiling(bloc.Bounds.Height);
return (premier, bas - dernier - 1, bloc.Bounds.Height);
}
finally
{
Marshal.FreeHGlobal(memoire);
}
}
/// <summary>
/// Enregistre une capture de la fenêtre de contrôle (rendu Skia réel), pour
/// regarder le résultat de la mise en page. Activé par OMIKA_VERIF_CAPTURE=1.
/// </summary>
static void Capturer(Window fenetre, string nom)
{
if (!string.Equals(
Environment.GetEnvironmentVariable("OMIKA_VERIF_CAPTURE"),
"1",
StringComparison.Ordinal))
{
return;
}
string dossier = Path.Combine(Path.GetTempPath(), "omika-verif");
Directory.CreateDirectory(dossier);
string fichier = Path.Combine(dossier, $"capture-{nom}.png");
using Avalonia.Media.Imaging.WriteableBitmap? image = fenetre.CaptureRenderedFrame();
image?.Save(fichier);
Console.WriteLine($" capture : {fichier}");
}
/// <summary>
/// Contrôles de traduction présents dans un arbre visuel (le contrôle racine
/// lui-même compris).
/// </summary>
static TranslateButton[] TrouverControles(Control racine)
=> [.. racine.GetVisualDescendants()
.Prepend(racine)
.OfType<TranslateButton>()];
/// <summary>
/// Moteur de dictée déclaré disponible, sans écoute réelle : suffit pour que le
/// bouton micro soit affiché, ce que vérifient les écrans de notes.
/// </summary>
file sealed class MoteurDicteePresent : IDictationService
{
public bool IsAvailable => true;
public Task<IDictationSession?> StartAsync(
string? codeLangue = null,
CancellationToken cancellationToken = default)
=> Task.FromResult<IDictationSession?>(null);
}
/// <summary>
/// Traduction déclarée exploitable en espagnol : suffit pour vérifier ce que
/// l'écran affiche (puce de langue, texte traduit seul), sans inférence réelle.
/// </summary>
file sealed class TraductionDisponible : IDicteeTraduiteService
{
/// <summary>Texte renvoyé pour toute demande de traduction.</summary>
public const string Reponse = "Buenos días, la nota ha sido traducida.";
public Task<DicteeTraduiteEtat> GetEtatAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new DicteeTraduiteEtat
{
LangueSecondaireCode = "es",
LangueSecondaireLibelle = "Espagnol",
TraductionDisponible = true,
ModelesInstalles = true
});
public Task<string> TraduireVersFrancaisAsync(
string texte,
string codeLangue,
CancellationToken cancellationToken = default)
=> Task.FromResult(texte);
public Task<string> TraduireDepuisFrancaisAsync(
string texte,
string codeLangue,
CancellationToken cancellationToken = default)
=> Task.FromResult(Reponse);
}