341 lines
12 KiB
PHP
341 lines
12 KiB
PHP
|
|
<?php
|
|||
|
|
|
|||
|
|
namespace App\Tests\Command;
|
|||
|
|
|
|||
|
|
use App\Command\TranslateCommand;
|
|||
|
|
use PHPUnit\Framework\TestCase;
|
|||
|
|
use Symfony\Component\Console\Application;
|
|||
|
|
use Symfony\Component\Console\Tester\CommandTester;
|
|||
|
|
use Symfony\Component\Yaml\Yaml;
|
|||
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|||
|
|
use Symfony\Contracts\HttpClient\ResponseInterface;
|
|||
|
|
|
|||
|
|
class TranslateCommandTest extends TestCase
|
|||
|
|
{
|
|||
|
|
private string $tmpDir;
|
|||
|
|
private string $translationsDir;
|
|||
|
|
|
|||
|
|
protected function setUp(): void
|
|||
|
|
{
|
|||
|
|
$this->tmpDir = sys_get_temp_dir().'/translate_test_'.uniqid();
|
|||
|
|
$this->translationsDir = $this->tmpDir.'/translations';
|
|||
|
|
mkdir($this->translationsDir, 0o755, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
protected function tearDown(): void
|
|||
|
|
{
|
|||
|
|
$files = glob($this->translationsDir.'/*') ?: [];
|
|||
|
|
foreach ($files as $f) {
|
|||
|
|
unlink($f);
|
|||
|
|
}
|
|||
|
|
rmdir($this->translationsDir);
|
|||
|
|
rmdir($this->tmpDir);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function createTester(HttpClientInterface $httpClient): CommandTester
|
|||
|
|
{
|
|||
|
|
$command = new TranslateCommand($httpClient, $this->tmpDir);
|
|||
|
|
$app = new Application();
|
|||
|
|
$app->addCommand($command);
|
|||
|
|
|
|||
|
|
return new CommandTester($app->find('app:translate'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function mockHttpClient(array $translateResponses = [], bool $apiAvailable = true): HttpClientInterface
|
|||
|
|
{
|
|||
|
|
$httpClient = $this->createMock(HttpClientInterface::class);
|
|||
|
|
$callIndex = 0;
|
|||
|
|
|
|||
|
|
$httpClient->method('request')->willReturnCallback(
|
|||
|
|
function (string $method, string $url) use (&$callIndex, $translateResponses, $apiAvailable) {
|
|||
|
|
$response = $this->createMock(ResponseInterface::class);
|
|||
|
|
|
|||
|
|
if ('GET' === $method && str_contains($url, '/languages')) {
|
|||
|
|
if (!$apiAvailable) {
|
|||
|
|
throw new \RuntimeException('Connection refused');
|
|||
|
|
}
|
|||
|
|
$response->method('getStatusCode')->willReturn(200);
|
|||
|
|
|
|||
|
|
return $response;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ('POST' === $method && str_contains($url, '/translate')) {
|
|||
|
|
$translated = $translateResponses[$callIndex] ?? 'Translated text';
|
|||
|
|
++$callIndex;
|
|||
|
|
$response->method('toArray')->willReturn(['translatedText' => $translated]);
|
|||
|
|
|
|||
|
|
return $response;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $response;
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return $httpClient;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testApiUnreachable(): void
|
|||
|
|
{
|
|||
|
|
$httpClient = $this->mockHttpClient([], false);
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(1, $tester->getStatusCode());
|
|||
|
|
self::assertStringContainsString('not reachable', $tester->getDisplay());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testNoFrenchFiles(): void
|
|||
|
|
{
|
|||
|
|
$httpClient = $this->mockHttpClient();
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(0, $tester->getStatusCode());
|
|||
|
|
self::assertStringContainsString('No French translation files', $tester->getDisplay());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testEmptyFrenchFile(): void
|
|||
|
|
{
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.fr.yaml', '');
|
|||
|
|
|
|||
|
|
$httpClient = $this->mockHttpClient();
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(0, $tester->getStatusCode());
|
|||
|
|
self::assertStringContainsString('Skipped', $tester->getDisplay());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testTranslatesNewKeys(): void
|
|||
|
|
{
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
|||
|
|
'hello' => 'Bonjour',
|
|||
|
|
'goodbye' => 'Au revoir',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
// 2 keys × 4 languages = 8 translations
|
|||
|
|
$responses = array_fill(0, 8, null);
|
|||
|
|
$responses[0] = 'Hello';
|
|||
|
|
$responses[1] = 'Goodbye';
|
|||
|
|
$responses[2] = 'Hola';
|
|||
|
|
$responses[3] = 'Adios';
|
|||
|
|
$responses[4] = 'Hallo';
|
|||
|
|
$responses[5] = 'Auf Wiedersehen';
|
|||
|
|
$responses[6] = 'Ciao';
|
|||
|
|
$responses[7] = 'Arrivederci';
|
|||
|
|
|
|||
|
|
$httpClient = $this->mockHttpClient($responses);
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(0, $tester->getStatusCode());
|
|||
|
|
self::assertStringContainsString('Translated 2 new keys', $tester->getDisplay());
|
|||
|
|
|
|||
|
|
// Check EN file
|
|||
|
|
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
|||
|
|
self::assertSame('Hello', $enData['hello']);
|
|||
|
|
self::assertSame('Goodbye', $enData['goodbye']);
|
|||
|
|
|
|||
|
|
// Check ES file
|
|||
|
|
$esData = Yaml::parseFile($this->translationsDir.'/messages.es.yaml');
|
|||
|
|
self::assertSame('Hola', $esData['hello']);
|
|||
|
|
|
|||
|
|
// Check DE file
|
|||
|
|
self::assertFileExists($this->translationsDir.'/messages.de.yaml');
|
|||
|
|
|
|||
|
|
// Check IT file
|
|||
|
|
self::assertFileExists($this->translationsDir.'/messages.it.yaml');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testSkipsExistingKeys(): void
|
|||
|
|
{
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
|||
|
|
'hello' => 'Bonjour',
|
|||
|
|
'new_key' => 'Nouveau',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.en.yaml', Yaml::dump([
|
|||
|
|
'hello' => 'Hello (manual)',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
// Only new_key needs translating for EN, then 2 keys for ES/DE/IT = 7
|
|||
|
|
$responses = [];
|
|||
|
|
$responses[0] = 'New'; // new_key → EN
|
|||
|
|
$responses[1] = 'Hola'; // hello → ES
|
|||
|
|
$responses[2] = 'Nuevo'; // new_key → ES
|
|||
|
|
$responses[3] = 'Hallo'; // hello → DE
|
|||
|
|
$responses[4] = 'Neu'; // new_key → DE
|
|||
|
|
$responses[5] = 'Ciao'; // hello → IT
|
|||
|
|
$responses[6] = 'Nuovo'; // new_key → IT
|
|||
|
|
|
|||
|
|
$httpClient = $this->mockHttpClient($responses);
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(0, $tester->getStatusCode());
|
|||
|
|
|
|||
|
|
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
|||
|
|
self::assertSame('Hello (manual)', $enData['hello']);
|
|||
|
|
self::assertSame('New', $enData['new_key']);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testRemovesObsoleteKeys(): void
|
|||
|
|
{
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
|||
|
|
'hello' => 'Bonjour',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.en.yaml', Yaml::dump([
|
|||
|
|
'hello' => 'Hello',
|
|||
|
|
'removed_key' => 'This was removed from FR',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
$httpClient = $this->mockHttpClient();
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(0, $tester->getStatusCode());
|
|||
|
|
|
|||
|
|
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
|||
|
|
self::assertArrayHasKey('hello', $enData);
|
|||
|
|
self::assertArrayNotHasKey('removed_key', $enData);
|
|||
|
|
self::assertStringContainsString('Removed 1 obsolete', $tester->getDisplay());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testAlreadyUpToDate(): void
|
|||
|
|
{
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
|||
|
|
'hello' => 'Bonjour',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
foreach (['en', 'es', 'de', 'it'] as $lang) {
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.'.$lang.'.yaml', Yaml::dump([
|
|||
|
|
'hello' => 'Translated',
|
|||
|
|
]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$httpClient = $this->mockHttpClient();
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(0, $tester->getStatusCode());
|
|||
|
|
self::assertStringContainsString('Already up to date', $tester->getDisplay());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testPreservesPlaceholders(): void
|
|||
|
|
{
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
|||
|
|
'retry' => 'Reessayez dans %minutes% minutes.',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
// LibreTranslate might mangle the placeholder
|
|||
|
|
$responses = [
|
|||
|
|
'Try again in % minutes % minutes.', // EN - mangled
|
|||
|
|
'Inténtelo de nuevo en %minutes% minutos.', // ES - kept
|
|||
|
|
'Versuchen Sie es in % minuten % Minuten erneut.', // DE - mangled
|
|||
|
|
'Riprova tra %minutes% minuti.', // IT - kept
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
$httpClient = $this->mockHttpClient($responses);
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(0, $tester->getStatusCode());
|
|||
|
|
|
|||
|
|
$esData = Yaml::parseFile($this->translationsDir.'/messages.es.yaml');
|
|||
|
|
self::assertStringContainsString('%minutes%', $esData['retry']);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testTranslationApiFailureFallbacksToOriginal(): void
|
|||
|
|
{
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
|||
|
|
'error' => 'Une erreur est survenue.',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
$httpClient = $this->createMock(HttpClientInterface::class);
|
|||
|
|
$callCount = 0;
|
|||
|
|
|
|||
|
|
$httpClient->method('request')->willReturnCallback(
|
|||
|
|
function (string $method, string $url) use (&$callCount) {
|
|||
|
|
$response = $this->createMock(ResponseInterface::class);
|
|||
|
|
|
|||
|
|
if ('GET' === $method && str_contains($url, '/languages')) {
|
|||
|
|
$response->method('getStatusCode')->willReturn(200);
|
|||
|
|
|
|||
|
|
return $response;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ('POST' === $method && str_contains($url, '/translate')) {
|
|||
|
|
++$callCount;
|
|||
|
|
throw new \RuntimeException('API error');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return $response;
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(0, $tester->getStatusCode());
|
|||
|
|
self::assertStringContainsString('Failed to translate', $tester->getDisplay());
|
|||
|
|
|
|||
|
|
// Fallback: original FR text used
|
|||
|
|
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
|||
|
|
self::assertSame('Une erreur est survenue.', $enData['error']);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testMultipleDomainFiles(): void
|
|||
|
|
{
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
|||
|
|
'hello' => 'Bonjour',
|
|||
|
|
]));
|
|||
|
|
file_put_contents($this->translationsDir.'/security.fr.yaml', Yaml::dump([
|
|||
|
|
'login' => 'Connexion',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
$httpClient = $this->mockHttpClient(array_fill(0, 8, 'Translated'));
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
self::assertSame(0, $tester->getStatusCode());
|
|||
|
|
|
|||
|
|
self::assertFileExists($this->translationsDir.'/messages.en.yaml');
|
|||
|
|
self::assertFileExists($this->translationsDir.'/security.en.yaml');
|
|||
|
|
self::assertFileExists($this->translationsDir.'/messages.it.yaml');
|
|||
|
|
self::assertFileExists($this->translationsDir.'/security.it.yaml');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function testKeysOrderMatchesFrench(): void
|
|||
|
|
{
|
|||
|
|
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
|
|||
|
|
'aaa' => 'Premier',
|
|||
|
|
'zzz' => 'Dernier',
|
|||
|
|
'mmm' => 'Milieu',
|
|||
|
|
]));
|
|||
|
|
|
|||
|
|
$responses = ['First', 'Last', 'Middle'];
|
|||
|
|
// Repeat for 4 languages
|
|||
|
|
$allResponses = array_merge($responses, $responses, $responses, $responses);
|
|||
|
|
|
|||
|
|
$httpClient = $this->mockHttpClient($allResponses);
|
|||
|
|
$tester = $this->createTester($httpClient);
|
|||
|
|
|
|||
|
|
$tester->execute(['--url' => 'http://fake:5000']);
|
|||
|
|
|
|||
|
|
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
|
|||
|
|
$keys = array_keys($enData);
|
|||
|
|
|
|||
|
|
self::assertSame(['aaa', 'zzz', 'mmm'], $keys);
|
|||
|
|
}
|
|||
|
|
}
|