517 lines
20 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|