298 lines
12 KiB
C#
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();
|
|
}
|