TQ-Slator/tests/Translation/Sync/KeySynchronizerTest.php
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
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>
2026-08-21 08:16:05 +02:00

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;
}
}