ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
TranslationCatalogPoTest.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
22
25use InvalidArgumentException;
26use PHPUnit\Framework\Attributes\DataProvider;
27use PHPUnit\Framework\TestCase;
28use RuntimeException;
29
37class TranslationCatalogPoTest extends TestCase
38{
39 private const string TOS_LANG_DIRECTORY = __DIR__ . '/../../../../TermsOfService/lang';
40
41 private ?string $directory = null;
42
43 protected function tearDown(): void
44 {
45 if ($this->directory !== null) {
47 }
48 }
49
50 private function temporaryDirectory(): string
51 {
52 if ($this->directory === null) {
53 $this->directory = sys_get_temp_dir() . '/ilias_translation_catalog_' . bin2hex(random_bytes(4));
54 mkdir($this->directory, 0775);
55 }
56
57 return $this->directory;
58 }
59
60 private static function roundTrip(TranslationCatalog $catalog): TranslationCatalog
61 {
63 }
64
66 {
67 $catalog = new TranslationCatalog();
68 $catalog->add($entry);
69
70 return $catalog;
71 }
72
73 private static function entry(?string $context, string $id, string $translation): TranslationEntry
74 {
75 $entry = new TranslationEntry($context, $id);
76 $entry->translate($translation);
77
78 return $entry;
79 }
80
81 #[DataProvider('values')]
82 public function testValuesRoundTripExactly(string $value): void
83 {
84 $read = self::roundTrip(self::singleEntryCatalog(self::entry('mod', 'id', $value)))->find('mod', 'id');
85
86 $this->assertNotNull($read);
87 $this->assertSame($value, $read->getTranslation());
88 }
89
90 public static function values(): array
91 {
92 return [
93 'empty' => [''],
94 'zero' => ['0'],
95 'zero with a line break' => ["0\n"],
96 'double zero' => ['00'],
97 'plain' => ['Hallo'],
98 'double quote' => ['Klick "hier"'],
99 'backslash' => ['C:\\temp\\'],
100 'backslash before quote' => ['a\\"b'],
101 'only a backslash' => ['\\'],
102 'tab and carriage return' => ["a\tb\rc"],
103 'multiline' => ["Zeile 1\nZeile 2\nZeile 3"],
104 'trailing newline' => ["Zeile\n"],
105 'only newlines' => ["\n\n"],
106 'leading and trailing spaces' => [' Hallo '],
107 'unicode' => ['Ünïcödé – 日本語 🙂'],
108 'placeholder and html' => ['<a href="%1$s">%s</a>'],
109 'literal escape sequence text' => ['\\n is not a newline'],
110 'hash at start' => ['# not a comment'],
111 'msgid-like text' => ["msgid \"x\"\nmsgstr \"y\""],
112 ];
113 }
114
120 {
121 $po = self::singleEntryCatalog(self::entry('mod', 'zero', '0'))->toPoString();
122
123 $this->assertStringContainsString("msgctxt \"mod\"\nmsgid \"zero\"\nmsgstr \"0\"\n", $po);
124 $this->assertStringNotContainsString("\0", $po);
125 }
126
132 {
133 $entry = self::entry('mod', 'zero', '0');
134 $catalog = self::singleEntryCatalog($entry);
135
136 $first_po = $catalog->toPoString();
137 $first_mo = $catalog->toMoString();
138
139 $this->assertSame('0', $entry->getTranslation());
140 $this->assertSame('0', $catalog->find('mod', 'zero')?->getTranslation());
141 $this->assertSame($first_po, $catalog->toPoString());
142 $this->assertSame($first_mo, $catalog->toMoString());
143 }
144
146 {
147 $catalog = self::singleEntryCatalog(self::entry("ctx \"q\"", "id\\with\nnewline", 'v'));
148
149 $this->assertSame('v', self::roundTrip($catalog)->find("ctx \"q\"", "id\\with\nnewline")?->getTranslation());
150 }
151
155 public function testAnIdentifierZeroRoundTrips(): void
156 {
157 $catalog = self::singleEntryCatalog(self::entry('mod', '0', 'null'));
158
159 $this->assertSame('null', self::roundTrip($catalog)->find('mod', '0')?->getTranslation());
160 }
161
167 #[DataProvider('unrepresentableMessages')]
168 public function testAnUnrepresentableContextOrPluralIdThrowsWhenGenerating(string $po, bool $mo): void
169 {
170 $catalog = TranslationCatalog::fromPoString($po);
171
172 $this->expectException(RuntimeException::class);
173 $mo ? $catalog->toMoString() : $catalog->toPoString();
174 }
175
176 public static function unrepresentableMessages(): array
177 {
178 $cases = [
179 'empty context' => "msgctxt \"\"\nmsgid \"a\"\nmsgstr \"b\"\n",
180 'context zero' => "msgctxt \"0\"\nmsgid \"a\"\nmsgstr \"b\"\n",
181 'empty plural id' => "msgid \"a\"\nmsgid_plural \"\"\nmsgstr[0] \"b\"\nmsgstr[1] \"c\"\n",
182 'plural id zero' => "msgid \"a\"\nmsgid_plural \"0\"\nmsgstr[0] \"b\"\nmsgstr[1] \"c\"\n",
183 ];
184 $data = [];
185 foreach ($cases as $name => $po) {
186 $data[$name . ', .po'] = [$po, false];
187 $data[$name . ', .mo'] = [$po, true];
188 }
189
190 return $data;
191 }
192
194 {
195 $catalog = self::singleEntryCatalog(self::entry('0', 'a', 'b'));
196
197 $this->expectException(RuntimeException::class);
198 $catalog->toPoString();
199 }
200
206 {
207 $entry = self::entry('mod', 'id', 'v');
208 $entry->addTranslatorComment('original: Hallo ');
209 $entry->addTranslatorComment('');
210 $entry->addTranslatorComment(' leading space');
211 $entry->addTranslatorComment('trailing space ');
212 $entry->addTranslatorComment('.looks like an extracted marker');
213 $entry->addExtractedComment(' extracted ');
214 $entry->addFlag('php-format');
215 $entry->addFlag('fuzzy');
216
217 $read = self::roundTrip(self::singleEntryCatalog($entry))->find('mod', 'id');
218
219 $this->assertNotNull($read);
220 $this->assertSame(
221 ['original: Hallo ', '', ' leading space', 'trailing space ', '.looks like an extracted marker'],
222 $read->getTranslatorComments()
223 );
224 $this->assertSame([' extracted '], $read->getExtractedComments());
225 $this->assertSame(['fuzzy', 'php-format'], $read->getFlags());
226 $this->assertTrue($read->hasFlag('fuzzy'));
227 }
228
233 public function testTheCommentAndFlagLayout(): void
234 {
235 $entry = self::entry('mod', 'id', 'v');
236 $entry->addTranslatorComment('');
237 $entry->addTranslatorComment(' note ');
238 $entry->addExtractedComment('ext');
239 $entry->addFlag('php-format');
240 $entry->addFlag('fuzzy');
241
242 $this->assertStringContainsString(
243 "# \n# note \n#. ext\n#, fuzzy,php-format\nmsgctxt \"mod\"\nmsgid \"id\"\nmsgstr \"v\"\n",
244 self::singleEntryCatalog($entry)->toPoString()
245 );
246 }
247
249 {
250 $entry = TranslationCatalog::fromPoString("#note\n#.ext\n#\nmsgid \"id\"\nmsgstr \"v\"\n")->find(null, 'id');
251
252 $this->assertNotNull($entry);
253 $this->assertSame(['note', ''], $entry->getTranslatorComments());
254 $this->assertSame(['ext'], $entry->getExtractedComments());
255 }
256
262 {
263 $entry = self::entry('mod', 'id', 'v');
264 $entry->addTranslatorComment("eins\nzwei\r\ndrei\rvier");
265 $entry->addExtractedComment("a\nb");
266
267 $read = self::roundTrip(self::singleEntryCatalog($entry))->find('mod', 'id');
268
269 $this->assertSame(['eins zwei drei vier'], $entry->getTranslatorComments());
270 $this->assertNotNull($read);
271 $this->assertSame(['eins zwei drei vier'], $read->getTranslatorComments());
272 $this->assertSame(['a b'], $read->getExtractedComments());
273 }
274
280 {
281 $po = "msgid \"\"\nmsgstr \"\"\n\"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 ? 1 : 2);\\n\"\n\n"
282 . "msgctxt \"mod\"\nmsgid \"item\"\nmsgid_plural \"items\"\nmsgstr[0] \"%d Eintrag\"\nmsgstr[1] \"%d Einträge\"\n"
283 . "msgstr[2] \"%d Einträgen\"\n";
284
285 $catalog = TranslationCatalog::fromPoString($po);
286
287 $this->assertSame($po, $catalog->toPoString());
288 $entry = $catalog->find('mod', 'item');
289 $this->assertNotNull($entry);
290 $this->assertSame('%d Eintrag', $entry->getTranslation());
291
292 $entry->translate('%d Stück');
293 $entry->addTranslatorComment('note');
294
295 $this->assertSame(
296 str_replace("msgctxt \"mod\"\nmsgid \"item\"", "# note\nmsgctxt \"mod\"\nmsgid \"item\"", str_replace('"%d Eintrag"', '"%d Stück"', $po)),
297 $catalog->toPoString()
298 );
299 }
300
305 #[DataProvider('pluralFormCounts')]
306 public function testTheNumberOfWrittenPluralFormsFollowsThePluralFormsHeader(string $header, string $forms, string $expected): void
307 {
308 $po = "msgid \"\"\nmsgstr \"\"\n" . $header . "\n"
309 . "msgid \"item\"\nmsgid_plural \"items\"\n" . $forms;
310
311 $this->assertSame(
312 "msgid \"\"\nmsgstr \"\"\n" . $header . "\nmsgid \"item\"\nmsgid_plural \"items\"\n" . $expected,
313 TranslationCatalog::fromPoString($po)->toPoString()
314 );
315 }
316
317 public static function pluralFormCounts(): array
318 {
319 $two = "msgstr[0] \"a\"\nmsgstr[1] \"b\"\n";
320 $three = $two . "msgstr[2] \"c\"\n";
321
322 return [
323 'as many as declared' => ["\"Plural-Forms: nplurals=3; plural=(n != 1);\\n\"\n", $three, $three],
324 'fewer than declared' => ["\"Plural-Forms: nplurals=3; plural=(n != 1);\\n\"\n", $two, $two . "msgstr[2] \"\"\n"],
325 'more than declared' => ["\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n", $three, $two],
326 'no header' => ['', $two, $two],
327 ];
328 }
329
331 {
332 $po = "msgid \"\"\nmsgstr \"\"\n\nmsgid \"a\"\nmsgid_plural \"as\"\nmsgstr[0] \"0\"\nmsgstr[1] \"0\"\n";
333
334 $this->assertSame($po, TranslationCatalog::fromPoString($po)->toPoString());
335 }
336
338 {
339 $catalog = new TranslationCatalog();
340 $catalog->setHeader('X-Domain', 'tos');
341 $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);');
342 $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8');
343 $catalog->setHeader('Language', 'de');
344
345 $expected = [
346 'Content-Type' => 'text/plain; charset=UTF-8',
347 'Language' => 'de',
348 'Plural-Forms' => 'nplurals=2; plural=(n != 1);',
349 'X-Domain' => 'tos',
350 ];
351 $this->assertSame($expected, $catalog->getHeaders());
352 $this->assertSame(
353 "msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Language: de\\n\"\n"
354 . "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\"X-Domain: tos\\n\"\n",
355 $catalog->toPoString()
356 );
357 $read = self::roundTrip($catalog);
358 $this->assertSame($expected, $read->getHeaders());
359 $this->assertSame([], $read->getEntries(), 'the header is not an entry');
360 }
361
363 {
364 $catalog = new TranslationCatalog();
365 $catalog->setHeader('Language', 'fr');
366 $catalog->setHeader('Language', " \t de ");
367
368 $this->assertSame('de', $catalog->getHeader('Language'));
369 $this->assertSame(['Language' => 'de'], $catalog->getHeaders());
370 $this->assertNull($catalog->getHeader('X-Domain'));
371 }
372
377 #[DataProvider('headerValuesWithALineBreak')]
378 public function testSetHeaderRefusesAValueWithALineBreak(string $value): void
379 {
380 $catalog = new TranslationCatalog();
381 $catalog->setHeader('X-Note', 'bisher');
382
383 try {
384 $catalog->setHeader('X-Note', $value);
385 $this->fail('Expected an InvalidArgumentException');
386 } catch (InvalidArgumentException $e) {
387 $this->assertStringContainsString('X-Note', $e->getMessage());
388 }
389
390 $this->assertSame(['X-Note' => 'bisher'], $catalog->getHeaders());
391 }
392
393 public static function headerValuesWithALineBreak(): array
394 {
395 return [
396 'LF inside' => ["a\nb"],
397 'CR inside' => ["a\rb"],
398 'CRLF inside' => ["a\r\nb"],
399 'trailing LF' => ["a\n"],
400 'leading CR' => ["\ra"],
401 'only CRLF' => ["\r\n"],
402 ];
403 }
404
410 {
411 $catalog = new TranslationCatalog();
412 $catalog->setHeader('X-Note', 'a "quoted" C:\\pfad\\');
413 $catalog->setHeader('X-Zero', '0');
414
415 $po = $catalog->toPoString();
416
417 $this->assertStringContainsString("\"X-Note: a \\\"quoted\\\" C:\\\\pfad\\\\\\n\"\n", $po);
418 $this->assertSame(
419 ['X-Note' => 'a "quoted" C:\\pfad\\', 'X-Zero' => '0'],
420 TranslationCatalog::fromPoString($po)->getHeaders()
421 );
422 }
423
428 #[DataProvider('headersWithALineBreakAfterLoading')]
430 {
431 $this->expectException(RuntimeException::class);
432 $this->expectExceptionMessage('The value of header "X-A" contains a line break.');
434 }
435
436 public static function headersWithALineBreakAfterLoading(): array
437 {
438 return [
439 'CR inside' => ["msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n"],
440 'CR inside, last header without a trailing \\n' => ["msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\"\n"],
441 'CR inside, after a valid header' => [
442 "msgid \"\"\nmsgstr \"\"\n\"Language: de\\n\"\n\"X-A: a\\r\\rb\\n\"\n\nmsgid \"id\"\nmsgstr \"v\"\n",
443 ],
444 ];
445 }
446
448 {
449 $file = $this->temporaryDirectory() . '/header_de.po';
450 file_put_contents($file, "msgid \"\"\nmsgstr \"\"\n\"X-A: a\\rb\\n\"\n");
451
452 $this->expectException(RuntimeException::class);
453 $this->expectExceptionMessageMatches('/^Invalid PO file ".*header_de\.po": The value of header "X-A" contains a line break\.$/');
455 }
456
463 #[DataProvider('headersLosingTheirLineBreakWhenLoaded')]
464 public function testAHeaderWhoseLineBreakIsRemovedWhenLoadedStaysValid(string $header_line, string $expected): void
465 {
466 $catalog = TranslationCatalog::fromPoString("msgid \"\"\r\nmsgstr \"\"\r\n" . $header_line . "\r\n");
467
468 $this->assertSame(['X-A' => $expected], $catalog->getHeaders());
469 $this->assertSame("msgid \"\"\nmsgstr \"\"\n\"X-A: " . $expected . "\\n\"\n", $catalog->toPoString());
470 }
471
472 public static function headersLosingTheirLineBreakWhenLoaded(): array
473 {
474 return [
475 'trailing CR' => ['"X-A: tail\\r\\n"', 'tail'],
476 'leading CR' => ['"X-A: \\rhead\\n"', 'head'],
477 'escaped LF inside' => ['"X-A: a\\nb\\n"', 'ab'],
478 'no line break' => ['"X-A: v\\n"', 'v'],
479 ];
480 }
481
483 {
484 $catalog = new TranslationCatalog();
485 $catalog->setHeader('Language', 'de');
486 $catalog->add(self::entry('tos', 'tos_agreement', "Zeile 1\nZeile 2"));
487 $catalog->add(self::entry('tos', 'tos_empty', ''));
488
489 $this->assertSame(
490 "msgid \"\"\nmsgstr \"\"\n\"Language: de\\n\"\n\n"
491 . "msgctxt \"tos\"\nmsgid \"tos_agreement\"\nmsgstr \"\"\n\"Zeile 1\\n\"\n\"Zeile 2\"\n\n"
492 . "msgctxt \"tos\"\nmsgid \"tos_empty\"\nmsgstr \"\"\n",
493 $catalog->toPoString()
494 );
495 }
496
503 {
504 $files = glob(self::TOS_LANG_DIRECTORY . '/*.{po,pot}', GLOB_BRACE) ?: [];
505 $this->assertNotSame([], $files, 'the shipped tos files were not found');
506
507 foreach ($files as $file) {
508 $catalog = TranslationCatalog::fromPoFile($file);
509 $this->assertSame(file_get_contents($file), $catalog->toPoString(), basename($file));
510
511 $expected = [];
512 foreach ($catalog->getEntries() as $entry) {
513 // written without msgctxt by convert_module_to_po.php (the module is the directory's)
514 $this->assertNull($entry->getContext(), basename($file));
515 if ($entry->getTranslation() !== '') {
516 $expected[$entry->getId()] = $entry->getTranslation();
517 }
518 }
519 $actual = \MigratedPoFixture::readMoTranslations($catalog->toMoString());
520 ksort($expected, SORT_STRING);
521 ksort($actual, SORT_STRING);
522 $this->assertSame($expected, $actual, basename($file));
523 }
524 }
525
527 {
528 $po = "\xEF\xBB\xBFmsgid \"\"\r\nmsgstr \"\"\r\n\"Language: de\\n\"\r\n\r\n"
529 . "msgctxt \"mod\"\r\nmsgid \"id\"\r\nmsgstr \"\"\r\n\"eins \"\r\n\"zwei\"\r\n";
530
531 $catalog = TranslationCatalog::fromPoString($po);
532
533 $this->assertSame('de', $catalog->getHeader('Language'));
534 $this->assertSame('eins zwei', $catalog->find('mod', 'id')?->getTranslation());
535 $this->assertStringStartsWith("msgid \"\"\nmsgstr \"\"\n", $catalog->toPoString(), 'no BOM, LF only');
536 }
537
541 public function testOnlyALeadingBomIsSkipped(): void
542 {
543 $this->expectException(RuntimeException::class);
544 TranslationCatalog::fromPoString("msgid \"a\"\nmsgstr \"b\"\n\xEF\xBB\xBFmsgid \"c\"\nmsgstr \"d\"\n");
545 }
546
553 {
554 $this->expectException(RuntimeException::class);
555 $this->expectExceptionMessage('not valid UTF-8');
556 // "\xE4" (a bare Latin-1 "ä") is not a valid UTF-8 byte sequence on its own.
557 TranslationCatalog::fromPoString("msgid \"id\"\nmsgstr \"caf\xE4\"\n");
558 }
559
566 {
567 $this->expectException(RuntimeException::class);
568 $this->expectExceptionMessage('Unsupported charset "ISO-8859-1"');
570 "msgid \"\"\nmsgstr \"\"\n\"Content-Type: text/plain; charset=ISO-8859-1\\n\"\n\n"
571 . "msgid \"id\"\nmsgstr \"value\"\n"
572 );
573 }
574
575 public function testDecodesControlEscapes(): void
576 {
577 $catalog = TranslationCatalog::fromPoString("msgid \"id\"\nmsgstr \"\\101\\x42\\a\\b\\f\\v\\t\\r\\n\"\n");
578
579 $this->assertSame("AB\x07\x08\x0c\x0b\t\r\n", $catalog->find(null, 'id')?->getTranslation());
580 }
581
585 public function testDropsObsoleteMessages(): void
586 {
588 "msgid \"id\"\nmsgstr \"v\"\n\n#~ msgctxt \"mod\"\n#~ msgid \"gone\"\n#~ msgstr \"weg\"\n"
589 );
590
591 $this->assertCount(1, $catalog->getEntries());
592 $this->assertNull($catalog->find('mod', 'gone'));
593 $this->assertNull($catalog->find(null, 'gone'));
594 $this->assertStringNotContainsString('gone', $catalog->toPoString());
595 }
596
601 {
602 $po = "msgid \"\"\nmsgstr \"\"\n\n#: file.php:12\n#: other.php\n#, fuzzy\n#| msgid \"old\"\nmsgid \"id\"\nmsgstr \"v\"\n";
603
604 $catalog = TranslationCatalog::fromPoString($po);
605
606 $this->assertSame([], $catalog->find(null, 'id')?->getTranslatorComments());
607 $this->assertSame($po, $catalog->toPoString());
608 }
609
611 {
613 "msgctxt \"a\"\nmsgid \"id\"\nmsgstr \"A\"\n\nmsgctxt \"b\"\nmsgid \"id\"\nmsgstr \"B\"\n\nmsgid \"id\"\nmsgstr \"ohne\"\n"
614 );
615
616 $this->assertSame('A', $catalog->find('a', 'id')?->getTranslation());
617 $this->assertSame('B', $catalog->find('b', 'id')?->getTranslation());
618 $this->assertSame('ohne', $catalog->find(null, 'id')?->getTranslation());
619 $this->assertSame(['A', 'B', 'ohne'], array_map(
620 static fn(TranslationEntry $entry): string => $entry->getTranslation(),
621 $catalog->getEntries()
622 ));
623 }
624
628 #[DataProvider('validButEmptyPo')]
629 public function testAnEmptyOrHeaderlessPoIsNoError(string $po, int $expected_entries): void
630 {
631 $catalog = TranslationCatalog::fromPoString($po);
632
633 $this->assertCount($expected_entries, $catalog->getEntries());
634 $this->assertSame([], $catalog->getHeaders());
635 }
636
637 public static function validButEmptyPo(): array
638 {
639 return [
640 'empty' => ['', 0],
641 'only a BOM' => ["\xEF\xBB\xBF", 0],
642 'only blank lines' => ["\n\r\n\n", 0],
643 'only a comment' => ["# just a comment\n", 0],
644 'no header' => ["msgid \"a\"\nmsgstr \"b\"\n", 1],
645 'empty header' => ["msgid \"\"\nmsgstr \"\"\n", 0],
646 ];
647 }
648
653 #[DataProvider('brokenPo')]
654 public function testThrowsARuntimeExceptionForBrokenContent(string $po): void
655 {
656 $this->expectException(RuntimeException::class);
658 }
659
660 public static function brokenPo(): array
661 {
662 $mo_catalog = new TranslationCatalog();
663 $mo_catalog->add(self::entry('mod', 'id', 'v'));
664
665 return [
666 'unterminated string' => ["msgid \"abc\nmsgstr \"\"\n"],
667 'escaped closing quote' => ["msgid \"abc\\\"\nmsgstr \"\"\n"],
668 'unquoted value' => ["msgid abc\nmsgstr \"\"\n"],
669 'unknown keyword' => ["msgfoo \"abc\"\n"],
670 'keyword without value' => ["msgid\n"],
671 'continuation without keyword' => ["\"dangling\"\n"],
672 'text after the closing quote' => ["msgid \"a\" trailing\nmsgstr \"\"\n"],
673 'invalid escape sequence' => ["msgid \"a\\q\"\nmsgstr \"\"\n"],
674 'octal escape above 127' => ["msgid \"a\"\nmsgstr \"\\303\\244\"\n"],
675 'missing msgstr before the next message' => ["msgid \"a\"\n\nmsgid \"b\"\nmsgstr \"B\"\n"],
676 'missing msgstr at the end' => ["msgid \"a\"\nmsgstr \"A\"\n\nmsgid \"b\"\n"],
677 'msgstr without msgid' => ["msgstr \"A\"\n"],
678 'duplicate message' => ["msgid \"a\"\nmsgstr \"first\"\n\nmsgid \"a\"\nmsgstr \"second\"\n"],
679 'duplicate message with context' => ["msgctxt \"m\"\nmsgid \"a\"\nmsgstr \"1\"\n\nmsgctxt \"m\"\nmsgid \"a\"\nmsgstr \"2\"\n"],
680 'gap in the plural forms' => ["msgid \"a\"\nmsgid_plural \"as\"\nmsgstr[0] \"x\"\nmsgstr[2] \"z\"\n"],
681 'plural forms not starting at 0' => ["msgid \"a\"\nmsgid_plural \"as\"\nmsgstr[1] \"y\"\n"],
682 'binary data' => ["\x00\x01\x02\xff\xfe binary"],
683 'a .mo file' => [$mo_catalog->toMoString()],
684 'truncated inside a string' => ["msgctxt \"mod\"\nmsgid \"id\"\nmsgstr \"Hal"],
685 'truncated inside a keyword' => ["msgctxt \"mod\"\nmsgid \"id\"\nmsg"],
686 'truncated after msgctxt' => ["msgctxt \"mod\"\n"],
687 ];
688 }
689
690 public function testTheLineOfTheErrorIsReported(): void
691 {
692 $this->expectException(RuntimeException::class);
693 $this->expectExceptionMessageMatches('/line 3\b/');
694 TranslationCatalog::fromPoString("msgid \"a\"\nmsgstr \"A\"\nbroken\n");
695 }
696
702 {
703 $handler = static fn(): bool => false;
704 set_error_handler($handler);
705 try {
706 TranslationCatalog::fromPoString("msgid \"a\"\nmsgstr \"b\"\n");
707 try {
709 $this->fail('Expected a RuntimeException');
710 } catch (RuntimeException) {
711 }
712
713 $current = set_error_handler(null);
714 restore_error_handler();
715 $this->assertSame($handler, $current);
716 } finally {
717 restore_error_handler();
718 }
719 }
720
721 public function testFromPoFileReadsAFile(): void
722 {
723 $file = $this->temporaryDirectory() . '/mod_de.po';
724 file_put_contents($file, "msgid \"\"\nmsgstr \"\"\n\"Language: de\\n\"\n\nmsgctxt \"mod\"\nmsgid \"id\"\nmsgstr \"v\"\n");
725
726 $catalog = TranslationCatalog::fromPoFile($file);
727
728 $this->assertSame('de', $catalog->getHeader('Language'));
729 $this->assertSame('v', $catalog->find('mod', 'id')?->getTranslation());
730 }
731
733 {
734 $file = $this->temporaryDirectory() . '/broken_de.po';
735 file_put_contents($file, "msgid \"a\"\nmsgstr \"A\"\nbroken\n");
736
737 $this->expectException(RuntimeException::class);
738 $this->expectExceptionMessageMatches('/^Invalid PO file ".*broken_de\.po": .*line 3\b/');
740 }
741
743 {
744 $this->expectException(RuntimeException::class);
745 $this->expectExceptionMessageMatches('/Could not read PO file/');
746 TranslationCatalog::fromPoFile($this->temporaryDirectory() . '/missing.po');
747 }
748
749 public function testFromPoFileThrowsForADirectory(): void
750 {
751 $this->expectException(RuntimeException::class);
752 $this->expectExceptionMessageMatches('/Could not read PO file/');
754 }
755
757 {
758 $file = $this->temporaryDirectory() . '/unreadable.po';
759 file_put_contents($file, "msgid \"a\"\nmsgstr \"b\"\n");
760 chmod($file, 0000);
761 if (is_readable($file)) {
762 $this->markTestSkipped('File permissions are not enforced for this user (root).');
763 }
764
765 $this->expectException(RuntimeException::class);
766 $this->expectExceptionMessageMatches('/Could not read PO file/');
768 }
769
775 {
776 $original = self::singleEntryCatalog(self::entry('common', 'save', 'Speichern'));
777 $original->setHeader('Language', 'de');
778 $original_po = $original->toPoString();
779
780 $clone = clone $original;
781 $cloned_entry = $clone->find('common', 'save');
782 $cloned_entry->translate('Sichern');
783 $cloned_entry->addFlag('fuzzy');
784 $cloned_entry->setExtractedComments(['changed']);
785 $clone->add($cloned_entry);
786 $clone->add(self::entry('common', 'cancel', 'Abbrechen'));
787 $clone->setHeader('Language', 'en');
788
789 $this->assertSame($original_po, $original->toPoString());
790 $this->assertSame('Speichern', $original->find('common', 'save')->getTranslation());
791 $this->assertNull($original->find('common', 'cancel'));
792 $this->assertSame('Sichern', $clone->find('common', 'save')->getTranslation());
793 $this->assertSame('en', $clone->getHeader('Language'));
794 }
795
799 private static function idsOf(TranslationCatalog $catalog): array
800 {
801 return array_map(static fn(TranslationEntry $entry): string => $entry->getId(), $catalog->getEntries());
802 }
803
804 private static function catalogOfIds(string ...$ids): TranslationCatalog
805 {
806 $catalog = new TranslationCatalog();
807 foreach ($ids as $id) {
808 $catalog->add(self::entry('common', $id, $id));
809 }
810
811 return $catalog;
812 }
813
814 public function testAddInIdOrderInsertsAtTheFront(): void
815 {
816 $catalog = self::catalogOfIds('m', 'z');
817
818 $catalog->addInIdOrder(self::entry('common', 'a', 'A'));
819
820 $this->assertSame(['a', 'm', 'z'], self::idsOf($catalog));
821 }
822
824 {
825 $catalog = self::catalogOfIds('a', 'z');
826
827 $catalog->addInIdOrder(self::entry('common', 'm', 'M'));
828
829 $this->assertSame(['a', 'm', 'z'], self::idsOf($catalog));
830 }
831
832 public function testAddInIdOrderInsertsAtTheEnd(): void
833 {
834 $catalog = self::catalogOfIds('a', 'm');
835
836 $catalog->addInIdOrder(self::entry('common', 'z', 'Z'));
837
838 $this->assertSame(['a', 'm', 'z'], self::idsOf($catalog));
839 }
840
846 {
847 $catalog = self::catalogOfIds('a', 'm', 'z');
848
849 $catalog->addInIdOrder(self::entry('common', 'm', 'replaced'));
850
851 $this->assertSame(['a', 'm', 'z'], self::idsOf($catalog));
852 $this->assertSame('replaced', $catalog->find('common', 'm')->getTranslation());
853 }
854
856 {
857 $catalog = new TranslationCatalog();
858
859 $catalog->addInIdOrder(self::entry('common', 'a', 'A'));
860
861 $this->assertSame(['a'], self::idsOf($catalog));
862 }
863}
$id
plugin.php for ilComponentBuildPluginInfoObjectiveTest::testAddPlugins
Definition: plugin.php:23
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
One message of a TranslationCatalog (a msgctxt/msgid/msgstr block of a .po file).
TranslationCatalog's .po side (fromPoString()/fromPoFile()/toPoString()), i.e.
testEveryShippedTosFileRoundTripsByteIdentically()
Property: every shipped .po/.pot of the migrated tos module is read by fromPoFile() and re-serialized...
testReferencesAndPreviousMessageLinesAreWrittenBackAsLoaded()
References and previous-message lines have no API any more, but are carried along untouched.
testDropsObsoleteMessages()
Obsolete messages are dropped on load - and therefore never written back.
testGeneratingDoesNotChangeTheCatalog()
The workaround works on a copy: generating must not change the catalog it was called on (a leaked "0\...
testRejectsContentThatIsNotValidUtf8()
Every value is used as UTF-8 as it is - no charset conversion takes place anywhere.
testThrowsARuntimeExceptionForBrokenContent(string $po)
Everything StrictPoLoader rejects - and all of it as RuntimeException (the library's own exceptions a...
testCommentsAndFlagsRoundTripWithoutTrimming()
"original: ..." comments are compared with the value byte for byte - surrounding whitespace must surv...
testCloneIsIndependentOfTheOriginal()
MigratedLanguageFileSync seeds a new overlay from a clone of the shipped catalog and relies on the sh...
testAnUnrepresentableContextOrPluralIdThrowsWhenGenerating(string $po, bool $mo)
The library cannot represent a context or msgid_plural of "" or "0" (PoGenerator/MoGenerator skip fal...
testAnIdentifierZeroRoundTrips()
Boundary of the "falsy" workaround: an identifier "0" is not special.
static entry(?string $context, string $id, string $translation)
testTheErrorHandlerIsRestoredAfterReading()
The adapter's temporary error handler must be removed again on success and on failure - otherwise eve...
testTheCommentAndFlagLayout()
Layout of comments and flags as the library writes them (characterization): an empty comment is "# " ...
testAddInIdOrderReplacesAnExistingEntryInPlace()
An entry with an id (and context) the catalog already has is replaced in place, like add() - neither ...
testALineBreakInACommentIsReplacedByASpace()
A comment is a single .po line: a line break inside it would end the comment and corrupt the file,...
testAHeaderValueKeepingALineBreakAfterLoadingIsRejected(string $po)
An escaped CR that stays inside a header value after loading could be read but never written back (se...
testAnEmptyOrHeaderlessPoIsNoError(string $po, int $expected_entries)
An empty file or one without a header block is a valid (empty or header-less) catalog.
testHeaderValuesWithQuotesAndBackslashesAreEscaped()
PoGenerator writes header values verbatim - a quote or backslash would end the string early and corru...
testTheNumberOfWrittenPluralFormsFollowsThePluralFormsHeader(string $header, string $forms, string $expected)
Documented deviation from the GNU tools: the number of msgstr[n] written follows the Plural-Forms hea...
testTheValueZeroIsWrittenAsExactlyZero()
PoGenerator treats a falsy translation as absent - without the adapter's workaround "0" (it occurs in...
testOnlyALeadingBomIsSkipped()
A BOM that is not at the very start is not skipped - it is data, i.e.
testSetHeaderRefusesAValueWithALineBreak(string $value)
The .po header block is split at line breaks when read - a value containing one could not be read bac...
testAHeaderWhoseLineBreakIsRemovedWhenLoadedStaysValid(string $header_line, string $expected)
Boundary: line breaks the loader removes itself leave a valid header - a CR at either end is trimmed,...
testRejectsAContentTypeHeaderDeclaringAnyCharsetOtherThanUtf8()
A .po can be valid UTF-8 while its "Content-Type" header declares another charset (e....
testALoadedPluralMessageRoundTripsByteIdentically()
ILIAS itself has no plural API any more, but a loaded plural message is carried along and written bac...
static removeDirectory(string $directory)
static readMoTranslations(string $data)
Messages of the .mo $data by identifier (contexts dropped), a plural message with its default form (P...
$handler
Definition: oai.php:31
$data
Definition: storeScorm.php:45