TQ-Slator/tests/Translation/Format/I18nextRoundTripTest.php
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
Système de gestion de traductions pensé en CMS headless : un back-office
pour ceux qui traduisent, une API pour ce qui consomme.

Architecture
- Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en
  même origine — ce qui rend viable le cookie de session plutôt qu'un
  jeton en localStorage.
- Deux APIs séparées : Management (session ou clé) et Delivery
  (stateless, clé seule). Les fusionner ferait porter à chaque lecture de
  bundle le coût de la session.
- Stockage canonique en ICU MessageFormat, sérialisation par plateforme.
  Le format d'une plateforme ne contamine pas la base.
- Publication par releases immuables ; le déploiement est un déplacement
  de pointeur, donc le rollback aussi.
- Isolation multi-organisation par filtre Doctrine, avec un test
  d'architecture qui casse la CI si une entité échappe à l'invariant.

Éditeur, deux vues
- Par langue : source et cible, jamais douze colonnes. Grille virtualisée,
  saisie sans bouton « Enregistrer », panneau de contexte permanent.
- Par clé : une clé, toutes ses langues empilées et repliées. Répond à
  « ce libellé est-il prêt partout ? ».
- Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner.
- Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ
  par catégorie CLDR de la langue cible.

Administration
- Deux niveaux : projet (membres, clés API, plateformes) et organisation
  (annuaire des comptes, création de projets).
- Invitations par e-mail, jeton 256 bits stocké haché.
- Désactiver un compte coupe les sessions en cours, pas seulement les
  connexions suivantes.
- Les plateformes s'archivent ; ni elles ni les environnements ne se
  suppriment — la trace explique pourquoi telle clé existe.

CLI tqs
- PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend
  ni de Composer ni de la disponibilité de TQ-Slator.
- init / push / pull / status ; le sync est non destructif par défaut et
  son prune est scopé plateforme.

126 tests, PHPStan niveau 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:16:05 +02:00

