Files
Omika/tests/Omika.Tests/Mobile/Local/NouveauEmailViewModelTests.cs
2026-10-23 18:46:50 +02:00

469 lines
20 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<RapportRubriqueResume>(
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<RapportRubriqueResume>([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<RapportRubriqueResume>([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<RapportChampDto> 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<RapportRubriqueResume>(
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<RapportRubriqueResume>(
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 = "<p>Bonjour Smartis</p>" }
};
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 = "<p>Bonjour</p>";
Assert.Equal("<p>Bonjour</p>", 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 = "<table><tr><td>Moreau Valérie</td><td>04 13 95 46 73</td></tr></table>";
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("<table>", vm.CorpsApercuTexte);
}
[Fact]
public void Html_AucunDecodeSurLaSource()
{
var vm = CreerViewModel(new FakeInterventionService(), new FakeContactsService(), new FakeDocumentService());
vm.Corps = "<p>R&amp;D &amp; Cie</p>";
// La source conserve les entités HTML (aucun HtmlDecode / StripTags).
Assert.Equal("<p>R&amp;D &amp; Cie</p>", 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 = "<p>Bonjour DigiNova</p><table><tr><td>Moreau Valérie</td></tr></table>"
}
};
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("<p>Bonjour DigiNova</p><table><tr><td>Moreau Valérie</td></tr></table>", 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 = "<p>Bonjour</p><table><tr><td>30/01/2026</td></tr></table>";
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<RapportChampDto> Champs { get; set; } = [];
public EmailModeleApercuDto? Apercu { get; set; }
public CreateEmailModeleRequest? LastApercuRequest { get; private set; }
public Task<IReadOnlyList<RapportChampDto>> GetMailChampsAsync(
GetRapportChampsRequest request, CancellationToken ct = default) => Task.FromResult(Champs);
public Task<EmailModeleApercuDto?> GetEmailModeleApercuAsync(
CreateEmailModeleRequest request, CancellationToken ct = default)
{
LastApercuRequest = request;
return Task.FromResult(Apercu);
}
public Task<IReadOnlyList<SmtpConfigurationDto>> GetSmtpConfigurationsAsync(
CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SmtpConfigurationDto>>([]);
public CreateEmailInterventionRequest? LastEmailRequest { get; private set; }
public Task<EmailInterventionDto> 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<Guid> CreateAsync(InterventionSyncPayload payload, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<Guid> UpdateAsync(Guid interventionGuid, InterventionSyncPayload payload, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<Guid> ArchiveAsync(Guid interventionGuid, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<InterventionDetail?> GetInterventionByIdAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<InterventionDetail?>(null);
public Task<IReadOnlyList<InterventionListItem>> GetInterventionsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<InterventionListItem>>([]);
}
private sealed class FakeContactsService : IInterventionContactsService
{
public Task<IReadOnlyList<InterventionContact>> GetContactsForInterventionAsync(
Guid interventionGuid, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<InterventionContact>>([]);
public Task<InterventionContact?> GetContactByIdAsync(
Guid contactGuid, CancellationToken ct = default) => Task.FromResult<InterventionContact?>(null);
public Task<IReadOnlyList<InterventionContact>> GetContactsDisponiblesAsync(
Guid interventionGuid, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<InterventionContact>>([]);
}
private sealed class FakeDocumentService : IDocumentLocalService
{
public Task<IReadOnlyList<InterventionDocumentItem>> GetDocumentsForInterventionAsync(
Guid interventionGuid, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<InterventionDocumentItem>>([]);
public Task<bool> EnsureDownloadedAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(true);
public Task<bool> OpenAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult(true);
public Task<string?> GetCachedFilePathAsync(Guid documentGuid, CancellationToken ct = default) => Task.FromResult<string?>(null);
public Task<IReadOnlyList<DocumentNoteItem>> GetNotesForDocumentAsync(Guid documentGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DocumentNoteItem>>([]);
public Task<DocumentNoteItem> AddNoteAsync(Guid documentGuid, string contenu, CancellationToken ct = default)
=> Task.FromResult(new DocumentNoteItem());
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 sealed class FakeSyncEngine : ISyncEngine
{
public Task<SyncCycleResult> SyncAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncCycleResult());
public Task<SyncCycleResult> PushAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncCycleResult());
}
}