ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
SaveValuesWritesPluralFormsTest.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
26use PHPUnit\Framework\Attributes\PreserveGlobalState;
27use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
28use PHPUnit\Framework\MockObject\Stub;
29
35#[RunTestsInSeparateProcesses]
36#[PreserveGlobalState(false)]
38{
39 private const string MODULE = 'ptest2';
40 private const string LANG_KEY = 'de';
41
42 private ?string $fixture_directory = null;
43 private bool $created_client_data_dir_root = false;
44
45 protected function setUp(): void
46 {
48
49 if (!defined('ILIAS_ABSOLUTE_PATH')) {
50 define('ILIAS_ABSOLUTE_PATH', realpath(__DIR__ . '/../../../../'));
51 }
52 $this->created_client_data_dir_root = MigratedPoFixture::ensureClientDataDirDefinedOrSkip($this);
53
55 (new ReflectionClass(ilCachedLanguage::class))->getProperty('instances')->setValue(null, []);
56 }
57
58 protected function tearDown(): void
59 {
60 if ($this->fixture_directory !== null) {
61 MigratedPoFixture::removeShippedDirectory('components/ILIAS/Language/tests/' . $this->fixture_directory);
62 MigratedPoFixture::removeDirectory(rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . self::MODULE);
63 }
64 if ($this->created_client_data_dir_root && defined('CLIENT_DATA_DIR') && is_dir(CLIENT_DATA_DIR)) {
66 }
67
69 }
70
73 {
74 $this->fixture_directory = 'tmp-savevalues-plural-fixtures-' . bin2hex(random_bytes(4));
75 $relative_path = 'components/ILIAS/Language/tests/' . $this->fixture_directory . '/';
76
77 $catalog = new TranslationCatalog();
78 $catalog->setHeader('Content-Type', 'text/plain; charset=UTF-8');
79 $catalog->setHeader('Plural-Forms', 'nplurals=2; plural=(n != 1);');
80 $entry = new TranslationEntry(self::MODULE, 'item');
81 $entry->setPlural('items', ['Eintrag', 'Einträge']);
82 $catalog->add($entry);
83 MigratedPoFixture::writeShippedPo($relative_path, self::MODULE, self::LANG_KEY, $catalog);
84
85 $directory = MigratedPoFixture::directory(self::MODULE, $relative_path);
86 $this->setGlobalVariable(
87 LanguageFileDirectoryManager::class,
89 );
90
91 return $directory;
92 }
93
94 private function stubLng(): ilLanguage
95 {
96 $lng = (new ReflectionClass(ilLanguage::class))->newInstanceWithoutConstructor();
97 $this->setGlobalVariable('lng', $lng);
98
99 return $lng;
100 }
101
102 private function seedEmptyGlobalLanguageFile(string $lang_key): void
103 {
104 $fake_global_file = (new ReflectionClass(ilLanguageFile::class))->newInstanceWithoutConstructor();
105 $fake_global_file->setAllValues([]);
106 $fake_global_file->setAllComments([]);
107
108 $cache = (new ReflectionClass(ilLanguageFile::class))->getProperty('global_file_objects');
109 $existing = $cache->isInitialized() ? $cache->getValue() : [];
110 $cache->setValue(null, $existing + [$lang_key => $fake_global_file]);
111 }
112
119 private function mockDatabaseForWrites(array &$replace_calls, array $existing_remarks = []): ilDBInterface&Stub
120 {
121 $lng_modules_stmt = $this->createStub(ilDBStatement::class);
122 $remarks_stmt = $this->createStub(ilDBStatement::class);
123 $lng_data_stmt = $this->createStub(ilDBStatement::class);
124 $query_f_stmt = $this->createStub(ilDBStatement::class);
125
126 $db = $this->createStub(ilDBInterface::class);
127 $db->method('quote')->willReturnCallback(static fn($value, string $type = ''): string => "'" . (string) $value . "'");
128 $db->method('in')->willReturn("module IN ('placeholder')");
129 $db->method('query')->willReturnCallback(
130 static function (string $sql) use ($lng_modules_stmt, $remarks_stmt, $lng_data_stmt): ilDBStatement {
131 if (str_contains($sql, 'lng_modules')) {
132 return $lng_modules_stmt;
133 }
134 return str_contains($sql, 'remarks') ? $remarks_stmt : $lng_data_stmt;
135 }
136 );
137 $db->method('queryF')->willReturn($query_f_stmt);
138 $remaining_remarks = $existing_remarks;
139 $db->method('fetchAssoc')->willReturnCallback(
140 static function (ilDBStatement $statement) use ($query_f_stmt, $remarks_stmt, &$remaining_remarks): ?array {
141 if ($statement === $query_f_stmt) {
142 return ['lang_array' => serialize([])];
143 }
144 if ($statement === $remarks_stmt) {
145 return array_shift($remaining_remarks) ?: null;
146 }
147 return null;
148 }
149 );
150 $db->method('manipulate')->willReturn(0);
151 $db->method('insert')->willReturn(1);
152
153 $db->method('replace')->willReturnCallback(
154 static function (string $table, array $keys, array $values) use (&$replace_calls): int {
155 if ($table === 'lng_data') {
156 $replace_calls[] = [$keys['identifier'][1] ?? null, [
157 'value' => $values['value'][1] ?? null,
158 'local_change' => $values['local_change'][1] ?? null,
159 'remarks' => $values['remarks'][1] ?? null,
160 ]];
161 }
162 return 1;
163 }
164 );
165
166 $this->setGlobalVariable('ilDB', $db);
167
168 return $db;
169 }
170
173 {
175 $lng = $this->stubLng();
176 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
177 $replace_calls = [];
178 $this->mockDatabaseForWrites($replace_calls);
179
181 self::LANG_KEY,
182 [self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU'],
183 []
184 );
185
186 $this->assertSame([], $unwritten, 'the overlay of the migrated module must be written');
187 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
188 $this->assertCount(1, $item_calls, 'exactly one lng_data row for the plural message, not one per form');
189 $this->assertSame('Einträge NEU', $item_calls[0][1]['value']);
190 $this->assertNotNull($item_calls[0][1]['local_change'], 'marked as a local change');
191 }
192
195 {
197 $lng = $this->stubLng();
198 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
199 $replace_calls = [];
200 $this->mockDatabaseForWrites($replace_calls);
201
203 self::LANG_KEY,
204 [self::MODULE . $lng->separator . 'item' => 'X'],
205 [self::MODULE . $lng->separator . 'item' => 'Bitte prüfen']
206 );
207
208 $this->assertSame([], $unwritten);
209 $overlay = TranslationCatalog::fromPoFile(
210 rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . self::MODULE . '/' . self::LANG_KEY . '/' . self::MODULE . '_' . self::LANG_KEY . '.po'
211 );
212 $entry = $overlay->find(null, 'item');
213 $this->assertNotNull($entry);
214 $this->assertSame(['Eintrag', 'X'], $entry->getPluralTranslations(), 'form [0] unchanged, form [1] (default) is the plain value');
215 $this->assertSame(
216 'Bitte prüfen',
217 \ILIAS\Language\ComponentTranslation\LocalChangeComments::getRemark($entry)
218 );
219 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
220 $this->assertCount(1, $item_calls);
221 $this->assertSame('X', $item_calls[0][1]['value']);
222 $this->assertNotNull($item_calls[0][1]['local_change']);
223 }
224
227 {
229 $lng = $this->stubLng();
230 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
231 $replace_calls = [];
232 $this->mockDatabaseForWrites($replace_calls);
233
235 self::LANG_KEY,
236 [
237 self::MODULE . $lng->separator . 'item' => 'Von Plain-Key',
238 self::MODULE . $lng->separator . 'item [1]' => 'Von Formzeile',
239 ],
240 []
241 );
242
243 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
244 $this->assertCount(1, $item_calls);
245 $this->assertSame('Von Formzeile', $item_calls[0][1]['value']);
246 }
247
250 {
252 $lng = $this->stubLng();
253 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
254 $replace_calls = [];
255 $this->mockDatabaseForWrites($replace_calls);
256
258 self::LANG_KEY,
259 [
260 self::MODULE . $lng->separator . 'item [1]' => 'Von Formzeile',
261 self::MODULE . $lng->separator . 'item' => 'Von Plain-Key',
262 ],
263 []
264 );
265
266 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
267 $this->assertCount(1, $item_calls);
268 $this->assertSame('Von Plain-Key', $item_calls[0][1]['value']);
269 }
270
273 {
275 $lng = $this->stubLng();
276 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
277 $replace_calls_1 = [];
278 $this->mockDatabaseForWrites($replace_calls_1);
280 self::LANG_KEY,
281 [self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU'],
282 []
283 );
284
285 $replace_calls_2 = [];
286 $this->mockDatabaseForWrites($replace_calls_2);
288 self::LANG_KEY,
289 [
290 self::MODULE . $lng->separator . 'item [0]' => 'Eintrag',
291 self::MODULE . $lng->separator . 'item [1]' => 'Einträge',
292 ],
293 []
294 );
295
296 $item_calls = array_values(array_filter($replace_calls_2, static fn(array $call): bool => $call[0] === 'item'));
297 $this->assertCount(1, $item_calls);
298 $this->assertSame('Einträge', $item_calls[0][1]['value']);
299 $this->assertNull($item_calls[0][1]['local_change'], 'reset to the shipped value is not a local change');
300 }
301
304 {
306 $lng = $this->stubLng();
307 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
308 $replace_calls = [];
309 $this->mockDatabaseForWrites($replace_calls);
310
312 self::LANG_KEY,
313 [
314 self::MODULE . $lng->separator . 'item [0]' => 'Eintrag NEU',
315 self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU',
316 ],
317 [self::MODULE . $lng->separator . 'item [0]' => '', self::MODULE . $lng->separator . 'item [1]' => 'Bemerkung zu Form 1'],
318 );
319
320 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
321 $this->assertCount(1, $item_calls);
322 $this->assertSame('Bemerkung zu Form 1', $item_calls[0][1]['remarks']);
323 }
324
327 {
329 $lng = $this->stubLng();
330 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
331 $replace_calls = [];
332 $this->mockDatabaseForWrites($replace_calls, [
333 ['module' => self::MODULE, 'identifier' => 'item', 'remarks' => 'Alte Bemerkung'],
334 ]);
335
337 self::LANG_KEY,
338 [
339 self::MODULE . $lng->separator . 'item [0]' => 'Eintrag NEU',
340 self::MODULE . $lng->separator . 'item [1]' => 'Einträge',
341 ],
342 [self::MODULE . $lng->separator . 'item [0]' => ''],
343 );
344
345 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
346 $this->assertCount(1, $item_calls);
347 $this->assertNull($item_calls[0][1]['remarks']);
348 }
349
352 {
354 $lng = $this->stubLng();
355 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
356 $replace_calls = [];
357 $this->mockDatabaseForWrites($replace_calls, [
358 ['module' => self::MODULE, 'identifier' => 'item', 'remarks' => 'Bestehende Bemerkung'],
359 ]);
360
362 self::LANG_KEY,
363 [
364 self::MODULE . $lng->separator . 'item [0]' => 'Eintrag',
365 self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU',
366 ],
367 [self::MODULE . $lng->separator . 'item [1]' => 'Bestehende Bemerkung'],
368 );
369
370 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
371 $this->assertCount(1, $item_calls);
372 $this->assertSame('Bestehende Bemerkung', $item_calls[0][1]['remarks']);
373 }
374
377 {
379 $lng = $this->stubLng();
380 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
381 $replace_calls = [];
382 $this->mockDatabaseForWrites($replace_calls);
383
385 self::LANG_KEY,
386 [
387 self::MODULE . $lng->separator . 'item [0]' => 'Eintrag',
388 self::MODULE . $lng->separator . 'item [1]' => 'Einträge',
389 ],
390 [self::MODULE . $lng->separator . 'item [1]' => 'Neue Bemerkung'],
391 );
392
393 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
394 $this->assertCount(1, $item_calls, 'the row must be written for the remark alone');
395 $this->assertSame('Einträge', $item_calls[0][1]['value']);
396 $this->assertNull($item_calls[0][1]['local_change'], 'the value itself did not change');
397 $this->assertSame('Neue Bemerkung', $item_calls[0][1]['remarks']);
398 }
399
402 {
404 $lng = $this->stubLng();
405 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
406 $replace_calls = [];
407 $this->mockDatabaseForWrites($replace_calls);
408
410 self::LANG_KEY,
411 [
412 self::MODULE . $lng->separator . 'item [0]' => 'Eintrag NEU',
413 self::MODULE . $lng->separator . 'item [1]' => 'Einträge',
414 ],
415 [self::MODULE . $lng->separator . 'item [1]' => 'Bemerkung aus Form 1'],
416 );
417
418 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
419 $this->assertCount(1, $item_calls);
420 $this->assertSame('Einträge', $item_calls[0][1]['value'], 'the default form (1) itself did not change');
421 $this->assertNull($item_calls[0][1]['local_change']);
422 $this->assertSame('Bemerkung aus Form 1', $item_calls[0][1]['remarks']);
423 }
424
427 {
429 $lng = $this->stubLng();
430 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
431 $replace_calls = [];
432 $this->mockDatabaseForWrites($replace_calls);
433
435 self::LANG_KEY,
436 [
437 self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU',
438 self::MODULE . $lng->separator . 'item [5]' => 'Geister-Form',
439 ],
440 []
441 );
442
443 $this->assertSame([], $unwritten);
444 $item_calls = array_values(array_filter($replace_calls, static fn(array $call): bool => $call[0] === 'item'));
445 $this->assertCount(1, $item_calls, 'exactly one row for the real message - the orphan form must not create its own');
446 $this->assertSame('Einträge NEU', $item_calls[0][1]['value']);
447 }
448
451 {
453 $lng = $this->stubLng();
454 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
455 $save_calls = [];
456 $this->mockDatabaseForWrites($save_calls);
458 self::LANG_KEY,
459 [self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU'],
460 []
461 );
462 $overlay = TranslationCatalog::fromPoFile(
463 rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . self::MODULE . '/' . self::LANG_KEY . '/' . self::MODULE . '_' . self::LANG_KEY . '.po'
464 );
465 $this->assertNotNull($overlay->find(null, 'item'), 'precondition: the overlay holds the local change');
466
467 $delete_calls = [];
468 $this->mockDatabaseForWrites($delete_calls);
469 $unwritten = ilObjLanguageExt::_deleteValues(self::LANG_KEY, [self::MODULE . $lng->separator . 'item [1]' => '']);
470
471 $this->assertSame([], $unwritten);
472 $item_calls = array_values(array_filter($delete_calls, static fn(array $call): bool => $call[0] === 'item'));
473 $this->assertCount(1, $item_calls, 'exactly one merged row, not a separate row per form');
474 $this->assertSame('Einträge', $item_calls[0][1]['value'], 'back to the shipped default form value');
475 $this->assertNull($item_calls[0][1]['local_change']);
476
477 $overlay_after_path = rtrim(CLIENT_DATA_DIR, '/') . '/lang/' . self::MODULE . '/' . self::LANG_KEY . '/' . self::MODULE . '_' . self::LANG_KEY . '.po';
478 $this->assertFalse(is_file($overlay_after_path), 'no local change left at all - the whole overlay is removed');
479 }
480
483 {
485 $lng = $this->stubLng();
486 $this->seedEmptyGlobalLanguageFile(self::LANG_KEY);
487 $save_calls = [];
488 $this->mockDatabaseForWrites($save_calls);
490 self::LANG_KEY,
491 [
492 self::MODULE . $lng->separator . 'item [0]' => 'Eintrag NEU',
493 self::MODULE . $lng->separator . 'item [1]' => 'Einträge NEU',
494 ],
495 []
496 );
497
498 $delete_calls = [];
499 $this->mockDatabaseForWrites($delete_calls, [
500 ['module' => self::MODULE, 'identifier' => 'item', 'remarks' => 'Alte Bemerkung'],
501 ]);
502 ilObjLanguageExt::_deleteValues(self::LANG_KEY, [self::MODULE . $lng->separator . 'item [1]' => '']);
503
504 $item_calls = array_values(array_filter($delete_calls, static fn(array $call): bool => $call[0] === 'item'));
505 $this->assertCount(1, $item_calls);
506 $this->assertSame('Alte Bemerkung', $item_calls[0][1]['remarks']);
507 }
508}
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).
static removeShippedDirectory(string $relative_path)
static ensureClientDataDirDefinedOrSkip(\PHPUnit\Framework\TestCase $test)
Guards every fixture that writes under CLIENT_DATA_DIR: a full-suite run may have defined it as the r...
static directory(string $prefix, string $path, bool $local=false)
static removeDirectory(string $directory)
static writeShippedPo(string $relative_path, string $module, string $lang_key, TranslationCatalog $catalog)
Writes $catalog as the shipped .po: a module only counts as migrated while that exists.
static resetRuntime(?string $artifact_directory=null)
Forgets the static state of the runtime read path and points build and runtime at $artifact_directory...
_saveValues()/_deleteValues() for a PO module with a plural message: a form key ("item [n]") has no l...
testSaveValuesExplicitFormKeyThenPlainKeyTheLaterPlainKeyWins()
Reverse order: the plain identifier comes later and wins.
testSaveValuesFormZeroValueChangedFormOneOnlyRemarkChangedTakesTheRemarkFromFormOne()
Form 0's value changes and form 1's remark changes: the row is written once, with form 1's remark.
testSaveValuesDropsAFormKeyWithAnIndexBeyondTheShippedFormCount()
A form index the shipped message does not declare (e.g.
testDeleteValuesOfOneFormRevertsThePluralRowToTheShippedDefaultAndDropsTheOverlayEntry()
Deleting a form of a locally changed plural message (a missing form counts as shipped) removes the ov...
seedShippedPluralModule()
Ships self::MODULE/self::LANG_KEY with one plural message ("item"/"items") and registers its director...
testDeleteValuesKeepsTheExistingRemarkOfThePluralRow()
The existing remark (from lng_data, a delete posts none) must survive the rewrite of the plural row.
testSaveValuesAnEmptyPostedRemarkThatDiffersFromStoredDeletesIt()
An empty remark for a form with a non-empty stored remark deletes it (written as NULL).
testSaveValuesResetsThePluralRowWhenEveryFormMatchesTheShippedValueAgain()
Resubmitting every form with its shipped value writes the shipped default value and no local change.
testSaveValuesAPureRemarkChangeWithNoValueChangeStillWritesTheRow()
A pure remark change still writes the lng_data row, with local_change staying null.
testSaveValuesMapsAPlainPluralKeyToItsDefaultFormInTheOverlayAndLngData()
A plain key of a plural message maps to its default (last) form: only that form changes in the overla...
testSaveValuesRemarkOfThePluralRowIsTheFirstFormRemarkThatDiffersFromStored()
The remark of the merged row is the first posted remark that differs from the stored one,...
testSaveValuesKeepsTheStoredRemarkWhenNoPostedRemarkDiffersFromIt()
If no posted remark differs from the stored one, the stored remark is kept.
mockDatabaseForWrites(array &$replace_calls, array $existing_remarks=[])
testSaveValuesPlainKeyThenExplicitFormKeyTheLaterFormKeyWins()
If one resolved form is submitted twice (plain and explicit key) the later one in $a_values wins; her...
testSaveValuesWritesOneMergedLngDataRowForAChangedPluralForm()
Only form 1 changed: one merged lng_data row under the plain identifier with the default form's value...
Class ilLanguageBaseTest.
setGlobalVariable(string $name, $value)
language handling
static _deleteValues(string $a_lang_key, array $a_values=array())
Delete a set of translation in the database.
static _saveValues(string $a_lang_key, array $a_values=array(), array $a_remarks=array())
Save a set of translation in the database.
const CLIENT_DATA_DIR
Definition: constants.php:46
A directory of language files with a path and a prefix for them.
Interface ilDBInterface.
Interface ilDBStatement.
Interface Observer \BackgroundTasks Contains several chained tasks and infos about them.
global $lng
Definition: privfeed.php:26