405 lines
17 KiB
C#
405 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 <c>try</c>. 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.
|
|
/// </summary>
|
|
[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<InterventionDocumentItem> Items { get; set; } = [];
|
|
public string? CachedPath { get; set; } = @"C:\omika-cache\photo.jpg";
|
|
public Exception? CachedPathException { get; set; }
|
|
public IReadOnlyList<DocumentNoteItem> NotesToReturn { get; set; } = [];
|
|
public Exception? NotesException { get; set; }
|
|
public Exception? AddNoteException { get; set; }
|
|
public int NotesCalls { get; private set; }
|
|
|
|
public Task<IReadOnlyList<InterventionDocumentItem>> GetDocumentsForInterventionAsync(
|
|
Guid interventionGuid, CancellationToken ct = default)
|
|
=> Task.FromResult(Items);
|
|
|
|
public Task<bool> EnsureDownloadedAsync(Guid documentGuid, CancellationToken ct = default)
|
|
=> Task.FromResult(CachedPath is not null);
|
|
|
|
public Task<string?> GetCachedFilePathAsync(Guid documentGuid, CancellationToken ct = default)
|
|
=> CachedPathException is not null
|
|
? Task.FromException<string?>(CachedPathException)
|
|
: Task.FromResult(CachedPath);
|
|
|
|
public Task<IReadOnlyList<DocumentNoteItem>> GetNotesForDocumentAsync(
|
|
Guid documentGuid, CancellationToken ct = default)
|
|
{
|
|
NotesCalls++;
|
|
return NotesException is not null
|
|
? Task.FromException<IReadOnlyList<DocumentNoteItem>>(NotesException)
|
|
: Task.FromResult(NotesToReturn);
|
|
}
|
|
|
|
public Task<DocumentNoteItem> AddNoteAsync(Guid documentGuid, string contenu, CancellationToken ct = default)
|
|
{
|
|
if (AddNoteException is not null)
|
|
{
|
|
return Task.FromException<DocumentNoteItem>(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<bool> OpenAsync(Guid documentGuid, CancellationToken ct = default)
|
|
=> Task.FromResult(true);
|
|
|
|
public Task<InterventionDocumentItem?> AddPhotoAsync(Guid interventionGuid, CancellationToken ct = default)
|
|
=> Task.FromResult<InterventionDocumentItem?>(null);
|
|
|
|
public Task<AddPhotosResult> AddPhotosAsync(Guid interventionGuid, CancellationToken ct = default)
|
|
=> Task.FromResult(new AddPhotosResult());
|
|
|
|
public Task<InterventionDocumentItem?> AddPhotoFromCameraAsync(Guid interventionGuid, CancellationToken ct = default)
|
|
=> Task.FromResult<InterventionDocumentItem?>(null);
|
|
|
|
public Task<InterventionDocumentItem?> AddDocumentAsync(Guid interventionGuid, CancellationToken ct = default)
|
|
=> Task.FromResult<InterventionDocumentItem?>(null);
|
|
|
|
public Task<InterventionDocumentItem?> AddAnnotatedPhotoAsync(
|
|
Guid interventionGuid, Guid sourceDocumentGuid, byte[] pngBytes, string fileName, CancellationToken ct = default)
|
|
=> Task.FromResult<InterventionDocumentItem?>(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<InterventionDocumentItem> photos)
|
|
{
|
|
var service = new FakeDocumentService { Items = photos };
|
|
var vm = new PhotoViewerViewModel(service, new NavigationService(), Network);
|
|
return (vm, service);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static async Task<CapturingTelemetry> RunWithTelemetryAsync(Func<Task> 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<DiagnosticEvent>()
|
|
.SingleOrDefault(e => e.Component == nameof(PhotoViewerViewModel));
|
|
|
|
private static void AssertAucunIncident(CapturingTelemetry telemetry) =>
|
|
Assert.DoesNotContain(
|
|
telemetry.Events.OfType<DiagnosticEvent>(),
|
|
e => e.Component == nameof(PhotoViewerViewModel) && e.Level == TelemetryLevel.Error);
|
|
|
|
// ------------------------------------------------------------------
|
|
// Chemin NOTES : appel réseau non essentiel
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<DiagnosticEvent>(Evenement(telemetry));
|
|
Assert.Equal(TelemetryLevel.Warning, evt.Level);
|
|
Assert.Equal("HttpRequestException", evt.ErrorCode);
|
|
AssertAucunIncident(telemetry);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Contre-épreuve : une vraie panne LOCALE du chemin image (SQLite, fichier)
|
|
/// reste un incident Error/P1 — aucune exception réseau ne peut arriver là,
|
|
/// <c>EnsureDownloadedAsync</c> absorbant déjà les siennes.
|
|
/// </summary>
|
|
[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<DiagnosticEvent>(Evenement(telemetry));
|
|
Assert.Equal(TelemetryLevel.Error, evt.Level);
|
|
Assert.Equal("IOException", evt.ErrorCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<DiagnosticEvent>(Evenement(telemetry));
|
|
Assert.Equal(TelemetryLevel.Error, evt.Level);
|
|
Assert.Equal("InvalidOperationException", evt.ErrorCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[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
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<DiagnosticEvent>(Evenement(telemetry));
|
|
Assert.Equal(TelemetryLevel.Warning, evt.Level);
|
|
AssertAucunIncident(telemetry);
|
|
}
|
|
|
|
/// <summary>Capture des événements de la télémétrie statique globale.</summary>
|
|
private sealed class CapturingTelemetry : IOmikaTelemetry
|
|
{
|
|
public List<OmikaEvent> Events { get; } = [];
|
|
|
|
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
|
|
|
|
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
|
|
}
|
|
}
|