using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using Omika.App.Local.Services; using Xunit; using Xunit.Sdk; namespace Omika.Tests.Mobile; /// /// Conformité du tokeniseur SentencePiece maison à la bibliothèque de référence. /// /// /// Le risque propre à un tokeniseur réécrit à la main n'est pas de lever une /// exception : c'est de découper légèrement autrement que le modèle, donc /// de dégrader les traductions sans aucun signal. Ces tests comparent les /// identifiants produits par à ceux de la /// bibliothèque sentencepiece, figés dans des fixtures JSON par /// scripts\Generer-Fixtures-SentencePiece.ps1. /// /// /// /// Sans fixtures, ces tests passent. Ni les fixtures ni les modèles /// .spm ne sont versionnés (modèles lourds, sous licence, et propres à /// chaque pack de langue) : une suite rouge sur une machine neuve pour de simples /// fichiers absents pousserait à désactiver le test — exactement l'inverse du /// but. L'absence est donc expliquée sur la sortie standard, et seul un écart /// réellement mesuré fait échouer. /// /// public sealed class SentencePieceFixturesTests : IDisposable { /// /// Variable d'environnement qui désigne le dossier des fixtures. Elle permet /// de vérifier un pack de langue sans rien copier dans le dépôt. /// private const string VariableDossierFixtures = "OMIKA_SPM_FIXTURES"; /// /// Textes témoins du test d'idempotence : ils n'ont besoin d'aucune fixture, /// seulement d'un modèle. Volontairement chargés en accents, en espaces /// parasites et en alphabet non latin, car ce sont ces entrées qui font /// diverger un aller-retour. /// private static readonly string[] TextesTemoins = [ "Bonjour le monde.", "L'été dernier, j'ai mangé une crêpe à Nîmes.", "¿Qué tal? ¡Hola, señor! El niño pequeño añadió más café.", " Bonjour le monde ", string.Empty, " ", "Traduction hors ligne : 1 234,56 € (soit 42 %).", "مرحبا بالعالم", "Здравствуйте, мир!" ]; private readonly string _racineTemporaire = Path.Combine( Path.GetTempPath(), "omika-spm-tests", Guid.NewGuid().ToString("N")); public void Dispose() { try { if (Directory.Exists(_racineTemporaire)) { Directory.Delete(_racineTemporaire, recursive: true); } } catch (IOException) { // Best-effort : un fichier encore verrouillé ne doit pas faire // échouer la suite de tests. } } // ===== Comparaison au découpage de référence ===== [Fact] public async Task Fixtures_LeDecoupageCorrespondAuxIdentifiantsDeReference() { string? dossier = TrouverDossierFixtures(); if (dossier is null) { Console.WriteLine( "Aucun dossier de fixtures SentencePiece : comparaison à la référence ignorée. " + "Générez-les avec .\\scripts\\Generer-Fixtures-SentencePiece.ps1 -DossierModeles ."); return; } string[] fixtures = Directory.GetFiles(dossier, "*.fixtures.json", SearchOption.TopDirectoryOnly); Array.Sort(fixtures, StringComparer.OrdinalIgnoreCase); if (fixtures.Length == 0) { Console.WriteLine( $"Le dossier de fixtures « {dossier} » ne contient aucun fichier *.fixtures.json : " + "comparaison à la référence ignorée (voir tests/fixtures/sentencepiece/LISEZMOI.md)."); return; } int modelesCharges = 0; int casVerifies = 0; foreach (string cheminFixture in fixtures) { string nomFixture = Path.GetFileName(cheminFixture); FixtureSentencePiece fixture = LireFixture(cheminFixture); string? cheminModele = TrouverModele(dossier, fixture.Modele); if (cheminModele is null) { // Le vocabulaire n'est pas versionné : son absence est le cas // NORMAL d'un dépôt fraîchement cloné, pas une anomalie. Console.WriteLine( $"Fixture « {nomFixture} » ignorée : le vocabulaire « {fixture.Modele} » " + $"est absent de {dossier} (copiez-le, ou lancez le générateur avec -CopierModeles)."); continue; } SentencePieceTokenizer tokeniseur = SentencePieceTokenizer.Charger(cheminModele); modelesCharges++; // Une fixture et un modèle de tailles de vocabulaire différentes ne // viennent pas du même modèle : comparer leurs découpages n'aurait // aucun sens, et le signaler ici évite de chercher une divergence de // Viterbi là où il n'y a qu'un mauvais appariement de fichiers. Assert.True( fixture.VocabSize == tokeniseur.TailleVocabulaire, $"Le vocabulaire « {Path.GetFileName(cheminModele)} » compte " + $"{tokeniseur.TailleVocabulaire} pièces, alors que la fixture « {nomFixture} » " + $"en annonce {fixture.VocabSize} : les deux fichiers ne viennent pas du même modèle."); if (fixture.Cas.Count == 0) { Console.WriteLine($"Fixture « {nomFixture} » ignorée : elle ne décrit aucun cas."); continue; } // Les réglages de la référence sont affichés avant les cas : ils // datent la comparaison (une divergence constatée dans six mois ne se // confond pas avec un changement de version de la bibliothèque) et // disent si le découpage attendu inclut un préfixe factice ou des // espaces échappés — deux causes classiques d'écart systématique. Console.WriteLine( $"Fixture « {nomFixture} » : {fixture.Cas.Count} cas sur « {Path.GetFileName(cheminModele)} » " + $"(sentencepiece {fixture.SentencePieceVersion}, normalisation {fixture.NormalizerName}, " + $"préfixe factice={fixture.AddDummyPrefix}, espaces superflus={fixture.RemoveExtraWhitespaces}, " + $"espaces échappés={fixture.EscapeWhitespaces})."); foreach (CasFixture cas in fixture.Cas) { await VerifierCasAsync(tokeniseur, cas, nomFixture, fixture.SentencePieceVersion); casVerifies++; } } Console.WriteLine($"{casVerifies} cas de référence vérifiés sur {modelesCharges} modèle(s)."); } /// /// Vérifie un cas de référence : les identifiants d'encodage, puis le texte /// reconstruit à partir de ces mêmes identifiants. /// private static async Task VerifierCasAsync( SentencePieceTokenizer tokeniseur, CasFixture cas, string nomFixture, string? versionReference) { string texte = cas.Texte ?? string.Empty; IReadOnlyList attendus = cas.Ids ?? []; string decodeAttendu = cas.Decode ?? string.Empty; IReadOnlyList obtenus = await tokeniseur.EncodeAsync(texte); int index = PremierEcart(attendus, obtenus); if (index >= 0) { // Comparaison détaillée plutôt que Assert.Equal sur les séquences : // « expected [1, 2, 3], actual [1, 2, 7] » sur des dizaines // d'identifiants ne dit ni OÙ le découpage diverge, ni sur quelle // pièce — c'est pourtant la seule information exploitable. Assert.True(false, $"Découpage divergent pour {DecrireTexte(texte)} " + $"(fixture {nomFixture}, référence sentencepiece {versionReference}).{Environment.NewLine}" + $"Premier écart à l'index {index} : référence {Valeur(attendus, index)} " + $"{DecrirePiece(tokeniseur, attendus, index)}, tokeniseur {Valeur(obtenus, index)} " + $"{DecrirePiece(tokeniseur, obtenus, index)}.{Environment.NewLine}" + $"Identifiants de référence ({attendus.Count}) : {AbregerListe(attendus)}{Environment.NewLine}" + $"Identifiants obtenus ({obtenus.Count}) : {AbregerListe(obtenus)}{Environment.NewLine}" + $"Texte reconstruit par la référence : « {decodeAttendu} »."); } // Le décodage est comparé sur les identifiants de la RÉFÉRENCE : c'est la // seule façon de tester la reconstruction sans que l'encodage maison // puisse masquer une erreur de décodage. string decode = tokeniseur.Decode(attendus); if (!string.Equals(decodeAttendu, decode, StringComparison.Ordinal)) { Assert.True(false, $"Décodage divergent pour {DecrireTexte(texte)} " + $"(fixture {nomFixture}, référence sentencepiece {versionReference}).{Environment.NewLine}" + $"Référence : « {decodeAttendu} »{Environment.NewLine}" + $"Obtenu : « {decode} »{Environment.NewLine}" + $"Identifiants décodés : {AbregerListe(attendus)}."); } } // ===== Idempotence de l'aller-retour (aucune fixture requise) ===== [Fact] public async Task AllerRetour_DecoderDeuxFois_DonneToujoursLeMemeTexte() { // Ce test ne lit AUCUNE fixture : il lui faut seulement un modèle .spm // pour construire un tokeniseur. Il vérifie la seule propriété de // l'aller-retour qui ne dépende pas du vocabulaire : l'idempotence, soit // f(f(x)) = f(x) avec f = Decode ∘ Encode. // // Il serait FAUX d'exiger f(x) == x : un modèle peut légitimement replier // une casse, réduire des espaces multiples ou rendre pour un // caractère hors vocabulaire. Exiger l'égalité au texte d'entrée ferait // échouer le test sur un tokeniseur pourtant correct. string? cheminModele = TrouverPremierModele(TrouverDossierFixtures()); if (cheminModele is null) { Console.WriteLine( "Aucun modèle .spm dans le dossier des fixtures : test d'idempotence de l'aller-retour ignoré " + "(voir tests/fixtures/sentencepiece/LISEZMOI.md)."); return; } SentencePieceTokenizer tokeniseur = SentencePieceTokenizer.Charger(cheminModele); Console.WriteLine($"Idempotence de l'aller-retour vérifiée sur le modèle « {Path.GetFileName(cheminModele)} »."); foreach (string texte in TextesTemoins) { IReadOnlyList ids = await tokeniseur.EncodeAsync(texte); string premier = tokeniseur.Decode(ids); IReadOnlyList idsReencodes = await tokeniseur.EncodeAsync(premier); string second = tokeniseur.Decode(idsReencodes); if (!string.Equals(premier, second, StringComparison.Ordinal)) { Assert.True(false, $"Aller-retour non idempotent pour {DecrireTexte(texte)} : " + $"premier décodage « {premier} », décodage du texte réencodé « {second} »."); } } } // ===== Diagnostic de chargement (aucune fixture requise) ===== [Fact] public void Chargement_UnFichierInexploitable_LeveUneErreurEnFrancais() { string dossier = Path.Combine(_racineTemporaire, "invalides"); Directory.CreateDirectory(dossier); // Octets DÉTERMINISTES plutôt qu'un tirage aléatoire : un vrai tirage // pourrait, avec une probabilité faible mais non nulle, former un // protobuf exploitable (une suite d'octets est un protobuf valide plus // souvent qu'on ne le croit) et faire passer le test au vert pour de // mauvaises raisons. Ici, le premier octet annonce un champ « pieces » // dont la longueur dépasse le fichier : la troncature est certaine. byte[] bruit = [0x0A, 0xFF, 0xFF, 0xFF, 0x7F, 0xC3, 0x28, 0x9A, 0x51]; string cheminBruit = Path.Combine(dossier, "bruit.spm"); File.WriteAllBytes(cheminBruit, bruit); SentencePieceException erreurBruit = Assert.Throws( () => { _ = SentencePieceTokenizer.Charger(cheminBruit); }); // Le message est destiné à l'utilisateur : il doit être en français et // nommer le fichier, jamais exposer un détail binaire ni un chemin de // sandbox. Une erreur de lecture protobuf brute (IndexOutOfRange, // ArgumentOutOfRange) signalerait une lecture hors bornes non maîtrisée. Assert.Contains("vocabulaire", erreurBruit.Message, StringComparison.Ordinal); // Un fichier qui n'est pas un protobuf du tout : la même erreur métier, // pas un plantage du lecteur minimal. string cheminTexte = Path.Combine(dossier, "texte.spm"); File.WriteAllBytes(cheminTexte, Encoding.UTF8.GetBytes("ceci n'est pas un modèle SentencePiece")); SentencePieceException erreurTexte = Assert.Throws( () => { _ = SentencePieceTokenizer.Charger(cheminTexte); }); Assert.Contains("vocabulaire", erreurTexte.Message, StringComparison.Ordinal); // Modèle absent : l'utilisateur doit lire qu'il faut réinstaller le pack // de langue, pas voir remonter le FileNotFoundException du système (que // l'application ne saurait pas traduire en conseil). string cheminAbsent = Path.Combine(dossier, "absent.spm"); Assert.False(File.Exists(cheminAbsent)); SentencePieceException erreurAbsente = Assert.Throws( () => { _ = SentencePieceTokenizer.Charger(cheminAbsent); }); Assert.Contains("introuvable", erreurAbsente.Message, StringComparison.Ordinal); } // ===== Localisation des fichiers ===== /// /// Dossier des fixtures : OMIKA_SPM_FIXTURES s'il est défini et /// existe, sinon tests/fixtures/sentencepiece sous la racine du /// dépôt. quand aucun des deux n'existe. /// private static string? TrouverDossierFixtures() { string? variable = Environment.GetEnvironmentVariable(VariableDossierFixtures); if (!string.IsNullOrWhiteSpace(variable) && Directory.Exists(variable)) { return variable; } string? racine = TrouverRacineDepot(); if (racine is null) { return null; } string dossier = Path.Combine(racine, "tests", "fixtures", "sentencepiece"); return Directory.Exists(dossier) ? dossier : null; } /// /// Racine du dépôt, trouvée en remontant depuis le dossier de l'assembly de /// test jusqu'au premier dossier contenant Omika.slnx. /// /// /// Le chemin n'est ni codé en dur ni pris dans le dossier courant : /// dotnet test s'exécute depuis bin\Debug\net10.0, un /// exécuteur d'IDE depuis la racine, et un test lancé depuis un autre dossier /// ne doit pas changer le résultat. La marche ascendante est la seule /// méthode qui fonctionne dans tous ces cas. /// /// private static string? TrouverRacineDepot() { DirectoryInfo? dossier = new(AppContext.BaseDirectory); while (dossier is not null) { if (File.Exists(Path.Combine(dossier.FullName, "Omika.slnx"))) { return dossier.FullName; } dossier = dossier.Parent; } return null; } /// /// Chemin du .spm annoncé par une fixture : d'abord le nom tel quel /// dans le dossier des fixtures (ce qui accepte un sous-dossier), puis le nom /// simple à la racine de ce dossier. /// private static string? TrouverModele(string dossierFixtures, string? nomAnnonce) { if (string.IsNullOrWhiteSpace(nomAnnonce)) { return null; } string nomSimple = Path.GetFileName(nomAnnonce); if (string.IsNullOrEmpty(nomSimple)) { return null; } // Un chemin annoncé qui remonterait hors du dossier des fixtures est // refusé : ces JSON sont produits par notre propre script, mais ils ne // constituent pas pour autant une source de confiance. bool cheminInterne = !Path.IsPathRooted(nomAnnonce) && !nomAnnonce.Split('/', '\\').Contains(".."); if (cheminInterne) { string dansSousDossier = Path.Combine(dossierFixtures, nomAnnonce); if (File.Exists(dansSousDossier)) { return dansSousDossier; } } string aLaRacine = Path.Combine(dossierFixtures, nomSimple); return File.Exists(aLaRacine) ? aLaRacine : null; } /// Premier .spm trouvé sous le dossier des fixtures, ou . private static string? TrouverPremierModele(string? dossierFixtures) { if (dossierFixtures is null || !Directory.Exists(dossierFixtures)) { return null; } string[] modeles = Directory.GetFiles(dossierFixtures, "*.spm", SearchOption.AllDirectories); Array.Sort(modeles, StringComparer.OrdinalIgnoreCase); return modeles.Length == 0 ? null : modeles[0]; } /// /// Lit une fixture. Un fichier illisible ou vide est une anomalie, pas /// une absence : il est signalé comme un échec, car il ferait passer le test /// au vert en n'ayant rien vérifié. /// private static FixtureSentencePiece LireFixture(string chemin) { string json = File.ReadAllText(chemin); try { return JsonSerializer.Deserialize(json) ?? throw new XunitException( $"La fixture « {Path.GetFileName(chemin)} » est vide : régénérez-la avec " + "scripts\\Generer-Fixtures-SentencePiece.ps1."); } catch (JsonException erreur) { throw new XunitException( $"La fixture « {Path.GetFileName(chemin)} » n'est pas un JSON valide : {erreur.Message}"); } } // ===== Aides de diagnostic ===== /// /// Index du premier identifiant qui diffère, ou -1 si les deux séquences sont /// identiques. Une séquence plus courte diverge dès le premier identifiant /// manquant : le retourner évite un accès hors bornes dans le message. /// private static int PremierEcart(IReadOnlyList attendus, IReadOnlyList obtenus) { int commun = Math.Min(attendus.Count, obtenus.Count); for (int i = 0; i < commun; i++) { if (attendus[i] != obtenus[i]) { return i; } } return attendus.Count == obtenus.Count ? -1 : commun; } /// Identifiant à un index, ou un tiret quand la séquence est plus courte. private static string Valeur(IReadOnlyList ids, int index) => index < ids.Count ? ids[index].ToString(CultureInfo.InvariantCulture) : "—"; /// /// Texte lisible d'un identifiant, pour le message d'échec. /// /// /// sur un identifiant isolé donne /// une indication suffisante (le marqueur ▁ devient une espace et les bords /// sont rognés) : c'est une aide au diagnostic, pas une restitution fidèle du /// texte exact de la pièce, qui n'est pas exposé par l'interface publique. /// /// private static string DecrirePiece( SentencePieceTokenizer tokeniseur, IReadOnlyList ids, int index) { if (index >= ids.Count) { return "(aucun identifiant de ce côté)"; } int id = ids[index]; if (id < 0 || id >= tokeniseur.TailleVocabulaire) { return "(hors vocabulaire)"; } return $"« {tokeniseur.Decode([id])} »"; } /// /// Texte d'un cas, rendu lisible dans un message d'échec : retour à la ligne, /// tabulation et longueur bornée, sinon une phrase de référence rendrait le /// message illisible. /// private static string DecrireTexte(string? texte) { if (string.IsNullOrEmpty(texte)) { return "le cas « chaîne vide »"; } string lisible = texte .Replace("\r", "\\r", StringComparison.Ordinal) .Replace("\n", "\\n", StringComparison.Ordinal) .Replace("\t", "\\t", StringComparison.Ordinal); string tronque = lisible.Length <= 80 ? lisible : lisible[..80] + "…"; return $"le cas « {tronque} »"; } /// Début d'une longue séquence d'identifiants, suivi d'une ellipse. private static string AbregerListe(IReadOnlyList ids) { const int maximum = 24; var texte = new StringBuilder(); for (int i = 0; i < Math.Min(maximum, ids.Count); i++) { if (i > 0) { texte.Append(", "); } texte.Append(ids[i].ToString(CultureInfo.InvariantCulture)); } if (ids.Count > maximum) { texte.Append(", …"); } return texte.ToString(); } // ===== Forme du fixture écrit par le générateur ===== /// /// Fixture de référence. Les noms JSON sont écrits explicitement en camelCase /// plutôt que déduits d'une politique de nommage : le schéma est un contrat /// partagé avec le script Python, il ne doit pas pouvoir changer en silence. /// private sealed class FixtureSentencePiece { [JsonPropertyName("modele")] public string? Modele { get; set; } [JsonPropertyName("sentencepieceVersion")] public string? SentencePieceVersion { get; set; } [JsonPropertyName("vocabSize")] public int VocabSize { get; set; } [JsonPropertyName("addDummyPrefix")] public bool AddDummyPrefix { get; set; } [JsonPropertyName("removeExtraWhitespaces")] public bool RemoveExtraWhitespaces { get; set; } [JsonPropertyName("escapeWhitespaces")] public bool EscapeWhitespaces { get; set; } [JsonPropertyName("normalizerName")] public string? NormalizerName { get; set; } [JsonPropertyName("cas")] public List Cas { get; set; } = []; } /// Un texte de référence, ses identifiants et la reconstruction de la référence. private sealed class CasFixture { [JsonPropertyName("texte")] public string? Texte { get; set; } [JsonPropertyName("ids")] public List? Ids { get; set; } [JsonPropertyName("decode")] public string? Decode { get; set; } } }