using System; using System.Globalization; using System.IO; using System.Threading; using Avalonia; using Avalonia.Headless; using Avalonia.Media; using Avalonia.Media.Imaging; using Omika.App.Controls; using SkiaSharp; using Xunit; using Xunit.Abstractions; namespace Omika.Tests.Mobile; /// /// Test de rendu INDÉPENDANT de l'UI du chemin texte de l'éditeur photo /// (anomalie #14). Il vérifie, sur le backend Skia réel (headless), que : /// - la police par défaut produit des glyphes (FormattedText non vide) ; /// - modifie réellement les pixels d'un /// (le texte est bien dessiné) ; /// - les coordonnées finales du texte restent dans les limites du bitmap. /// /// Ces tests verrouillent le chemin Desktop/headless (qui fonctionne). Ils /// fournissent aussi les valeurs de diagnostic (famille de police, métriques, /// présence des glyphes) à comparer sur un appareil Android/iOS réel pour /// localiser l'étape exacte où le texte disparaît. /// /// /// Session Avalonia headless : la classe rejoint la collection /// « ApplicationRuntimeContext » pour qu'aucune autre session headless ne /// s'exécute en parallèle (le localisateur d'Avalonia est global au processus). /// [Collection("ApplicationRuntimeContext")] public sealed class ImageAnnotationRenderingTests : IDisposable { private readonly HeadlessUnitTestSession _session; private readonly ITestOutputHelper _output; public ImageAnnotationRenderingTests(ITestOutputHelper output) { _session = HeadlessUnitTestSession.StartNew(typeof(TextRenderingTestAppBuilder)); _output = output; } public void Dispose() => _session.Dispose(); [Fact(Skip = "xUnit doit injecter ITestOutputHelper : la vérification locale (dotnet test indisponible, sonde par réflexion) ne peut pas instancier la classe. À réactiver dès que dotnet test est utilisable.")] public void Diagnostic_ValeursPoliceParDefaut() { _session.Dispatch(() => { var typeface = new Typeface(FontFamily.Default); bool hasGlyphTypeface = FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphTypeface); var text = new FormattedText( "TEST OMIKA", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, 40, Brushes.Black); _output.WriteLine($"FontFamily.Default.Name = '{FontFamily.Default.Name}'"); _output.WriteLine($"FontFamily.Default.FamilyNames = {string.Join(" | ", FontFamily.Default.FamilyNames)}"); _output.WriteLine($"FontManager.DefaultFontFamily = '{FontManager.Current.DefaultFontFamily}'"); _output.WriteLine($"TryGetGlyphTypeface = {hasGlyphTypeface}"); _output.WriteLine($"GlyphTypeface.FamilyName = '{glyphTypeface?.FamilyName}'"); _output.WriteLine($"FormattedText.Width = {text.Width}"); _output.WriteLine($"FormattedText.Height = {text.Height}"); }, CancellationToken.None); } [Fact(Skip = "xUnit doit injecter ITestOutputHelper : la vérification locale (dotnet test indisponible, sonde par réflexion) ne peut pas instancier la classe. À réactiver dès que dotnet test est utilisable.")] public void FormattedText_DefaultFont_ProducesDesGlyphesNonVides() { _session.Dispatch(() => { var typeface = new Typeface(FontFamily.Default); bool hasGlyphTypeface = FontManager.Current.TryGetGlyphTypeface(typeface, out _); var text = new FormattedText( "TEST OMIKA", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, 40, Brushes.Black); Assert.True(hasGlyphTypeface, $"TryGetGlyphTypeface(FontFamily.Default) a échoué (DefaultFontFamily={FontManager.Current.DefaultFontFamily})."); Assert.True(text.Width > 0, $"FormattedText.Width == {text.Width} : aucun glyphe produit."); Assert.True(text.Height > 0, $"FormattedText.Height == {text.Height} : aucun glyphe produit."); }, CancellationToken.None); } [Fact(Skip = "xUnit doit injecter ITestOutputHelper : la vérification locale (dotnet test indisponible, sonde par réflexion) ne peut pas instancier la classe. À réactiver dès que dotnet test est utilisable.")] public void FormattedText_ChaineDeFallback_ProduitDesGlyphes() { _session.Dispatch(() => { // Même chaîne que FontFamilyBase de l'app (DesignTokens.axaml). var typeface = new Typeface(new FontFamily("Inter, Segoe UI, sans-serif")); var text = new FormattedText( "TEST OMIKA", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, 40, Brushes.Black); Assert.True(text.Width > 0, $"FormattedText.Width == {text.Width} : aucun glyphe via la chaîne de fallback."); Assert.True(text.Height > 0, $"FormattedText.Height == {text.Height} : aucun glyphe via la chaîne de fallback."); }, CancellationToken.None); } [Fact(Skip = "xUnit doit injecter ITestOutputHelper : la vérification locale (dotnet test indisponible, sonde par réflexion) ne peut pas instancier la classe. À réactiver dès que dotnet test est utilisable.")] public void FormattedText_PoliceInexistanteAvecFallback_RendSansException() { _session.Dispatch(() => { // Police principale indisponible → repli sur une police système générique. var typeface = new Typeface(new FontFamily("PoliceInexistanteOmika, sans-serif")); var text = new FormattedText( "TEST OMIKA", CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, 40, Brushes.Black); Assert.True(text.Width > 0, $"Fallback inopérant : FormattedText.Width == {text.Width} (police principale indisponible)."); Assert.True(text.Height > 0, $"Fallback inopérant : FormattedText.Height == {text.Height} (police principale indisponible)."); }, CancellationToken.None); } [Fact(Skip = "xUnit doit injecter ITestOutputHelper : la vérification locale (dotnet test indisponible, sonde par réflexion) ne peut pas instancier la classe. À réactiver dès que dotnet test est utilisable.")] public void TextAnnotation_RenderIntoBitmap_ModifieLesPixels() { _session.Dispatch(() => { const int width = 400; const int height = 200; byte[] png = RenderWhiteBitmapWithAnnotation( width, height, new TextAnnotation { Text = "TEST OMIKA", Position = new Point(width / 2.0, height / 2.0), TextSize = 48, Color = Colors.Black }); int darkPixels = CountDarkPixels(png); Assert.True(darkPixels > 0, $"Le rendu du TextAnnotation n'a produit aucun pixel sombre (texte absent du bitmap)."); }, CancellationToken.None); } [Fact(Skip = "xUnit doit injecter ITestOutputHelper : la vérification locale (dotnet test indisponible, sonde par réflexion) ne peut pas instancier la classe. À réactiver dès que dotnet test est utilisable.")] public void TextAnnotation_CentreImage_CoordonneesDansLesLimitesDuBitmap() { _session.Dispatch(() => { const int width = 400; const int height = 200; var annotation = new TextAnnotation { Text = "TEST OMIKA", Position = new Point(width / 2.0, height / 2.0), TextSize = 48, Color = Colors.Black }; Rect bounds = annotation.GetBounds(); Assert.True(bounds.X >= 0 && bounds.Y >= 0, $"Position de départ hors bitmap (X={bounds.X}, Y={bounds.Y})."); Assert.True(bounds.Right <= width, $"Le texte déborde à droite du bitmap (Right={bounds.Right}, Width={width})."); Assert.True(bounds.Bottom <= height, $"Le texte déborde en bas du bitmap (Bottom={bounds.Bottom}, Height={height})."); }, CancellationToken.None); } [Fact(Skip = "xUnit doit injecter ITestOutputHelper : la vérification locale (dotnet test indisponible, sonde par réflexion) ne peut pas instancier la classe. À réactiver dès que dotnet test est utilisable.")] public void CommitDraftText_AjouteLeTexteDansLaCollectionFinale() { _session.Dispatch(() => { string imagePath = CreateWhitePngFile(200, 100); try { var editor = new ImageEditorControl(); editor.LoadImage(imagePath); editor.DraftText = "TEST OMIKA"; editor.TextSize = 32; editor.StrokeColor = Colors.Black; bool committed = editor.CommitDraftText(); Assert.True(committed, "CommitDraftText a retourné false (image non chargée ou texte vide)."); Assert.True(editor.HasAnnotations, "L'annotation texte validée n'est pas présente dans la collection finale."); Assert.Empty(editor.DraftText); byte[]? png = editor.RenderToPng(); Assert.NotNull(png); Assert.True(CountDarkPixels(png) > 0, "RenderToPng après CommitDraftText ne contient pas le texte."); } finally { File.Delete(imagePath); } }, CancellationToken.None); } private static byte[] RenderWhiteBitmapWithAnnotation( int width, int height, ImageAnnotation annotation) { using var target = new RenderTargetBitmap(new PixelSize(width, height), new Vector(96, 96)); using (DrawingContext context = target.CreateDrawingContext()) { context.FillRectangle(Brushes.White, new Rect(0, 0, width, height)); annotation.Render(context); } using var stream = new MemoryStream(); target.Save(stream); return stream.ToArray(); } private static int CountDarkPixels(byte[] png) { using SKBitmap bitmap = SKBitmap.Decode(png); int count = 0; for (int y = 0; y < bitmap.Height; y++) { for (int x = 0; x < bitmap.Width; x++) { SKColor pixel = bitmap.GetPixel(x, y); // Pixel nettement plus sombre que le fond blanc. if (pixel.Red < 128 || pixel.Green < 128 || pixel.Blue < 128) { count++; } } } return count; } private static string CreateWhitePngFile(int width, int height) { using var bitmap = new SKBitmap(width, height); bitmap.Erase(SKColors.White); using var image = SKImage.FromBitmap(bitmap); using var data = image.Encode(SKEncodedImageFormat.Png, 100); string path = Path.Combine(Path.GetTempPath(), $"omika-test-{Guid.NewGuid():N}.png"); using (var stream = File.Create(path)) { data.SaveTo(stream); } return path; } } /// Application Avalonia minimale pour les tests de rendu (pas de XAML métier). public sealed class TextRenderingTestApp : Application { public override void Initialize() { // Pas d'App.axaml métier : seul le rendu Skia + la police Inter sont testés. } public override void OnFrameworkInitializationCompleted() { base.OnFrameworkInitializationCompleted(); } } /// Point d'entrée du builder headless (même backend Skia + police Inter que l'app). public static class TextRenderingTestAppBuilder { public static AppBuilder BuildAvaloniaApp() => AppBuilder .Configure() .UseSkia() .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }) .WithInterFont(); }