Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user