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

1117 lines
45 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
[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<RapportChampEdition> 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);
}
/// <summary>
/// Un champ texte NON modifiable s'affiche en texte seul (aucun contrôle de
/// saisie, donc aucun champ grisé dans la page).
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<RapportRubriqueResume>(
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<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, 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<RapportRubriqueResume>(
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<ModeleRapportDto> Modeles { get; set; } = [];
public IReadOnlyList<RapportChampDto> Champs { get; set; } = [];
public DocumentSummaryDto? RapportToCreate { get; set; }
public TaskCompletionSource<DocumentSummaryDto?>? Gate { get; set; }
public int CreateCalls { get; private set; }
/// <summary>Panne simulée du catalogue des modèles (réseau ou métier).</summary>
public Exception? ModeleRapportsException { get; set; }
/// <summary>Panne simulée du calcul des champs d'un modèle.</summary>
public Exception? ChampsException { get; set; }
public int ModeleRapportsCalls { get; private set; }
public int ChampsCalls { get; private set; }
public Task<IReadOnlyList<ModeleRapportDto>> GetModeleRapportsAsync(
CancellationToken ct = default)
{
ModeleRapportsCalls++;
return ModeleRapportsException is not null
? Task.FromException<IReadOnlyList<ModeleRapportDto>>(ModeleRapportsException)
: Task.FromResult(Modeles);
}
public Task<IReadOnlyList<RapportChampDto>> GetRapportChampsAsync(
GetRapportChampsRequest request, CancellationToken ct = default)
{
ChampsCalls++;
return ChampsException is not null
? Task.FromException<IReadOnlyList<RapportChampDto>>(ChampsException)
: Task.FromResult(Champs);
}
public Task<DocumentSummaryDto?> 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<Guid> CreateAsync(Omika.Core.Contracts.Mobile.Sync.InterventionSyncPayload payload, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<Guid> UpdateAsync(Guid interventionGuid, Omika.Core.Contracts.Mobile.Sync.InterventionSyncPayload payload, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<Guid> ArchiveAsync(Guid interventionGuid, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<Omika.App.Local.Models.InterventionDetail?> GetInterventionByIdAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<Omika.App.Local.Models.InterventionDetail?>(null);
public Task<IReadOnlyList<Omika.App.Local.Models.InterventionListItem>> GetInterventionsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Omika.App.Local.Models.InterventionListItem>>([]);
}
private sealed class FakeBrouillonRepository : IRapportBrouillonRepository
{
/// <summary>Brouillons déjà enregistrés localement (SQLite) pour l'intervention.</summary>
public IReadOnlyList<RapportBrouillonEntry> Entries { get; set; } = [];
/// <summary>Panne locale simulée de la lecture des brouillons.</summary>
public Exception? GetException { get; set; }
public int GetCalls { get; private set; }
public Task UpsertAsync(RapportBrouillonEntry entry, CancellationToken ct = default) => Task.CompletedTask;
public Task<RapportBrouillonEntry?> GetAsync(Guid id, CancellationToken ct = default)
=> Task.FromResult<RapportBrouillonEntry?>(null);
public Task<IReadOnlyList<RapportBrouillonEntry>> GetByInterventionAsync(
Guid interventionGuid, CancellationToken ct = default)
{
GetCalls++;
return GetException is not null
? Task.FromException<IReadOnlyList<RapportBrouillonEntry>>(GetException)
: Task.FromResult(Entries);
}
public Task DeleteAsync(Guid id, CancellationToken ct = default) => Task.CompletedTask;
}
/// <summary>Connectivité simulée (en ligne par défaut), injectée dans le ViewModel.</summary>
private sealed class FakeReseau : INetworkConnectivity
{
public bool Online { get; set; } = true;
public int Calls { get; private set; }
public Task<bool> 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<bool> OpenAsync(Guid documentGuid, CancellationToken ct = default)
{
OpenCalls++;
return Task.FromResult(OpenResult);
}
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<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 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<DocumentSummaryDto?>(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)
};
/// <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;
}
/// <summary>
/// Événements d'INCIDENT du ViewModel (OMK-MOB-ERR-002). Les événements d'usage
/// (<c>TrackFeature</c> → Information) sont exclus : ils ne portent pas de gravité.
/// </summary>
private static IReadOnlyList<DiagnosticEvent> EvenementsIncident(CapturingTelemetry telemetry) =>
telemetry.Events
.OfType<DiagnosticEvent>()
.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);
/// <summary>
/// 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
/// <c>await</c> exposé à l'appelant.
/// </summary>
private static async Task<bool> AttendreAsync(Func<bool> condition)
{
for (int i = 0; i < 100 && !condition(); i++)
{
await Task.Delay(10);
}
return condition();
}
/// <summary>
/// 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 <c>try</c>).
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Une réponse serveur illisible n'est pas une coupure réseau : elle reste un
/// incident Error/P1 avec le message dédié.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>En ligne, l'envoi d'un email reste possible.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task Load_MessagerieExterne_DesactiveLeBoutonEnvoyerEmail()
{
Guid interventionId = Guid.NewGuid();
var permissions = new Omika.App.Services.PermissionService();
permissions.SetRights(new Dictionary<string, bool>
{
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <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);
}
}