276 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Translation\Format;
use App\Translation\Format\Exception\UnsupportedMessageFeatureException;
use App\Translation\Format\Parser\I18nextParser;
use App\Translation\Format\Parser\IcuParser;
use App\Translation\Format\Serializer\I18nextSerializer;
use App\Translation\Format\Serializer\IcuSerializer;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Aller-retour ICU → i18next → ICU.
*
* Contrairement à l'aller-retour ICU pur, celui-ci est LOSSY par construction :
* i18next n'a ni type d'argument, ni style de formatage. Les tests ci-dessous
* délimitent précisément ce qui survit et ce qui ne survit pas, plutôt que de
* laisser la question ouverte jusqu'à ce qu'un intégrateur la découvre.
*/
final class I18nextRoundTripTest extends TestCase
{
private const FRENCH = ['one', 'many', 'other'];
private const ARABIC = ['zero', 'one', 'two', 'few', 'many', 'other'];
private IcuParser $icuParser;
private IcuSerializer $icuSerializer;
private I18nextParser $i18nextParser;
private I18nextSerializer $i18nextSerializer;
protected function setUp(): void
{
$this->icuParser = new IcuParser();
$this->icuSerializer = new IcuSerializer();
$this->i18nextParser = new I18nextParser();
$this->i18nextSerializer = new I18nextSerializer();
}
#[DataProvider('losslessCorpus')]
public function testRoundTripThroughI18next(string $icu): void
{
$ast = $this->icuParser->parse($icu);
$entries = $this->i18nextSerializer->serializeEntries('cle', $ast, self::FRENCH);
$reparsed = $this->i18nextParser->parseEntries($this->stripPrefix($entries, 'cle'), self::FRENCH);
self::assertSame(
$ast->toComparable(),
$reparsed->toComparable(),
sprintf("Perte à l'aller-retour i18next.\n ICU : %s\n i18next : %s", $icu, json_encode($entries, \JSON_UNESCAPED_UNICODE)),
);
}
/**
* @return iterable<string, array{string}>
*/
public static function losslessCorpus(): iterable
{
yield 'texte simple' => ['Enregistrer'];
yield 'apostrophe française' => ["Séance d'aujourd'hui"];
yield 'argument simple' => ['Bonjour {prenom}'];
yield 'arguments multiples' => ['{prenom} a invité {invite}'];
// En ICU l'accolade nue est invalide : elle doit être citée. En i18next
// elle traverse telle quelle, seule la paire « {{ » est interprétée.
yield 'accolade simple littérale' => ["Utilisez la touche '{'"];
yield 'pluriel' => ['{count, plural, one {# séance} other {# séances}}'];
yield 'sélecteur exact zéro' => ['{count, plural, =0 {aucune séance} one {# séance} other {# séances}}'];
}
/**
* L'arabe possède réellement une catégorie CLDR `zero`. Le suffixe `_zero`
* doit alors se relire comme une catégorie, et non comme un `=0` — c'est
* l'ambiguïté que la langue cible permet de trancher.
*/
public function testZeroSuffixIsACldrCategoryForArabic(): void
{
$icu = '{count, plural, zero {لا جلسات} one {جلسة} two {جلستان} few {# جلسات} many {# جلسة} other {# جلسة}}';
$ast = $this->icuParser->parse($icu);
$entries = $this->i18nextSerializer->serializeEntries('cle', $ast, self::ARABIC);
$reparsed = $this->i18nextParser->parseEntries($this->stripPrefix($entries, 'cle'), self::ARABIC);
self::assertSame($ast->toComparable(), $reparsed->toComparable());
self::assertArrayHasKey('cle_zero', $entries);
}
/**
* En français, `zero` n'est pas une catégorie CLDR : le suffixe `_zero` ne
* peut provenir que d'un sélecteur exact.
*/
public function testZeroSuffixIsAnExactSelectorForFrench(): void
{
$ast = $this->i18nextParser->parseEntries(
['zero' => 'aucune séance', 'one' => '{{count}} séance', 'other' => '{{count}} séances'],
self::FRENCH,
);
self::assertSame(
'{count, plural, =0 {aucune séance} one {# séance} other {# séances}}',
$this->icuSerializer->serialize($ast),
);
}
public function testPluralInSentenceRepeatsTheWholeMessage(): void
{
$ast = $this->icuParser->parse('Vous avez {count, plural, one {# message} other {# messages}} non lus');
$entries = $this->i18nextSerializer->serializeEntries('boite.non_lus', $ast, self::FRENCH);
self::assertSame([
'boite.non_lus_one' => 'Vous avez {{count}} message non lus',
'boite.non_lus_other' => 'Vous avez {{count}} messages non lus',
], $entries);
}
/**
* Un pluriel au MILIEU d'une phrase ne revient pas à sa position d'origine.
*
* i18next fait porter la variation à la clé entière : le texte qui entoure
* le pluriel est recopié dans chaque branche. La structure de l'AST change
* donc irrémédiablement — mais le TEXTE RENDU, lui, doit être identique pour
* toute valeur. C'est la seule équivalence qui compte pour un utilisateur,
* et c'est elle qu'on vérifie ici, en confrontant les deux formes au moteur
* ICU de PHP.
*
* @param array<string, mixed> $arguments
*/
#[DataProvider('inlinePluralCorpus')]
public function testInlinePluralIsRedistributedButStaysEquivalent(string $icu, array $arguments): void
{
$ast = $this->icuParser->parse($icu);
$entries = $this->i18nextSerializer->serializeEntries('cle', $ast, self::FRENCH);
$reparsed = $this->i18nextParser->parseEntries($this->stripPrefix($entries, 'cle'), self::FRENCH);
$redistributed = $this->icuSerializer->serialize($reparsed);
self::assertNotSame($icu, $redistributed, 'Ce cas est censé être restructuré ; sinon il relève du corpus sans perte.');
foreach ([0, 1, 2, 21] as $count) {
$values = [...$arguments, 'count' => $count];
self::assertSame(
\MessageFormatter::formatMessage('fr_FR', $icu, $values),
\MessageFormatter::formatMessage('fr_FR', $redistributed, $values),
sprintf('Rendu divergent pour count=%d.', $count),
);
}
}
/**
* @return iterable<string, array{string, array<string, mixed>}>
*/
public static function inlinePluralCorpus(): iterable
{
yield 'pluriel dans une phrase' => [
'Vous avez {count, plural, one {# message} other {# messages}} non lus',
[],
];
yield 'pluriel et autre argument' => [
'{prenom}, vous avez {count, plural, one {# message} other {# messages}}',
['prenom' => 'María'],
];
yield 'pluriel avec sélecteur exact en milieu de phrase' => [
'Panier : {count, plural, =0 {vide} one {# article} other {# articles}} au total',
[],
];
}
/**
* Perte assumée et documentée : i18next délègue le formatage à ses propres
* `formatters`, déclarés côté application. Le NOM de la variable survit, ce
* qui préserve le contrat avec le code appelant — seul le type est perdu.
*/
public function testArgumentTypeIsLostButNameSurvives(): void
{
$ast = $this->icuParser->parse('Réglé le {date, date, long}');
$entries = $this->i18nextSerializer->serializeEntries('facture.date', $ast, self::FRENCH);
self::assertSame(['facture.date' => 'Réglé le {{date}}'], $entries);
$reparsed = $this->i18nextParser->parseEntries($this->stripPrefix($entries, 'facture.date'), self::FRENCH);
self::assertSame('Réglé le {date}', $this->icuSerializer->serialize($reparsed));
}
#[DataProvider('unsupportedCorpus')]
public function testUnsupportedConstructsFailLoudly(string $icu, string $expectedRemedyFragment): void
{
$ast = $this->icuParser->parse($icu);
try {
$this->i18nextSerializer->serializeEntries('ma.cle', $ast, self::FRENCH);
self::fail(sprintf('Attendu un échec de publication pour : %s', $icu));
} catch (UnsupportedMessageFeatureException $exception) {
self::assertStringContainsString('ma.cle', $exception->getMessage(), 'Le message doit nommer la clé fautive.');
self::assertStringContainsString(
$expectedRemedyFragment,
$exception->getMessage(),
'Le message doit indiquer quoi faire, pas seulement ce qui ne va pas.',
);
}
}
/**
* @return iterable<string, array{string, string}>
*/
public static function unsupportedCorpus(): iterable
{
yield 'select de genre' => [
'{genre, select, homme {Bienvenu} other {Bienvenue}}',
'contexte i18next',
];
yield 'offset' => [
'{count, plural, offset:1 one {vous et # autre} other {vous et # autres}}',
'code appelant',
];
yield 'variable de pluriel non nommée count' => [
'{nombre, plural, one {# séance} other {# séances}}',
'Renommez la variable',
];
yield 'ordinal' => [
'{count, selectordinal, one {#er} other {#e}}',
'clés distinctes',
];
yield 'sélecteur exact autre que zéro' => [
'{count, plural, =5 {cinq} one {#} other {#}}',
'catégories CLDR',
];
yield 'deux pluriels' => [
'{count, plural, one {# chat} other {# chats}} et {count, plural, one {# chien} other {# chiens}}',
'deux clés distinctes',
];
yield 'double accolade littérale' => [
"Motif '{{'nom'}}'",
'Reformulez le texte',
];
}
/**
* Le sélecteur exact `=0` et la catégorie `zero` produisent tous deux
* `_zero` : i18next ne peut pas les distinguer, donc on refuse plutôt que
* d'en écraser un silencieusement.
*/
public function testZeroCollisionIsRejected(): void
{
$ast = $this->icuParser->parse('{count, plural, =0 {rien} zero {zéro} one {#} other {#}}');
$this->expectException(UnsupportedMessageFeatureException::class);
$this->expectExceptionMessageMatches('/même suffixe/');
$this->i18nextSerializer->serializeEntries('ma.cle', $ast, self::ARABIC);
}
/**
* @param array<string, string> $entries
*
* @return array<string, string>
*/
private function stripPrefix(array $entries, string $keyPath): array
{
$variants = [];
foreach ($entries as $key => $value) {
$variants[$key === $keyPath ? '' : substr($key, \strlen($keyPath) + 1)] = $value;
}
return $variants;
}
}