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

581 lines
27 KiB
C#

using Omika.App.Local.Abstractions;
using Omika.Core.Contracts.Documents;
using Omika.App.Local.Models;
using Omika.App.Local.Repositories;
using Omika.App.Local.Services;
using Omika.App.Navigation;
using Omika.App.Services;
using Omika.App.ViewModels;
using Omika.Core.Contracts.Common;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Mobile.Sync;
using Omika.Core.Contracts.Planning;
namespace Omika.Tests.Mobile.Local;
/// <summary>
/// Tests ciblés de la sélection de couleur d'accent de la page Paramètres.
/// Vérifie que le clic sur un preset transmet sa couleur à la commande,
/// met à jour <see cref="ParametresViewModel.SelectedPrimaryColor"/> et
/// délègue l'application/persistance à <see cref="ThemeService"/>.
/// </summary>
public class ParametresViewModelTests : LocalTestBase
{
private sealed class FakeSyncEngine : ISyncEngine
{
public Task<SyncCycleResult> SyncAsync(CancellationToken ct = default)
=> Task.FromResult(new SyncCycleResult { Executed = true });
public Task<SyncCycleResult> PushAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new SyncCycleResult { Executed = true });
}
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(false);
public Task<bool> OpenAsync(Guid documentGuid, CancellationToken ct = default)
=> Task.FromResult(false);
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 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 FakeDossierService : IDossierLocalService
{
public Task<IReadOnlyList<DossierListItem>> GetDossiersAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierListItem>>([]);
public Task<IReadOnlyList<DossierListItem>> SearchDossiersAsync(string? refInterne, string? titre, string? client, string? statut, string? type, string? refExterne, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierListItem>>([]);
public Task<IReadOnlyList<DossierListItem>> GetLastSearchAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierListItem>>([]);
public Task ClearLastSearchAsync(CancellationToken ct = default)
=> Task.CompletedTask;
public Task<DossierDetail?> GetDossierByIdAsync(Guid dossierGuid, CancellationToken ct = default)
=> Task.FromResult<DossierDetail?>(null);
public Task SetPinnedAsync(Guid dossierGuid, bool isPinned, DossierListItem? item, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<IReadOnlyList<DossierListItem>> GetPinnedAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierListItem>>([]);
public Task<IReadOnlyList<DossierIntervention>> GetDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierIntervention>>([]);
public Task<IReadOnlyList<DossierIntervention>> RefreshDossierInterventionsAsync(Guid dossierGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<DossierIntervention>>([]);
}
private sealed class FakeClientService : IClientLocalService
{
public Task<IReadOnlyList<ClientListItem>> GetClientsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ClientListItem>>([]);
public Task<ClientDetail?> GetClientByIdAsync(Guid clientGuid, CancellationToken ct = default)
=> Task.FromResult<ClientDetail?>(null);
}
private sealed class FakeSiteService : ISiteLocalService
{
public Task<IReadOnlyList<SiteListItem>> GetSitesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SiteListItem>>([]);
public Task<SiteDetail?> GetSiteByIdAsync(Guid siteGuid, CancellationToken ct = default)
=> Task.FromResult<SiteDetail?>(null);
}
private sealed class FakeCollaborateurService : ICollaborateurLocalService
{
public Task<IReadOnlyList<CollaborateurListItem>> GetCollaborateursAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<CollaborateurListItem>>([]);
public Task<CollaborateurDetail?> GetCollaborateurByIdAsync(Guid collaborateurGuid, CancellationToken ct = default)
=> Task.FromResult<CollaborateurDetail?>(null);
}
private sealed class FakeEquipeService : IEquipeLocalService
{
public Task<IReadOnlyList<EquipeListItem>> GetEquipesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<EquipeListItem>>([]);
public Task<IReadOnlyList<EquipeListItem>> GetAllEquipesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<EquipeListItem>>([]);
}
private sealed class FakeSuiviTempsService : ISuiviTempsLocalService
{
public Task<Guid> CreateAsync(SuiviTempsSyncPayload payload, CancellationToken ct = default)
=> Task.FromResult(Guid.NewGuid());
public Task<SuiviTempsDto?> GetByGuidAsync(Guid suiviTempsGuid, CancellationToken ct = default)
=> Task.FromResult<SuiviTempsDto?>(null);
public Task<IReadOnlyList<SuiviTempsDto>> GetAllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDto>> GetByInterventionAsync(Guid interventionGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDto>> GetByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDto>>([]);
public Task<IReadOnlyList<SuiviTempsDraftEntry>> GetDraftsByRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<SuiviTempsDraftEntry>>([]);
public Task CreateDraftAsync(SuiviTempsDraftEntry draft, CancellationToken ct = default)
=> Task.CompletedTask;
public Task DeleteDraftAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<bool> HasTypeTempsAsync(Guid rendezVousGuid, string typeTempsKey, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<bool> IsValideAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.FromResult(false);
public Task ValidateRendezVousAsync(Guid rendezVousGuid, CancellationToken ct = default)
=> Task.CompletedTask;
public Task SaveRendezVousPourBrouillonAsync(AgendaItem rendezVous, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<IReadOnlyList<AgendaItem>> GetRendezVousAvecBrouillonAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<AgendaItem>>([]);
public Task DeleteRendezVousPourBrouillonAsync(Guid rendezVousId, CancellationToken ct = default)
=> Task.CompletedTask;
}
private sealed class FakeParametreMetierService : IParametreMetierLocalService
{
public Task<IReadOnlyList<ReferenceValueDto>> GetTypeTempsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<Omika.Core.Contracts.Configuration.TypeTempsParametresDto?> GetTypeTempsParametresAsync(string typeTempsKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.TypeTempsParametresDto?>(null);
public Task<Omika.Core.Contracts.Configuration.TypeRdvParametresDto?> GetTypeRdvParametresAsync(string typeRdvKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.TypeRdvParametresDto?>(null);
public Task<Omika.Core.Contracts.Configuration.StatutRdvParametresDto?> GetStatutRdvParametresAsync(string statutRdvKey, CancellationToken ct = default)
=> Task.FromResult<Omika.Core.Contracts.Configuration.StatutRdvParametresDto?>(null);
public Task<IReadOnlyList<ReferenceValueDto>> GetInterventionTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetInterventionEtatsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetDossierTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetDossierStatutsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetClientTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetClientStatutsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetSiteTypesAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
public Task<IReadOnlyList<ReferenceValueDto>> GetTypeSignatairesAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ReferenceValueDto>>([]);
}
private sealed class FakePhoneLauncher : IPhoneLauncher
{
public Task<bool> DialAsync(string phoneNumber) => Task.FromResult(true);
}
private sealed class FakeEmailLauncher : IEmailLauncher
{
public string? LastEmail { get; private set; }
public IReadOnlyList<string>? LastAttachments { get; private set; }
public bool ComposeResult { get; set; } = true;
public Task<bool> ComposeAsync(string email)
{
LastEmail = email;
LastAttachments = null;
return Task.FromResult(ComposeResult);
}
public Task<bool> ComposeAsync(string email, string? subject, string? body, IReadOnlyList<string>? attachmentPaths)
{
LastEmail = email;
LastAttachments = attachmentPaths;
return Task.FromResult(ComposeResult);
}
}
private sealed class FakeSupportLogExporter : ISupportLogExporter
{
public string? ExportResult { get; set; }
public string? ExportLatestLogs() => ExportResult;
}
private sealed class FakeMapLauncher : IMapLauncher
{
public Task<bool> OpenItineraireAsync(string address) => Task.FromResult(true);
}
private sealed class FakeSessionService : IMobileSessionService
{
public Task<bool> HasValidSessionAsync(CancellationToken ct = default) => Task.FromResult(false);
public Task<bool> HasExpiredSessionAsync(CancellationToken ct = default) => Task.FromResult(false);
public Task<LicenseResponse> ActivateAsync(
string codeClient, string numeroLicence, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<LicenseResponse> ValidateAsync(CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<LocalBootstrapState?> GetBootstrapAsync(CancellationToken ct = default)
=> Task.FromResult<LocalBootstrapState?>(null);
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
=> Task.FromResult<string?>(null);
public Task ClearSessionAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task EnsureDevelopmentSessionAsync(CancellationToken ct = default) => Task.CompletedTask;
}
private readonly UiPreferencesRepository _preferencesRepository;
private readonly ThemeService _themeService;
private readonly FakeSessionService _sessionService = new();
private readonly FakeEmailLauncher _emailLauncher = new();
private readonly FakeSupportLogExporter _supportLogExporter = new();
private readonly ParametresViewModel _viewModel;
public ParametresViewModelTests()
{
_preferencesRepository = new UiPreferencesRepository(Database);
_themeService = new ThemeService(_preferencesRepository);
var navigation = new NavigationService();
var syncEngine = new FakeSyncEngine();
IAgendaService agendaService = new AgendaService(
RendezVousRepository, ClientRepository, SiteRepository, InterventionRepository, CollaborateurRepository, ApiClient, SessionService);
IInterventionLocalService interventionService = new InterventionLocalService(
InterventionRepository, ClientRepository, SiteRepository, DossierRepository,
RendezVousRepository, DocumentMetadataRepository, CollaborateurRepository,
OutboxService, ReconciliationService, ApiClient, InterventionCacheRepository, ContextInterventionCacheRepository, Database);
var sessionService = _sessionService;
var pauseDejeunerPreference = new PauseDejeunerPreferenceService(
PreferencesUtilisateurRepository, sessionService);
var pageFactory = new PageViewModelFactory(
_themeService, agendaService, interventionService, new FakeDocumentService(),
new FakeContactsService(), new FakeDossierService(), new FakeClientService(),
new FakeSiteService(), new FakeCollaborateurService(), new FakeEquipeService(),
new FakeSuiviTempsService(), new FakeParametreMetierService(), InterventionRepository, OutboxRepository, null!,
new FakePhoneLauncher(), new FakeEmailLauncher(), new FakeMapLauncher(), null!, navigation, sessionService, new PermissionService(),
syncEngine,
null!,
new FakePhotoPolicyProvider(),
pauseDejeunerPreference,
new RendezVousAnnulationLocalService(RendezVousRepository, OutboxService, new RendezVousStateNotifier()),
new RendezVousFinalisationLocalService(RendezVousRepository, new RendezVousStateNotifier()),
new CompteRenduScopeService(interventionService),
new RendezVousStateNotifier(),
null!,
Omika.Core.Observability.NullTelemetryService.Instance,
Network,
_langueSecondaire,
_languagePacks,
_traduction);
var shell = new ShellViewModel(navigation, _themeService, syncEngine, pageFactory, sessionService, new PermissionService(), OutboxService, null!);
pageFactory.SetShell(shell);
_viewModel = new ParametresViewModel(
_themeService, shell, sessionService, null!, new FakePhotoPolicyProvider(), pauseDejeunerPreference,
_emailLauncher, _supportLogExporter,
_langueSecondaire, _languagePacks, _traduction);
}
/// <summary>Préférence de langue secondaire simulée (aucun stockage réel).</summary>
private readonly FakeLangueSecondairePreferenceService _langueSecondaire = new();
/// <summary>Packs de langue simulés (aucun réseau, aucun fichier).</summary>
private readonly FakeLanguagePackService _languagePacks = new();
/// <summary>Traduction simulée : la disponibilité est pilotée par les tests.</summary>
private readonly FakeTranslationService _traduction = new();
[Fact]
public async Task ApplyPrimaryColorCommand_CouleurCliquee_EstAppliqueeEtPersistee()
{
// Arrange : couleur initiale = défaut du ThemeService.
Assert.Equal(ThemeService.DefaultPrimaryColor, _viewModel.SelectedPrimaryColor);
// Act : clic sur un preset (couleur transmise via CommandParameter).
await _viewModel.ApplyPrimaryColorCommand.ExecuteAsync("#2563eb");
// Assert : la couleur cliquée devient la sélection…
Assert.Equal("#2563eb", _viewModel.SelectedPrimaryColor);
// … est appliquée au ThemeService…
Assert.Equal("#2563eb", _themeService.PrimaryColor);
// … et persistée via UiPreferences.
UiPreferences saved = await _preferencesRepository.GetAsync();
Assert.Equal("#2563eb", saved.PrimaryColor);
// Le mode de thème n'est pas modifié.
Assert.Equal(ThemeMode.System, _viewModel.SelectedThemeMode);
Assert.Equal(ThemeMode.System, _themeService.CurrentMode);
}
[Fact]
public async Task ApplyPrimaryColorCommand_DeuxCouleursSuccessives_LaDerniereGagne()
{
// Act : deux couleurs successives.
await _viewModel.ApplyPrimaryColorCommand.ExecuteAsync("#2563eb");
await _viewModel.ApplyPrimaryColorCommand.ExecuteAsync("#0d9488");
// Assert : la valeur finale est la dernière couleur appliquée.
Assert.Equal("#0d9488", _viewModel.SelectedPrimaryColor);
Assert.Equal("#0d9488", _themeService.PrimaryColor);
UiPreferences saved = await _preferencesRepository.GetAsync();
Assert.Equal("#0d9488", saved.PrimaryColor);
}
[Fact]
public async Task ApplyPrimaryColorCommand_ParametreNullOuVide_EstIgnoreSansEffet()
{
// Arrange : couleur initiale.
string initial = _viewModel.SelectedPrimaryColor;
// Act : paramètres invalides.
await _viewModel.ApplyPrimaryColorCommand.ExecuteAsync((string?)null);
await _viewModel.ApplyPrimaryColorCommand.ExecuteAsync(string.Empty);
await _viewModel.ApplyPrimaryColorCommand.ExecuteAsync(" ");
// Assert : aucun crash, aucun changement, aucune persistance invalide.
Assert.Equal(initial, _viewModel.SelectedPrimaryColor);
Assert.Equal(ThemeService.DefaultPrimaryColor, _themeService.PrimaryColor);
UiPreferences saved = await _preferencesRepository.GetAsync();
Assert.Equal(ThemeService.DefaultPrimaryColor, saved.PrimaryColor);
}
// ===== Pause déjeuner (préférence locale) =====
[Fact]
public async Task LoadPauseDejeuner_SansPreference_AfficheLesValeursParDefaut()
{
// Act
await _viewModel.LoadAsync();
// Assert : 12:00 → 13:00, aucun message d'erreur.
Assert.Equal(PauseDejeunerPreference.DebutParDefaut, _viewModel.PauseDejeunerDebut);
Assert.Equal(PauseDejeunerPreference.FinParDefaut, _viewModel.PauseDejeunerFin);
Assert.False(_viewModel.PauseDejeunerHasError);
Assert.Null(_viewModel.PauseDejeunerStatusText);
}
[Fact]
public async Task SavePauseDejeuner_EnregistreEtPersisteLocalement()
{
// Arrange
_viewModel.PauseDejeunerDebut = new TimeSpan(12, 15, 0);
_viewModel.PauseDejeunerFin = new TimeSpan(13, 15, 0);
// Act
await _viewModel.SavePauseDejeunerCommand.ExecuteAsync(null);
// Assert : confirmation affichée, aucune erreur…
Assert.False(_viewModel.PauseDejeunerHasError);
Assert.Equal("Horaires enregistrés.", _viewModel.PauseDejeunerStatusText);
// … et valeur réellement persistée dans SQLite (aucun appel réseau).
PauseDejeunerPreference? stockee = await PreferencesUtilisateurRepository.GetPauseDejeunerAsync(
PauseDejeunerPreferenceService.CleSansUtilisateur);
Assert.NotNull(stockee);
Assert.Equal(new TimeSpan(12, 15, 0), stockee!.Debut);
Assert.Equal(new TimeSpan(13, 15, 0), stockee.Fin);
}
[Fact]
public async Task SavePauseDejeuner_FinAnterieureAuDebut_RefuseEtNEnregistreRien()
{
// Arrange : 13:30 → 12:00, configuration incohérente.
_viewModel.PauseDejeunerDebut = new TimeSpan(13, 30, 0);
_viewModel.PauseDejeunerFin = new TimeSpan(12, 0, 0);
// Act
await _viewModel.SavePauseDejeunerCommand.ExecuteAsync(null);
// Assert : message clair, aucune écriture silencieuse.
Assert.True(_viewModel.PauseDejeunerHasError);
Assert.Equal(
"L'heure de fin doit être postérieure à l'heure de début.",
_viewModel.PauseDejeunerStatusText);
Assert.Null(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync(
PauseDejeunerPreferenceService.CleSansUtilisateur));
}
[Fact]
public async Task SavePauseDejeuner_FinEgaleAuDebut_RefuseEtNEnregistreRien()
{
// Arrange : durée nulle, configuration incohérente.
_viewModel.PauseDejeunerDebut = new TimeSpan(12, 0, 0);
_viewModel.PauseDejeunerFin = new TimeSpan(12, 0, 0);
// Act
await _viewModel.SavePauseDejeunerCommand.ExecuteAsync(null);
// Assert
Assert.True(_viewModel.PauseDejeunerHasError);
Assert.Null(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync(
PauseDejeunerPreferenceService.CleSansUtilisateur));
}
[Fact]
public async Task LoadPauseDejeuner_ApresEnregistrement_RestitueLesHoraires()
{
// Arrange : l'utilisateur enregistre 11:45 → 12:45.
_viewModel.PauseDejeunerDebut = new TimeSpan(11, 45, 0);
_viewModel.PauseDejeunerFin = new TimeSpan(12, 45, 0);
await _viewModel.SavePauseDejeunerCommand.ExecuteAsync(null);
// Act : réouverture de la page Paramètres (simule un redémarrage).
_viewModel.PauseDejeunerDebut = TimeSpan.Zero;
_viewModel.PauseDejeunerFin = TimeSpan.Zero;
await _viewModel.LoadAsync();
// Assert
Assert.Equal(new TimeSpan(11, 45, 0), _viewModel.PauseDejeunerDebut);
Assert.Equal(new TimeSpan(12, 45, 0), _viewModel.PauseDejeunerFin);
}
// ===== Masquage du numéro de licence (affichage uniquement) =====
[Theory]
[InlineData("fgts12345654sf", "fgts…54sf")]
[InlineData("12345678", "1234…5678")]
[InlineData("1234567", "1…7")]
[InlineData("12", "…")]
[InlineData("1", "…")]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
public void MasquerNumeroLicence_MasqueSansModifierLaValeurReelle(string? input, string? expected)
{
// Le masquage est purement visuel : la valeur d'entrée n'est pas altérée,
// et la sortie ne contient jamais plus de 4 premiers + 4 derniers caractères.
Assert.Equal(expected, ParametresViewModel.MasquerNumeroLicence(input));
}
// ===== Support (email avec journaux de diagnostic en pièce jointe) =====
[Fact]
public async Task SendSupportEmail_SansAdresseSupport_NeComposePas()
{
// Arrange : aucune adresse de support (licence absente).
_viewModel.SupportEmail = null;
// Act
await _viewModel.SendSupportEmailCommand.ExecuteAsync(null);
// Assert : aucun lancement du client email, message explicite.
Assert.Null(_emailLauncher.LastEmail);
Assert.Equal("Aucune adresse de support configurée.", _viewModel.SupportStatusText);
}
[Fact]
public async Task SendSupportEmail_AvecLogs_ComposeAvecPieceJointe()
{
// Arrange : adresse de support + journaux exportés dans un fichier .txt.
_viewModel.SupportEmail = "support@example.test";
_supportLogExporter.ExportResult = "/tmp/omika-support-logs.txt";
// Act
await _viewModel.SendSupportEmailCommand.ExecuteAsync(null);
// Assert : le destinataire est l'adresse de support et le fichier .txt
// est passé en pièce jointe.
Assert.Equal("support@example.test", _emailLauncher.LastEmail);
Assert.NotNull(_emailLauncher.LastAttachments);
Assert.Single(_emailLauncher.LastAttachments!);
Assert.Equal("/tmp/omika-support-logs.txt", _emailLauncher.LastAttachments![0]);
Assert.Null(_viewModel.SupportStatusText);
}
[Fact]
public async Task SendSupportEmail_SansLogs_ComposeSansPieceJointe()
{
// Arrange : adresse de support mais aucun journal exportable.
_viewModel.SupportEmail = "support@example.test";
_supportLogExporter.ExportResult = null;
// Act
await _viewModel.SendSupportEmailCommand.ExecuteAsync(null);
// Assert : composeur ouvert vers le support, sans pièce jointe.
Assert.Equal("support@example.test", _emailLauncher.LastEmail);
Assert.Null(_emailLauncher.LastAttachments);
Assert.Null(_viewModel.SupportStatusText);
}
[Fact]
public async Task SendSupportEmail_ComposeEchoue_AfficheUnMessage()
{
// Arrange : le client email refuse le lancement.
_viewModel.SupportEmail = "support@example.test";
_supportLogExporter.ExportResult = null;
_emailLauncher.ComposeResult = false;
// Act
await _viewModel.SendSupportEmailCommand.ExecuteAsync(null);
// Assert
Assert.Equal("Aucune application de messagerie disponible.", _viewModel.SupportStatusText);
}
}