1516 lines
58 KiB
C#
1516 lines
58 KiB
C#
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);
|
||
} |