ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
ConvertModuleToPoToolTest.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
22use PHPUnit\Framework\Attributes\DataProvider;
23use PHPUnit\Framework\TestCase;
24
28class ConvertModuleToPoToolTest extends TestCase
29{
30 private const string TOOL = __DIR__ . '/../tools/po-migration/convert_module_to_po.php';
31
32 private string $directory;
33
34 protected function setUp(): void
35 {
36 // the tool prints resolved paths: macOS /var is a link, FreeBSD's temp dir ends with a slash
37 $this->directory = (string) realpath(sys_get_temp_dir()) . '/ilias_convert_tool_' . bin2hex(random_bytes(4));
38 mkdir($this->directory . '/out', 0775, true);
39 }
40
41 protected function tearDown(): void
42 {
43 MigratedPoFixture::removeDirectory($this->directory);
44 }
45
47 private function runTool(string ...$arguments): array
48 {
49 return $this->runToolAt(self::TOOL, ...$arguments);
50 }
51
58 private function runToolAt(string $tool_path, string ...$arguments): array
59 {
60 $result = ChildPhp::run(
61 ['-d', 'error_reporting=-1', '-d', 'display_errors=stderr', $tool_path, ...$arguments]
62 );
63 $this->assertNotNull($result, 'could not start the child PHP process');
64
65 return $result;
66 }
67
69 private function buildFixtureRepo(): string
70 {
71 $root = $this->directory . '/repo';
72 $tool_dir = $root . '/components/ILIAS/Language/tools/po-migration';
73 mkdir($tool_dir, 0775, true);
74 mkdir($root . '/lang', 0775, true);
75 mkdir($root . '/out', 0775, true);
76 copy(self::TOOL, $tool_dir . '/convert_module_to_po.php');
77
78 mkdir($root . '/vendor/composer/vendor', 0775, true);
79 $real_autoload = realpath(__DIR__ . '/../../../../vendor/composer/vendor/autoload.php');
80 $this->assertIsString($real_autoload, 'the real Composer autoloader was not found');
81 symlink($real_autoload, $root . '/vendor/composer/vendor/autoload.php');
82
83 return $root;
84 }
85
86 private function toolPathOf(string $fixture_root): string
87 {
88 return $fixture_root . '/components/ILIAS/Language/tools/po-migration/convert_module_to_po.php';
89 }
90
98 private function writePluralsConfig(string $fixture_root, array $plural_forms, array $modules): void
99 {
100 file_put_contents(
101 $fixture_root . '/components/ILIAS/Language/tools/po-migration/plurals.json',
102 (string) json_encode(['plural_forms' => $plural_forms, 'modules' => $modules], JSON_THROW_ON_ERROR)
103 );
104 }
105
107 private function writeLangFile(string $fixture_root, string $lang_key, string $content): void
108 {
109 if (!str_contains($content, self::START)) {
110 $content = self::START . "\n" . $content;
111 }
112 file_put_contents($fixture_root . '/lang/ilias_' . $lang_key . '.lang', $content);
113 }
114
116 private static function files(string $directory): array
117 {
118 $files = [];
119 foreach (glob($directory . '/*.{po,pot}', GLOB_BRACE) ?: [] as $file) {
120 $files[basename($file)] = (string) file_get_contents($file);
121 }
122 ksort($files);
123
124 return $files;
125 }
126
129 {
130 $root = $this->buildFixtureRepo();
131 $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n");
132 $this->writeLangFile($root, 'fr', "tst#:#greeting#:#Bonjour\ntst#:#extra#:#En trop\n");
133
134 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
135
136 $this->assertSame(1, $exit_code, $stdout . $stderr);
137 $this->assertStringContainsString("reference language 'de' does not have", $stderr);
138 $this->assertStringContainsString('fr: extra', $stderr);
139 $this->assertSame([], glob($root . '/out/*') ?: [], 'nothing must be written once an inconsistency was found');
140 }
141
144 {
145 $root = $this->buildFixtureRepo();
146 $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo#:#Extra\n");
147
148 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
149
150 $this->assertSame(0, $exit_code, $stdout . $stderr);
151 $this->assertStringContainsString(
152 'contains "#:#" in its value - only the part before it is used, like the installer does.',
153 $stderr
154 );
156 $entry = $catalog->find(null, 'greeting');
157 $this->assertNotNull($entry);
158 $this->assertSame('Hallo', $entry->getTranslation());
159 }
160
163 {
164 $root = $this->buildFixtureRepo();
165 $note = 'Bitte pruefen, ob hier evtl. eine neue Variable (englisch: new variable) gemeint war';
166 $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo###$note\n");
167
168 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
169
170 $this->assertSame(0, $exit_code, $stdout . $stderr);
172 $entry = $catalog->find(null, 'greeting');
173 $this->assertNotNull($entry);
174 $this->assertFalse($entry->hasFlag('fuzzy'));
175 $this->assertSame([$note], $entry->getExtractedComments());
176 }
177
180 {
181 $root = $this->buildFixtureRepo();
182 $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n");
183 $this->writeLangFile($root, 'fr', "tst#:#greeting#:####13 3 2019 new variable\n");
184
185 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
186
187 $this->assertSame(0, $exit_code, $stdout . $stderr);
189 $entry = $catalog->find(null, 'greeting');
190 $this->assertNotNull($entry);
191 $this->assertTrue($entry->hasFlag('fuzzy'));
192 $this->assertSame([], $entry->getExtractedComments());
193 }
194
200 {
201 $root = $this->buildFixtureRepo();
202 $this->writeLangFile($root, 'en', "tst#:#greeting#:#Hello\ntst#:#bye#:#Bye\ntst#:#none#:#\n");
203 $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n");
204 $this->writeLangFile($root, 'fr', "tst#:#greeting#:####Bitte uebersetzen\ntst#:#bye#:#Salut\n");
205
206 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst');
207
208 $this->assertSame(0, $exit_code, $stdout . $stderr);
209 $out = dirname($this->toolPathOf($root)) . '/output/tst';
211 $this->assertSame(['greeting', 'bye', 'none'], array_map(static fn($e): string => $e->getId(), $pot->getEntries()), 'msgids from en');
212
214 $this->assertSame('Hallo', $de->find(null, 'greeting')?->getTranslation());
215 $this->assertFalse($de->find(null, 'greeting')->hasFlag('fuzzy'));
216 $this->assertSame('Bye', $de->find(null, 'bye')?->getTranslation(), 'no line: the en value');
217 $this->assertTrue($de->find(null, 'bye')->hasFlag('fuzzy'));
218 $this->assertSame('', $de->find(null, 'none')?->getTranslation(), 'an empty en value fills nothing');
219 $this->assertFalse($de->find(null, 'none')->hasFlag('fuzzy'));
220
222 $this->assertSame('Hello', $fr->find(null, 'greeting')?->getTranslation(), 'empty value: the en value');
223 $this->assertTrue($fr->find(null, 'greeting')->hasFlag('fuzzy'));
224 $this->assertSame(['Bitte uebersetzen'], $fr->find(null, 'greeting')->getTranslatorComments());
225 $this->assertSame([], $fr->find(null, 'greeting')->getExtractedComments());
226 $this->assertFalse($fr->find(null, 'bye')?->hasFlag('fuzzy'));
227
228 $this->assertMatchesRegularExpression('/^de\s+1\s+0\s+1\s+yes$/m', $stdout);
229 $this->assertMatchesRegularExpression('/^en\s+3\s+0\s+0\s+yes$/m', $stdout);
230 $this->assertMatchesRegularExpression('/^fr\s+2\s+0\s+1\s+yes$/m', $stdout);
231 $this->assertStringContainsString("(2 entries filled from 'en', flagged fuzzy)", $stdout);
232 }
233
236 {
237 $root = $this->buildFixtureRepo();
238 $this->writeLangFile($root, 'en', "tst#:#item#:#Entries\ntst#:#item_singular#:#One entry\n");
239 $this->writeLangFile($root, 'de', "tst#:#item#:#Eintraege\n");
240 $this->writeLangFile($root, 'fr', "tst#:#item_singular#:#Une entree\n");
241 $this->writePluralsConfig(
242 $root,
243 ['en' => 'nplurals=2; plural=(n != 1);', 'de' => 'nplurals=2; plural=(n != 1);', 'fr' => 'nplurals=2; plural=(n > 1);'],
244 ['tst' => ['item' => ['singular' => 'item_singular']]]
245 );
246
247 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out');
248
249 $this->assertSame(0, $exit_code, $stdout . $stderr);
250 $de_item = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_de.po')->find(null, 'item');
251 $this->assertSame(['Eintraege', 'Eintraege'], $de_item?->getPluralTranslations(), 'the singular key filled from the reference language is not taken over');
252 $this->assertFalse($de_item->hasFlag('fuzzy'), 'de: a source language, its own value is present');
253 $fr_item = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/tst_fr.po')->find(null, 'item');
254 $this->assertSame(['Entries', 'Entries'], $fr_item?->getPluralTranslations(), 'the en value in every form');
255 $this->assertTrue($fr_item->hasFlag('fuzzy'));
256 $this->assertStringContainsString('round-trip identically through PO', $stdout);
257 }
258
261 {
262 $root = $this->buildFixtureRepo();
263 $this->writeLangFile($root, 'de', "tst#:#item#:#Eintraege\ntst#:#item_singular#:#Ein Eintrag\n");
264 $this->writeLangFile(
265 $root,
266 'fr',
267 "tst#:#item#:#Des entrees\ntst#:#item_singular#:#Une entree###13 3 2019 new variable\n"
268 );
269 $this->writeLangFile(
270 $root,
271 'zh',
272 "tst#:#item#:#Eintraege ZH\ntst#:#item_singular#:#Ein Eintrag ZH###13 3 2019 new variable\n"
273 );
274 $this->writePluralsConfig(
275 $root,
276 [
277 'de' => 'nplurals=2; plural=(n != 1);',
278 'fr' => 'nplurals=2; plural=(n != 1);',
279 'zh' => 'nplurals=1; plural=0;',
280 ],
281 ['tst' => ['item' => ['singular' => 'item_singular']]]
282 );
283
284 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
285
286 $this->assertSame(0, $exit_code, $stdout . $stderr);
288 ->find(null, 'item');
289 $this->assertNotNull($fr_item);
290 $this->assertSame(['Des entrees', 'Des entrees'], $fr_item->getPluralTranslations(), 'a fuzzy singular key is not taken over');
291 $this->assertTrue($fr_item->hasFlag('fuzzy'), 'fr: the own value is copied into both forms');
292
294 ->find(null, 'item');
295 $this->assertNotNull($zh_item);
296 $this->assertSame(['Eintraege ZH'], $zh_item->getPluralTranslations(), 'precondition: single form, own value - not the singular key');
297 $this->assertFalse($zh_item->hasFlag('fuzzy'), 'zh: the singular key is never used here, so its fuzziness must not leak in');
298 }
299
302 {
303 $root = $this->buildFixtureRepo();
304 $this->writeLangFile($root, 'de', "tst#:#multi#:#Mehrere Werte\n");
305 $this->writeLangFile($root, 'fr', "tst#:#multi#:#Plusieurs valeurs\n");
306 $this->writeLangFile($root, 'zh', "tst#:#multi#:#Mehrere Werte ZH\n");
307 $this->writePluralsConfig(
308 $root,
309 ['de' => 'nplurals=2; plural=(n != 1);', 'fr' => 'nplurals=2; plural=(n > 1);', 'zh' => 'nplurals=1; plural=0;'],
310 ['tst' => ['multi' => []]]
311 );
312
313 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
314
315 $this->assertSame(0, $exit_code, $stdout . $stderr);
317 ->find(null, 'multi');
318 $this->assertNotNull($de_multi);
319 $this->assertSame(['Mehrere Werte', 'Mehrere Werte'], $de_multi->getPluralTranslations());
320 $this->assertFalse($de_multi->hasFlag('fuzzy'), 'de: a source language');
322 ->find(null, 'multi');
323 $this->assertSame(['Plusieurs valeurs', 'Plusieurs valeurs'], $fr_multi?->getPluralTranslations());
324 $this->assertTrue($fr_multi->hasFlag('fuzzy'), 'fr: copied into 2 forms');
325
327 ->find(null, 'multi');
328 $this->assertNotNull($zh_multi);
329 $this->assertSame(['Mehrere Werte ZH'], $zh_multi->getPluralTranslations());
330 $this->assertFalse($zh_multi->hasFlag('fuzzy'), 'zh: a single form is not "copied" anywhere');
331 }
332
335 {
336 $root = $this->buildFixtureRepo();
337 $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n");
338 file_put_contents($root . '/lang/ilias_ABC.lang', "tst#:#greeting#:#Sollte nicht gelesen werden\n");
339
340 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
341
342 $this->assertSame(0, $exit_code, $stdout . $stderr);
343 $this->assertStringContainsString('WARNING: skipping', $stderr);
344 $this->assertStringContainsString('ilias_ABC.lang', $stderr);
345 $this->assertStringContainsString('no language key in its name', $stderr);
346 $this->assertFileDoesNotExist($root . '/out/tst_ABC.po');
347 }
348
351 {
352 $root = $this->buildFixtureRepo();
353 $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n");
354 $this->writeLangFile($root, 'en', "tst#:#greeting#:#Hello\nmeta#:#meta_l_de#:#German\nmeta#:#meta_l_en#:#English\n");
355 $this->writeLangFile($root, 'fr', "tst#:#greeting#:#Bonjour\n");
356
357 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
358
359 $this->assertSame(0, $exit_code, $stdout . $stderr);
361 $this->assertSame('de', $de->getHeader('Language'));
362 $this->assertSame('German', $de->getHeader('Language-Team'));
364 $this->assertSame('English', $en->getHeader('Language-Team'));
366 $this->assertNull($fr->getHeader('Language-Team'));
367 $this->assertStringContainsString("NOTE: no meta_l_fr in lang/ilias_en.lang", $stdout);
369 $this->assertNull($pot->getHeader('Language-Team'));
370 }
371
374 {
375 $root = $this->buildFixtureRepo();
376 $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n");
377
378 [$exit_code, $stdout, $stderr] = $this->runToolAt(
379 $this->toolPathOf($root),
380 '--pattern=ilias_%s',
381 'tst',
382 'de',
383 $root . '/out'
384 );
385
386 $this->assertSame(0, $exit_code, $stdout . $stderr);
387 $this->assertFileExists($root . '/out/ilias_de.po');
388 $this->assertFileExists($root . '/out/ilias.pot');
389 $this->assertFileDoesNotExist($root . '/out/tst_de.po');
390 $this->assertFileDoesNotExist($root . '/out/tst.pot');
391 $entry = \ILIAS\Language\ComponentTranslation\Catalog\TranslationCatalog::fromPoFile($root . '/out/ilias_de.po')->find(null, 'greeting');
392 $this->assertNotNull($entry);
393 $this->assertSame('Hallo', $entry->getTranslation());
394 }
395
398 {
399 $root = $this->buildFixtureRepo();
400 $this->writeLangFile($root, 'de', "tst#:#greeting#:#Hallo\n");
401
402 [$exit_code, $stdout, $stderr] = $this->runToolAt(
403 $this->toolPathOf($root),
404 '--pattern=no-placeholder-at-all',
405 'tst',
406 'de',
407 $root . '/out'
408 );
409
410 $this->assertSame(1, $exit_code, $stdout . $stderr);
411 $this->assertStringContainsString('not a valid file name pattern', $stderr);
412 $this->assertSame([], glob($root . '/out/*') ?: [], 'nothing must be written for an invalid pattern');
413 }
414
415 private const string START = "<!-- language file start -->";
416
418 private static function contribution(string $module, string $further_arguments = ''): string
419 {
420 return '$contribute[LanguageFileDirectory::class] = fn() => '
421 . "new ComponentLanguageFileDirectory(\$this, '$module'$further_arguments);";
422 }
423
424 private static function componentSource(string $code): string
425 {
426 return "<?php\n\nclass Comp\n{\n public function init(array &\$contribute): void\n {\n $code\n }\n}\n";
427 }
428
430 private function addComponent(string $root, ?string $source, string $vendor = 'ILIAS'): string
431 {
432 $directory = $root . '/components/' . $vendor . '/Comp';
433 mkdir($directory . '/lang', 0775, true);
434 if ($source !== null) {
435 file_put_contents($directory . '/Comp.php', $source);
436 }
437
438 return $directory . '/lang';
439 }
440
441 private function addRegisteredComponent(string $root, string $module): string
442 {
443 return $this->addComponent($root, self::componentSource(self::contribution($module)));
444 }
445
447 private static function langSnapshot(string $root): array
448 {
449 clearstatcache();
450 $snapshot = [];
451 foreach (glob($root . '/lang/*') ?: [] as $file) {
452 $snapshot[basename($file)] = [(string) file_get_contents($file), fileperms($file)];
453 }
454 ksort($snapshot);
455
456 return $snapshot;
457 }
458
460 private static function idsOf(string $file): array
461 {
462 return array_map(
463 static fn($entry): string => $entry->getId(),
464 TranslationCatalog::fromPoFile($file)->getEntries()
465 );
466 }
467
469 public static function failureProvider(): array
470 {
471 return [
472 'key missing from the reference language' => [
473 ['de' => "tst#:#a#:#A\n", 'fr' => "tst#:#a#:#B\ntst#:#x#:#X\n"], [], 'fr: x',
474 ],
475 'empty key in the reference language' => [
476 ['de' => "tst#:#a#:#A\ntst#:##:#E\n"], [], 'de: "" (empty key)',
477 ],
478 'identical duplicate without the option' => [
479 ['de' => "tst#:#a#:#A\ntst#:#a#:#A\n"], [], 'de: a',
480 ],
481 'duplicate with different values, also with the option' => [
482 ['de' => "tst#:#a#:#A\ntst#:#a#:#B\n"], ['--skip-unmigratable-keys'], 'duplicate identifiers',
483 ],
484 'duplicate differing only in its ### comment, also with the option' => [
485 ['de' => "tst#:#a#:#A###x\ntst#:#a#:#A###y\n"], ['--skip-unmigratable-keys'], 'duplicate identifiers',
486 ],
487 'module without any line (migrated already)' => [
488 ['de' => "other#:#a#:#A\n", 'fr' => "other#:#a#:#B\n"], [], 'has no lines in any of the 2 lang/ilias_*.lang files',
489 ],
490 'control character in a key is shown escaped' => [
491 ['de' => "tst#:#a#:#A\n", 'fr' => "tst#:#a#:#B\ntst#:#x\033y#:#X\n"], [], 'fr: x\033y',
492 ],
493 'unknown option' => [
494 ['de' => "tst#:#a#:#A\n"], ['--remove-from-lang-typo'], "unknown option '--remove-from-lang-typo'",
495 ],
496 ];
497 }
498
505 #[DataProvider('failureProvider')]
506 public function testAFailedRunNeverTouchesALangFileNorWritesAnything(array $files, array $options, string $expected_stderr): void
507 {
508 $root = $this->buildFixtureRepo();
509 $component_lang = $this->addRegisteredComponent($root, 'tst');
510 foreach ($files as $lang_key => $content) {
511 $this->writeLangFile($root, $lang_key, $content);
512 }
513 $before = self::langSnapshot($root);
514
515 [$exit_code, $stdout, $stderr] = $this->runToolAt(
516 $this->toolPathOf($root),
517 '--remove-from-lang',
518 ...$options,
519 ...['tst', 'de', $component_lang]
520 );
521 $this->assertSame(1, $exit_code, $stdout . $stderr);
522 $this->assertStringContainsString($expected_stderr, $stderr);
523 $this->assertStringNotContainsString("\033", $stderr);
524 $this->assertSame($before, self::langSnapshot($root));
525 $this->assertSame([], glob($component_lang . '/*') ?: [], 'nothing written');
526
527 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), ...$options, ...['tst', 'de', $root . '/newout']);
528 $this->assertSame(1, $exit_code, $stdout . $stderr);
529 $this->assertDirectoryDoesNotExist($root . '/newout');
530 }
531
533 public static function invalidArgumentsProvider(): array
534 {
535 return [
536 'short option' => [['-x', 'tst', 'de']],
537 'misspelled long option' => [['--foo', 'tst', 'de']],
538 'module with a path' => [['../x', 'de']],
539 'reference language with a path' => [['tst', '../de']],
540 'a fourth argument' => [['tst', 'de', 'out']],
541 ];
542 }
543
545 #[DataProvider('invalidArgumentsProvider')]
547 {
548 $root = $this->buildFixtureRepo();
549 $this->writeLangFile($root, 'de', "tst#:#a#:#A\n");
550 $before = self::langSnapshot($root);
551
552 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), ...$arguments, ...[$root . '/newout']);
553
554 $this->assertSame(1, $exit_code, $stdout . $stderr);
555 $this->assertNotSame('', $stderr);
556 $this->assertSame($before, self::langSnapshot($root));
557 $this->assertDirectoryDoesNotExist($root . '/newout');
558 $this->assertDirectoryDoesNotExist($root . '/x');
559 }
560
562 {
563 $root = $this->buildFixtureRepo();
564 $this->writeLangFile($root, 'de', "tst#:#a#:#A\ntst#:#a#:#A\ntst#:##:#leer\ntst#:#k#1#:#V###note\n");
565 $this->writeLangFile($root, 'fr', "tst#:#a#:#FA\ntst#:#c#:#C\ntst#:#c#:#C\ntst#:#x#y#:#X\ntst#:#k#1#:#FV\n");
566 $before = self::langSnapshot($root);
567
568 [$exit_code, , $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
569 $this->assertSame(1, $exit_code);
570 $this->assertStringContainsString('--skip-unmigratable-keys', $stderr);
571 $this->assertSame([], glob($root . '/out/*') ?: []);
572
573 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--skip-unmigratable-keys', 'tst', 'de', $root . '/out');
574
575 $this->assertSame(0, $exit_code, $stdout . $stderr);
576 $this->assertStringContainsString('WARNING', $stderr);
577 $this->assertStringContainsString(" de: \"\" (empty key)\n", $stderr);
578 $this->assertStringContainsString(" fr: c, x#y\n", $stderr, 'the key with # whole');
579 $this->assertStringContainsString(" fr: c\n", $stderr, 'identical duplicate');
580 $this->assertStringContainsString(" de: a\n", $stderr);
581 $this->assertStringContainsString('round-trip identically', $stdout);
582 $this->assertSame(['a', 'k#1'], self::idsOf($root . '/out/tst_de.po'), 'only the migratable keys');
583 $this->assertSame(['a', 'k#1'], self::idsOf($root . '/out/tst_fr.po'));
584 $this->assertSame('note', TranslationCatalog::fromPoFile($root . '/out/tst_de.po')->find(null, 'k#1')?->getExtractedComments()[0] ?? null);
585 $this->assertSame($before, self::langSnapshot($root), 'without --remove-from-lang the .lang files stay');
586 }
587
590 {
591 $root = $this->buildFixtureRepo();
592 $this->writeLangFile($root, 'de', "tst#:#a#:#A\n");
593 $this->writeLangFile($root, 'fr', "tst#:#a#:#B\ntst#:#x#:#1\ntst#:#x#:#2\n");
594
595 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
596 $this->assertSame(1, $exit_code, $stdout . $stderr);
597
598 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--skip-unmigratable-keys', 'tst', 'de', $root . '/out');
599 $this->assertSame(0, $exit_code, $stdout . $stderr);
600 $this->assertStringContainsString('WARNING', $stderr);
601 $this->assertStringContainsString(" fr: x\n", $stderr);
602 $this->assertSame(['a'], self::idsOf($root . '/out/tst_fr.po'));
603 }
604
606 {
607 $root = $this->buildFixtureRepo();
608 foreach (['de' => ['Eintraege', 'Ein Eintrag'], 'es' => ['Entradas', 'Una entrada'], 'ar' => ['AR viele', 'AR eins']] as $lang_key => [$plural, $singular]) {
609 $this->writeLangFile($root, $lang_key, "tst#:#item#:#$plural\ntst#:#item_singular#:#$singular\n");
610 }
611 $this->writePluralsConfig(
612 $root,
613 [
614 'de' => 'nplurals=2; plural=(n != 1);',
615 'es' => 'nplurals=3; plural=(n == 1) ? 0 : ((n != 0 && n % 1000000 == 0) ? 1 : 2);',
616 'ar' => 'nplurals=6; plural=(n == 0) ? 0 : ((n == 1) ? 1 : ((n == 2) ? 2 : ((n % 100 >= 3 && n % 100 <= 10) ? 3 : ((n % 100 >= 11 && n % 100 <= 99) ? 4 : 5))));',
617 ],
618 ['tst' => ['item' => ['singular' => 'item_singular']]]
619 );
620
621 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
622
623 $this->assertSame(0, $exit_code, $stdout . $stderr);
624 $forms = static fn(string $lang_key): ?array => TranslationCatalog::fromPoFile($root . "/out/tst_$lang_key.po")->find(null, 'item')?->getPluralTranslations();
625 $this->assertSame(['Ein Eintrag', 'Eintraege'], $forms('de'));
626 $this->assertSame(['Una entrada', 'Entradas', 'Entradas'], $forms('es'));
627 $this->assertSame(['AR viele', 'AR eins', 'AR viele', 'AR viele', 'AR viele', 'AR viele'], $forms('ar'));
628 }
629
631 {
632 $root = $this->buildFixtureRepo();
633 $this->writeLangFile($root, 'de', "tst#:#item#:#Eintraege\ntst#:#item_singular#:#Ein Eintrag\n");
634 $this->writeLangFile($root, 'fr', "tst#:#item#:#Photo\ntst#:#item_singular#:#Photo\n");
635 $this->writePluralsConfig(
636 $root,
637 ['de' => 'nplurals=2; plural=(n != 1);', 'fr' => 'nplurals=2; plural=(n != 1);'],
638 ['tst' => ['item' => ['singular' => 'item_singular']]]
639 );
640
641 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
642
643 $this->assertSame(0, $exit_code, $stdout . $stderr);
644 $item = TranslationCatalog::fromPoFile($root . '/out/tst_fr.po')->find(null, 'item');
645 $this->assertSame(['Photo', 'Photo'], $item?->getPluralTranslations());
646 $this->assertFalse($item->hasFlag('fuzzy'), 'both forms are translated, they just are equal');
647 }
648
650 {
651 $root = $this->buildFixtureRepo();
652 $this->writeLangFile($root, 'de', "tst#:#a#:#A\ntst#:#item [0]#:#Form\n");
653
654 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
655 $this->assertSame(1, $exit_code, $stdout . $stderr);
656 $this->assertStringContainsString('FAILURE', $stderr);
657 $this->assertStringContainsString('item [0]', $stderr);
658 $this->assertSame([], glob($root . '/out/*') ?: [], 'nothing was written');
659
660 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--skip-unmigratable-keys', 'tst', 'de', $root . '/out');
661 $this->assertSame(0, $exit_code, $stdout . $stderr);
662 $this->assertStringContainsString('item [0]', $stderr);
663 $this->assertSame(['a'], self::idsOf($root . '/out/tst_de.po'));
664 }
665
668 {
669 $root = $this->buildFixtureRepo();
670 $out = $this->addRegisteredComponent($root, 'crs');
671 $start = self::START;
672 $this->writeLangFile($root, 'de', "# header\n$start\ncrs#:#a#:#A\ncrsr#:#a#:#Other\ncrs#:#b#:#B###c\nxcrs#:#a#:#X\nmeta#:#crs#:#crs#:#M\n");
673 $this->writeLangFile($root, 'fr', "# header\r\n$start\r\ncrs#:#a#:#FA\r\ncrs#:#b#:#FB\r\ncrsr#:#a#:#R\r\n");
674 $this->writeLangFile($root, 'en', "$start\ncrsr#:#a#:#R\ncrs#:#a#:#EA\ncrs#:#b#:#EB");
675 $this->writeLangFile($root, 'es', "crs#:#a#:#SA\ncrs#:#b#:#SB\nkeep#:#k#:#K\n");
676 $this->writeLangFile($root, 'it', "$start\nmeta#:#x#:#y\n");
677 $this->writeLangFile($root, 'ABC', "$start\ncrs#:#a#:#ignored\n");
678 chmod($root . '/lang/ilias_de.lang', 0640);
679 $before = self::langSnapshot($root);
680
681 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out);
682
683 $this->assertSame(0, $exit_code, $stdout . $stderr);
684 $after = self::langSnapshot($root);
685 $this->assertSame("# header\n$start\ncrsr#:#a#:#Other\nxcrs#:#a#:#X\nmeta#:#crs#:#crs#:#M\n", $after['ilias_de.lang'][0]);
686 $this->assertSame("# header\r\n$start\r\ncrsr#:#a#:#R\r\n", $after['ilias_fr.lang'][0]);
687 $this->assertSame("$start\ncrsr#:#a#:#R", $after['ilias_en.lang'][0], 'no final line break stays none');
688 $this->assertSame("$start\nkeep#:#k#:#K\n", $after['ilias_es.lang'][0]);
689 $this->assertSame($before['ilias_it.lang'], $after['ilias_it.lang']);
690 $this->assertSame($before['ilias_ABC.lang'], $after['ilias_ABC.lang']);
691 $this->assertSame(0640, $after['ilias_de.lang'][1] & 07777);
692 $this->assertSame(array_keys($before), array_keys($after), 'no temporary file left behind');
693 $this->assertStringContainsString(sprintf("removed %5d line(s) of 'crs' from lang/ilias_de.lang", 2), $stdout);
694 $this->assertStringContainsString(sprintf("removed %5d line(s) of 'crs' from lang/ilias_en.lang", 2), $stdout);
695 $this->assertStringNotContainsString('ilias_it.lang', $stdout);
696 $this->assertFileExists($out . '/crs_de.po');
697 $this->assertFileExists($out . '/crs.pot');
698 }
699
702 {
703 $root = $this->buildFixtureRepo();
704 $out = $this->addRegisteredComponent($root, 'crs');
705 $start = self::START;
706 $this->writeLangFile($root, 'de', "$start\ncrs#:#a#:#A\ncrs#:#a#:#A\ncrs#:##:#leer\ncrs#:#b#:#B\nkeep#:#k#:#K\n");
707 $this->writeLangFile($root, 'fr', "$start\ncrs#:#a#:#FA\ncrs#:#extra#:#E\nkeep#:#k#:#K2\n");
708
709 [$exit_code, $stdout, $stderr] = $this->runToolAt(
710 $this->toolPathOf($root),
711 '--skip-unmigratable-keys',
712 '--remove-from-lang',
713 'crs',
714 'de',
715 $out
716 );
717
718 $this->assertSame(0, $exit_code, $stdout . $stderr);
719 $after = self::langSnapshot($root);
720 $this->assertSame("$start\nkeep#:#k#:#K\n", $after['ilias_de.lang'][0]);
721 $this->assertSame("$start\nkeep#:#k#:#K2\n", $after['ilias_fr.lang'][0]);
722 $this->assertSame(['a', 'b'], self::idsOf($out . '/crs_fr.po'));
723 }
724
727 {
728 $root = $this->buildFixtureRepo();
729 $out = $this->addRegisteredComponent($root, 'crs');
730 $start = self::START;
731 // the header line would fail twice if it was read: a key the reference language lacks, a Latin-1 byte
732 $header = "crs#:#x#:#Kopfzeile \xFC\n";
733 $this->writeLangFile($root, 'de', "$start\ncrs#:#a#:#A\ncrs#:#b#:#B\n");
734 $this->writeLangFile($root, 'fr', "$header$start\ncrs#:#b#:#FB\n");
735
736 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out);
737
738 $this->assertSame(0, $exit_code, $stdout . $stderr);
739 $after = self::langSnapshot($root);
740 $this->assertSame("$header$start\n", $after['ilias_fr.lang'][0]);
741 $this->assertSame("$start\n", $after['ilias_de.lang'][0]);
742 $ids = self::idsOf($out . '/crs_fr.po');
743 sort($ids);
744 $this->assertSame(['a', 'b'], $ids, '"x" of the header line is no key');
745 $this->assertSame('FB', TranslationCatalog::fromPoFile($out . '/crs_fr.po')->find(null, 'b')?->getTranslation());
746 }
747
750 {
751 $root = $this->buildFixtureRepo();
752 $out = $this->addRegisteredComponent($root, 'crs');
753 $this->writeLangFile($root, 'de', self::START . "\ncrs#:#a#:#A\n");
754 file_put_contents($root . '/lang/ilias_fr.lang', "crs#:#a#:#FA\ncrs#:#x#:#X\n");
755 $before = self::langSnapshot($root);
756
757 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out);
758
759 $this->assertSame(1, $exit_code, $stdout . $stderr);
760 $this->assertStringContainsString('ilias_fr.lang', $stderr);
761 $this->assertStringContainsString('nothing was written', $stderr);
762 $this->assertSame($before, self::langSnapshot($root));
763 $this->assertSame([], glob($out . '/*') ?: []);
764
765 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'crs', 'de', $root . '/out');
766
767 $this->assertSame(0, $exit_code, $stdout . $stderr);
768 $this->assertStringContainsString('WARNING: skipping ' . $root . '/lang/ilias_fr.lang', $stderr);
769 $this->assertFileDoesNotExist($root . '/out/crs_fr.po', '"x" would have failed the run if the file was read');
770 $this->assertSame($before, self::langSnapshot($root));
771 }
772
775 {
776 $root = $this->buildFixtureRepo();
777 $out = $this->addRegisteredComponent($root, 'crs');
778 $start = self::START;
779 $this->writeLangFile($root, 'de', "$start\ncrs#:#a#:#Gr\xC3\xBC\xC3\x9Fe\n");
780 $this->writeLangFile($root, 'fr', "$start\ncrs#:#a#:#Gr\xFC\xDFe\n");
781 file_put_contents($out . '/crs_de.po', "existing de\n");
782 file_put_contents($out . '/crs_fr.po', "existing fr\n");
783 file_put_contents($out . '/crs.pot', "existing pot\n");
784 $lang_before = self::langSnapshot($root);
785 $out_before = self::files($out);
786
787 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out);
788
789 $this->assertSame(1, $exit_code, $stdout . $stderr);
790 $this->assertStringContainsString('not valid UTF-8', $stderr);
791 $this->assertStringContainsString('fr: a', $stderr);
792 $this->assertSame($lang_before, self::langSnapshot($root));
793 $this->assertSame($out_before, self::files($out));
794 }
795
798 {
799 $root = $this->buildFixtureRepo();
800 $this->writeLangFile($root, 'de', "crs#:#a#:#A\n");
801 file_put_contents($root . '/out/crs_de.po', "msgid \"kaputt\n");
802
803 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'crs', 'de', $root . '/out');
804
805 $this->assertSame(0, $exit_code, $stdout . $stderr);
806 $this->assertStringContainsString('WARNING: the existing ' . $root . '/out/crs_de.po is not readable', $stderr);
807 $this->assertSame(['a'], self::idsOf($root . '/out/crs_de.po'));
808 }
809
812 {
813 $root = $this->buildFixtureRepo();
814 $this->writeLangFile($root, 'de', "crs#:#a#:#A\n");
815
816 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'crs', 'de', $root . '/typo/lang');
817
818 $this->assertSame(1, $exit_code, $stdout . $stderr);
819 $this->assertStringContainsString("outputDir '$root/typo/lang' does not exist. Nothing was written.", $stderr);
820 $this->assertDirectoryDoesNotExist($root . '/typo');
821 }
822
825 {
826 $root = $this->buildFixtureRepo();
827 $this->writeLangFile(
828 $root,
829 'en',
830 "tst#:#role#:#ILIAS role name\ntst#:#bad#:#Bad <b onclick=\"x\">en</b>\ntst#:#open#:#Open\ntst#:#none#:#\n"
831 );
832 $this->writeLangFile(
833 $root,
834 'it',
835 "tst#:#role#:#<nome del Ruolo ILIAS\ntst#:#bad#:#Male\ntst#:#open#:#Apri <a href=\"x\">qui\ntst#:#none#:#<niente\n"
836 );
837
838 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out');
839
840 $this->assertSame(0, $exit_code, $stdout . $stderr);
841 $this->assertStringContainsString("empty once cleaned, replaced by the reference value (fuzzy):\n it: role (", $stderr);
842 $this->assertStringContainsString("empty once cleaned, left empty like a missing value:\n it: none (", $stderr);
843 $this->assertMatchesRegularExpression('/kept, the build cleans it:\n( .*\n)* en: bad \‍(/', $stderr);
844 $this->assertMatchesRegularExpression('/kept, the build cleans it:\n( .*\n)* it: open \‍(/', $stderr);
846 $this->assertSame('ILIAS role name', $it->find(null, 'role')?->getTranslation());
847 $this->assertTrue($it->find(null, 'role')->hasFlag('fuzzy'));
848 $this->assertSame('Apri <a href="x">qui', $it->find(null, 'open')?->getTranslation(), 'kept, the build cleans it');
849 $this->assertFalse($it->find(null, 'open')->hasFlag('fuzzy'));
850 $this->assertSame('', $it->find(null, 'none')?->getTranslation());
851 $this->assertFalse($it->find(null, 'none')->hasFlag('fuzzy'));
852 $this->assertSame('Male', $it->find(null, 'bad')?->getTranslation());
853 $this->assertFalse($it->find(null, 'bad')->hasFlag('fuzzy'));
854 $this->assertMatchesRegularExpression('/^it\s+4\s+0\s+1\s+yes$/m', $stdout);
856 $this->assertSame('Bad <b onclick="x">en</b>', $en->find(null, 'bad')?->getTranslation(), 'the reference value is kept');
857 $this->assertFalse($en->find(null, 'bad')->hasFlag('fuzzy'));
858 }
859
862 {
863 $root = $this->buildFixtureRepo();
864 $this->writeLangFile($root, 'en', "tst#:#item#:#Items\ntst#:#item_singular#:#One <b>item\n");
865 $this->writeLangFile($root, 'de', "tst#:#item#:#Eintraege\ntst#:#item_singular#:#Ein <b>Eintrag\n");
866 $this->writePluralsConfig(
867 $root,
868 ['en' => 'nplurals=2; plural=(n != 1);', 'de' => 'nplurals=2; plural=(n != 1);'],
869 ['tst' => ['item' => ['singular' => 'item_singular']]]
870 );
871
872 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out');
873
874 $this->assertSame(0, $exit_code, $stdout . $stderr);
875 foreach (['en' => ['One <b>item', 'Items'], 'de' => ['Ein <b>Eintrag', 'Eintraege']] as $lang_key => $forms) {
877 ->find(null, 'item');
878 $this->assertSame($forms, $item?->getPluralTranslations(), $lang_key);
879 }
880 }
881
884 {
885 $root = $this->buildFixtureRepo();
886 $this->writeLangFile($root, 'en', "tst#:#a#:#A\ntst#:#b#:#B\n");
887 $this->writeLangFile($root, 'sv', "tst#:#a#:#Ä####24 10 2013 new variable\ntst#:#b#:#Bb##24 10 2013 new variable\n");
888
889 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out');
890
891 $this->assertSame(0, $exit_code, $stdout . $stderr);
893 $this->assertTrue($sv->find(null, 'a')?->hasFlag('fuzzy'));
894 $this->assertSame([], $sv->find(null, 'a')->getTranslatorComments());
895 $this->assertFalse($sv->find(null, 'b')?->hasFlag('fuzzy'));
896 $this->assertSame('Bb##24 10 2013 new variable', $sv->find(null, 'b')->getTranslation());
897 }
898
901 {
902 $root = $this->buildFixtureRepo();
903 $this->writeLangFile($root, 'en', "tst#:#a#:#A###en note\ntst#:#b#:#B###same note\n");
904 $this->writeLangFile($root, 'sv', "tst#:#a#:#Ä###egen anteckning\ntst#:#b#:#Bb###same note\n");
905
906 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'en', $root . '/out');
907
908 $this->assertSame(0, $exit_code, $stdout . $stderr);
910 $this->assertSame(['en note'], $pot->find(null, 'a')?->getExtractedComments());
912 $this->assertSame([], $en->find(null, 'a')?->getTranslatorComments());
913 $this->assertSame(['en note'], $en->find(null, 'a')->getExtractedComments());
915 $this->assertSame(['egen anteckning'], $sv->find(null, 'a')?->getTranslatorComments());
916 $this->assertSame(['en note'], $sv->find(null, 'a')->getExtractedComments());
917 $this->assertSame([], $sv->find(null, 'b')?->getTranslatorComments(), 'a note equal to the reference note is not repeated');
918 }
919
921 public static function runsWithoutAModule(): array
922 {
923 return [
924 'no argument' => [[]],
925 'only options' => [['--skip-unmigratable-keys']],
926 ];
927 }
928
930 #[DataProvider('runsWithoutAModule')]
932 {
933 $root = $this->buildFixtureRepo();
934 $this->writeLangFile($root, 'en', self::START . "\ntos#:#a#:#A\n");
935 $before = self::langSnapshot($root);
936
937 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), ...$arguments);
938
939 $this->assertSame(1, $exit_code, $stdout . $stderr);
940 $this->assertStringContainsString('the module is required', $stderr);
941 $this->assertSame($before, self::langSnapshot($root));
942 $this->assertSame([], glob($root . '/out/*') ?: []);
943 }
944
947 {
948 $root = $this->buildFixtureRepo();
949 $out = $this->addRegisteredComponent($root, 'crs');
950 $this->writeLangFile($root, 'de', self::START . "\ncrs#:#a#:#A\n");
951 $this->writeLangFile($root, 'fr', self::START . "\ncrs#:#a#:#FA\n");
952 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out);
953 $this->assertSame(0, $exit_code, $stdout . $stderr);
954 $po_files = self::files($out);
955 $this->assertCount(3, $po_files);
957
958 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'crs', 'de', $out);
959
960 $this->assertSame(1, $exit_code, $stdout . $stderr);
961 $this->assertStringContainsString('has no lines in any of the 2 lang/ilias_*.lang files', $stderr);
962 $this->assertStringContainsString('most likely it is migrated already', $stderr);
963 $this->assertSame($po_files, self::files($out));
964 $this->assertSame($lang_files, self::langSnapshot($root));
965 }
966
971 public static function refusedTargetProvider(): array
972 {
973 $registered = self::componentSource(self::contribution('tst'));
974
975 return [
976 'no outputDir' => [null, $registered, [], 'the outputDir argument is required'],
977 'outputDir does not exist' => ['{root}/components/ILIAS/Comp/missing', $registered, [], 'does not exist'],
978 'outputDir outside of components' => ['{root}/out', $registered, [], 'is not components/<Vendor>/<Component>/lang'],
979 'outputDir is the component, not its lang/' => ['{root}/components/ILIAS/Comp', $registered, [], 'is not components/<Vendor>/<Component>/lang'],
980 'component class missing' => ['{root}/components/ILIAS/Comp/lang', null, [], 'Comp.php was not found'],
981 'contribution only in a comment' => [
982 '{root}/components/ILIAS/Comp/lang',
983 self::componentSource("// " . self::contribution('tst') . "\n /* " . self::contribution('tst') . ' */'),
984 [],
985 "contributes no ComponentLanguageFileDirectory for module 'tst'",
986 ],
987 'contribution for another module' => [
988 '{root}/components/ILIAS/Comp/lang',
989 self::componentSource(self::contribution('tstx')),
990 [],
991 "contributes no ComponentLanguageFileDirectory for module 'tst'",
992 ],
993 'contributed path is not lang/' => [
994 '{root}/components/ILIAS/Comp/lang',
995 self::componentSource(self::contribution('tst', ", 'translations/'")),
996 [],
997 "from 'translations/', not from lang/",
998 ],
999 'contributed pattern differs from the contribution default' => [
1000 '{root}/components/ILIAS/Comp/lang',
1001 self::componentSource(self::contribution('tst', ", 'lang/', 'x_%s'")),
1002 [],
1003 "pattern 'x_%s', but the run uses 'tst_%s'",
1004 ],
1005 '--pattern differs from the contribution' => [
1006 '{root}/components/ILIAS/Comp/lang',
1007 $registered,
1008 ['--pattern=ilias_%s'],
1009 "but the run uses 'ilias_%s'",
1010 ],
1011 ];
1012 }
1013
1015 #[DataProvider('refusedTargetProvider')]
1017 ?string $output,
1018 ?string $component_source,
1019 array $options,
1020 string $expected_stderr
1021 ): void {
1022 $root = $this->buildFixtureRepo();
1023 $component_lang = $this->addComponent($root, $component_source);
1024 $this->writeLangFile($root, 'de', self::START . "\ntst#:#a#:#A\n");
1025 $this->writeLangFile($root, 'fr', self::START . "\ntst#:#a#:#FA\n");
1026 $before = self::langSnapshot($root);
1027 $arguments = ['tst', 'de', ...($output === null ? [] : [str_replace('{root}', $root, $output)])];
1028
1029 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', ...$options, ...$arguments);
1030
1031 $this->assertSame(1, $exit_code, $stdout . $stderr);
1032 $this->assertStringContainsString($expected_stderr, $stderr);
1033 $this->assertStringContainsString('Nothing was written.', $stderr);
1034 $this->assertSame($before, self::langSnapshot($root));
1035 $this->assertSame([], glob($component_lang . '/*') ?: []);
1036 $this->assertSame([], glob($root . '/out/*') ?: []);
1037 }
1038
1040 public static function acceptedTargetProvider(): array
1041 {
1042 return [
1043 'one line, defaults' => [self::componentSource(self::contribution('tst')), [], 'tst_de.po'],
1044 'multi-line with fully qualified names and a trailing comma' => [
1045 self::componentSource(
1046 "\$contribute[\\ILIAS\\Language\\ComponentTranslation\\LanguageFileDirectory::class] = fn(): \\X\\LanguageFileDirectory => \n"
1047 . " new \\ILIAS\\Language\\ComponentTranslation\\ComponentLanguageFileDirectory(\n"
1048 . " \$this,\n 'tst',\n 'lang/',\n 'x_%s',\n );"
1049 ),
1050 ['--pattern=x_%s'],
1051 'x_de.po',
1052 ],
1053 'a comment with another module does not matter' => [
1054 self::componentSource('// ' . self::contribution('tstx') . "\n " . self::contribution('tst', ", 'lang'")),
1055 [],
1056 'tst_de.po',
1057 ],
1058 ];
1059 }
1060
1062 #[DataProvider('acceptedTargetProvider')]
1063 public function testRemoveFromLangAcceptsAContributionInEveryNotationOfTheComponentClass(string $component_source, array $options, string $written): void
1064 {
1065 $root = $this->buildFixtureRepo();
1066 $component_lang = $this->addComponent($root, $component_source);
1067 $this->writeLangFile($root, 'de', self::START . "\ntst#:#a#:#A\n");
1068
1069 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', ...$options, ...['tst', 'de', $component_lang]);
1070
1071 $this->assertSame(0, $exit_code, $stdout . $stderr);
1072 $this->assertFileExists($component_lang . '/' . $written);
1073 $this->assertSame(self::START . "\n", self::langSnapshot($root)['ilias_de.lang'][0]);
1074 }
1075
1077 {
1078 $root = $this->buildFixtureRepo();
1079 $component_lang = $this->addRegisteredComponent($root, 'tst');
1080 symlink($component_lang, $root . '/link');
1081 $this->writeLangFile($root, 'de', self::START . "\ntst#:#a#:#A\n");
1082
1083 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'tst', 'de', $root . '/link');
1084
1085 $this->assertSame(0, $exit_code, $stdout . $stderr);
1086 $this->assertFileExists($component_lang . '/tst_de.po');
1087 }
1088
1091 {
1092 $root = $this->buildFixtureRepo();
1093 $component_lang = $this->addRegisteredComponent($root, 'tst');
1094 $this->writeLangFile($root, 'de', self::START . "\ntst#:#a#:#A\n");
1095 symlink($root . '/lang/ilias_de.lang', $root . '/lang/ilias_zz.lang');
1096 $before = self::langSnapshot($root);
1097
1098 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), '--remove-from-lang', 'tst', 'de', $component_lang);
1099
1100 $this->assertSame(1, $exit_code, $stdout . $stderr);
1101 $this->assertStringContainsString('ilias_zz.lang', $stderr);
1102 $this->assertSame($before, self::langSnapshot($root));
1103 $this->assertTrue(is_link($root . '/lang/ilias_zz.lang'));
1104 $this->assertSame([], glob($component_lang . '/*') ?: []);
1105
1106 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
1107
1108 $this->assertSame(0, $exit_code, $stdout . $stderr);
1109 $this->assertFileExists($root . '/out/tst_zz.po');
1110 $this->assertSame($before, self::langSnapshot($root));
1111 }
1112
1113 private function writeMetaPo(string $root, string $content): void
1114 {
1115 mkdir($root . '/components/ILIAS/Meta/lang', 0775, true);
1116 file_put_contents($root . '/components/ILIAS/Meta/lang/meta_en.po', $content);
1117 }
1118
1120 {
1121 $root = $this->buildFixtureRepo();
1122 $this->writeLangFile($root, 'de', "tst#:#a#:#A\n");
1123 $this->writeLangFile($root, 'en', "tst#:#a#:#A\n");
1124 $this->writeMetaPo($root, "msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\nmsgid \"meta_l_de\"\nmsgstr \"German\"\n");
1125
1126 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
1127
1128 $this->assertSame(0, $exit_code, $stdout . $stderr);
1129 $this->assertSame('German', TranslationCatalog::fromPoFile($root . '/out/tst_de.po')->getHeader('Language-Team'));
1130 $this->assertNull(TranslationCatalog::fromPoFile($root . '/out/tst_en.po')->getHeader('Language-Team'));
1131
1132 file_put_contents($root . '/components/ILIAS/Meta/lang/meta_en.po', "msgid \"meta_l_de\"\nmsgstr \"unterminated\n\"x\n garbage");
1133 MigratedPoFixture::removeDirectory($root . '/out');
1134 mkdir($root . '/out');
1135
1136 [$exit_code, $stdout, $stderr] = $this->runToolAt($this->toolPathOf($root), 'tst', 'de', $root . '/out');
1137
1138 $this->assertSame(0, $exit_code, $stdout . $stderr);
1139 $this->assertStringContainsString('meta_en.po cannot be read for the language names', $stderr);
1140 $this->assertFileExists($root . '/out/tst_de.po');
1141 }
1142}
$out
Definition: buildRTE.php:24
static run(array $arguments, ?string $working_directory=null)
Definition: ChildPhp.php:60
Re-running tools/po-migration/convert_module_to_po.php for "tos" over the shipped files reproduces th...
testAMissingOutputDirFailsAndIsNotCreated()
A mistyped outputDir must not become a new directory tree.
testFuzzyMarkerBehindFourHashesIsFuzzyAndTwoHashesStayInTheValue()
"####" + date + marker is the fuzzy marker; "##" + date + marker stays in the value.
writeMetaPo(string $root, string $content)
testANoteThatMerelyMentionsNewVariableStaysANoteAndIsNotMarkedFuzzy()
A note merely mentioning "new variable" (no leading date) stays an extracted comment,...
testLanguageTeamHeaderNamesTheLanguageInEnglishFromTheMetaModule()
"Language-Team" is the English name from meta_l_<lang> of ilias_en.lang; without it the header is lef...
addComponent(string $root, ?string $source, string $vendor='ILIAS')
Creates components/<vendor>/Comp/lang (and Comp.php unless $source is null), the outputDir –remove-fr...
testWithoutTheOptionTheHintNamesItAndWithItEverythingUnmigratableIsReportedAndLeftOut()
testALineAboveTheStartMarkerIsNeitherConvertedNorRemoved()
Like the installer, the tool reads only behind the start marker: a line above it is neither converted...
testAFileWithoutALanguageKeyInItsNameIsSkippedWithAWarning()
A lang/ilias_*.lang whose name yields no lowercase language key is skipped with a warning.
buildFixtureRepo()
Throwaway root with a copy of the tool at the same relative depth and a symlinked Composer autoloader...
testAnInvalidPatternFailsWithExitOneAndWritesNothing()
An invalid –pattern fails with exit code 1 before the .lang files are read and writes nothing.
testMarkupOfTheSingularKeyInAPluralFormPassesTheSelfCheck()
A plural form taken from the singular key (plurals.json) may carry its warned markup.
testRemoveFromLangAlsoRemovesTheLeftOutLines()
Left out lines (empty key, other language, identical duplicates) leave the .lang files too.
testALatinOneLangFileFailsTheRunAndKeepsPoAndLangFilesByteIdentical()
A .lang file with bytes that are no UTF-8 fails the run before anything is written: no ....
testAPluralMessageIsMarkedFuzzyWhenItsSingularKeyIsButOnlyWhereTheSingularValueIsActuallyUsed()
A singular key carrying the dated placeholder is not used: every form is the message's own value.
runToolAt(string $tool_path, string ... $arguments)
The tool derives $repo_root from its own __DIR__ (five levels up) with no override,...
testAPluralMessageWithoutASingularKeyIsFuzzyOnlyInATranslationWithSeveralForms()
Without a singular key the own value is copied into every form: fuzzy only for multi-form non-source ...
testInvalidArgumentsFailWithExitOneAndWriteNothing(array $arguments)
testRemoveFromLangRefusesASymbolicLinkInLangButTheOptionlessRunStillReadsIt()
A symlinked .lang file would fail only when written, after other files changed: –remove-from-lang ref...
testAnOwnNoteIsATranslatorCommentAndTheReferenceNoteAnExtractedOne()
The reference note is "#." like in the .pot, a differing own note a translator comment.
testAMissingPluralMessageGetsTheReferenceValuesFlaggedFuzzy()
A missing plural message (and its singular key) gets the reference values, flagged fuzzy.
writeLangFile(string $fixture_root, string $lang_key, string $content)
The marker is put in front if $content has none: like the installer, the tool reads only such files.
testRemoveFromLangRefusesATargetThatTheInstallationWouldNotReadAndWritesNothing(?string $output, ?string $component_source, array $options, string $expected_stderr)
testRemoveFromLangAcceptsAContributionInEveryNotationOfTheComponentClass(string $component_source, array $options, string $written)
testAFileWithoutTheStartMarkerIsSkippedAndRefusedByRemoveFromLang()
Like the installer, a file without the start marker is not read; –remove-from-lang refuses to run the...
testFailsWithExitOneAndWritesNothingWhenALanguageHasAKeyMissingFromTheReferenceLanguage()
Every language's keys must be a subset of the reference language's, else the run fails before writing...
writePluralsConfig(string $fixture_root, array $plural_forms, array $modules)
Without plurals.json the tool writes no "Plural-Forms" header and no plural message.
testRemoveFromLangRemovesExactlyTheModulesLinesAndKeepsEveryOtherByte()
Byte-exact removal of the module's own lines only; line endings, mode and the header above the start ...
testARunWithoutAModuleFailsAndWritesNothing(array $arguments)
There is no default module any more: a run without one fails and writes nothing.
testTheDatedNewVariablePlaceholderFormatIsMarkedFuzzyNotKeptAsANote()
Counterpart of the test above: the dated placeholder format does become the fuzzy flag.
testAFailedRunNeverTouchesALangFileNorWritesAnything(array $files, array $options, string $expected_stderr)
A run failing before the .po files are written touches no .lang file, and creates no output directory...
testWarnsOnAnExtraHashColonHashInAValueAndUsesOnlyThePartBeforeItLikeTheInstaller()
A value with several "#:#" is parsed like LanguageInstallationManager::insertLanguage(): first part o...
static contribution(string $module, string $further_arguments='')
The contribution –remove-from-lang looks for in a component class.
testCustomPatternNamesThePoFilesAndThePotTemplate()
A custom –pattern names the .po files and the .pot template (pattern without "%s",...
testAnUnreadableExistingPoIsWarnedAboutAndReplaced()
An existing target .po that cannot be read only costs its kept headers: a WARNING,...
testAValueWithMarkupIsKeptUnlessTheBuildWouldCleanItToNothing()
Markup the build cleans is kept (WARNING); only a value cleaned to nothing counts as missing.
testAMissingOrEmptyValueGetsTheReferenceValueFlaggedFuzzy()
A reference key a language lacks or has empty gets the reference value, flagged fuzzy; an own note of...
addRegisteredComponent(string $root, string $module)
testASecondRunForAMigratedModuleFailsAndKeepsThePoFilesByteIdentical()
A second run must not overwrite the now only-source .po/.pot with empty ones.
static files(string $directory)
testADuplicateWithDifferentValuesOfAKeyThatIsLeftOutAnywayOnlyWarns()
A key left out anyway does not fail the run for a conflicting duplicate: warning with the option,...
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
static removeDirectory(string $directory)
if(!isset($arguments[0])) if(count( $arguments) > 3) if(! $remove_from_lang &&isset($arguments[2]) &&!is_dir($arguments[2])) $module
if(! $all_ok) if(!is_dir($output_dir) &&! @mkdir($output_dir, 0775, true) &&!is_dir($output_dir)) $written