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); } }