using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.ViewModels;
using Omika.Core.Observability;
namespace Omika.Tests.Mobile.Local;
///
/// Tests du LicenseActivationViewModel : saisie manuelle ET QR sont deux entrées
/// distinctes vers la même procédure d'inscription. Le QR ne remplit jamais le
/// champ manuel ; le champ manuel n'est jamais utilisé comme stockage du QR.
///
/// Couvre aussi la journalisation des échecs de scan : une DÉFAILLANCE technique
/// (ouverture/énumération de la caméra en échec) est un incident Error/P1 et doit
/// être visible dans le Diagnostic Center ; une INDISPONIBILITÉ attendue (repli
/// plateforme, permission refusée, appareil sans caméra arrière) n'en est pas un.
///
[Collection("TelemetrieStatique")]
public class LicenseActivationViewModelTests
{
private sealed class FakeEnrollmentService : IEnrollmentService
{
public EnrollmentResult EnrollToReturn { get; set; } = EnrollmentResult.Failed("Inscription refusée.");
public bool ThrowHttp { get; set; }
public string? LastQrContent { get; private set; }
public Task LoadAsync(CancellationToken ct = default)
=> Task.FromResult(null);
public Task EnrollAsync(string qrContent, CancellationToken ct = default)
{
LastQrContent = qrContent;
if (ThrowHttp)
{
throw new System.Net.Http.HttpRequestException("Network");
}
return Task.FromResult(EnrollToReturn);
}
public Task ClearAsync(CancellationToken ct = default) => Task.CompletedTask;
}
private sealed class FakeQrScanner : IEnrollmentQrScanner
{
public string? ScanResult { get; set; }
/// Échec du scanner à simuler (défaillance technique ou indisponibilité).
public QrScanException? ScanException { get; set; }
public Task ScanAsync(IQrScannerPreviewSink? previewSink, CancellationToken ct = default)
=> ScanException is { } ex
? Task.FromException(ex)
: Task.FromResult(ScanResult);
}
private readonly FakeEnrollmentService _enrollment = new();
private readonly FakeQrScanner _scanner = new();
private readonly LicenseActivationViewModel _vm;
public LicenseActivationViewModelTests()
{
_vm = new LicenseActivationViewModel(_enrollment, _scanner);
}
// ===== Saisie manuelle =====
[Fact]
public async Task Enroll_SansCode_AfficheErreur()
{
await _vm.EnrollCommand.ExecuteAsync(null);
Assert.NotNull(_vm.ErrorMessage);
Assert.Contains("saisir", _vm.ErrorMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(_vm.IsEnrolled);
Assert.Null(_enrollment.LastQrContent);
}
[Fact]
public async Task Enroll_Manuel_EnvoieLaValeurSaisieEtBascule()
{
// Test 2 : ManualCode = "ABC" + clic S'inscrire → EnrollAsync reçoit "ABC".
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
{
OmikaApiUrl = "https://api.example.com"
});
_vm.ManualEnrollmentCode = "ABC";
bool raised = false;
_vm.Enrolled += () => raised = true;
await _vm.EnrollCommand.ExecuteAsync(null);
Assert.Equal("ABC", _enrollment.LastQrContent);
Assert.True(_vm.IsEnrolled);
Assert.True(raised);
Assert.Null(_vm.ErrorMessage);
}
[Fact]
public async Task Enroll_Manuel_TrimLaValeur()
{
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
{
OmikaApiUrl = "https://api.example.com"
});
_vm.ManualEnrollmentCode = " ABC ";
await _vm.EnrollCommand.ExecuteAsync(null);
Assert.Equal("ABC", _enrollment.LastQrContent);
}
[Fact]
public async Task Enroll_Refusee_AfficheMessage()
{
_enrollment.EnrollToReturn = EnrollmentResult.Failed("Licence expirée");
_vm.ManualEnrollmentCode = "code";
await _vm.EnrollCommand.ExecuteAsync(null);
Assert.False(_vm.IsEnrolled);
Assert.Equal("Licence expirée", _vm.ErrorMessage);
}
[Fact]
public async Task Enroll_ErreurReseau_AfficheMessageConnexion()
{
_enrollment.ThrowHttp = true;
_vm.ManualEnrollmentCode = "code";
await _vm.EnrollCommand.ExecuteAsync(null);
Assert.False(_vm.IsEnrolled);
Assert.Contains("serveur", _vm.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Enroll_IsEnrolling_ResetApresExecution()
{
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
{
OmikaApiUrl = "https://api.example.com"
});
_vm.ManualEnrollmentCode = "code";
await _vm.EnrollCommand.ExecuteAsync(null);
Assert.False(_vm.IsEnrolling);
}
// ===== QR (automatique, sans toucher au champ manuel) =====
[Fact]
public async Task Scan_RetourneValeur_LanceInscriptionSansCopierDansLeChampManuel()
{
// Test 3 : Scan retourne "XYZ" → EnrollAsync reçoit "XYZ" et le champ
// manuel ne reçoit PAS "XYZ".
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
{
OmikaApiUrl = "https://api.example.com"
});
_scanner.ScanResult = "XYZ";
bool raised = false;
_vm.Enrolled += () => raised = true;
await _vm.ScanCommand.ExecuteAsync(null);
Assert.Equal("XYZ", _enrollment.LastQrContent);
Assert.Equal("XYZ", _vm.QrContent);
Assert.Equal(string.Empty, _vm.ManualEnrollmentCode);
Assert.True(_vm.IsEnrolled);
Assert.True(raised);
}
[Fact]
public async Task Scan_ConserveLaSaisieManuelleExistante()
{
// Le QR ne doit pas écraser une saisie manuelle déjà présente.
_enrollment.EnrollToReturn = EnrollmentResult.Succeeded(new MobileEnrollmentConfiguration
{
OmikaApiUrl = "https://api.example.com"
});
_vm.ManualEnrollmentCode = "ABC";
_scanner.ScanResult = "XYZ";
await _vm.ScanCommand.ExecuteAsync(null);
Assert.Equal("XYZ", _enrollment.LastQrContent);
Assert.Equal("ABC", _vm.ManualEnrollmentCode);
}
[Fact]
public async Task Scan_Annule_NeLancePasInscriptionEtChampManuelInchange()
{
// Test 4 : Scan retourne null → aucune inscription, champ manuel inchangé.
_scanner.ScanResult = null;
_vm.ManualEnrollmentCode = "ABC";
await _vm.ScanCommand.ExecuteAsync(null);
Assert.Equal("Scan annulé.", _vm.ErrorMessage);
Assert.Null(_enrollment.LastQrContent);
Assert.False(_vm.IsEnrolled);
Assert.Equal("ABC", _vm.ManualEnrollmentCode);
}
// ===== Journalisation des échecs de scan =====
///
/// Défaillance technique du scanner (ouverture de la caméra en échec, cause
/// d'origine conservée) : l'utilisateur ne peut plus scanner, donc plus
/// s'inscrire — c'est un incident Error/P1, et il doit être VISIBLE dans le
/// Diagnostic Center (auparavant seule une ligne de journal fichier était écrite,
/// l'incident n'apparaissait nulle part dans le pipeline de diagnostic).
///
[Fact]
public async Task Scan_DefaillanceCamera_SignaleEnIncident()
{
_scanner.ScanException = QrScanException.Defaillance(
"Ouverture de la caméra impossible : caméra déjà utilisée",
new InvalidOperationException("caméra déjà utilisée"));
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await _vm.ScanCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.False(_vm.IsScanning);
Assert.Equal("Ouverture de la caméra impossible : caméra déjà utilisée", _vm.ErrorMessage);
DiagnosticEvent evt = EvenementActivation(telemetry);
Assert.Equal(TelemetryLevel.Error, evt.Level);
Assert.Equal("QrScanException", evt.ErrorCode);
}
///
/// Repli plateforme (aucune implémentation caméra compilée) : l'application
/// demande simplement de coller le contenu du QR. État ATTENDU, pas un incident —
/// le journaliser en Error/P1 produirait un faux P1 à chaque appui sur « Scanner ».
///
[Fact]
public async Task Scan_ScanIndisponible_AucunIncident()
{
_scanner.ScanException = new QrScanException(
"Scan non disponible sur cet appareil : collez le contenu du QR dans le champ ci-dessous.");
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await _vm.ScanCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.False(_scanner.ScanException.EstDefaillance);
Assert.Contains("collez le contenu du QR", _vm.ErrorMessage);
Assert.Empty(telemetry.Events.OfType());
}
///
/// Permission caméra refusée par l'utilisateur : décision de l'utilisateur, pas
/// une défaillance de l'application — aucun incident.
///
[Fact]
public async Task Scan_PermissionRefusee_AucunIncident()
{
_scanner.ScanException = new QrScanException(
"Permission caméra refusée. Autorisez l'accès à la caméra pour scanner le QR d'inscription.");
var telemetry = new CapturingTelemetry();
ExceptionTelemetry.Initialize(telemetry, null);
try
{
await _vm.ScanCommand.ExecuteAsync(null);
}
finally
{
ExceptionTelemetry.Initialize(null, null);
}
Assert.False(_scanner.ScanException.EstDefaillance);
Assert.Contains("Permission caméra refusée", _vm.ErrorMessage);
Assert.Empty(telemetry.Events.OfType());
}
/// Seul événement de diagnostic attribué à ce ViewModel (l'usage est un TelemetryEvent).
private static DiagnosticEvent EvenementActivation(CapturingTelemetry telemetry) =>
Assert.Single(
telemetry.Events.OfType(),
e => e.Component == nameof(LicenseActivationViewModel));
/// Capture des événements de la télémétrie statique globale.
private sealed class CapturingTelemetry : IOmikaTelemetry
{
public List Events { get; } = [];
public void Track(TelemetryEvent telemetryEvent) => Events.Add(telemetryEvent);
public void Log(DiagnosticEvent diagnosticEvent) => Events.Add(diagnosticEvent);
}
}