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

517 lines
20 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Omika.App.Local.Abstractions;
using Omika.App.Services;
using SkiaSharp;
namespace Omika.Tests.Mobile.Local;
/// <summary>
/// Tests de la lecture de la date réelle d'un fichier (EXIF) et de son repli.
///
/// Les images de test sont de VRAIS JPEG produits par SkiaSharp dans lesquels un
/// segment APP1 EXIF est réellement inséré : MetadataExtractor analyse donc de
/// véritables métadonnées, et non un double de test.
/// </summary>
public class MetadataDocumentDateResolverTests : IDisposable
{
private readonly string _directory;
private readonly MetadataDocumentDateResolver _resolver =
new(NullLogger<MetadataDocumentDateResolver>.Instance);
public MetadataDocumentDateResolverTests()
{
_directory = Path.Combine(Path.GetTempPath(), "omika-date-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_directory);
}
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
GC.SuppressFinalize(this);
}
// ===================== Fabrique d'un JPEG porteur d'EXIF =====================
/// <summary>Format ASCII EXIF : « aaaa:MM:jj hh:mm:ss » + octet nul.</summary>
private static byte[] ExifDate(DateTime value)
=> System.Text.Encoding.ASCII.GetBytes(value.ToString("yyyy:MM:dd HH:mm:ss") + "\0");
/// <summary>
/// Construit un segment APP1 EXIF (métadonnées TIFF little-endian) contenant
/// les tags de date demandés. Les tags absents ne sont pas écrits, ce qui
/// permet de tester précisément l'ordre de priorité.
/// </summary>
private static byte[] BuildApp1Segment(
DateTime? dateTimeOriginal,
DateTime? dateTimeDigitized,
DateTime? ifd0DateTime)
{
// IFD0 : toujours le pointeur vers le SubIFD Exif, plus éventuellement
// le tag DateTime (0x0132).
int ifd0Count = 1 + (ifd0DateTime.HasValue ? 1 : 0);
int subCount = (dateTimeOriginal.HasValue ? 1 : 0) + (dateTimeDigitized.HasValue ? 1 : 0);
const int tiffHeaderSize = 8;
int ifd0Offset = tiffHeaderSize;
int ifd0Size = 2 + (ifd0Count * 12) + 4;
int subIfdOffset = ifd0Offset + ifd0Size;
int subIfdSize = 2 + (subCount * 12) + 4;
int dataOffset = subIfdOffset + subIfdSize;
var tiff = new List<byte>();
void U16(ushort v) { tiff.Add((byte)(v & 0xFF)); tiff.Add((byte)(v >> 8)); }
void U32(uint v) { tiff.Add((byte)(v & 0xFF)); tiff.Add((byte)((v >> 8) & 0xFF)); tiff.Add((byte)((v >> 16) & 0xFF)); tiff.Add((byte)((v >> 24) & 0xFF)); }
// En-tête TIFF : « II », 42, offset IFD0.
tiff.Add((byte)'I');
tiff.Add((byte)'I');
U16(42);
U32((uint)ifd0Offset);
// --- IFD0 ---
U16((ushort)ifd0Count);
// Pointeur vers le SubIFD Exif (0x8769, type LONG, 1 valeur).
U16(0x8769);
U16(4);
U32(1);
U32((uint)subIfdOffset);
var inlineData = new List<byte>();
int inlineDataOffset = dataOffset;
if (ifd0DateTime.HasValue)
{
byte[] data = ExifDate(ifd0DateTime.Value);
U16(0x0132);
U16(2); // ASCII
U32((uint)data.Length);
U32((uint)inlineDataOffset); // chaîne de 20 octets : hors des 4 octets en ligne
inlineData.AddRange(data);
inlineDataOffset += data.Length;
}
U32(0); // pas d'IFD0 suivant
// --- SubIFD Exif ---
U16((ushort)subCount);
if (dateTimeOriginal.HasValue)
{
byte[] data = ExifDate(dateTimeOriginal.Value);
U16(0x9003); // DateTimeOriginal
U16(2);
U32((uint)data.Length);
U32((uint)inlineDataOffset);
inlineData.AddRange(data);
inlineDataOffset += data.Length;
}
if (dateTimeDigitized.HasValue)
{
byte[] data = ExifDate(dateTimeDigitized.Value);
U16(0x9004); // DateTimeDigitized
U16(2);
U32((uint)data.Length);
U32((uint)inlineDataOffset);
inlineData.AddRange(data);
inlineDataOffset += data.Length;
}
U32(0); // pas de SubIFD suivant
tiff.AddRange(inlineData);
// Segment APP1 : FF E1 <longueur> "Exif\0\0" <TIFF>
var payload = new List<byte>();
payload.AddRange("Exif\0\0"u8.ToArray());
payload.AddRange(tiff);
ushort segmentLength = (ushort)(payload.Count + 2); // la longueur se compte elle-même
var segment = new List<byte> { 0xFF, 0xE1, (byte)(segmentLength >> 8), (byte)(segmentLength & 0xFF) };
segment.AddRange(payload);
return [.. segment];
}
/// <summary>
/// Écrit un VRAI JPEG (encodé par SkiaSharp) puis y insère le segment APP1 EXIF
/// juste après le marqueur SOI : le fichier reste un JPEG lisible.
/// </summary>
private string WriteJpegWithExif(
string name,
DateTime? dateTimeOriginal,
DateTime? dateTimeDigitized = null,
DateTime? ifd0DateTime = null)
{
using var bitmap = new SKBitmap(new SKImageInfo(16, 16, SKColorType.Rgba8888, SKAlphaType.Premul));
using SKImage image = SKImage.FromBitmap(bitmap);
using SKData encoded = image.Encode(SKEncodedImageFormat.Jpeg, quality: 90);
byte[] jpeg = encoded.ToArray();
byte[] app1 = BuildApp1Segment(dateTimeOriginal, dateTimeDigitized, ifd0DateTime);
// SOI = FF D8 : le segment EXIF s'insère immédiatement après.
var result = new List<byte>(jpeg.Length + app1.Length);
result.AddRange(jpeg[..2]);
result.AddRange(app1);
result.AddRange(jpeg[2..]);
string path = Path.Combine(_directory, name);
File.WriteAllBytes(path, [.. result]);
return path;
}
private string WriteFile(string name, byte[] content)
{
string path = Path.Combine(_directory, name);
File.WriteAllBytes(path, content);
return path;
}
private string WritePngWithoutExif(string name)
{
using var bitmap = new SKBitmap(new SKImageInfo(16, 16, SKColorType.Rgba8888, SKAlphaType.Premul));
using SKImage image = SKImage.FromBitmap(bitmap);
using SKData encoded = image.Encode(SKEncodedImageFormat.Png, quality: 100);
string path = Path.Combine(_directory, name);
File.WriteAllBytes(path, encoded.ToArray());
return path;
}
// ============================ Priorité des sources ============================
[Fact]
public async Task DateTimeOriginal_EstPrioritaire()
{
var prise = new DateTime(2026, 9, 10, 14, 32, 18);
string path = WriteJpegWithExif("IMG_4521.jpg",
dateTimeOriginal: prise,
dateTimeDigitized: new DateTime(2026, 9, 11, 10, 0, 0),
ifd0DateTime: new DateTime(2026, 9, 12, 11, 0, 0));
DocumentDateResolution result = await _resolver.ResolveAsync(path, "IMG_4521.jpg");
Assert.Equal(prise, result.Value);
Assert.Equal(DocumentDateSource.ExifDateTimeOriginal, result.Source);
Assert.False(result.IsFallback);
}
[Fact]
public async Task LHeureEstConservee_PasSeulementLaDate()
{
var prise = new DateTime(2026, 9, 10, 14, 32, 18);
string path = WriteJpegWithExif("heure.jpg", dateTimeOriginal: prise);
DocumentDateResolution result = await _resolver.ResolveAsync(path, "heure.jpg");
Assert.Equal(14, result.Value.Hour);
Assert.Equal(32, result.Value.Minute);
Assert.Equal(18, result.Value.Second);
}
[Fact]
public async Task SansDateTimeOriginal_UtiliseDateTimeDigitized()
{
var numerisee = new DateTime(2025, 4, 5, 6, 7, 8);
string path = WriteJpegWithExif("scan.jpg",
dateTimeOriginal: null,
dateTimeDigitized: numerisee,
ifd0DateTime: new DateTime(2026, 1, 1, 0, 0, 0));
DocumentDateResolution result = await _resolver.ResolveAsync(path, "scan.jpg");
Assert.Equal(numerisee, result.Value);
Assert.Equal(DocumentDateSource.ExifDateTimeDigitized, result.Source);
}
[Fact]
public async Task SansTagSubIfd_UtiliseDateTimeDeLIfd0()
{
var ecriture = new DateTime(2024, 7, 8, 9, 10, 11);
string path = WriteJpegWithExif("ifd0.jpg", dateTimeOriginal: null, ifd0DateTime: ecriture);
DocumentDateResolution result = await _resolver.ResolveAsync(path, "ifd0.jpg");
Assert.Equal(ecriture, result.Value);
Assert.Equal(DocumentDateSource.ExifDateTime, result.Source);
}
// ============================== Replis sûrs ==============================
[Fact]
public async Task PngSansExif_RetombeSurLaDateSysteme()
{
// Cas d'usage : capture d'écran ou image dont les métadonnées ont été
// supprimées.
string path = WritePngWithoutExif("capture.png");
DateTime avant = DateTime.Now.AddSeconds(-1);
DocumentDateResolution result = await _resolver.ResolveAsync(path, "capture.png");
Assert.Equal(DocumentDateSource.SystemFallback, result.Source);
Assert.True(result.IsFallback);
Assert.InRange(result.Value, avant, DateTime.Now.AddSeconds(5));
}
[Fact]
public async Task FichierNonImage_RetombeSurLaDateSysteme_SansErreur()
{
string path = WriteFile("document.pdf", [0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]);
DocumentDateResolution result = await _resolver.ResolveAsync(path, "document.pdf");
Assert.Equal(DocumentDateSource.SystemFallback, result.Source);
}
[Fact]
public async Task FichierCorrompu_RetombeSurLaDateSysteme_SansLeverDException()
{
string path = WriteFile("corrompu.jpg", [0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x02, 0x41, 0x42, 0x43]);
DocumentDateResolution result = await _resolver.ResolveAsync(path, "corrompu.jpg");
Assert.Equal(DocumentDateSource.SystemFallback, result.Source);
}
[Fact]
public async Task FichierInexistant_RetombeSurLaDateSysteme()
{
string path = Path.Combine(_directory, "absent.jpg");
DocumentDateResolution result = await _resolver.ResolveAsync(path, "absent.jpg");
Assert.Equal(DocumentDateSource.SystemFallback, result.Source);
}
[Fact]
public async Task CheminVide_RetombeSurLaDateSysteme()
{
DocumentDateResolution result = await _resolver.ResolveAsync(string.Empty, "vide.jpg");
Assert.Equal(DocumentDateSource.SystemFallback, result.Source);
}
[Fact]
public async Task DateDansLeFutur_EstRejetee()
{
// Une horloge d'appareil déréglée ne doit pas produire une date de document
// postérieure à aujourd'hui : une fausse date serait pire que le repli.
string path = WriteJpegWithExif("futur.jpg",
dateTimeOriginal: DateTime.Now.AddYears(3));
DocumentDateResolution result = await _resolver.ResolveAsync(path, "futur.jpg");
Assert.Equal(DocumentDateSource.SystemFallback, result.Source);
}
[Fact]
public async Task DateAberrante_EstRejetee()
{
// Sous le plancher : métadonnée illisible ou valeur sentinelle.
string path = WriteJpegWithExif("aberration.jpg",
dateTimeOriginal: new DateTime(1800, 1, 1, 0, 0, 0));
DocumentDateResolution result = await _resolver.ResolveAsync(path, "aberration.jpg");
Assert.Equal(DocumentDateSource.SystemFallback, result.Source);
}
[Fact]
public async Task DateAncienne_MaisValide_EstConservee()
{
// Une vieille photo doit conserver sa date : c'est exactement le besoin.
var ancienne = new DateTime(2025, 6, 23, 16, 42, 0);
string path = WriteJpegWithExif("ancienne.jpg", dateTimeOriginal: ancienne);
DocumentDateResolution result = await _resolver.ResolveAsync(path, "ancienne.jpg");
Assert.Equal(ancienne, result.Value);
Assert.Equal(DocumentDateSource.ExifDateTimeOriginal, result.Source);
}
// ================================ Libellés ================================
[Theory]
[InlineData(DocumentDateSource.ExifDateTimeOriginal, "EXIF DateTimeOriginal")]
[InlineData(DocumentDateSource.ExifDateTimeDigitized, "EXIF DateTimeDigitized")]
[InlineData(DocumentDateSource.ExifDateTime, "EXIF DateTime")]
[InlineData(DocumentDateSource.SourceDocument, "SourceDocument")]
[InlineData(DocumentDateSource.SystemFallback, "SystemFallback")]
public void SourceLabel_EstExplicite(DocumentDateSource source, string attendu)
{
var resolution = new DocumentDateResolution(DateTime.Now, source);
Assert.Equal(attendu, resolution.SourceLabel);
}
// ============================== Diagnosticabilité ==============================
//
// Un repli silencieux était indiscernable d'un fonctionnement normal : ces tests
// garantissent que le journal explique POURQUOI la date système a été retenue,
// et notamment si le fichier contenait des métadonnées de date ou pas du tout.
private static MetadataDocumentDateResolver CreateWithLogger(out CapturingLogger logger)
{
logger = new CapturingLogger();
return new MetadataDocumentDateResolver(logger);
}
[Fact]
public async Task Repli_JournaliseLInventaireDesMetadonnees()
{
// Une capture d'écran PNG ne contient aucune date : le journal doit le dire
// explicitement, avec les répertoires effectivement lus.
var resolver = CreateWithLogger(out CapturingLogger logger);
string path = WritePngWithoutExif("capture.png");
await resolver.ResolveAsync(path, "capture.png");
string journal = string.Join("\n", logger.Entries);
Assert.Contains("aucune date exploitable", journal, StringComparison.Ordinal);
Assert.Contains("capture.png", journal, StringComparison.Ordinal);
Assert.Contains("Metadonnees=", journal, StringComparison.Ordinal);
Assert.Contains("Png", journal, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Succes_NeJournaliseAucunRepli()
{
var resolver = CreateWithLogger(out CapturingLogger logger);
string path = WriteJpegWithExif("ok.jpg", dateTimeOriginal: new DateTime(2026, 9, 10, 14, 32, 18));
DocumentDateResolution result = await resolver.ResolveAsync(path, "ok.jpg");
Assert.Equal(DocumentDateSource.ExifDateTimeOriginal, result.Source);
Assert.DoesNotContain(logger.Entries,
e => e.Contains("aucune date exploitable", StringComparison.Ordinal));
}
[Fact]
public async Task LectureImpossible_JournaliseLException()
{
var resolver = CreateWithLogger(out CapturingLogger logger);
string path = WriteFile("corrompu.jpg", [0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x02, 0x41, 0x42, 0x43]);
await resolver.ResolveAsync(path, "corrompu.jpg");
string journal = string.Join("\n", logger.Entries);
Assert.Contains("lecture des métadonnées impossible", journal, StringComparison.Ordinal);
Assert.True(logger.HasException, "L'exception de lecture doit être journalisée.");
}
[Fact]
public async Task FichierIntrouvable_EstJournalise()
{
var resolver = CreateWithLogger(out CapturingLogger logger);
await resolver.ResolveAsync(Path.Combine(_directory, "absent.jpg"), "absent.jpg");
Assert.Contains("fichier introuvable", string.Join("\n", logger.Entries), StringComparison.Ordinal);
}
/// <summary>
/// Écrit un JPEG réellement dépourvu d'EXIF : en-tête JFIF + profil ICC, comme
/// les images réencodées par un outil de partage, une capture d'écran ou un
/// téléchargement. C'est le cas le plus fréquent en production.
/// </summary>
private string WriteJpegWithoutExif(string name)
{
using var bitmap = new SKBitmap(new SKImageInfo(16, 16, SKColorType.Rgba8888, SKAlphaType.Premul));
using SKImage image = SKImage.FromBitmap(bitmap);
using SKData encoded = image.Encode(SKEncodedImageFormat.Jpeg, quality: 90);
string path = Path.Combine(_directory, name);
File.WriteAllBytes(path, encoded.ToArray());
return path;
}
[Fact]
public async Task JpegSansBlocExif_Repli_PuisJournaliseBlocExifAbsent()
{
// Cas réel observé : le fichier est un JPEG valide, mais AUCUN segment
// EXIF n'est présent (réencodage par un outil de partage). Le repli système
// est alors le comportement ATTENDU — et le journal doit le dire, pour ne
// pas laisser croire à une panne de lecture.
var resolver = CreateWithLogger(out CapturingLogger logger);
string path = WriteJpegWithoutExif("shared image (5).jpg");
// Le fichier ne contient réellement aucune métadonnée : vérification du
// prérequis, sans quoi le test ne prouverait rien.
byte[] octets = File.ReadAllBytes(path);
Assert.Equal(0xFF, octets[0]);
Assert.Equal(0xD8, octets[1]);
Assert.DoesNotContain("Exif", System.Text.Encoding.ASCII.GetString(octets), StringComparison.Ordinal);
DateTime avant = DateTime.Now.AddSeconds(-1);
DocumentDateResolution result = await resolver.ResolveAsync(path, "shared image (5).jpg");
Assert.Equal(DocumentDateSource.SystemFallback, result.Source);
Assert.InRange(result.Value, avant, DateTime.Now.AddSeconds(5));
string journal = string.Join("\n", logger.Entries);
Assert.Contains("aucune date exploitable", journal, StringComparison.Ordinal);
Assert.Contains("BlocExif=ABSENT", journal, StringComparison.Ordinal);
}
[Fact]
public async Task BlocExifSansDate_Repli_PuisJournaliseBlocExifPresent()
{
// Un bloc EXIF existe mais ne porte aucune date de prise de vue : le repli
// reste nécessaire, mais la cause est différente et doit être distinguable.
var resolver = CreateWithLogger(out CapturingLogger logger);
string path = WriteJpegWithExif("sansdate.jpg", dateTimeOriginal: null);
DocumentDateResolution result = await resolver.ResolveAsync(path, "sansdate.jpg");
Assert.Equal(DocumentDateSource.SystemFallback, result.Source);
string journal = string.Join("\n", logger.Entries);
Assert.Contains("BlocExif=présent", journal, StringComparison.Ordinal);
}
[Fact]
public async Task DateDansLeFutur_JournaliseLaValeurRejetee()
{
// Le rejet doit être explicite : sinon on ne saurait pas que l'EXIF existait.
var resolver = CreateWithLogger(out CapturingLogger logger);
string path = WriteJpegWithExif("futur.jpg", dateTimeOriginal: DateTime.Now.AddYears(3));
await resolver.ResolveAsync(path, "futur.jpg");
string journal = string.Join("\n", logger.Entries);
Assert.Contains("dans le futur", journal, StringComparison.Ordinal);
Assert.Contains("Date/Time Original", journal, StringComparison.Ordinal);
}
/// <summary>Double de test de journalisation : conserve les entrées émises.</summary>
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<MetadataDocumentDateResolver>
{
public List<string> Entries { get; } = [];
public bool HasException { get; private set; }
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(
Microsoft.Extensions.Logging.LogLevel logLevel,
Microsoft.Extensions.Logging.EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add(formatter(state, exception));
if (exception is not null)
{
HasException = true;
}
}
}
}