get(EntityManagerInterface::class); self::assertInstanceOf(EntityManagerInterface::class, $entityManager); $this->entityManager = $entityManager; $synchronizer = $container->get(KeySynchronizer::class); self::assertInstanceOf(KeySynchronizer::class, $synchronizer); $this->synchronizer = $synchronizer; $keys = $container->get(TranslationKeyRepository::class); self::assertInstanceOf(TranslationKeyRepository::class, $keys); $this->keys = $keys; $this->seed(); } protected function tearDown(): void { $this->entityManager->createQuery('DELETE FROM '.TranslationKey::class)->execute(); $this->entityManager->createQuery('DELETE FROM '.Platform::class)->execute(); $this->entityManager->createQuery('DELETE FROM '.ProjectLocale::class)->execute(); $this->entityManager->createQuery('DELETE FROM '.Project::class)->execute(); $this->entityManager->createQuery('DELETE FROM '.Locale::class)->execute(); $this->entityManager->createQuery('DELETE FROM '.Organization::class)->execute(); $this->entityManager->clear(); parent::tearDown(); } public function testNewKeysAreCreatedWithCanonicalIcuAndTargetRows(): void { $report = $this->synchronizer->synchronize($this->web, [ 'cart.empty' => ['value' => 'Votre panier est vide'], ]); self::assertSame(['cart.empty'], $report->added); $key = $this->find('cart.empty'); self::assertNotNull($key); self::assertSame('Votre panier est vide', $key->getSourceTranslation()?->getValue()); // Une ligne par langue activée, y compris non traduite : c'est ce qui rend // le filtre par statut uniforme et les statistiques calculables. self::assertCount(3, $key->getTranslations()); $spanish = $key->getTranslation($this->locale('es-ES')); self::assertNotNull($spanish); self::assertSame(TranslationStatus::Untranslated, $spanish->getStatus()); } /** * Le cas qui distingue un vrai moteur i18n d'un CRUD : deux clés plates * envoyées par le CI doivent redevenir un unique message pluriel. */ public function testI18nextPluralVariantsAreMergedIntoOneKey(): void { $report = $this->synchronizer->synchronize($this->web, [ 'cart.items_one' => ['value' => '{{count}} article'], 'cart.items_other' => ['value' => '{{count}} articles'], ]); self::assertSame(['cart.items'], $report->added); $key = $this->find('cart.items'); self::assertNotNull($key); self::assertSame( '{count, plural, one {# article} other {# articles}}', $key->getSourceTranslation()?->getValue(), ); self::assertSame([['name' => 'count', 'type' => 'number']], $key->getPlaceholders()); } /** * Une clé métier qui se termine par `_one` sans avoir de `_other` n'est PAS * un pluriel. La confondre la ferait disparaître sous un nom tronqué. */ public function testKeyEndingLikeAPluralSuffixIsNotMerged(): void { $report = $this->synchronizer->synchronize($this->web, [ 'wizard.step_one' => ['value' => 'Première étape'], ]); self::assertSame(['wizard.step_one'], $report->added); self::assertNotNull($this->find('wizard.step_one')); } public function testRunningTwiceChangesNothing(): void { $entries = ['cart.empty' => ['value' => 'Votre panier est vide']]; $this->synchronizer->synchronize($this->web, $entries); $second = $this->synchronizer->synchronize($this->web, $entries); self::assertSame([], $second->added); self::assertSame([], $second->updated); self::assertSame(1, $second->unchanged); } public function testChangingTheSourceFlagsExistingTranslations(): void { $this->synchronizer->synchronize($this->web, ['cart.empty' => ['value' => 'Panier vide']]); $key = $this->find('cart.empty'); self::assertNotNull($key); $spanish = $key->getTranslation($this->locale('es-ES')); self::assertNotNull($spanish); $spanish->write('Carrito vacío', TranslationStatus::Translated, 'Panier vide'); $this->entityManager->flush(); $report = $this->synchronizer->synchronize($this->web, [ 'cart.empty' => ['value' => 'Votre panier est vide'], ]); self::assertSame(['cart.empty'], $report->updated); self::assertSame(1, $report->flagged); $this->entityManager->refresh($spanish); self::assertSame(TranslationStatus::NeedsReview, $spanish->getStatus()); self::assertSame('Carrito vacío', $spanish->getValue(), 'La valeur doit être conservée : le traducteur doit pouvoir juger.'); } /** * LA règle du modèle à plateformes enfants. Sans elle, le CI de * l'application web archive les clés propres à iOS. */ public function testPruneOnlyDetachesFromTheSynchronisedPlatform(): void { $this->synchronizer->synchronize($this->web, [ 'common.save' => ['value' => 'Enregistrer'], 'web.only' => ['value' => 'Spécifique au web'], ]); $this->synchronizer->synchronize($this->ios, [ 'common.save' => ['value' => 'Enregistrer'], ]); // Le push suivant ne contient NI l'une NI l'autre : les deux deviennent // orphelines pour la plateforme web, et leur sort doit différer. $report = $this->synchronizer->synchronize($this->web, [ 'autre.cle' => ['value' => 'Autre'], ], prune: true); self::assertSame(['common.save', 'web.only'], $report->pruned); $shared = $this->find('common.save'); self::assertNotNull($shared); self::assertSame(['ios'], $this->platformSlugs($shared), 'Seule l\'appartenance à web doit disparaître.'); self::assertFalse($shared->isArchived(), 'La clé appartient encore à iOS : elle ne doit pas être archivée.'); $exclusive = $this->find('web.only'); self::assertNotNull($exclusive); self::assertSame([], $this->platformSlugs($exclusive)); self::assertTrue($exclusive->isArchived(), 'Plus aucune plateforme ne la référence : archivage attendu.'); } public function testWithoutPruneNothingIsDetached(): void { $this->synchronizer->synchronize($this->web, [ 'a' => ['value' => 'A'], 'b' => ['value' => 'B'], ]); $report = $this->synchronizer->synchronize($this->web, ['a' => ['value' => 'A']]); self::assertSame(['b'], $report->orphaned, 'L\'orpheline est signalée…'); self::assertSame([], $report->pruned, '…mais jamais retirée sans demande explicite.'); $key = $this->find('b'); self::assertNotNull($key); self::assertFalse($key->isArchived()); } public function testDryRunWritesNothing(): void { $report = $this->synchronizer->synchronize( $this->web, ['cart.empty' => ['value' => 'Votre panier est vide']], dryRun: true, ); self::assertSame(['cart.empty'], $report->added); self::assertTrue($report->dryRun); self::assertNull($this->find('cart.empty'), 'Une simulation ne doit rien écrire.'); } /** * Un push de plusieurs milliers de clés dont deux sont fautives doit * appliquer les autres. L'inverse obligerait à corriger en aveugle. */ public function testInvalidKeysAreReportedWithoutBlockingTheOthers(): void { $report = $this->synchronizer->synchronize($this->web, [ 'valide' => ['value' => 'Correct'], 'casse' => ['value' => 'Bonjour {{prenom'], ]); self::assertSame(['valide'], $report->added); self::assertCount(1, $report->errors); self::assertSame('casse', $report->errors[0]['key']); self::assertNotNull($this->find('valide')); } public function testKeyNamingPatternIsEnforced(): void { $report = $this->synchronizer->synchronize($this->web, [ 'Header.LoginButton' => ['value' => 'Connexion'], ]); self::assertSame([], $report->added); self::assertCount(1, $report->errors); self::assertStringContainsString('convention de nommage', $report->errors[0]['message']); } /** * La description saisie par une traductrice ne doit pas être effacée par un * push qui n'en fournit pas. */ public function testPushWithoutDescriptionDoesNotEraseTheExistingOne(): void { $this->synchronizer->synchronize($this->web, [ 'cart.empty' => ['value' => 'Panier vide', 'description' => 'État vide du panier.'], ]); $this->synchronizer->synchronize($this->web, [ 'cart.empty' => ['value' => 'Votre panier est vide'], ]); $key = $this->find('cart.empty'); self::assertNotNull($key); self::assertSame('État vide du panier.', $key->getDescription()); } // ── Utilitaires ─────────────────────────────────────────────────────── private function seed(): void { $organization = new Organization('Test', 'test'); $this->entityManager->persist($organization); $french = new Locale('fr-FR', 'French', 'Français', TextDirection::Ltr, ['one', 'many', 'other']); $spanish = new Locale('es-ES', 'Spanish', 'Español', TextDirection::Ltr, ['one', 'many', 'other']); $english = new Locale('en-GB', 'English', 'English', TextDirection::Ltr, ['one', 'other']); $this->entityManager->persist($french); $this->entityManager->persist($spanish); $this->entityManager->persist($english); $this->project = new Project($organization, 'Test', 'test', $french); $this->entityManager->persist($this->project); foreach ([$french, $spanish, $english] as $locale) { $this->entityManager->persist(new ProjectLocale($this->project, $locale)); } $this->web = new Platform($this->project, 'Web', 'web', PlatformKind::Web, MessageFormat::I18next); $this->ios = new Platform($this->project, 'iOS', 'ios', PlatformKind::Ios, MessageFormat::Icu); $this->entityManager->persist($this->web); $this->entityManager->persist($this->ios); $this->entityManager->flush(); } private function find(string $keyPath): ?TranslationKey { return $this->keys->findOneBy([ 'project' => $this->project, 'keyHash' => TranslationKey::hashPath($keyPath), ]); } private function locale(string $code): Locale { $locale = $this->entityManager->getRepository(Locale::class)->findOneBy(['code' => $code]); self::assertInstanceOf(Locale::class, $locale); return $locale; } /** * @return list */ private function platformSlugs(TranslationKey $key): array { $slugs = []; foreach ($key->getPlatforms() as $platform) { $slugs[] = $platform->getSlug(); } sort($slugs); return $slugs; } }