using System.Collections.ObjectModel; using System.Text.Json; using Omika.App.Local.Abstractions; using Omika.App.Local.Models; using Omika.App.Navigation; using Omika.App.ViewModels; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Mobile.Sync; namespace Omika.Tests.Mobile.Local; /// /// Tests UX du ViewModel « Nouveau courriel » : MÊME moteur de saisie que le /// Rapport (carrousel d'étapes interactives, UN/TAB en cartes, EXTRA, read-only /// hors parcours) + conservation du corps HTML jusqu'à l'aperçu. /// public class NouveauEmailViewModelTests { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); // ===================================================================== // Helpers // ===================================================================== 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 string RdvJson(params (string dateDebut, string dateFin, string objet)[] rdvs) { var items = rdvs.Select(r => new { dateDebut = r.dateDebut, dateFin = r.dateFin, objet = r.objet, note = string.Empty, collaborateurs = "Jean Claude ROMERO" }); 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 }; 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 }; private static RapportChampSousElementDto SousElement(string rubrique, string? membreMob) => new() { Rubrique = rubrique, MembreMob = membreMob }; private static NouveauEmailViewModel CreerViewModel( FakeInterventionService intervention, FakeContactsService contacts, FakeDocumentService documents) => new(intervention, contacts, documents, new NavigationService(), new FakeSyncEngine(), Omika.Core.Observability.NullTelemetryService.Instance); // ===================================================================== // TEST 1-9 : moteur de saisie identique au Rapport // ===================================================================== [Fact] public void Simple_ReadOnly_PasDansEtapes_ValeurConservee() { var nomClient = new RapportChampEdition(Champ("$NOMCLIENT$", "INTER", "IN_NomClient", listeDonnesBD: "Smartis")); var extra = new RapportChampEdition(Champ("$Saisie_Mob_1$", "EXTRA")); var vm = CreerViewModel(new FakeInterventionService(), new FakeContactsService(), new FakeDocumentService()); vm.Rubriques = new ObservableCollection( new[] { nomClient, extra }.Select(x => new RapportRubriqueResume(x))); // « Nom du client » (Modifiable=0) ne fait PAS partie du parcours. Assert.DoesNotContain(vm.EtapesInteractives, e => e.Champ == nomClient); Assert.Same(extra, Assert.Single(vm.EtapesInteractives).Champ); // …mais sa valeur reste disponible pour la génération ($NOMCLIENT$). Assert.Equal("Smartis", nomClient.ToDto().ListeDonnesMob); } [Fact] public void Simple_Modifiable_EstEtape() { var modifiable = new RapportChampEdition(Champ( "$REFINTERNE$", "INTER", "IN_RefInterne", listeDonnesBD: "REF", modifiable: true)); var vm = CreerViewModel(new FakeInterventionService(), new FakeContactsService(), new FakeDocumentService()); vm.Rubriques = new ObservableCollection([new(modifiable)]); Assert.Same(modifiable, Assert.Single(vm.EtapesInteractives).Champ); } [Fact] public void Extra_EstEtape() { var extra = new RapportChampEdition(Champ("$Saisie_Mob_1$", "EXTRA")); var vm = CreerViewModel(new FakeInterventionService(), new FakeContactsService(), new FakeDocumentService()); vm.Rubriques = new ObservableCollection([new(extra)]); Assert.Same(extra, Assert.Single(vm.EtapesInteractives).Champ); } [Fact] public void UnRdv_TroisRubriques_UnSeulSelecteur() { string json = RdvJson( ("2022-08-31 08:00:00", "2022-08-31 17:00:00", "Maintenance"), ("2022-09-01 08:00:00", "2022-09-01 17:00:00", "Contrôle"), ("2022-09-15 08:00:00", "2022-09-15 09:00:00", "Visite")); IReadOnlyList champs = [ Champ("$UNRDV.DATEDEBUT$", "RDV_INTER", "dateDebut", listeDonnesChoix: json), Champ("$UNRDV.DATEDEBUT_D$", "RDV_INTER", "dateDebut_D", listeDonnesChoix: json), Champ("$UNRDV.DATEFIN$", "RDV_INTER", "dateFin", listeDonnesChoix: json) ]; var intervention = new FakeInterventionService { Champs = champs }; var vm = CreerViewModel(intervention, new FakeContactsService(), new FakeDocumentService()); vm.InterventionGuid = Guid.NewGuid(); vm.ModeleSelectionne = new ModeleMailDto { Id = "1", Nom = "Modèle" }; // Les trois rubriques $UNRDV.* sont regroupées en UNE FAMILLE : un seul // sélecteur (une seule entrée dans Rubriques, une seule étape interactive). RapportRubriqueResume resume = Assert.Single(vm.Rubriques); Assert.Equal(3, resume.Champ.MembresFamille.Count); Assert.Single(vm.EtapesInteractives); } [Fact] public void TabContact_MultiSelection_Cartes() { 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"))); Assert.True(edition.EstChoixMultiple); Assert.Equal(2, edition.Options.Count); edition.BasculerOptionCommand.Execute(edition.Options[0]); edition.BasculerOptionCommand.Execute(edition.Options[1]); Assert.Equal(2, edition.NombreSelectionnes); } [Fact] public void UnContact_SingleSelection_Cartes() { string json = ContactsJson( ("Jean Dupont", "Occupant", "04 11 22 33 44"), ("Marie Martin", "Responsable", "04 55 66 77 88")); var edition = new RapportChampEdition(Champ( "$UNCTSITE.NOMPRENOM$", "CONTACT_SITE", "saNom", listeDonnesBD: "Jean Dupont", listeDonnesChoix: json)); Assert.True(edition.EstSelectionUnique); Assert.Equal(1, edition.NombreSelectionnes); // Clic sur une autre carte : l'ancienne sélection se replie, une seule cochée. edition.BasculerOptionCommand.Execute(edition.Options[1]); Assert.Equal(1, edition.NombreSelectionnes); Assert.True(edition.Options[1].EstSelectionne); Assert.False(edition.Options[0].EstSelectionne); } [Fact] public void Modele100ReadOnly_ApercuDirect() { 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 FakeContactsService(), new FakeDocumentService()); vm.Rubriques = new ObservableCollection( new[] { a, b }.Select(x => new RapportRubriqueResume(x))); Assert.Empty(vm.EtapesInteractives); Assert.True(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 FakeContactsService(), new FakeDocumentService()); 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). vm.RubriqueSuivanteCommand.Execute(null); Assert.Same(d, vm.RubriqueSelectionnee); // Précédent → B. vm.RubriquePrecedenteCommand.Execute(null); Assert.Same(b, vm.RubriqueSelectionnee); // Retour à la liste des rubriques. vm.RetourRubriquesCommand.Execute(null); Assert.False(vm.AfficheDetail); Assert.True(vm.AfficheListe); } [Fact] public async Task Continuer_PayloadContientTousLesChamps() { var nomClient = Champ("$NOMCLIENT$", "INTER", "IN_NomClient", listeDonnesBD: "Smartis"); var extra = Champ("$Saisie_Mob_1$", "EXTRA"); var intervention = new FakeInterventionService { Champs = [nomClient, extra], Apercu = new EmailModeleApercuDto { Sujet = "Rapport intervention 42", Corps = "

