using Microsoft.Extensions.Logging.Abstractions;
using Omika.App.Local.Abstractions;
using Omika.App.Services;
using SkiaSharp;
namespace Omika.Tests.Mobile.Local;
///
/// Tests de l'optimisation réelle du poids des images (SkiaSharp).
/// Vérifie que la limite de poids est respectée, que le format est conservé et
/// que l'échec n'empêche jamais la création du document.
///
public class SkiaImageOptimizerTests : IDisposable
{
private readonly string _directory;
private readonly SkiaImageOptimizer _optimizer = new(NullLogger.Instance);
public SkiaImageOptimizerTests()
{
_directory = Path.Combine(Path.GetTempPath(), "omika-image-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_directory);
}
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
GC.SuppressFinalize(this);
}
/// Bitmap à fort entropie : le JPEG reste volumineux, ce qui force l'optimisation.
private static SKBitmap CreateNoisyBitmap(int width, int height, int seed = 12345)
{
var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul));
var random = new Random(seed);
var pixels = new SKColor[width * height];
for (int i = 0; i < pixels.Length; i++)
{
byte value = (byte)random.Next(256);
pixels[i] = new SKColor(value, (byte)(value ^ 0x5A), (byte)(255 - value));
}
bitmap.Pixels = pixels;
return bitmap;
}
private string WriteJpeg(string name, int width, int height)
{
using SKBitmap bitmap = CreateNoisyBitmap(width, height);
using SKImage image = SKImage.FromBitmap(bitmap);
using SKData data = image.Encode(SKEncodedImageFormat.Jpeg, quality: 100);
string path = Path.Combine(_directory, name);
File.WriteAllBytes(path, data.ToArray());
return path;
}
private string WritePng(string name, int width, int height)
{
using SKBitmap bitmap = CreateNoisyBitmap(width, height, seed: 777);
using SKImage image = SKImage.FromBitmap(bitmap);
using SKData data = image.Encode(SKEncodedImageFormat.Png, quality: 100);
string path = Path.Combine(_directory, name);
File.WriteAllBytes(path, data.ToArray());
return path;
}
[Fact]
public async Task ImageSousLaLimite_AucuneModification()
{
// Arrange : limite très large, l'image est déjà conforme.
string source = WriteJpeg("petite.jpg", 200, 150);
string destination = Path.Combine(_directory, "out.jpg");
byte[] original = await File.ReadAllBytesAsync(source);
// Act
ImageOptimizationResult result = await _optimizer.OptimizeFileAsync(
source, destination, "petite.jpg", "image/jpeg", maxSizeKb: 10240);
// Assert : fichier copié octet pour octet, rien n'est réencodé.
Assert.False(result.WasOptimized);
Assert.Equal(original, await File.ReadAllBytesAsync(destination));
}
[Fact]
public async Task ImageAuDessusDeLaLimite_OptimiseeSousLaLimite()
{
// Arrange : image volumineuse, limite serrée.
string source = WriteJpeg("grande.jpg", 1200, 900);
string destination = Path.Combine(_directory, "out.jpg");
long originalBytes = new FileInfo(source).Length;
const int limiteKo = 120;
// Prérequis du test : l'image dépasse bien la limite.
Assert.True(originalBytes > limiteKo * 1024L,
$"L'image de test devrait dépasser la limite ({originalBytes} octets).");
// Act
ImageOptimizationResult result = await _optimizer.OptimizeFileAsync(
source, destination, "grande.jpg", "image/jpeg", limiteKo);
// Assert : la limite de poids est respectée, et il y a bien eu réencodage.
Assert.True(result.WasOptimized);
Assert.True(result.FinalBytes <= limiteKo * 1024L,
$"Taille finale {result.FinalBytes} octets > limite {limiteKo * 1024L} octets.");
Assert.Equal(result.FinalBytes, new FileInfo(destination).Length);
Assert.NotNull(result.Quality);
// Le résultat reste une image JPEG valide.
using SKBitmap? decoded = SKBitmap.Decode(destination);
Assert.NotNull(decoded);
Assert.True(decoded.Width > 0 && decoded.Height > 0);
}
[Fact]
public async Task ImageAuDessusDeLaLimite_RapportDAspectConserve()
{
// Arrange : source 4:3.
string source = WriteJpeg("ratio.jpg", 1200, 900);
string destination = Path.Combine(_directory, "out.jpg");
// Act
ImageOptimizationResult result = await _optimizer.OptimizeFileAsync(
source, destination, "ratio.jpg", "image/jpeg", 120);
// Assert : l'orientation reste paysage, le rapport est préservé à l'arrondi près.
Assert.True(result.FinalWidth >= result.FinalHeight);
double ratioSource = 1200d / 900d;
double ratioFinal = (double)result.FinalWidth / result.FinalHeight;
Assert.True(Math.Abs(ratioSource - ratioFinal) < 0.05,
$"Rapport d'aspect modifié : {ratioSource:0.###} → {ratioFinal:0.###}.");
}
[Fact]
public async Task Png_RestePng()
{
// Arrange : un PNG ne doit jamais être converti en JPEG.
string source = WritePng("image.png", 300, 200);
string destination = Path.Combine(_directory, "out.png");
// Act
await _optimizer.OptimizeFileAsync(source, destination, "image.png", "image/png", 40);
// Assert : signature PNG conservée (89 50 4E 47).
byte[] bytes = await File.ReadAllBytesAsync(destination);
Assert.True(bytes.Length > 8);
Assert.Equal(0x89, bytes[0]);
Assert.Equal(0x50, bytes[1]);
Assert.Equal(0x4E, bytes[2]);
Assert.Equal(0x47, bytes[3]);
}
[Fact]
public async Task FormatNonSupporte_ConserveTelQuel()
{
// Arrange : un HEIC n'est pas décodable par Skia : il doit être conservé.
string source = Path.Combine(_directory, "photo.heic");
byte[] content = [1, 2, 3, 4, 5];
await File.WriteAllBytesAsync(source, content);
string destination = Path.Combine(_directory, "out.heic");
// Act
ImageOptimizationResult result = await _optimizer.OptimizeFileAsync(
source, destination, "photo.heic", "image/heic", 100);
// Assert
Assert.False(result.WasOptimized);
Assert.True(result.FormatNotOptimizable);
Assert.Equal(content, await File.ReadAllBytesAsync(destination));
}
[Fact]
public async Task AucuneLimite_FichierCopie()
{
// Arrange
string source = WriteJpeg("libre.jpg", 300, 200);
string destination = Path.Combine(_directory, "out.jpg");
byte[] original = await File.ReadAllBytesAsync(source);
// Act
ImageOptimizationResult result = await _optimizer.OptimizeFileAsync(
source, destination, "libre.jpg", "image/jpeg", maxSizeKb: 0);
// Assert
Assert.False(result.WasOptimized);
Assert.Equal(original, await File.ReadAllBytesAsync(destination));
}
[Fact]
public async Task FichierIllisible_ConserveEtNeLevePas()
{
// Arrange : contenu corrompu portant une extension image.
string source = Path.Combine(_directory, "corrompu.jpg");
byte[] content = [0xFF, 0x00, 0x11, 0x22, 0x33, 0x44];
await File.WriteAllBytesAsync(source, content);
string destination = Path.Combine(_directory, "out.jpg");
// Act : ne doit jamais lever, l'utilisateur continue de travailler.
ImageOptimizationResult result = await _optimizer.OptimizeFileAsync(
source, destination, "corrompu.jpg", "image/jpeg", 10);
// Assert : le document reste exploitable avec le contenu d'origine.
Assert.False(result.WasOptimized);
Assert.True(File.Exists(destination));
Assert.Equal(content, await File.ReadAllBytesAsync(destination));
}
[Fact]
public async Task DestinationToujoursProduite_QuelQueSoitLeCas()
{
// Arrange : un cas par famille.
string jpeg = WriteJpeg("a.jpg", 300, 200);
string pdf = Path.Combine(_directory, "d.pdf");
await File.WriteAllBytesAsync(pdf, [1, 2, 3]);
// Act / Assert : la destination existe dans tous les cas.
foreach ((string source, string name, string mime) in new[]
{
(jpeg, "a.jpg", "image/jpeg"),
(pdf, "d.pdf", "application/pdf")
})
{
string destination = Path.Combine(_directory, $"dest-{name}");
await _optimizer.OptimizeFileAsync(source, destination, name, mime, 50);
Assert.True(File.Exists(destination), $"Destination absente pour {name}.");
}
}
[Fact]
public async Task FichierNonImage_NonOptimise()
{
// Arrange : extension non image, même avec un type MIME ambigu.
string source = Path.Combine(_directory, "archive.zip");
byte[] content = [9, 8, 7];
await File.WriteAllBytesAsync(source, content);
string destination = Path.Combine(_directory, "out.zip");
// Act
ImageOptimizationResult result = await _optimizer.OptimizeFileAsync(
source, destination, "archive.zip", "application/zip", 10);
// Assert
Assert.False(result.WasOptimized);
Assert.True(result.FormatNotOptimizable);
Assert.Equal(content, await File.ReadAllBytesAsync(destination));
}
}