builder = new BundleBuilder( new MessageFormatRegistry( [new IcuParser(), new I18nextParser()], [new IcuSerializer(), new I18nextSerializer()], ), new IcuParser(), ); $organization = new Organization('Test', 'test'); $this->french = new Locale('fr-FR', 'French', 'Français', TextDirection::Ltr, ['one', 'many', 'other']); $this->spanish = new Locale('es-ES', 'Spanish', 'Español', TextDirection::Ltr, ['one', 'many', 'other']); $this->canadianFrench = new Locale('fr-CA', 'French (CA)', 'Français (CA)', TextDirection::Ltr, ['one', 'many', 'other']); $this->canadianFrench->setFallbackLocale($this->french); $this->project = new Project($organization, 'Test', 'test', $this->french); } public function testOnlyKeysOfThePlatformAreIncluded(): void { $web = $this->platform('web', MessageFormat::Icu, ExportLayout::Flat); $ios = $this->platform('ios', MessageFormat::Icu, ExportLayout::Flat); $shared = $this->key('common.save', ['fr-FR' => 'Enregistrer'], [$web, $ios]); $iosOnly = $this->key('ios.settings', ['fr-FR' => 'Réglages'], [$ios]); $bundle = $this->builder->build($web, $this->french, [$shared, $iosOnly]); self::assertSame(['common.save' => 'Enregistrer'], $this->decode($bundle->payload)); self::assertSame(1, $bundle->keyCount); } /** * La garantie structurelle de la décision 4 : un brouillon ne PEUT PAS * atteindre la production. */ public function testUntranslatedKeysAreAbsentRatherThanEmpty(): void { $web = $this->platform('web', MessageFormat::Icu, ExportLayout::Flat); $key = $this->key('common.save', ['fr-FR' => 'Enregistrer'], [$web]); // Ligne espagnole présente en base, mais vide et non traduite. new Translation($key, $this->spanish); $bundle = $this->builder->build($web, $this->spanish, [$key]); // Repli sur la source plutôt qu'une chaîne vide : une valeur vide // s'afficherait comme un blanc dans l'application cliente. self::assertSame(['common.save' => 'Enregistrer'], $this->decode($bundle->payload)); self::assertSame(1, $bundle->fallbackCount); } public function testFallbackChainIsFollowedBeforeTheSource(): void { $web = $this->platform('web', MessageFormat::Icu, ExportLayout::Flat); $key = $this->key('common.save', [ 'fr-FR' => 'Enregistrer', 'fr-CA' => 'Sauvegarder', ], [$web]); // fr-CA a sa propre valeur : aucun repli. $direct = $this->builder->build($web, $this->canadianFrench, [$key]); self::assertSame(['common.save' => 'Sauvegarder'], $this->decode($direct->payload)); self::assertSame(0, $direct->fallbackCount); } public function testArchivedKeysAreExcluded(): void { $web = $this->platform('web', MessageFormat::Icu, ExportLayout::Flat); $ios = $this->platform('ios', MessageFormat::Icu, ExportLayout::Flat); $key = $this->key('legacy.key', ['fr-FR' => 'Ancien'], [$web, $ios]); $key->removePlatform($web); $key->removePlatform($ios); // plus aucune plateforme : archivée $bundle = $this->builder->build($web, $this->french, [$key]); self::assertSame([], $this->decode($bundle->payload)); } /** * Le format de la plateforme décide de la forme du fichier — c'est tout * l'intérêt du stockage canonique. */ public function testTheSameKeyRendersDifferentlyPerPlatform(): void { $web = $this->platform('web', MessageFormat::I18next, ExportLayout::Nested); $ios = $this->platform('ios', MessageFormat::Icu, ExportLayout::Flat); $key = $this->key( 'cart.items', ['fr-FR' => '{count, plural, one {# article} other {# articles}}'], [$web, $ios], ); // i18next : une clé par forme plurielle, interpolation en double accolade. self::assertSame( ['cart' => ['items_one' => '{{count}} article', 'items_other' => '{{count}} articles']], $this->decode($this->builder->build($web, $this->french, [$key])->payload), ); // ICU : le pluriel reste dans la chaîne, structure plate. self::assertSame( ['cart.items' => '{count, plural, one {# article} other {# articles}}'], $this->decode($this->builder->build($ios, $this->french, [$key])->payload), ); } /** * Décision 6, règle 3 : une construction non exprimable fait ÉCHOUER la * publication. L'alternative serait de livrer un texte faux pour la moitié * des utilisateurs, sans que personne ne le remarque. */ public function testUnsupportedConstructAbortsTheBundle(): void { $web = $this->platform('web', MessageFormat::I18next, ExportLayout::Nested); $key = $this->key( 'welcome', ['fr-FR' => '{genre, select, femme {Bienvenue} other {Bienvenu}}'], [$web], ); $this->expectException(UnsupportedMessageFeatureException::class); $this->expectExceptionMessageMatches('/contexte i18next/'); $this->builder->build($web, $this->french, [$key]); } /** * Une clé à la fois valeur et groupe produirait un JSON ambigu, dont la * bibliothèque cliente perdrait silencieusement une des deux entrées. */ public function testNestingConflictIsRejected(): void { $web = $this->platform('web', MessageFormat::Icu, ExportLayout::Nested); $keys = [ $this->key('cart', ['fr-FR' => 'Panier'], [$web]), $this->key('cart.total', ['fr-FR' => 'Total'], [$web]), ]; $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Conflit de structure/'); $this->builder->build($web, $this->french, $keys); } public function testFlatLayoutToleratesWhatNestedRefuses(): void { $web = $this->platform('web', MessageFormat::Icu, ExportLayout::Flat); $keys = [ $this->key('cart', ['fr-FR' => 'Panier'], [$web]), $this->key('cart.total', ['fr-FR' => 'Total'], [$web]), ]; self::assertSame( ['cart' => 'Panier', 'cart.total' => 'Total'], $this->decode($this->builder->build($web, $this->french, $keys)->payload), ); } /** * Le contenu doit être stable d'une publication à l'autre : sinon le * checksum change sans raison et toutes les applications retéléchargent. */ public function testOutputIsDeterministic(): void { $web = $this->platform('web', MessageFormat::Icu, ExportLayout::Flat); $keys = [ $this->key('zeta', ['fr-FR' => 'Z'], [$web]), $this->key('alpha', ['fr-FR' => 'A'], [$web]), ]; $first = $this->builder->build($web, $this->french, $keys); $second = $this->builder->build($web, $this->french, array_reverse($keys)); self::assertSame($first->payload, $second->payload); self::assertSame($first->checksum(), $second->checksum()); } // ── Utilitaires ─────────────────────────────────────────────────────── private function platform(string $slug, MessageFormat $format, ExportLayout $layout): Platform { $platform = new Platform($this->project, $slug, $slug, PlatformKind::Web, $format); $platform->setExportLayout($layout); return $platform; } /** * @param array $values code de langue => valeur ICU * @param list $platforms */ private function key(string $path, array $values, array $platforms): TranslationKey { $key = new TranslationKey($this->project, $path); foreach ($platforms as $platform) { $key->addPlatform($platform); } $locales = [ 'fr-FR' => $this->french, 'es-ES' => $this->spanish, 'fr-CA' => $this->canadianFrench, ]; foreach ($values as $code => $value) { $translation = new Translation($key, $locales[$code]); $translation->write($value, TranslationStatus::Reviewed, $value); } return $key; } /** * @return array */ private function decode(string $payload): array { $decoded = json_decode($payload, true); self::assertIsArray($decoded); return $decoded; } }