Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user