442 lines
16 KiB
C#
442 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Téléchargement et intégrité des packs de langue hors ligne.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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<PackLangueInstalle> 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<ProgressionTelechargement>();
|
|
PackLangueInstalle pack = await service.TelechargerAsync(
|
|
"es", new Progress<ProgressionTelechargement>(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<LanguagePackException>(() => 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<LanguagePackException>(() => 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<LanguagePackException>(() => 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<string?>(null),
|
|
new HttpClient(new FauxHandler([])));
|
|
|
|
LanguagePackException erreur = await Assert.ThrowsAsync<LanguagePackException>(
|
|
() => 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<LangueDisponible> 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<LangueDisponible> horsLigne = await service.ListerLanguesAsync();
|
|
|
|
Assert.Single(horsLigne);
|
|
Assert.Equal(appelsApresPremier, handler.NombreAppels);
|
|
}
|
|
|
|
// ===== Fabrique de test =====
|
|
|
|
/// <summary>Description d'un pack servi par le faux serveur.</summary>
|
|
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<string?>("https://langues.exemple.test"),
|
|
new HttpClient(handler));
|
|
}
|
|
|
|
/// <summary>Construit un fichier de test dont l'empreinte est calculée sur le contenu réel.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Faux serveur : sert un manifeste et les octets d'un fichier, sans réseau.
|
|
/// </summary>
|
|
private sealed class FauxHandler : HttpMessageHandler
|
|
{
|
|
private readonly byte[] _contenu;
|
|
|
|
public FauxHandler(byte[] contenu) => _contenu = contenu;
|
|
|
|
/// <summary>Pack annoncé par le manifeste (null : manifeste vide).</summary>
|
|
public Manifeste? Manifeste { get; set; }
|
|
|
|
/// <summary>Nombre d'appels reçus (permet de vérifier le cache local).</summary>
|
|
public int NombreAppels { get; private set; }
|
|
|
|
protected override Task<HttpResponseMessage> 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) };
|
|
}
|
|
}
|