Files
Omika/tests/Omika.Tests/Mobile/ImageAnnotationRenderingTests.cs
T
2026-10-23 18:46:50 +02:00

298 lines
12 KiB
C#

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;
/// <summary>
/// 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) ;
/// - <see cref="TextAnnotation.Render"/> modifie réellement les pixels d'un
/// <see cref="RenderTargetBitmap"/> (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.
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
[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;
}
}
/// <summary>Application Avalonia minimale pour les tests de rendu (pas de XAML métier).</summary>
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();
}
}
/// <summary>Point d'entrée du builder headless (même backend Skia + police Inter que l'app).</summary>
public static class TextRenderingTestAppBuilder
{
public static AppBuilder BuildAvaloniaApp() => AppBuilder
.Configure<TextRenderingTestApp>()
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
.WithInterFont();
}