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

304 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Navigation;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.App.Views;
using Omika.Core.Contracts.Documents;
using Omika.Core.Contracts.Mobile.Sync;
using Xunit;
namespace Omika.Tests.Mobile;
/// <summary>
/// Écran de composition d'un courriel en MESSAGERIE EXTERNE : le choix de
/// configuration SMTP disparaît (l'envoi ne passe pas par Omika), une explication
/// le remplace, et le panneau « Le courriel a-t-il été envoyé ? » (Oui / Non)
/// apparaît après le retour de l'application de messagerie.
/// </summary>
/// <remarks>
/// Session headless partagée au niveau du processus (localisateur Avalonia global) :
/// la classe rejoint la collection « ApplicationRuntimeContext ».
/// </remarks>
[Collection("ApplicationRuntimeContext")]
public sealed class NouveauEmailViewExterneTests : IDisposable
{
private readonly HeadlessUnitTestSession _session =
HeadlessUnitTestSession.StartNew(typeof(LayoutTestAppBuilder));
public void Dispose() => _session.Dispose();
private void Executer(Action scenario)
=> _session.Dispatch(
() =>
{
scenario();
return true;
},
CancellationToken.None).GetAwaiter().GetResult();
/// <summary>
/// Messagerie externe : la configuration SMTP est MASQUÉE et l'explication
/// affichée (l'utilisateur doit comprendre que l'envoi se fera ailleurs).
/// </summary>
[Fact]
public void MessagerieExterne_MasqueLaConfigurationSmtpEtExplique()
=> Executer(() =>
{
NouveauEmailView vue = Ouvrir(messagerieExterne: true);
Window fenetre = Fenetre(vue);
try
{
ComboBox? configuration = vue.FindControl<ComboBox>("ConfigurationSmtp");
Assert.NotNull(configuration);
Assert.False(
configuration!.IsEffectivelyVisible,
"La configuration SMTP ne doit pas être proposée en messagerie externe.");
TextBlock explication = vue.GetVisualDescendants()
.OfType<TextBlock>()
.Single(t => t.Text?.StartsWith("Messagerie externe", StringComparison.Ordinal) == true);
Assert.True(explication.IsEffectivelyVisible, "L'explication de la messagerie externe n'est pas affichée.");
}
finally
{
fenetre.Close();
}
});
/// <summary>Messagerie interne (Back-Office) : rien ne change, la configuration reste proposée.</summary>
[Fact]
public void MessagerieInterne_GardeLaConfigurationSmtp()
=> Executer(() =>
{
NouveauEmailView vue = Ouvrir(messagerieExterne: false);
Window fenetre = Fenetre(vue);
try
{
ComboBox? configuration = vue.FindControl<ComboBox>("ConfigurationSmtp");
Assert.NotNull(configuration);
Assert.True(configuration!.IsEffectivelyVisible);
TextBlock? explication = vue.GetVisualDescendants()
.OfType<TextBlock>()
.FirstOrDefault(t => t.Text?.StartsWith("Messagerie externe", StringComparison.Ordinal) == true);
Assert.True(explication is null || !explication.IsEffectivelyVisible);
}
finally
{
fenetre.Close();
}
});
/// <summary>
/// La question n'est PAS affichée avant l'envoi, puis apparaît avec ses deux
/// réponses « Oui » (enregistre comme envoyé) et « Non » (conserve en brouillon).
/// </summary>
[Fact]
public void QuestionEnvoiExterne_SafficheApresLeRetourAvecOuiEtNon()
=> Executer(() =>
{
NouveauEmailView vue = Ouvrir(messagerieExterne: true);
Window fenetre = Fenetre(vue);
try
{
var modele = (NouveauEmailViewModel)vue.DataContext!;
Button oui = Bouton(vue, "Oui");
Button non = Bouton(vue, "Non");
Assert.False(
oui.IsEffectivelyVisible,
"La question ne doit pas apparaître avant l'ouverture de la messagerie.");
modele.AfficherQuestionEnvoiExterne = true;
Dispatcher.UIThread.RunJobs();
Assert.True(oui.IsEffectivelyVisible, "Le bouton « Oui » n'est pas affiché.");
Assert.True(non.IsEffectivelyVisible, "Le bouton « Non » n'est pas affiché.");
Assert.NotNull(oui.Command);
Assert.NotNull(non.Command);
Assert.NotSame(oui.Command, non.Command);
}
finally
{
fenetre.Close();
}
});
// =====================================================================
// Aides
// =====================================================================
private static Button Bouton(Control vue, string libelle)
=> vue.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content is string texte && texte == libelle);
/// <summary>
/// Construit la vue réelle avec un ViewModel minimal : seule la préférence de
/// messagerie influence les éléments testés (aucun appel réseau n'est déclenché).
/// </summary>
private static NouveauEmailView Ouvrir(bool messagerieExterne)
{
var modele = new NouveauEmailViewModel(
new FakeInterventionService(),
new FakeContactsService(),
new FakeDocumentService(),
new NavigationService(),
new FakeSyncEngine(),
Omika.Core.Observability.NullTelemetryService.Instance,
new FakeEmailLauncher(),
new FakeAppRetourService(),
new FakePermissionService(messagerieExterne));
// Étape 2 : c'est elle qui contient le bloc d'envoi (configuration SMTP /
// explication de la messagerie externe).
modele.Etape = 2;
Dispatcher.UIThread.RunJobs();
return new NouveauEmailView { DataContext = modele };
}
private static Window Fenetre(Control contenu)
{
var fenetre = new Window
{
Width = 400,
Height = 900,
RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Light,
Content = contenu
};
fenetre.Show();
Dispatcher.UIThread.RunJobs();
Dispatcher.UIThread.RunJobs();
return fenetre;
}
// =====================================================================
// Fakes
// =====================================================================
private sealed class FakePermissionService : IPermissionService
{
public FakePermissionService(bool messagerieExterne) => MessagerieExterneValue = messagerieExterne;
private bool MessagerieExterneValue { get; }
public IReadOnlyDictionary<string, bool> Rights => new Dictionary<string, bool>();
public void SetRights(IReadOnlyDictionary<string, bool> rights) { }
public bool HasRight(string key) => true;
public bool MessagerieExterne => MessagerieExterneValue;
public void SetMessagerieExterne(bool externe) { }
public bool PeutRechercherInterventions => true;
public bool VoirRdvSansRestriction => true;
public bool PeutVoirAgendaEquipe => true;
public bool PeutEnvoyerEmail => !MessagerieExterneValue;
public bool PeutComposerEmail => true;
public bool PeutCreerRapport => true;
public bool PeutGererLicence => true;
public bool PeutSaisirTempsAutrui => true;
public bool PeutVoirNotesAdmin => true;
public bool PeutVoirClients => true;
public bool PeutVoirSites => true;
public bool PeutVoirDossiers => true;
}
private sealed class FakeEmailLauncher : IEmailLauncher
{
public Task<bool> ComposeAsync(string email) => Task.FromResult(true);
}
private sealed class FakeAppRetourService : IAppRetourService
{
public IAppRetourAttente Armer() => new RetourImmediat();
private sealed class RetourImmediat : IAppRetourAttente
{
public Task AttendreAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() { }
}
}
private sealed class FakeInterventionService : IInterventionLocalService
{
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());
}
}