using Microsoft.Extensions.Logging.Abstractions; using Omika.App.Local.Abstractions; using Omika.App.Services; using SkiaSharp; namespace Omika.Tests.Mobile.Local; /// /// 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. /// public class MetadataDocumentDateResolverTests : IDisposable { private readonly string _directory; private readonly MetadataDocumentDateResolver _resolver = new(NullLogger.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 ===================== /// Format ASCII EXIF : « aaaa:MM:jj hh:mm:ss » + octet nul. private static byte[] ExifDate(DateTime value) => System.Text.Encoding.ASCII.GetBytes(value.ToString("yyyy:MM:dd HH:mm:ss") + "\0"); /// /// 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é. /// 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(); 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(); 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 "Exif\0\0" var payload = new List(); 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 { 0xFF, 0xE1, (byte)(segmentLength >> 8), (byte)(segmentLength & 0xFF) }; segment.AddRange(payload); return [.. segment]; } /// /// É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. /// 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(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); } /// /// É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. /// 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); } /// Double de test de journalisation : conserve les entrées émises. private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger { public List Entries { get; } = []; public bool HasException { get; private set; } public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; public void Log( Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add(formatter(state, exception)); if (exception is not null) { HasException = true; } } } }