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>
333 lines
12 KiB
PHP
333 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Translation\Sync;
|
|
|
|
use App\Entity\Locale;
|
|
use App\Entity\Organization;
|
|
use App\Entity\Platform;
|
|
use App\Entity\Project;
|
|
use App\Entity\ProjectLocale;
|
|
use App\Entity\TranslationKey;
|
|
use App\Enum\MessageFormat;
|
|
use App\Enum\PlatformKind;
|
|
use App\Enum\TextDirection;
|
|
use App\Enum\TranslationStatus;
|
|
use App\Repository\TranslationKeyRepository;
|
|
use App\Translation\Sync\KeySynchronizer;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
|
|
/**
|
|
* Verrouille les quatre règles du `sync`.
|
|
*
|
|
* Ce sont des règles dont la violation ne se voit pas immédiatement : un prune
|
|
* trop large n'échoue pas, il supprime. D'où des tests plutôt qu'une vigilance
|
|
* en revue de code.
|
|
*/
|
|
final class KeySynchronizerTest extends KernelTestCase
|
|
{
|
|
private EntityManagerInterface $entityManager;
|
|
private KeySynchronizer $synchronizer;
|
|
private TranslationKeyRepository $keys;
|
|
|
|
private Project $project;
|
|
private Platform $web;
|
|
private Platform $ios;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
self::bootKernel();
|
|
$container = self::getContainer();
|
|
|
|
$entityManager = $container->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<string>
|
|
*/
|
|
private function platformSlugs(TranslationKey $key): array
|
|
{
|
|
$slugs = [];
|
|
foreach ($key->getPlatforms() as $platform) {
|
|
$slugs[] = $platform->getSlug();
|
|
}
|
|
sort($slugs);
|
|
|
|
return $slugs;
|
|
}
|
|
}
|