using System.Net.Http;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Navigation;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.Core.Contracts.Documents;
using Omika.Core.Observability;
namespace Omika.Tests.Mobile.Local;
///
/// Tests de la visionneuse photo (carrousel).
///
/// Ticket de production du 09/10 (« Connection failure », Error/P1, ScreenCode
/// PHOTO_VIEWER) : l'affichage d'une photo — fichier purement LOCAL — échouait
/// parce que le chargement des notes du document, appel réseau NON essentiel du
/// panneau latéral, partageait le même try. L'échec réseau invalidait
/// l'affichage et produisait un incident.
///
/// Les deux chemins sont désormais isolés : le réseau du panneau notes ne peut
/// plus faire échouer une image affichée, et il est classé en Warning/P2 — état
/// dégradé d'une application offline-first — jamais en Error/P1. Les vraies
/// pannes locales de l'image, elles, restent des incidents.
///
[Collection("TelemetrieStatique")]
public class PhotoViewerViewModelTests : LocalTestBase
{
private const string MessageNotesIndisponibles = "Notes indisponibles pour le moment.";
// ------------------------------------------------------------------
// Fake IDocumentLocalService : seuls EnsureDownloaded / GetCachedFilePath /
// GetNotesForDocument / AddNote sont utilisés par l'écran.
// ------------------------------------------------------------------
private sealed class FakeDocumentService : IDocumentLocalService
{
public IReadOnlyList Items { get; set; } = [];
public string? CachedPath { get; set; } = @"C:\omika-cache\photo.jpg";
public Exception? CachedPathException { get; set; }
public IReadOnlyList NotesToReturn { get; set; } = [];
public Exception? NotesException { get; set; }
public Exception? AddNoteException { get; set; }
public int NotesCalls { get; private set; }
public Task> GetDocumentsForInterventionAsync(
Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult(Items);
public Task EnsureDownloadedAsync(Guid documentGuid, CancellationToken ct = default)
=> Task.FromResult(CachedPath is not null);
public Task GetCachedFilePathAsync(Guid documentGuid, CancellationToken ct = default)
=> CachedPathException is not null
? Task.FromException(CachedPathException)
: Task.FromResult(CachedPath);
public Task> GetNotesForDocumentAsync(
Guid documentGuid, CancellationToken ct = default)
{
NotesCalls++;
return NotesException is not null
? Task.FromException>(NotesException)
: Task.FromResult(NotesToReturn);
}
public Task AddNoteAsync(Guid documentGuid, string contenu, CancellationToken ct = default)
{
if (AddNoteException is not null)
{
return Task.FromException(AddNoteException);
}
// Comportement offline-first réel : la note est enregistrée localement
// et apparaît immédiatement dans la liste, marquée en attente d'envoi.
var note = new DocumentNoteItem
{
NoteGuid = Guid.NewGuid(),
Contenu = contenu,
DateCreation = new DateTime(2026, 9, 10, 15, 0, 0),
SyncLabel = "En attente d'envoi"
};
NotesToReturn = [note, .. NotesToReturn];
return Task.FromResult(note);
}
public Task OpenAsync(Guid documentGuid, CancellationToken ct = default)
=> Task.FromResult(true);
public Task AddPhotoAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult(null);
public Task AddPhotosAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult(new AddPhotosResult());
public Task AddPhotoFromCameraAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult(null);
public Task AddDocumentAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult(null);
public Task AddAnnotatedPhotoAsync(
Guid interventionGuid, Guid sourceDocumentGuid, byte[] pngBytes, string fileName, CancellationToken ct = default)
=> Task.FromResult(null);
}
private static InterventionDocumentItem Photo(Guid id, string nom, DateTime dateCreation)
=> new()
{
DocumentGuid = id,
Nom = nom,
Titre = nom,
IsPhoto = true,
IsCached = true,
DateCreation = dateCreation
};
private (PhotoViewerViewModel Vm, FakeDocumentService Service) CreateViewer(
IReadOnlyList photos)
{
var service = new FakeDocumentService { Items = photos };
var vm = new PhotoViewerViewModel(service, new NavigationService(), Network);
return (vm, service);
}
///
/// Exécute une action en capturant la télémétrie statique globale, puis la
/// désinstalle : la télémétrie est partagée par tous les tests de la collection.
///
private static async Task RunWithTelemetryAsync(Func action)
{
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await action();
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
return telemetry;
}
private static DiagnosticEvent? Evenement(CapturingTelemetry telemetry) =>
telemetry.Events
.OfType()
.SingleOrDefault(e => e.Component == nameof(PhotoViewerViewModel));
private static void AssertAucunIncident(CapturingTelemetry telemetry) =>
Assert.DoesNotContain(
telemetry.Events.OfType(),
e => e.Component == nameof(PhotoViewerViewModel) && e.Level == TelemetryLevel.Error);
// ------------------------------------------------------------------
// Chemin NOTES : appel réseau non essentiel
// ------------------------------------------------------------------
///
/// Cœur du ticket : l'échec réseau des notes ne doit plus faire échouer
/// l'affichage de l'image (déjà en cache local) ni produire d'incident.
///
[Fact]
public async Task Load_EchecReseauDesNotes_LImageResteAfficheeSansIncident()
{
Guid documentId = Guid.NewGuid();
(PhotoViewerViewModel vm, FakeDocumentService service) =
CreateViewer([Photo(documentId, "photo.jpg", new DateTime(2026, 9, 10, 14, 0, 0))]);
service.NotesException = new HttpRequestException("Connection failure");
CapturingTelemetry telemetry = await RunWithTelemetryAsync(
() => vm.LoadCommand.ExecuteAsync(new PhotoViewerNavigation(Guid.NewGuid(), documentId)));
// L'image n'est pas déclarée en échec : c'est elle qui était cassée avant.
Assert.Null(vm.ErrorMessage);
Assert.Equal(@"C:\omika-cache\photo.jpg", vm.CurrentImagePath);
Assert.False(vm.IsLoading);
// L'état dégradé est porté par le panneau notes, pas par la photo.
Assert.Empty(vm.Notes);
Assert.Equal(MessageNotesIndisponibles, vm.NotesEmptyMessage);
DiagnosticEvent evt = Assert.IsType(Evenement(telemetry));
Assert.Equal(TelemetryLevel.Warning, evt.Level);
Assert.Equal("HttpRequestException", evt.ErrorCode);
AssertAucunIncident(telemetry);
}
///
/// Le message d'échec réseau du panneau notes ne doit pas écraser le message
/// local : l'utilisateur doit savoir que le FICHIER est absent, pas croire à
/// une simple coupure réseau.
///
[Fact]
public async Task Load_EchecReseauDesNotes_FichierAbsent_MessageLocalConserve()
{
Guid documentId = Guid.NewGuid();
(PhotoViewerViewModel vm, FakeDocumentService service) =
CreateViewer([Photo(documentId, "photo.jpg", new DateTime(2026, 9, 10, 14, 0, 0))]);
service.CachedPath = null;
service.NotesException = new HttpRequestException("Connection failure");
await vm.LoadCommand.ExecuteAsync(new PhotoViewerNavigation(Guid.NewGuid(), documentId));
Assert.Equal("Le fichier n'est pas disponible localement.", vm.ErrorMessage);
Assert.Equal(MessageNotesIndisponibles, vm.NotesEmptyMessage);
}
///
/// Hors ligne, le panneau notes n'est plus « indisponible » : la liste est
/// servie depuis l'appareil (notes déjà enregistrées, y compris celles qui
/// attendent leur envoi). Aucun incident n'est produit.
///
[Fact]
public async Task Load_HorsLigne_SertLesNotesLocales()
{
Guid documentId = Guid.NewGuid();
(PhotoViewerViewModel vm, FakeDocumentService service) =
CreateViewer([Photo(documentId, "photo.jpg", new DateTime(2026, 9, 10, 14, 0, 0))]);
Network.Online = false;
service.NotesToReturn =
[
new DocumentNoteItem
{
NoteGuid = Guid.NewGuid(),
Contenu = "Note saisie sur le terrain",
SyncLabel = "En attente d'envoi"
}
];
CapturingTelemetry telemetry = await RunWithTelemetryAsync(
() => vm.LoadCommand.ExecuteAsync(new PhotoViewerNavigation(Guid.NewGuid(), documentId)));
Assert.Equal(1, service.NotesCalls);
Assert.True(vm.HasNotes);
Assert.Equal("Note saisie sur le terrain", Assert.Single(vm.Notes).Contenu);
Assert.Null(vm.ErrorMessage);
Assert.NotNull(vm.CurrentImagePath);
Assert.Null(Evenement(telemetry));
}
///
/// Hors ligne et aucune note locale : le panneau annonce que la saisie est
/// possible et que l'envoi se fera à la reconnexion, plutôt qu'un « Aucune
/// note. » qui laisserait croire que l'ajout est impossible hors réseau.
///
[Fact]
public async Task Load_HorsLigne_SansNote_AnnonceLaSynchronisation()
{
Guid documentId = Guid.NewGuid();
(PhotoViewerViewModel vm, FakeDocumentService service) =
CreateViewer([Photo(documentId, "photo.jpg", new DateTime(2026, 9, 10, 14, 0, 0))]);
Network.Online = false;
await vm.LoadCommand.ExecuteAsync(new PhotoViewerNavigation(Guid.NewGuid(), documentId));
Assert.False(vm.HasNotes);
Assert.Equal(
"Hors ligne : les notes saisies seront envoyées à la reconnexion.",
vm.NotesEmptyMessage);
Assert.Null(vm.ErrorMessage);
}
///
/// Contre-épreuve : une vraie panne LOCALE du chemin image (SQLite, fichier)
/// reste un incident Error/P1 — aucune exception réseau ne peut arriver là,
/// EnsureDownloadedAsync absorbant déjà les siennes.
///
[Fact]
public async Task Load_EchecLocalDeLImage_ResteUnIncident()
{
Guid documentId = Guid.NewGuid();
(PhotoViewerViewModel vm, FakeDocumentService service) =
CreateViewer([Photo(documentId, "photo.jpg", new DateTime(2026, 9, 10, 14, 0, 0))]);
service.CachedPathException = new IOException("disque plein");
CapturingTelemetry telemetry = await RunWithTelemetryAsync(
() => vm.LoadCommand.ExecuteAsync(new PhotoViewerNavigation(Guid.NewGuid(), documentId)));
Assert.Equal("L'image n'a pas pu être affichée.", vm.ErrorMessage);
DiagnosticEvent evt = Assert.IsType(Evenement(telemetry));
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("IOException", evt.ErrorCode);
}
///
/// Contre-épreuve : une erreur métier des notes (hors réseau) reste un incident
/// Error/P1. Le classement ne dégrade que le réseau, jamais le reste.
///
[Fact]
public async Task Load_ErreurMetierDesNotes_ResteUnIncident()
{
Guid documentId = Guid.NewGuid();
(PhotoViewerViewModel vm, FakeDocumentService service) =
CreateViewer([Photo(documentId, "photo.jpg", new DateTime(2026, 9, 10, 14, 0, 0))]);
service.NotesException = new InvalidOperationException("réponse serveur illisible");
CapturingTelemetry telemetry = await RunWithTelemetryAsync(
() => vm.LoadCommand.ExecuteAsync(new PhotoViewerNavigation(Guid.NewGuid(), documentId)));
Assert.Null(vm.ErrorMessage);
DiagnosticEvent evt = Assert.IsType(Evenement(telemetry));
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("InvalidOperationException", evt.ErrorCode);
}
///
/// Passer à l'image suivante en échec réseau ne doit pas laisser les notes de
/// l'image PRÉCÉDENTE affichées sous la nouvelle (notes attribuées au mauvais
/// document).
///
[Fact]
public async Task Next_EchecReseau_NeConservePasLesNotesDeLImagePrecedente()
{
Guid premierId = Guid.NewGuid();
Guid secondId = Guid.NewGuid();
(PhotoViewerViewModel vm, FakeDocumentService service) = CreateViewer(
[
Photo(premierId, "a.jpg", new DateTime(2026, 9, 10, 9, 0, 0)),
Photo(secondId, "b.jpg", new DateTime(2026, 9, 10, 10, 0, 0))
]);
service.NotesToReturn = [new DocumentNoteItem { NoteGuid = Guid.NewGuid(), Contenu = "Note de la première image" }];
await vm.LoadCommand.ExecuteAsync(new PhotoViewerNavigation(Guid.NewGuid(), premierId));
Assert.True(vm.HasNotes);
// L'image suivante ne peut pas charger ses notes.
service.NotesToReturn = [];
service.NotesException = new HttpRequestException("Connection failure");
await vm.NextCommand.ExecuteAsync(null);
Assert.False(vm.HasNotes);
Assert.Empty(vm.Notes);
Assert.Equal(MessageNotesIndisponibles, vm.NotesEmptyMessage);
}
// ------------------------------------------------------------------
// Ajout d'une note
// ------------------------------------------------------------------
///
/// Hors ligne, l'ajout d'une note sur une photo aboutit : elle est enregistrée
/// sur l'appareil, s'affiche immédiatement comme « en attente d'envoi » et le
/// champ de saisie est vidé. Aucun message d'erreur réseau n'apparaît.
///
[Fact]
public async Task AddNote_HorsLigne_NoteAfficheeEnAttenteSansErreur()
{
Guid documentId = Guid.NewGuid();
(PhotoViewerViewModel vm, FakeDocumentService service) =
CreateViewer([Photo(documentId, "photo.jpg", new DateTime(2026, 9, 10, 14, 0, 0))]);
Network.Online = false;
await vm.LoadCommand.ExecuteAsync(new PhotoViewerNavigation(Guid.NewGuid(), documentId));
vm.NoteText = "Fuite sous l'évier";
await vm.AddNoteCommand.ExecuteAsync(null);
DocumentNoteItem note = Assert.Single(vm.Notes);
Assert.Equal("Fuite sous l'évier", note.Contenu);
Assert.True(note.EnAttenteSynchronisation);
Assert.Equal("En attente d'envoi", note.SyncLabel);
Assert.Equal(string.Empty, vm.NoteText);
Assert.Null(vm.ErrorMessage);
}
///
/// Un échec d'ajout de note concerne le panneau notes : le message ne doit pas
/// s'afficher au-dessus de la photo, et le texte saisi ne doit pas être perdu.
///
[Fact]
public async Task AddNote_EchecReseau_MessageDansLePanneauNotesEtTexteConserve()
{
Guid documentId = Guid.NewGuid();
(PhotoViewerViewModel vm, FakeDocumentService service) =
CreateViewer([Photo(documentId, "photo.jpg", new DateTime(2026, 9, 10, 14, 0, 0))]);
await vm.LoadCommand.ExecuteAsync(new PhotoViewerNavigation(Guid.NewGuid(), documentId));
vm.NoteText = "à envoyer plus tard";
service.AddNoteException = new HttpRequestException("Connection failure");
CapturingTelemetry telemetry = await RunWithTelemetryAsync(() => vm.AddNoteCommand.ExecuteAsync(null));
Assert.Null(vm.ErrorMessage);
Assert.Equal("Hors ligne — impossible de contacter le serveur.", vm.NotesEmptyMessage);
Assert.Equal("à envoyer plus tard", vm.NoteText);
DiagnosticEvent evt = Assert.IsType(Evenement(telemetry));
Assert.Equal(TelemetryLevel.Warning, evt.Level);
AssertAucunIncident(telemetry);
}
/// Capture des événements de la télémétrie statique globale.
private sealed class CapturingTelemetry : IOmikaTelemetry
{
public List Events { get; } = [];
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
}
}