using System.Collections.ObjectModel; using System.Net.Http; using System.Text.Json; 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 du ViewModel et du modèle d'édition des rubriques de rapport : /// sémantique $UN (sélection unique) vs $TAB (sélection multiple), pré-remplissage, /// EXTRA seul saisie libre, états indisponibles, overlay de génération et ouverture /// automatique du PDF. /// /// Couvre également le découplage local/réseau du chargement de l'écran (ticket de /// production du 09/10 : « Connection failure », Error/P1, ScreenCode RAPPORT) : les /// brouillons — contenu SQLite — ne doivent plus dépendre du catalogue serveur des /// modèles, une coupure réseau doit rester un Warning/P2, et une panne locale doit /// rester un incident. /// [Collection("TelemetrieStatique")] public class RapportChampsViewModelTests { // ===================================================================== // Helpers // ===================================================================== private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private static string ContactsJson(params (string Nom, string Role, string TelFixe)[] contacts) { var items = contacts.Select(c => new { id = Guid.NewGuid().ToString(), nom = c.Nom, role = c.Role, telFixe = c.TelFixe, telMobile = "06 00 00 00 00", etage = "1" }); return JsonSerializer.Serialize(items, Json); } private static RapportChampDto Champ( string rubrique, string? groupeApp = null, string? membreMob = null, string? type = "ChpSimpleChaine", string? listeDonnesBD = null, string? listeDonnesChoix = null, bool modifiable = false, string? nom = null) => new() { Id = "1", Nom = nom, Rubrique = rubrique, Type = type, GroupeApp = groupeApp, MembreMob = membreMob, ListeDonnesBD = listeDonnesBD, ListeDonnesChoix = listeDonnesChoix, Modifiable = modifiable }; // ===================================================================== // CAS 1-8 : sémantique des rubriques (modèle RapportChampEdition) // ===================================================================== [Fact] public void Extra_EstSaisieLibre() { var edition = new RapportChampEdition(Champ("$Saisie_Mob_1$", "EXTRA")); Assert.True(edition.EstGroupeExtra); Assert.True(edition.EstSaisieLibre); Assert.False(edition.EstLectureSeule); } [Fact] public void RefInterne_PreRempli_ReadOnly() { var edition = new RapportChampEdition(Champ( "$REFINTERNE$", "INTER", "IN_RefInterne", listeDonnesBD: "REF-2026-123")); Assert.Equal("REF-2026-123", edition.Valeur); Assert.True(edition.EstLectureSeule); Assert.False(edition.EstSaisieLibre); } [Fact] public void ChampModifiable_EstEditable_MaisPasSaisieLibre() { var edition = new RapportChampEdition(Champ( "$REFINTERNE$", "INTER", "IN_RefInterne", listeDonnesBD: "REF", modifiable: true)); // Modifiable ≠ saisie libre : un champ métier modifiable reste pré-rempli. Assert.False(edition.EstSaisieLibre); Assert.True(edition.EstEditable); Assert.False(edition.EstLectureSeule); } [Fact] public void ChampNonModifiable_ReadOnly() { var edition = new RapportChampEdition(Champ( "$REFINTERNE$", "INTER", "IN_RefInterne", listeDonnesBD: "REF", modifiable: false)); Assert.False(edition.EstSaisieLibre); Assert.True(edition.EstLectureSeule); } [Fact] public void UnCtsite_TroisContacts_SelectionUnique() { string json = ContactsJson( ("Jean Dupont", "Occupant", "04 11 22 33 44"), ("Marie Martin", "Responsable", "04 55 66 77 88"), ("Paul Durand", "Technicien", "04 99 88 77 66")); var edition = new RapportChampEdition(Champ( "$UNCTSITE.NOMPRENOM$", "CONTACT_SITE", "saNom", listeDonnesBD: "Jean Dupont", listeDonnesChoix: json)); Assert.True(edition.EstSelectionUnique); Assert.True(edition.AChoixMultiple); Assert.True(edition.AttendUnSeulElement); Assert.Equal(3, edition.Options.Count); // Pré-sélection du premier élément. Assert.Equal("Jean Dupont", edition.Valeur); Assert.Same(edition.Options[0], edition.SelectionUnique); } [Fact] public void TabCtsite_TroisContacts_MultiSelection() { string json = ContactsJson( ("Jean Dupont", "Occupant", "04 11 22 33 44"), ("Marie Martin", "Responsable", "04 55 66 77 88"), ("Paul Durand", "Technicien", "04 99 88 77 66")); var edition = new RapportChampEdition(new RapportChampDto { Id = "1", Nom = "Contacts du site", Rubrique = "$TABCONTACTSITE$", Type = "Tableau", GroupeApp = "CONTACT_SITE", ListeDonnesBD = json }); Assert.False(edition.EstSelectionUnique); Assert.True(edition.AChoixMultiple); Assert.Equal(3, edition.Options.Count); // Sélection multiple possible : deux lignes cochées. edition.Options[0].EstSelectionne = true; edition.Options[1].EstSelectionne = true; Assert.Equal(2, edition.NombreSelectionnes); } [Fact] public void UnCtsite_Famille_UnSeulSelecteur_TroisValeurs() { string json = ContactsJson( ("Jean Dupont", "Occupant", "04 11 22 33 44"), ("Marie Martin", "Responsable", "04 55 66 77 88"), ("Paul Durand", "Technicien", "04 99 88 77 66")); var nomPrenom = new RapportChampEdition(Champ( "$UNCTSITE.NOMPRENOM$", "CONTACT_SITE", "saNom", listeDonnesBD: "Jean Dupont", listeDonnesChoix: json)); var role = new RapportChampEdition(Champ( "$UNCTSITE.ROLE$", "CONTACT_SITE", "saRole", listeDonnesBD: "Occupant", listeDonnesChoix: json)); var telFixe = new RapportChampEdition(Champ( "$UNCTSITE.TELFIXE$", "CONTACT_SITE", "saTFix", listeDonnesBD: "04 11 22 33 44", listeDonnesChoix: json)); IReadOnlyList famille = [nomPrenom, role, telFixe]; foreach (RapportChampEdition membre in famille) { membre.DefinirFamille(famille); } // Le maître porte la sélection unique. Assert.True(nomPrenom.EstMaitreFamille); Assert.False(role.EstMaitreFamille); // Sélectionner « Marie Martin » alimente les trois rubriques du même contact. nomPrenom.SelectionnerOption(nomPrenom.Options[1]); Assert.Equal("Marie Martin", nomPrenom.Valeur); Assert.Equal("Responsable", role.Valeur); Assert.Equal("04 55 66 77 88", telFixe.Valeur); } [Fact] public void AucunContact_AucuneDonnee() { var edition = new RapportChampEdition(Champ( "$UNPHOTO.IMAGE$", "PHOTO_INTER", "DC_Nom", listeDonnesBD: "", listeDonnesChoix: null)); Assert.True(edition.AucuneDonnee); Assert.True(edition.EstSelectionUnique); } [Fact] public void UnCtsite_BasculerOption_UneSeuleSelectionnee() { string json = ContactsJson( ("Jean Dupont", "Occupant", "04 11 22 33 44"), ("Marie Martin", "Responsable", "04 55 66 77 88"), ("Paul Durand", "Technicien", "04 99 88 77 66")); var edition = new RapportChampEdition(Champ( "$UNCTSITE.NOMPRENOM$", "CONTACT_SITE", "saNom", listeDonnesBD: "Jean Dupont", listeDonnesChoix: json)); // Sélection initiale = premier élément. Assert.Equal(1, edition.NombreSelectionnes); // Clic sur une autre carte : l'ancienne sélection est désélectionnée, // jamais plus d'un IsSelected. edition.BasculerOptionCommand.Execute(edition.Options[1]); Assert.Equal(1, edition.NombreSelectionnes); Assert.True(edition.Options[1].EstSelectionne); Assert.False(edition.Options[0].EstSelectionne); } /// /// Un champ texte NON modifiable s'affiche en texte seul (aucun contrôle de /// saisie, donc aucun champ grisé dans la page). /// [Fact] public void ChampNonModifiable_ChampTexte_AffichageSeulSansEditeur() { var edition = new RapportChampEdition(Champ( "$REFINTERNE$", "INTER", "IN_RefInterne", listeDonnesBD: "REF", modifiable: false)); Assert.True(edition.EstLectureSeule); Assert.True(edition.EstMultiLigneLectureSeule); Assert.False(edition.EstMultiLigneEditable); } /// /// Un champ texte modifiable est TOUJOURS multiligne, même avec une valeur /// courte (« REF ») : une saisie sur une seule ligne masquait la fin d'une /// valeur longue, qui défilait horizontalement au lieu de revenir à la ligne. /// [Fact] public void ChampModifiable_ChampTexte_EstToujoursMultiligne() { var edition = new RapportChampEdition(Champ( "$REFINTERNE$", "INTER", "IN_RefInterne", listeDonnesBD: "REF", modifiable: true)); Assert.False(edition.EstLectureSeule); Assert.True(edition.EstMultiLigne); Assert.True(edition.EstMultiLigneEditable); Assert.False(edition.EstMultiLigneLectureSeule); } /// /// Le type RTF, une valeur courte et une valeur arrondie à la ligne suivent la /// MÊME règle : tous les champs texte sont multilignes, aucun n'est renvoyé /// vers un contrôle sur une seule ligne. /// [Theory] [InlineData("ChpSimpleChaine", "ok")] [InlineData("ChpSimpleChaine", "un texte nettement plus long que soixante caractères, sans retour à la ligne du tout")] [InlineData("ChpSimpleRTF", "")] [InlineData("ChpSimpleChaine", "ligne 1\nligne 2")] public void ChampTexte_QuelQueSoitLeContenu_EstMultiligne(string type, string valeur) { var edition = new RapportChampEdition(Champ( "$TEXTE$", "INTER", "IN_Texte", type: type, listeDonnesBD: valeur, modifiable: true)); Assert.True(edition.EstMultiLigne); Assert.True(edition.EstMultiLigneEditable); } // ===================================================================== // Étapes interactives du carrousel (EstEtapeInteractive / navigation) // ===================================================================== [Fact] public void EtapeInteractive_SimpleReadOnly_False() { var edition = new RapportChampEdition(Champ( "$TITREINTERV$", "INTER", "IN_Titre", listeDonnesBD: "Rénovation", modifiable: false)); Assert.False(edition.EstEtapeInteractive); } [Fact] public void EtapeInteractive_SimpleModifiable_True() { var edition = new RapportChampEdition(Champ( "$REFINTERNE$", "INTER", "IN_RefInterne", listeDonnesBD: "REF", modifiable: true)); Assert.True(edition.EstEtapeInteractive); } [Fact] public void EtapeInteractive_Extra_True() { var edition = new RapportChampEdition(Champ("$Saisie_Mob_1$", "EXTRA")); Assert.True(edition.EstEtapeInteractive); } [Fact] public void EtapeInteractive_UnAvecDonnees_True() { string json = ContactsJson(("Jean Dupont", "Occupant", "04 11 22 33 44")); var edition = new RapportChampEdition(Champ( "$UNCTSITE.NOMPRENOM$", "CONTACT_SITE", "saNom", listeDonnesBD: "Jean Dupont", listeDonnesChoix: json)); Assert.True(edition.EstEtapeInteractive); } [Fact] public void EtapeInteractive_UnSansDonnees_False() { var edition = new RapportChampEdition(Champ( "$UNPHOTO.IMAGE$", "PHOTO_INTER", "DC_Nom", listeDonnesBD: "", listeDonnesChoix: null)); Assert.False(edition.EstEtapeInteractive); } [Fact] public void EtapeInteractive_TabAvecDonnees_True() { string json = ContactsJson(("Jean Dupont", "Occupant", "04 11 22 33 44")); var edition = new RapportChampEdition(TableauChamp( "$TABCONTACTSITE$", "CONTACT_SITE", json, SousElement("$TABCTSITE.NOMPRENOM$", "saNom"))); Assert.True(edition.EstEtapeInteractive); } [Fact] public void EtapeInteractive_TabSansDonnees_False() { var edition = new RapportChampEdition(TableauChamp( "$TABCONTACTSITE$", "CONTACT_SITE", "[]", SousElement("$TABCTSITE.NOMPRENOM$", "saNom"))); Assert.False(edition.EstEtapeInteractive); } [Fact] public void ReadOnlyChamp_ValeurConserveeDansPayload() { var edition = new RapportChampEdition(Champ( "$TITREINTERV$", "INTER", "IN_Titre", listeDonnesBD: "Rénovation du logement", modifiable: false)); // Non interactif : absent du carrousel… Assert.False(edition.EstEtapeInteractive); // …mais sa valeur reste dans le payload de génération. RapportChampDto dto = edition.ToDto(); Assert.Equal("Rénovation du logement", dto.ListeDonnesMob); Assert.Equal("Rénovation du logement", dto.ListeDonnesBD); } [Fact] public void EtapesInteractives_ExclutReadOnlyEtGroupeVide() { var a = new RapportChampEdition(Champ("$NOM$", "INTER", "IN_RefInterne", listeDonnesBD: "Nom")); var b = new RapportChampEdition(Champ("$COMMENTAIRE$", "EXTRA")); var c = new RapportChampEdition(Champ("$ADRESSE$", "INTER", "IN_RefInterne", listeDonnesBD: "Adresse")); var vm = CreerViewModel(new FakeInterventionService(), new FakeDocumentService(), new NavigationService()); vm.Rubriques = new ObservableCollection( new[] { a, b, c }.Select(x => new RapportRubriqueResume(x))); Assert.Single(vm.EtapesInteractives); Assert.Same(b, vm.EtapesInteractives[0].Champ); Assert.False(vm.AucuneEtapeInteractive); } [Fact] public void Navigation_Carrousel_SauteLesReadOnly() { var a = new RapportChampEdition(Champ("$A$", "INTER", "IN_RefInterne", listeDonnesBD: "A")); var b = new RapportChampEdition(Champ("$B$", "EXTRA")); var c = new RapportChampEdition(Champ("$C$", "INTER", "IN_RefInterne", listeDonnesBD: "C")); var d = new RapportChampEdition(Champ("$D$", "EXTRA")); var vm = CreerViewModel(new FakeInterventionService(), new FakeDocumentService(), new NavigationService()); vm.Rubriques = new ObservableCollection( new[] { a, b, c, d }.Select(x => new RapportRubriqueResume(x))); // Ouvre la première étape interactive (B, la A read-only est sautée). vm.OuvrirRubriqueCommand.Execute(vm.EtapesInteractives[0]); Assert.Same(b, vm.RubriqueSelectionnee); // Suivant → D (saute C read-only, jamais de page intermédiaire). vm.RubriqueSuivanteCommand.Execute(null); Assert.Same(d, vm.RubriqueSelectionnee); // Précédent → B. vm.RubriquePrecedenteCommand.Execute(null); Assert.Same(b, vm.RubriqueSelectionnee); } [Fact] public void Modele100ReadOnly_AucuneEtapeInteractive() { var a = new RapportChampEdition(Champ("$NOM$", "INTER", "IN_RefInterne", listeDonnesBD: "Nom")); var b = new RapportChampEdition(Champ("$ADRESSE$", "INTER", "IN_RefInterne", listeDonnesBD: "Adresse")); var vm = CreerViewModel(new FakeInterventionService(), new FakeDocumentService(), new NavigationService()); vm.Rubriques = new ObservableCollection( new[] { a, b }.Select(x => new RapportRubriqueResume(x))); Assert.Empty(vm.EtapesInteractives); Assert.True(vm.AucuneEtapeInteractive); } // ===================================================================== // Extra par ligne de tableau (membre .EXTRA$) // ===================================================================== [Fact] public void ExtraTableau_ListeMembreAvecExtra_ChaqueLigneAUnChamp() { string json = ContactsJson( ("Jean Dupont", "Occupant", "04 11 22 33 44"), ("Marie Martin", "Responsable", "04 55 66 77 88")); var edition = new RapportChampEdition(TableauChamp( "$TABCONTACTSITE$", "CONTACT_SITE", json, SousElement("$TABCTSITE.NOMPRENOM$", "saNom"), SousElement("$TABCTSITE.ROLE$", "saRole"), SousElement("$TABCTSITE.EXTRA$", "saExtra"))); Assert.Equal(2, edition.Options.Count); Assert.All(edition.Options, o => { Assert.Single(o.Extras); Assert.Equal("Information supplémentaire", o.Extras[0].Nom); }); } [Fact] public void ExtraTableau_ListeMembreSansExtra_AucunChamp() { string json = ContactsJson(("Jean Dupont", "Occupant", "04 11 22 33 44")); var edition = new RapportChampEdition(TableauChamp( "$TABCONTACTSITE$", "CONTACT_SITE", json, SousElement("$TABCTSITE.NOMPRENOM$", "saNom"), SousElement("$TABCTSITE.ROLE$", "saRole"))); Assert.All(edition.Options, o => Assert.Empty(o.Extras)); } [Fact] public void ExtraTableau_DeuxContacts_ValeursConserveesSeparement() { string json = ContactsJson( ("Jean Dupont", "Occupant", "04 11 22 33 44"), ("Marie Martin", "Responsable", "04 55 66 77 88")); var edition = new RapportChampEdition(TableauChamp( "$TABCONTACTSITE$", "CONTACT_SITE", json, SousElement("$TABCTSITE.EXTRA$", "saExtra"))); edition.Options[0].EstSelectionne = true; edition.Options[1].EstSelectionne = true; edition.Options[0].Extras[0].Valeur = "Information A"; edition.Options[1].Extras[0].Valeur = "Information B"; using JsonDocument doc = JsonDocument.Parse(edition.SerialiserValeur()!); var items = doc.RootElement.EnumerateArray().ToList(); Assert.Equal(2, items.Count); Assert.Contains(items, i => i.GetProperty("extra").GetString() == "Information A"); Assert.Contains(items, i => i.GetProperty("extra").GetString() == "Information B"); } [Fact] public void ExtraTableau_AppliquerValeurMob_RestaureSaExtra() { string json = ContactsJson(("Jean Dupont", "Occupant", "04 11 22 33 44")); var edition = new RapportChampEdition(TableauChamp( "$TABCONTACTSITE$", "CONTACT_SITE", json, SousElement("$TABCTSITE.EXTRA$", "saExtra"))); string id = edition.Options[0].Donnees.GetProperty("id").GetString()!; string mob = $"[{{\"id\":\"{id}\",\"nom\":\"Jean Dupont\",\"extra\":\"Appeler avant arrivée\"}}]"; edition.AppliquerValeurMob(mob); Assert.True(edition.Options[0].EstSelectionne); Assert.Equal("Appeler avant arrivée", edition.Options[0].Extras[0].Valeur); } private static RapportChampSousElementDto SousElement(string rubrique, string? membreMob) => new() { Rubrique = rubrique, MembreMob = membreMob }; private static RapportChampDto TableauChamp( string rubrique, string groupeApp, string listeDonnesBD, params RapportChampSousElementDto[] membres) => new() { Id = "1", Nom = "Tableau", Rubrique = rubrique, Type = "Tableau", GroupeApp = groupeApp, ListeDonnesBD = listeDonnesBD, ListeMembre = membres }; // ===================================================================== // CAS 9-14 : ViewModel (overlay + ouverture PDF) // ===================================================================== private sealed class FakeInterventionService : IInterventionLocalService { public IReadOnlyList Modeles { get; set; } = []; public IReadOnlyList Champs { get; set; } = []; public DocumentSummaryDto? RapportToCreate { get; set; } public TaskCompletionSource? Gate { get; set; } public int CreateCalls { get; private set; } /// Panne simulée du catalogue des modèles (réseau ou métier). public Exception? ModeleRapportsException { get; set; } /// Panne simulée du calcul des champs d'un modèle. public Exception? ChampsException { get; set; } public int ModeleRapportsCalls { get; private set; } public int ChampsCalls { get; private set; } public Task> GetModeleRapportsAsync( CancellationToken ct = default) { ModeleRapportsCalls++; return ModeleRapportsException is not null ? Task.FromException>(ModeleRapportsException) : Task.FromResult(Modeles); } public Task> GetRapportChampsAsync( GetRapportChampsRequest request, CancellationToken ct = default) { ChampsCalls++; return ChampsException is not null ? Task.FromException>(ChampsException) : Task.FromResult(Champs); } public Task CreateRapportAsync( CreateRapportRequest request, CancellationToken ct = default) { CreateCalls++; return Gate is not null ? Gate.Task : Task.FromResult(RapportToCreate); } // Membres abstraits non utilisés par ces tests. public Task CreateAsync(Omika.Core.Contracts.Mobile.Sync.InterventionSyncPayload payload, CancellationToken ct = default) => throw new NotImplementedException(); public Task UpdateAsync(Guid interventionGuid, Omika.Core.Contracts.Mobile.Sync.InterventionSyncPayload payload, CancellationToken ct = default) => throw new NotImplementedException(); public Task ArchiveAsync(Guid interventionGuid, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetInterventionByIdAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetInterventionsAsync(CancellationToken ct = default) => Task.FromResult>([]); } private sealed class FakeBrouillonRepository : IRapportBrouillonRepository { /// Brouillons déjà enregistrés localement (SQLite) pour l'intervention. public IReadOnlyList Entries { get; set; } = []; /// Panne locale simulée de la lecture des brouillons. public Exception? GetException { get; set; } public int GetCalls { get; private set; } public Task UpsertAsync(RapportBrouillonEntry entry, CancellationToken ct = default) => Task.CompletedTask; public Task GetAsync(Guid id, CancellationToken ct = default) => Task.FromResult(null); public Task> GetByInterventionAsync( Guid interventionGuid, CancellationToken ct = default) { GetCalls++; return GetException is not null ? Task.FromException>(GetException) : Task.FromResult(Entries); } public Task DeleteAsync(Guid id, CancellationToken ct = default) => Task.CompletedTask; } /// Connectivité simulée (en ligne par défaut), injectée dans le ViewModel. private sealed class FakeReseau : INetworkConnectivity { public bool Online { get; set; } = true; public int Calls { get; private set; } public Task IsOnlineAsync(CancellationToken cancellationToken = default) { Calls++; return Task.FromResult(Online); } } private sealed class FakeDocumentService : IDocumentLocalService { public bool OpenResult { get; set; } = true; public int OpenCalls { get; private set; } public Task OpenAsync(Guid documentGuid, CancellationToken ct = default) { OpenCalls++; return Task.FromResult(OpenResult); } public Task> GetDocumentsForInterventionAsync(Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task EnsureDownloadedAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(true); public Task GetCachedFilePathAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetNotesForDocumentAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task AddNoteAsync(Guid documentGuid, string contenu, CancellationToken ct = default) => Task.FromResult(new DocumentNoteItem()); 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 ModeleRapportDto Modele(string id = "42") => new() { Id = id, Nom = "Modèle" }; private static RapportChampsViewModel CreerViewModel( FakeInterventionService intervention, FakeDocumentService documents, NavigationService navigation, FakeReseau? reseau = null, FakeBrouillonRepository? brouillons = null, Omika.App.Services.IPermissionService? permissions = null) => new( intervention, brouillons ?? new FakeBrouillonRepository(), documents, navigation, Omika.Core.Observability.NullTelemetryService.Instance, reseau ?? new FakeReseau(), permissions); private static void PreparerGeneration( RapportChampsViewModel vm, FakeInterventionService intervention, DocumentSummaryDto rapport) { vm.InterventionGuid = Guid.NewGuid(); vm.ModeleSelectionne = Modele(); intervention.RapportToCreate = rapport; } [Fact] public async Task Generer_OverlayVisiblePuisFerme() { var intervention = new FakeInterventionService { Modeles = [Modele()] }; var documents = new FakeDocumentService(); var navigation = new NavigationService(); var vm = CreerViewModel(intervention, documents, navigation); var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); intervention.Gate = gate; PreparerGeneration(vm, intervention, new DocumentSummaryDto { Id = Guid.NewGuid().ToString(), Nom = "Rapport intervention.pdf" }); // CAS 9 : pendant la génération, l'overlay est visible. Task generation = vm.GenererCommand.ExecuteAsync(null); await Task.Delay(100); Assert.True(vm.IsGenerating); // CAS 10 : à la fin, l'overlay est fermé. gate.SetResult(intervention.RapportToCreate); await generation; Assert.False(vm.IsGenerating); } [Fact] public async Task Generer_OuvrePdf_UneSeuleFois() { var intervention = new FakeInterventionService { Modeles = [Modele()] }; var documents = new FakeDocumentService { OpenResult = true }; var navigation = new NavigationService(); var vm = CreerViewModel(intervention, documents, navigation); PreparerGeneration(vm, intervention, new DocumentSummaryDto { Id = Guid.NewGuid().ToString(), Nom = "Rapport intervention.pdf" }); // CAS 13 : ouverture du PDF appelée une seule fois. await vm.GenererCommand.ExecuteAsync(null); Assert.Equal(1, documents.OpenCalls); Assert.False(vm.IsGenerating); Assert.Null(vm.ErrorMessage); } [Fact] public async Task Generer_OuvertureEchoue_MessageSpecifique() { var intervention = new FakeInterventionService { Modeles = [Modele()] }; var documents = new FakeDocumentService { OpenResult = false }; var navigation = new NavigationService(); var vm = CreerViewModel(intervention, documents, navigation); PreparerGeneration(vm, intervention, new DocumentSummaryDto { Id = Guid.NewGuid().ToString(), Nom = "Rapport intervention.pdf" }); // CAS 14 : le rapport est créé, mais l'ouverture échoue → message dédié // (et non « Échec de création du rapport »). await vm.GenererCommand.ExecuteAsync(null); Assert.Equal(1, documents.OpenCalls); Assert.Equal(1, intervention.CreateCalls); Assert.Equal("Le rapport a été créé, mais le PDF n'a pas pu être ouvert.", vm.ErrorMessage); } // ===================================================================== // Découplage local / réseau du chargement // Ticket du 09/10 : « Connection failure », Error/P1, ScreenCode RAPPORT // ===================================================================== private const string MessageModelesHorsLigne = "Hors ligne — impossible de contacter le serveur."; private static RapportBrouillonEntry Brouillon(Guid interventionId, string modeleId = "42") => new() { Id = Guid.NewGuid(), InterventionId = interventionId, ModeleId = modeleId, Nom = "Rapport en cours", ChampsJson = "[]", UpdatedAtUtc = new DateTimeOffset(2026, 10, 9, 13, 0, 0, TimeSpan.Zero) }; /// /// 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; } /// /// Événements d'INCIDENT du ViewModel (OMK-MOB-ERR-002). Les événements d'usage /// (TrackFeature → Information) sont exclus : ils ne portent pas de gravité. /// private static IReadOnlyList EvenementsIncident(CapturingTelemetry telemetry) => telemetry.Events .OfType() .Where(e => e.Component == nameof(RapportChampsViewModel) && e.EventCode == TelemetryEventCodes.MobileHandledException) .ToList(); private static void AssertAucunIncident(CapturingTelemetry telemetry) => Assert.DoesNotContain(EvenementsIncident(telemetry), e => e.Level == TelemetryLevel.Error); /// /// Attend qu'une condition devienne vraie : la sélection d'un modèle déclenche le /// chargement des champs depuis un partial method (fire-and-forget), donc sans /// await exposé à l'appelant. /// private static async Task AttendreAsync(Func condition) { for (int i = 0; i < 100 && !condition(); i++) { await Task.Delay(10); } return condition(); } /// /// Cœur du ticket : le catalogue des modèles vient du serveur, les brouillons non. /// Un échec réseau ne doit plus priver l'utilisateur des rapports déjà enregistrés /// sur son terminal (les deux chargements partageaient un seul try). /// [Fact] public async Task Load_EchecReseauDesModeles_LesBrouillonsLocauxRestentAffiches() { Guid interventionId = Guid.NewGuid(); var intervention = new FakeInterventionService { ModeleRapportsException = new HttpRequestException("Connection failure") }; var brouillons = new FakeBrouillonRepository { Entries = [Brouillon(interventionId)] }; var vm = CreerViewModel( intervention, new FakeDocumentService(), new NavigationService(), brouillons: brouillons); CapturingTelemetry telemetry = await RunWithTelemetryAsync( () => vm.LoadCommand.ExecuteAsync(interventionId)); // Le contenu LOCAL reste disponible malgré l'échec réseau sur les modèles. Assert.Single(vm.Brouillons); Assert.Empty(vm.Modeles); Assert.Equal(MessageModelesHorsLigne, vm.ErrorMessage); Assert.False(vm.IsLoading); DiagnosticEvent evt = Assert.Single(EvenementsIncident(telemetry)); Assert.Equal(TelemetryLevel.Warning, evt.Level); Assert.Equal("HttpRequestException", evt.ErrorCode); AssertAucunIncident(telemetry); } /// /// L'échec réseau du catalogue des modèles est l'état dégradé attendu d'une /// application offline-first : Warning/P2, jamais l'incident Error/P1 du ticket. /// [Fact] public async Task Load_EchecReseauDesModeles_NEstJamaisUnIncident() { var intervention = new FakeInterventionService { ModeleRapportsException = new HttpRequestException("Connection failure") }; var vm = CreerViewModel(intervention, new FakeDocumentService(), new NavigationService()); CapturingTelemetry telemetry = await RunWithTelemetryAsync( () => vm.LoadCommand.ExecuteAsync(Guid.NewGuid())); DiagnosticEvent evt = Assert.Single(EvenementsIncident(telemetry)); Assert.NotEqual(TelemetryLevel.Error, evt.Level); Assert.Equal(TelemetryLevel.Warning, evt.Level); AssertAucunIncident(telemetry); } /// /// Hors ligne : aucun appel réseau n'est tenté (donc aucune latence d'échec), les /// brouillons locaux restent affichés, et AUCUN événement n'est produit — une /// coupure attendue n'est pas un incident. /// [Fact] public async Task Load_HorsLigne_NeTenteAucunAppelEtConserveLesBrouillons() { Guid interventionId = Guid.NewGuid(); var intervention = new FakeInterventionService { Modeles = [Modele()] }; var brouillons = new FakeBrouillonRepository { Entries = [Brouillon(interventionId)] }; var reseau = new FakeReseau { Online = false }; var vm = CreerViewModel( intervention, new FakeDocumentService(), new NavigationService(), reseau, brouillons); CapturingTelemetry telemetry = await RunWithTelemetryAsync( () => vm.LoadCommand.ExecuteAsync(interventionId)); Assert.Equal(1, reseau.Calls); Assert.Equal(0, intervention.ModeleRapportsCalls); Assert.Single(vm.Brouillons); Assert.Empty(vm.Modeles); Assert.Equal(MessageModelesHorsLigne, vm.ErrorMessage); Assert.Empty(EvenementsIncident(telemetry)); } /// /// Une panne LOCALE (SQLite) n'est pas une coupure réseau : elle doit rester /// visible et rester un incident Error/P1, sans empêcher le chargement serveur. /// [Fact] public async Task Load_EchecLocalDesBrouillons_ResteUnIncidentEtNEmpechePasLesModeles() { Guid interventionId = Guid.NewGuid(); var intervention = new FakeInterventionService { Modeles = [Modele()] }; var brouillons = new FakeBrouillonRepository { GetException = new InvalidOperationException("Base locale indisponible") }; var vm = CreerViewModel( intervention, new FakeDocumentService(), new NavigationService(), brouillons: brouillons); CapturingTelemetry telemetry = await RunWithTelemetryAsync( () => vm.LoadCommand.ExecuteAsync(interventionId)); Assert.Equal(1, brouillons.GetCalls); Assert.Single(vm.Modeles); Assert.Equal("Impossible de charger les brouillons enregistrés.", vm.ErrorMessage); DiagnosticEvent evt = Assert.Single(EvenementsIncident(telemetry)); Assert.Equal(TelemetryLevel.Error, evt.Level); Assert.Equal("InvalidOperationException", evt.ErrorCode); } /// /// Une réponse serveur illisible n'est pas une coupure réseau : elle reste un /// incident Error/P1 avec le message dédié. /// [Fact] public async Task Load_EchecMetierDesModeles_ResteUnIncident() { var intervention = new FakeInterventionService { ModeleRapportsException = new InvalidOperationException("Réponse de modèle illisible") }; var vm = CreerViewModel(intervention, new FakeDocumentService(), new NavigationService()); CapturingTelemetry telemetry = await RunWithTelemetryAsync( () => vm.LoadCommand.ExecuteAsync(Guid.NewGuid())); Assert.Equal("Impossible de charger les modèles de rapport.", vm.ErrorMessage); DiagnosticEvent evt = Assert.Single(EvenementsIncident(telemetry)); Assert.Equal(TelemetryLevel.Error, evt.Level); Assert.Equal("InvalidOperationException", evt.ErrorCode); } /// /// Les champs d'un modèle sont calculés côté serveur : hors ligne, la sélection /// est annoncée comme indisponible sans tenter l'appel ni produire d'événement. /// [Fact] public async Task ChargerChamps_HorsLigne_NeTenteAucunAppelEtNeProduitAucunIncident() { var intervention = new FakeInterventionService { Modeles = [Modele()], Champs = [Champ("INTER$OBJET")] }; var reseau = new FakeReseau { Online = false }; var vm = CreerViewModel(intervention, new FakeDocumentService(), new NavigationService(), reseau); CapturingTelemetry telemetry = await RunWithTelemetryAsync(async () => { vm.InterventionGuid = Guid.NewGuid(); vm.ModeleSelectionne = Modele(); await AttendreAsync(() => !vm.IsLoading); }); Assert.Equal(0, intervention.ChampsCalls); Assert.Empty(vm.Champs); Assert.Equal(MessageModelesHorsLigne, vm.ErrorMessage); Assert.Empty(EvenementsIncident(telemetry)); } /// /// En ligne mais serveur injoignable : la sélection d'un modèle échoue en /// Warning/P2 (jamais P1) et l'utilisateur est informé en français. /// [Fact] public async Task ChargerChamps_EchecReseau_NEstPasUnIncident() { var intervention = new FakeInterventionService { Modeles = [Modele()], ChampsException = new HttpRequestException("Connection failure") }; var vm = CreerViewModel(intervention, new FakeDocumentService(), new NavigationService()); CapturingTelemetry telemetry = await RunWithTelemetryAsync(async () => { vm.InterventionGuid = Guid.NewGuid(); vm.ModeleSelectionne = Modele(); await AttendreAsync(() => !vm.IsLoading); }); Assert.Equal(1, intervention.ChampsCalls); Assert.Equal(MessageModelesHorsLigne, vm.ErrorMessage); DiagnosticEvent evt = Assert.Single(EvenementsIncident(telemetry)); Assert.Equal(TelemetryLevel.Warning, evt.Level); AssertAucunIncident(telemetry); } /// /// Hors ligne, le bouton « Envoyer un email » est DÉSACTIVÉ : générer le PDF /// et composer le courriel passent par le serveur. Le bouton reste visible, /// simplement inactif — l'utilisateur comprend que l'action existe mais qu'elle /// est momentanément impossible. /// [Fact] public async Task Load_HorsLigne_DesactiveLeBoutonEnvoyerEmail() { Guid interventionId = Guid.NewGuid(); var reseau = new FakeReseau { Online = false }; var vm = CreerViewModel( new FakeInterventionService { Modeles = [Modele()] }, new FakeDocumentService(), new NavigationService(), reseau); await vm.LoadCommand.ExecuteAsync(interventionId); Assert.False(vm.PeutEnvoyerEmail); Assert.Equal(1, reseau.Calls); } /// En ligne, l'envoi d'un email reste possible. [Fact] public async Task Load_EnLigne_ActiveLeBoutonEnvoyerEmail() { Guid interventionId = Guid.NewGuid(); var vm = CreerViewModel( new FakeInterventionService { Modeles = [Modele()] }, new FakeDocumentService(), new NavigationService(), new FakeReseau { Online = true }); await vm.LoadCommand.ExecuteAsync(interventionId); Assert.True(vm.PeutEnvoyerEmail); } /// /// Messagerie EXTERNE : le bouton « Envoyer un email » est désactivé même en /// ligne (l'envoi ne passe pas par Omika), et l'info-bulle dit pourquoi — au /// lieu d'annoncer à tort une simple coupure réseau. /// [Fact] public async Task Load_MessagerieExterne_DesactiveLeBoutonEnvoyerEmail() { Guid interventionId = Guid.NewGuid(); var permissions = new Omika.App.Services.PermissionService(); permissions.SetRights(new Dictionary { [Omika.Core.Constants.MobilePermissionKeys.EnvoiEmail] = true }); permissions.SetMessagerieExterne(true); var vm = CreerViewModel( new FakeInterventionService { Modeles = [Modele()] }, new FakeDocumentService(), new NavigationService(), new FakeReseau { Online = true }, permissions: permissions); await vm.LoadCommand.ExecuteAsync(interventionId); Assert.True(vm.EstEnLigne); Assert.False(vm.PeutEnvoyerEmail); Assert.Contains("Messagerie externe", vm.InfoBulleEnvoiEmail); } /// /// Garde de sécurité : même déclenchée hors ligne (état périmé, appel direct), /// l'action ne lance AUCUNE génération et ne navigue pas. L'utilisateur reçoit /// immédiatement le message hors ligne au lieu d'un échec tardif. /// [Fact] public async Task EnvoyerEmail_HorsLigne_NGenereRienEtExpliqueLEtat() { var intervention = new FakeInterventionService { Modeles = [Modele()] }; var vm = CreerViewModel( intervention, new FakeDocumentService(), new NavigationService(), new FakeReseau { Online = false }); await vm.LoadCommand.ExecuteAsync(Guid.NewGuid()); intervention.RapportToCreate = new DocumentSummaryDto { Id = Guid.NewGuid().ToString(), Nom = "Rapport intervention.pdf" }; await vm.EnvoyerEmailCommand.ExecuteAsync(null); Assert.Equal(0, intervention.CreateCalls); Assert.False(vm.IsGenerating); Assert.Equal(MessageModelesHorsLigne, vm.ErrorMessage); } /// 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); } }