using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Infrastructure;
using Omika.App.Local.Models;
using Omika.App.Local.Services;
using Omika.Core.Contracts.Mobile.Languages;
using Xunit;
namespace Omika.Tests.Mobile;
///
/// Téléchargement et intégrité des packs de langue hors ligne.
///
///
/// Ces tests ne font AUCUN appel réseau réel : le client HTTP est remplacé par un
/// gestionnaire en mémoire, et les fichiers sont écrits dans un dossier
/// temporaire — jamais dans le dossier de données de l'application.
///
///
public sealed class LanguagePackTests : IDisposable
{
private readonly string _racine =
Path.Combine(Path.GetTempPath(), "omika-langues-tests", Guid.NewGuid().ToString("N"));
public void Dispose()
{
try
{
if (Directory.Exists(_racine))
{
Directory.Delete(_racine, recursive: true);
}
}
catch (IOException)
{
// Best-effort : un fichier encore verrouillé ne doit pas faire
// échouer la suite de tests.
}
}
// ===== Assainissement des codes et des chemins =====
[Theory]
[InlineData("es")]
[InlineData("pt")]
[InlineData("pt-br")]
[InlineData("zh")]
[InlineData("ar")]
public void EstCodeValide_AccepteLesCodesIso(string code)
=> Assert.True(LanguagePaths.EstCodeValide(code));
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("..")]
[InlineData("../es")]
[InlineData("es/../..")]
[InlineData("es\\..")]
[InlineData("e")]
[InlineData("codebeaucouptroplong")]
[InlineData("-es")]
[InlineData("es-")]
[InlineData("es 1")]
[InlineData("ES")]
public void EstCodeValide_RefuseCeQuiNEstPasUnCodeDeLangue(string code)
=> Assert.False(LanguagePaths.EstCodeValide(code));
[Fact]
public void FichierModele_UnNomDeFichierRelatif_neSortJamaisDuDossierDeLaLangue()
{
// Un manifeste serveur compromis ne doit JAMAIS pouvoir faire écrire
// hors du dossier de la langue : le nom est réduit à son nom simple.
string chemin = LanguagePaths.FichierModeleDans(
_racine, "es", LanguageModelDirections.VersFrancais, "../../../evil.onnx");
string dossierAttendu = LanguagePaths.DossierSensDans(
_racine, "es", LanguageModelDirections.VersFrancais);
Assert.Equal(dossierAttendu, Path.GetDirectoryName(chemin));
Assert.Equal("evil.onnx", Path.GetFileName(chemin));
}
// ===== Dépôt local =====
[Fact]
public async Task Store_UnPackMarqueInstalle_EstReluAvecSesFichiers()
{
var store = new LanguagePackStore(_racine);
(FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE");
EcrireFichier(store, "es", LanguageModelDirections.VersFrancais, fichier.NomFichier, contenu);
await store.EnregistrerPackAsync(Pack("es", fichier));
PackLangueInstalle? relu = await store.GetPackInstalleAsync("es");
Assert.NotNull(relu);
Assert.Equal("es", relu!.Code);
Assert.Single(relu.Fichiers);
Assert.True(await store.VerifierFichierAsync("es", relu.Fichiers[0]));
IReadOnlyList tous = await store.ListerPacksInstallesAsync();
Assert.Single(tous);
}
[Fact]
public async Task Store_UnFichierManquant_AnnuleLInstallation()
{
var store = new LanguagePackStore(_racine);
(FichierModeleInstalle fichier, _) = CreerFichier("pt", "encoder.onnx", "MODELE");
// Marqueur écrit SANS le fichier : c'est exactement ce que laisse un
// effacement par le système (nettoyage de stockage).
await store.EnregistrerPackAsync(Pack("pt", fichier));
Assert.Null(await store.GetPackInstalleAsync("pt"));
}
[Fact]
public async Task Store_UneEmpreinteQuiNeCorrespondPas_EstRefusee()
{
var store = new LanguagePackStore(_racine);
(FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE");
EcrireFichier(store, "es", LanguageModelDirections.VersFrancais, fichier.NomFichier, contenu);
var falsifie = new FichierModeleInstalle
{
Sens = fichier.Sens,
NomFichier = fichier.NomFichier,
TailleOctets = contenu.Length,
Sha256 = new string('0', 64)
};
Assert.False(await store.VerifierFichierAsync("es", falsifie));
}
[Fact]
public async Task Store_UnFichierPartiel_NEstJamaisPrisPourUnFichierInstalle()
{
var store = new LanguagePackStore(_racine);
(FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE");
// Le téléchargement s'écrit d'abord en « .part » : ce fichier ne doit
// jamais satisfaire les contrôles de présence.
string partiel = store.PreparerFichierPartiel(
"es", LanguageModelDirections.VersFrancais, fichier.NomFichier);
await File.WriteAllBytesAsync(partiel, contenu);
Assert.False(store.FichierPresent("es", fichier));
}
// ===== Téléchargement =====
[Fact]
public async Task Telechargement_VerifieLIntegrite_PuisMarqueLePackInstalle()
{
(FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE");
var store = new LanguagePackStore(_racine);
var service = CreerService(store, new Manifeste("es", "Espagnol", fichier), contenu);
var progression = new List();
PackLangueInstalle pack = await service.TelechargerAsync(
"es", new Progress(progression.Add));
Assert.Equal("es", pack.Code);
Assert.True(File.Exists(LanguagePaths.PackInstalleDans(_racine, "es")));
Assert.Equal(contenu, await File.ReadAllBytesAsync(
LanguagePaths.FichierModeleDans(_racine, "es", fichier.Sens, fichier.NomFichier)));
// Le fichier partiel ne doit jamais subsister après vérification.
Assert.False(File.Exists(
LanguagePaths.FichierModeleDans(_racine, "es", fichier.Sens, fichier.NomFichier)
+ LanguagePaths.ExtensionPartielle));
}
[Fact]
public async Task Telechargement_SensCommun_EstAccepteEtRangeDansLeDossierPartage()
{
// Un modèle multilingue (famille de langues) couvre les deux sens avec les
// MÊMES poids : il les déclare une seule fois sous « commun ». Refuser ce
// sens ferait télécharger deux fois le même giga-octet sur chaque
// téléphone — c'est exactement ce que le partage évite.
(FichierModeleInstalle origine, byte[] contenu) = CreerFichier("es", "encoder_model.onnx", "MODELE");
var partage = new FichierModeleInstalle
{
Sens = LanguageModelDirections.Commun,
NomFichier = origine.NomFichier,
TailleOctets = contenu.Length,
Sha256 = origine.Sha256
};
var store = new LanguagePackStore(_racine);
var service = CreerService(store, new Manifeste("es", "Espagnol", partage), contenu);
PackLangueInstalle pack = await service.TelechargerAsync("es");
Assert.True(pack.ContientSens(LanguageModelDirections.Commun));
Assert.True(File.Exists(LanguagePaths.FichierModeleDans(
_racine, "es", LanguageModelDirections.Commun, partage.NomFichier)));
}
[Fact]
public async Task Manifeste_SensInconnu_RefuseLePack()
{
(FichierModeleInstalle origine, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE");
var inconnu = new FichierModeleInstalle
{
Sens = "vertical",
NomFichier = origine.NomFichier,
TailleOctets = contenu.Length,
Sha256 = origine.Sha256
};
var store = new LanguagePackStore(_racine);
var service = CreerService(store, new Manifeste("es", "Espagnol", inconnu), contenu);
LanguagePackException erreur =
await Assert.ThrowsAsync(() => service.TelechargerAsync("es"));
Assert.Contains("vertical", erreur.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Telechargement_EmpreinteNonConforme_NInstalleRien()
{
(FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE");
var store = new LanguagePackStore(_racine);
// Le manifeste annonce une empreinte qui ne correspond PAS aux octets
// réellement servis (fichier altéré en transit ou sur le disque serveur).
var falsifie = new FichierModeleInstalle
{
Sens = fichier.Sens,
NomFichier = fichier.NomFichier,
TailleOctets = contenu.Length,
Sha256 = new string('f', 64)
};
var service = CreerService(store, new Manifeste("es", "Espagnol", falsifie), contenu);
await Assert.ThrowsAsync(() => service.TelechargerAsync("es"));
// Ni le fichier ni le marqueur ne doivent exister : mieux vaut ne rien
// installer que d'installer un modèle non vérifié.
Assert.False(File.Exists(LanguagePaths.PackInstalleDans(_racine, "es")));
Assert.Null(await store.GetPackInstalleAsync("es"));
}
[Fact]
public async Task Manifeste_SansEmpreinte_RefuseLePack()
{
(FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE");
var store = new LanguagePackStore(_racine);
var sansEmpreinte = new FichierModeleInstalle
{
Sens = fichier.Sens,
NomFichier = fichier.NomFichier,
TailleOctets = contenu.Length,
Sha256 = string.Empty
};
var service = CreerService(store, new Manifeste("es", "Espagnol", sansEmpreinte), contenu);
LanguagePackException erreur =
await Assert.ThrowsAsync(() => service.TelechargerAsync("es"));
Assert.Contains("SHA-256", erreur.Message, StringComparison.Ordinal);
Assert.False(File.Exists(LanguagePaths.PackInstalleDans(_racine, "es")));
}
[Fact]
public async Task Manifeste_UrlNonConfiguree_LeDitClairement()
{
var store = new LanguagePackStore(_racine);
var service = new LanguagePackService(
store,
_ => Task.FromResult(null),
new HttpClient(new FauxHandler([])));
LanguagePackException erreur = await Assert.ThrowsAsync(
() => service.ListerLanguesAsync());
Assert.Contains("n'est pas configuré", erreur.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Manifeste_EstMemorisePuisReluSansReseau()
{
(FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE");
var store = new LanguagePackStore(_racine);
var handler = new FauxHandler([]);
var service = CreerService(store, new Manifeste("es", "Espagnol", fichier), contenu, handler);
IReadOnlyList enLigne = await service.ListerLanguesAsync(forcerReseau: true);
Assert.Single(enLigne);
// Deuxième appel sans forcer : le manifeste mémorisé suffit, aucun nouvel
// appel réseau (l'écran Paramètres doit rester consultable hors ligne).
int appelsApresPremier = handler.NombreAppels;
IReadOnlyList horsLigne = await service.ListerLanguesAsync();
Assert.Single(horsLigne);
Assert.Equal(appelsApresPremier, handler.NombreAppels);
}
// ===== Fabrique de test =====
/// Description d'un pack servi par le faux serveur.
private sealed record Manifeste(string Code, string Libelle, FichierModeleInstalle Fichier);
private static LanguagePackService CreerService(
LanguagePackStore store,
Manifeste manifeste,
byte[] contenu,
FauxHandler? handler = null)
{
handler ??= new FauxHandler(contenu);
handler.Manifeste = manifeste;
return new LanguagePackService(
store,
_ => Task.FromResult("https://langues.exemple.test"),
new HttpClient(handler));
}
/// Construit un fichier de test dont l'empreinte est calculée sur le contenu réel.
private static (FichierModeleInstalle Fichier, byte[] Contenu) CreerFichier(
string code, string nom, string texte)
{
_ = code;
byte[] contenu = Encoding.UTF8.GetBytes(texte);
string empreinte = Convert.ToHexString(SHA256.HashData(contenu)).ToLowerInvariant();
return (new FichierModeleInstalle
{
Sens = LanguageModelDirections.VersFrancais,
NomFichier = nom,
TailleOctets = contenu.Length,
Sha256 = empreinte
}, contenu);
}
private static PackLangueInstalle Pack(string code, FichierModeleInstalle fichier)
=> new()
{
Code = code,
Libelle = code,
Version = "1.0.0",
InstalleLeUtc = DateTimeOffset.UtcNow,
Fichiers = [fichier]
};
private static void EcrireFichier(
LanguagePackStore store, string code, string sens, string nom, byte[] contenu)
{
string chemin = store.CheminFichier(code, sens, nom);
Directory.CreateDirectory(Path.GetDirectoryName(chemin)!);
File.WriteAllBytes(chemin, contenu);
}
///
/// Faux serveur : sert un manifeste et les octets d'un fichier, sans réseau.
///
private sealed class FauxHandler : HttpMessageHandler
{
private readonly byte[] _contenu;
public FauxHandler(byte[] contenu) => _contenu = contenu;
/// Pack annoncé par le manifeste (null : manifeste vide).
public Manifeste? Manifeste { get; set; }
/// Nombre d'appels reçus (permet de vérifier le cache local).
public int NombreAppels { get; private set; }
protected override Task SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
NombreAppels++;
string chemin = request.RequestUri?.AbsolutePath ?? string.Empty;
if (chemin.EndsWith("/api/langues", StringComparison.Ordinal))
{
return Task.FromResult(Reponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes(ManifesteJson())));
}
if (Manifeste is not null
&& chemin.EndsWith($"/{Manifeste.Fichier.NomFichier}", StringComparison.Ordinal))
{
return Task.FromResult(Reponse(HttpStatusCode.OK, _contenu));
}
return Task.FromResult(Reponse(HttpStatusCode.NotFound, []));
}
private string ManifesteJson()
{
if (Manifeste is null)
{
return """{"packs":[]}""";
}
FichierModeleInstalle fichier = Manifeste.Fichier;
// Sérialisation manuelle : le test vérifie la FORME attendue du
// manifeste (clés camelCase) telle que la produit l'API des langues.
return $$"""
{
"packs": [
{
"code": "{{Manifeste.Code}}",
"libelle": "{{Manifeste.Libelle}}",
"version": "1.0.0",
"fichiers": [
{
"sens": "{{fichier.Sens}}",
"nomFichier": "{{fichier.NomFichier}}",
"tailleOctets": {{fichier.TailleOctets}},
"sha256": "{{fichier.Sha256}}"
}
]
}
]
}
""";
}
private static HttpResponseMessage Reponse(HttpStatusCode statut, byte[] contenu)
=> new(statut) { Content = new ByteArrayContent(contenu) };
}
}