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 */ 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 $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}> */ 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 */ 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 $entries * * @return array */ 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; } }