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>
276 lines
11 KiB
PHP
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;
|
|
}
|
|
}
|