Bonjour Smartis

" } }; var vm = CreerViewModel(intervention, new FakeContactsService(), new FakeDocumentService()); vm.InterventionGuid = Guid.NewGuid(); vm.ModeleSelectionne = new ModeleMailDto { Id = "1", Nom = "Modèle" }; await vm.ContinuerCommand.ExecuteAsync(null); // Le payload envoyé au serveur contient TOUS les champs (read-only inclus). Assert.NotNull(intervention.LastApercuRequest); Assert.Equal(2, intervention.LastApercuRequest!.ListeMob.Count); Assert.Contains(intervention.LastApercuRequest.ListeMob, c => c.Rubrique == "$NOMCLIENT$" && c.ListeDonnesMob == "Smartis"); Assert.Equal(2, vm.Etape); } // ===================================================================== // TEST HTML 1-5 : conservation du corps HTML // ===================================================================== [Fact] public void Html_CorpsHtml_ConserveIntegralementDansViewModel() { var vm = CreerViewModel(new FakeInterventionService(), new FakeContactsService(), new FakeDocumentService()); vm.Corps = "

Bonjour

"; Assert.Equal("

Bonjour

", vm.Corps); Assert.True(vm.CorpsContientHtml); Assert.Equal("Bonjour", vm.CorpsApercuTexte); } [Fact] public void Html_Tableau_NonConvertiEnTexteSepareParPipes() { var vm = CreerViewModel(new FakeInterventionService(), new FakeContactsService(), new FakeDocumentService()); string html = "
Moreau Valérie04 13 95 46 73
"; vm.Corps = html; // La SOURCE reste le HTML intégral : jamais « Moreau Valérie | … ». Assert.Equal(html, vm.Corps); // L'aperçu visuel contient bien la valeur, séparée par des espaces (pas des pipes). Assert.Contains("Moreau Valérie", vm.CorpsApercuTexte); Assert.DoesNotContain("", vm.CorpsApercuTexte); } [Fact] public void Html_AucunDecodeSurLaSource() { var vm = CreerViewModel(new FakeInterventionService(), new FakeContactsService(), new FakeDocumentService()); vm.Corps = "

