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érie | 04 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
"
}
};
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
", 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
";
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());
}
}