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>
272 lines
9.8 KiB
PHP
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;
|
|
}
|
|
}
|