R&D & Cie

"; // La source conserve les entités HTML (aucun HtmlDecode / StripTags). Assert.Equal("

R&D & Cie

", vm.Corps); // Seul l'aperçu visuel décode. Assert.Equal("R&D & Cie", vm.CorpsApercuTexte); } [Fact] public void Html_SujetResteTexteSimple() { var vm = CreerViewModel(new FakeInterventionService(), new FakeContactsService(), new FakeDocumentService()); vm.Sujet = "Rapport intervention 391244"; // Le sujet est un string : aucun contrôle HTML, aucun rendu (seul le Corps l'est). Assert.Equal("Rapport intervention 391244", vm.Sujet); Assert.False(vm.CorpsContientHtml); } [Fact] public async Task Html_Continuer_ConserveLeHtmlDuServeur() { var intervention = new FakeInterventionService { Champs = [Champ("$NOMCLIENT$", "INTER", "IN_NomClient", listeDonnesBD: "DigiNova")], Apercu = new EmailModeleApercuDto { Sujet = "Rapport intervention 391244", Corps = "

Bonjour DigiNova

Moreau Valérie
" } }; var vm = CreerViewModel(intervention, new FakeContactsService(), new FakeDocumentService()); vm.InterventionGuid = Guid.NewGuid(); vm.ModeleSelectionne = new ModeleMailDto { Id = "1", Nom = "Modèle" }; await vm.ContinuerCommand.ExecuteAsync(null); // Sujet = texte ; Corps = HTML intégral (même moteur aperçu/création côté serveur). Assert.Equal("Rapport intervention 391244", vm.Sujet); Assert.Equal("

Bonjour DigiNova

Moreau Valérie
", vm.Corps); Assert.True(vm.CorpsContientHtml); Assert.Contains("Moreau Valérie", vm.CorpsApercuTexte); } [Fact] public async Task Enregistrer_EnvoieCorpsHtml_PasTexteAplati() { var intervention = new FakeInterventionService(); var vm = CreerViewModel(intervention, new FakeContactsService(), new FakeDocumentService()); vm.InterventionGuid = Guid.NewGuid(); vm.Sujet = "Rapport"; vm.Corps = "

Bonjour

30/01/2026
"; await vm.EnregistrerCommand.ExecuteAsync(null); // La source envoyée est le HTML intégral (Corps), jamais l'aperçu texte aplati. Assert.NotNull(intervention.LastEmailRequest); Assert.Equal(vm.Corps, intervention.LastEmailRequest!.Message); Assert.NotEqual(vm.CorpsApercuTexte, intervention.LastEmailRequest!.Message); } // ===================================================================== // Fakes // ===================================================================== private sealed class FakeInterventionService : IInterventionLocalService { public IReadOnlyList Champs { get; set; } = []; public EmailModeleApercuDto? Apercu { get; set; } public CreateEmailModeleRequest? LastApercuRequest { get; private set; } public Task> GetMailChampsAsync( GetRapportChampsRequest request, CancellationToken ct = default) => Task.FromResult(Champs); public Task GetEmailModeleApercuAsync( CreateEmailModeleRequest request, CancellationToken ct = default) { LastApercuRequest = request; return Task.FromResult(Apercu); } public Task> GetSmtpConfigurationsAsync( CancellationToken ct = default) => Task.FromResult>([]); public CreateEmailInterventionRequest? LastEmailRequest { get; private set; } public Task AddEmailAsync( Guid interventionGuid, CreateEmailInterventionRequest request, CancellationToken ct = default) { LastEmailRequest = request; return Task.FromResult(new EmailInterventionDto { Id = Guid.NewGuid().ToString() }); } // Membres abstraits non utilisés par ces tests. public Task CreateAsync(InterventionSyncPayload payload, CancellationToken ct = default) => throw new NotImplementedException(); public Task UpdateAsync(Guid interventionGuid, 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 FakeContactsService : IInterventionContactsService { public Task> GetContactsForInterventionAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task GetContactByIdAsync( Guid contactGuid, CancellationToken ct = default) => Task.FromResult(null); public Task> GetContactsDisponiblesAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); } private sealed class FakeDocumentService : IDocumentLocalService { public Task> GetDocumentsForInterventionAsync( Guid interventionGuid, CancellationToken ct = default) => Task.FromResult>([]); public Task EnsureDownloadedAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(true); public Task OpenAsync(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 sealed class FakeSyncEngine : ISyncEngine { public Task SyncAsync(CancellationToken cancellationToken = default) => Task.FromResult(new SyncCycleResult()); public Task PushAsync(CancellationToken cancellationToken = default) => Task.FromResult(new SyncCycleResult()); } }