TQ-Slator/tests/Translation/Release/BundleBuilderTest.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

272 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Translation\Release;
use App\Entity\Locale;
use App\Entity\Organization;
use App\Entity\Platform;
use App\Entity\Project;
use App\Entity\Translation;
use App\Entity\TranslationKey;
use App\Enum\ExportLayout;
use App\Enum\MessageFormat;
use App\Enum\PlatformKind;
use App\Enum\TextDirection;
use App\Enum\TranslationStatus;
use App\Translation\Format\Exception\UnsupportedMessageFeatureException;
use App\Translation\Format\MessageFormatRegistry;
use App\Translation\Format\Parser\I18nextParser;
use App\Translation\Format\Parser\IcuParser;
use App\Translation\Format\Serializer\I18nextSerializer;
use App\Translation\Format\Serializer\IcuSerializer;
use App\Translation\Release\BundleBuilder;
use PHPUnit\Framework\TestCase;
/**
* Vérifie ce qui entre — et surtout ce qui n'entre PAS — dans un fichier livré.
*
* Ces règles sont invisibles à l'exécution : un bundle contenant un brouillon
* n'échoue pas, il livre du texte non validé en production. D'où des tests.
*/
final class BundleBuilderTest extends TestCase
{
private BundleBuilder $builder;
private Project $project;
private Locale $french;
private Locale $spanish;
private Locale $canadianFrench;
protected function setUp(): void
{
$this->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<string, string> $values code de langue => valeur ICU
* @param list<Platform> $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<string, mixed>
*/
private function decode(string $payload): array
{
$decoded = json_decode($payload, true);
self::assertIsArray($decoded);
return $decoded;
}
}