ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
MigratedLanguageFileSyncOverlayFilesTest.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
28use PHPUnit\Framework\Attributes\DataProvider;
29use PHPUnit\Framework\TestCase;
30
35{
41 #[DataProvider('refreshFlags')]
42 public function testAnUnchangedOverlayWithACorrectMoWritesNothing(bool $refresh): void
43 {
44 $this->seedShipped(['greeting' => 'Hallo']);
45 $this->sync(['greeting' => 'Servus']);
46 $po_before = file_get_contents($this->overlayBase() . '.po');
47 $mo_before = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
48 $po_inode = fileinode($this->overlayBase() . '.po');
49 $mo_inode = fileinode(MigratedPoFixture::overlayMo($this->overlayBase()));
50 $cache = new ReflectionProperty(\ILIAS\Language\ComponentTranslation\TranslationGettextRepository::class, 'states');
51 $cache->setValue(null, [self::MODULE . '|de' => ['greeting' => 'cached']]);
52
53 try {
54 $this->sync(['greeting' => 'Servus'], $refresh);
55
56 $this->assertSame([self::MODULE . '|de' => ['greeting' => 'cached']], $cache->getValue(), 'no invalidation');
57 } finally {
58 $cache->setValue(null, []);
59 }
60 clearstatcache();
61 $this->assertSame($po_before, file_get_contents($this->overlayBase() . '.po'));
62 $this->assertSame($mo_before, file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())));
63 $this->assertSame($po_inode, fileinode($this->overlayBase() . '.po'), 'the .po was not replaced');
64 $this->assertSame($mo_inode, fileinode(MigratedPoFixture::overlayMo($this->overlayBase())), 'the .mo was not replaced');
65 }
66
68 {
69 // a local change: a shipped value creates no overlay (delta)
70 $this->seedShipped(['greeting' => 'Hallo']);
71 $this->sync(['greeting' => 'Servus']);
72 $po_before = file_get_contents($this->overlayBase() . '.po');
73 $mo_before = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
74 $po_inode = fileinode($this->overlayBase() . '.po');
76
77 $this->sync(['greeting' => 'Servus']);
78
79 clearstatcache();
80 $this->assertSame($po_before, file_get_contents($this->overlayBase() . '.po'));
81 $this->assertSame($po_inode, fileinode($this->overlayBase() . '.po'), 'the unchanged .po is not rewritten');
82 $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
83 $this->assertSame($mo_before, file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase())));
84 }
85
92 #[DataProvider('staleMoContents')]
93 public function testAStaleMoNextToAnUnchangedPoIsRebuilt(\Closure $stale_content, bool $refresh): void
94 {
95 $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
96 $this->sync(['greeting' => 'Servus', 'farewell' => 'Tschüss']);
97 $po_before = file_get_contents($this->overlayBase() . '.po');
98 $po_inode = fileinode($this->overlayBase() . '.po');
99 $mo_before = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
100 file_put_contents(MigratedPoFixture::overlayMo($this->overlayBase()), $stale_content($mo_before));
101 $cache = new ReflectionProperty(\ILIAS\Language\ComponentTranslation\TranslationGettextRepository::class, 'states');
102 $cache->setValue(null, [self::MODULE . '|de' => ['greeting' => 'stale']]);
103
104 try {
105 $this->sync(['greeting' => 'Servus', 'farewell' => 'Tschüss'], $refresh);
106
107 $this->assertSame([], $cache->getValue(), 'the rebuilt .mo invalidates the read cache');
108 } finally {
109 $cache->setValue(null, []);
110 }
111
112 clearstatcache();
113 $mo_after = file_get_contents(MigratedPoFixture::overlayMo($this->overlayBase()));
114 $this->assertSame($mo_before, $mo_after);
115 $this->assertSame(\ILIAS\Language\ComponentTranslation\ShippedTranslations::compileCatalog(MigratedPoFixture::readPo($this->overlayBase() . '.po'), self::MODULE, false)['mo'], $mo_after);
116 // "farewell" carries the shipped value and is not in the overlay
117 $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
118 $this->assertSame($po_before, file_get_contents($this->overlayBase() . '.po'));
119 $this->assertSame($po_inode, fileinode($this->overlayBase() . '.po'), 'the unchanged .po is not rewritten');
120 }
121
125 public static function staleMoContents(): array
126 {
127 return [
128 'garbage' => [static fn(string $mo): string => 'SENTINEL', false],
129 'truncated' => [static fn(string $mo): string => substr($mo, 0, intdiv(strlen($mo), 2)), false],
130 'one byte missing' => [static fn(string $mo): string => substr($mo, 0, -1), false],
131 'empty' => [static fn(string $mo): string => '', false],
132 'another catalog' => [
133 static fn(string $mo): string => MigratedPoFixture::catalog(self::MODULE, ['greeting' => 'Veraltet'])->toMoString(),
134 false,
135 ],
136 'garbage, reconciling write' => [static fn(string $mo): string => 'SENTINEL', true],
137 ];
138 }
139
141 {
142 // a first local change creates the overlay to start from
143 $this->seedShipped(['greeting' => 'Hallo']);
144 $this->sync(['greeting' => 'Moin']);
145 file_put_contents(MigratedPoFixture::overlayMo($this->overlayBase()), 'SENTINEL');
146
147 $this->sync(['greeting' => 'Servus']);
148
149 $this->assertSame(['greeting' => 'Servus'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
150 }
151
158 {
159 $this->seedShipped(['greeting' => 'Hallo', 'farewell' => 'Tschüss']);
160
161 MigratedLanguageFileSync::sync(
162 $this->manager,
163 $this->ilias_root,
164 'de',
165 self::MODULE,
166 ['greeting' => 'Servus', 'farewell' => 'Pfiat di'],
167 $this->client_data_dir,
168 true,
169 null,
170 null,
171 ['greeting' => '2026-10-02 09:36:30', 'farewell' => 'not a timestamp']
172 );
173
174 $this->assertSame('2026-10-02T09:36:30Z', LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'greeting')));
175 $this->assertNotNull(LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'farewell')), 'an unreadable date: the time of the write');
176 $this->assertNotSame('2026-10-02T09:36:30Z', LocalChangeComments::getLocalChange($this->overlayPo()->find(null, 'farewell')));
177 }
178
180 {
181 $this->seedShipped(['greeting' => 'Hallo']);
182 $cache = new ReflectionProperty(\ILIAS\Language\ComponentTranslation\TranslationGettextRepository::class, 'states');
183 $cache->setValue(null, [self::MODULE . '|de' => ['greeting' => 'stale'], 'other|de' => ['x' => 'y']]);
184
185 try {
186 $this->sync(['greeting' => 'Servus']);
187
188 $this->assertSame(['other|de' => ['x' => 'y']], $cache->getValue());
189 } finally {
190 $cache->setValue(null, []);
191 }
192 }
193
201 {
202 // a shipped "0" is not written, so the local value "0" (shipped is "1") goes through the overlay files
203 $this->seedShipped(['zero' => '1', 'greeting' => 'Hallo']);
204
205 $this->sync(['zero' => '0', 'greeting' => 'Hallo']);
206
207 $po = file_get_contents($this->overlayBase() . '.po');
208 $this->assertStringContainsString("msgid \"zero\"\nmsgstr \"0\"\n", $po);
209 $this->assertSame(['zero' => '0'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
210 $zero = $this->overlayPo()->find(null, 'zero');
211 $this->assertSame('1', LocalChangeComments::getOriginal($zero));
212 $this->assertNotNull(LocalChangeComments::getLocalChange($zero));
213
214 $po_inode = fileinode($this->overlayBase() . '.po');
215 $mo_inode = fileinode(MigratedPoFixture::overlayMo($this->overlayBase()));
216 $this->sync(['zero' => '0', 'greeting' => 'Hallo'], true);
217 $this->sync(['zero' => '0', 'greeting' => 'Hallo']);
218
219 clearstatcache();
220 $this->assertSame($po_inode, fileinode($this->overlayBase() . '.po'), 'the .po was not replaced');
221 $this->assertSame($mo_inode, fileinode(MigratedPoFixture::overlayMo($this->overlayBase())), 'the .mo was not replaced');
222 }
223
225 {
226 $this->seedShipped(['count' => '1']);
227 $this->sync(['count' => '1']);
228
229 $this->sync(['count' => '0']);
230
231 $count = $this->overlayPo()->find(null, 'count');
232 $this->assertSame('0', $count?->getTranslation());
233 $this->assertSame('1', LocalChangeComments::getOriginal($count));
234 $this->assertNotNull(LocalChangeComments::getLocalChange($count));
235 $this->assertSame(['count' => '0'], MigratedPoFixture::readMo(MigratedPoFixture::overlayMo($this->overlayBase())));
236 }
237
243 {
244 $shipped = ['lf' => "Zeile 1\nZeile 2", 'crlf' => "Zeile 1\r\nZeile 2", 'backslash' => 'C:\\pfad', 'literal' => 'a\\nb'];
245 $this->seedShipped($shipped);
246
247 $this->sync($shipped);
248 $this->sync($shipped, true);
249 $this->sync($shipped);
250
251 // an unchanged multi-line value is recognised as the shipped one: no overlay is written
252 $this->assertFileDoesNotExist($this->overlayBase() . '.po');
253 $translations = MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir);
254 foreach ($shipped as $identifier => $value) {
255 $this->assertSame($value, $translations[$identifier]['value'], $identifier);
256 $this->assertFalse($translations[$identifier]['local_change'], $identifier);
257 }
258 }
259
261 {
262 $this->seedShipped(['lf' => "Zeile 1\nZeile 2"]);
263 $this->sync(['lf' => "Zeile 1\nZeile 2"]);
264
265 $this->sync(['lf' => "Zeile 1\r\nZeile 2"]);
266
267 $entry = $this->overlayPo()->find(null, 'lf');
268 $this->assertSame("Zeile 1\nZeile 2", LocalChangeComments::getOriginal($entry));
269 $this->assertNotNull(LocalChangeComments::getLocalChange($entry));
270 }
271
278 {
279 $this->seedShipped(['lf' => "Zeile 1\nZeile 2"]);
280 $catalog = MigratedPoFixture::catalog(self::MODULE, ['lf' => ['value' => "Zeile 1\nZeile 2", 'local_change' => '2020-01-01T00:00:00Z']]);
281 $catalog->find(self::MODULE, 'lf')->addTranslatorComment('original: Zeile 1 Zeile 2');
282 MigratedPoFixture::writePair($this->overlayBase(), $catalog);
283
284 $this->sync(['lf' => "Zeile 1\nZeile 2"], true);
285
286 // the value is the shipped one, so the legacy entry with its false local change leaves the overlay instead of
287 // being repaired
288 $this->assertFileDoesNotExist($this->overlayBase() . '.po');
289 $this->assertFalse(MigratedLanguageFileSync::loadModuleTranslations(
290 $this->manager,
291 'de',
292 self::MODULE,
293 $this->client_data_dir
294 )['lf']['local_change']);
295 }
296
302 {
303 // the shipped value changed since, so "path" is still part of the delta (a value equal to the shipped one would
304 // leave the overlay)
305 $this->seedShipped(['path' => 'C:\\neu', 'greeting' => 'Hallo']);
306 $catalog = MigratedPoFixture::catalog(self::MODULE, ['path' => 'C:\\pfad', 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo']]);
307 $catalog->find(self::MODULE, 'path')->addTranslatorComment('original: C:\\pfad');
308 MigratedPoFixture::writePair($this->overlayBase(), $catalog);
309
310 $this->sync(['path' => 'C:\\pfad', 'greeting' => 'Servus']);
311
312 $path = $this->overlayPo()->find(null, 'path');
313 $this->assertSame('C:\\pfad', LocalChangeComments::getOriginal($path));
314 $this->assertNull(LocalChangeComments::getLocalChange($path));
315 $this->assertContains('original: C:\\pfad', $path->getTranslatorComments(), 'an admin edit never moves the original');
316 }
317
319 {
320 $this->seedShipped(['lf' => 'Zeile 1']);
321 $this->seedOverlay(['lf' => ['value' => "Zeile 1\nZeile 2", 'original' => "Zeile 1\nZeile 2"]]);
322
323 $this->assertSame(
324 "Zeile 1\nZeile 2",
325 MigratedLanguageFileSync::loadModuleTranslations($this->manager, 'de', self::MODULE, $this->client_data_dir)['lf']['original']
326 );
327 }
328
330 {
331 $this->seedShipped(['greeting' => 'Hallo']);
332 $this->seedOverlay([
333 'greeting' => ['value' => 'Hallo', 'original' => 'Hallo'],
334 'dropped' => ['value' => "Neu\nZeile", 'original' => "Alt\nZeile", 'local_change' => '2020-01-01T00:00:00Z'],
335 ]);
336
337 $this->sync(['greeting' => 'Hallo', 'dropped' => "Neu\nZeile"], true);
338
339 $dropped = $this->overlayPo()->find(null, 'dropped');
340 $this->assertSame("Alt\nZeile", LocalChangeComments::getOriginal($dropped));
341 $this->assertSame('2020-01-01T00:00:00Z', LocalChangeComments::getLocalChange($dropped));
342 }
343}
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
Tracks per overlay entry whether the current value still matches the value of the shipped ....
Reads the shipped .po of a migrated module and maintains its per-installation overlay: the ....
sync() and the overlay files: no-op guard, self-healing .mo, cache invalidation, value "0",...
testAStaleMoNextToAnUnchangedPoIsRebuilt(\Closure $stale_content, bool $refresh)
Self-healing: a .mo that does not hold exactly what TranslationCatalog::toMoString() compiles from th...
testALegacyOriginalWithABackslashIsReadVerbatimByAnAdminEdit()
An "original: " comment of the legacy format containing a backslash is read verbatim: an admin edit o...
testAnUnchangedMultiLineShippedValueIsNoLocalChangeAndResyncingIsANoOp()
A shipped value with line breaks or backslashes is stored as "original" losslessly: re-syncing it is ...
testTheValueZeroSurvivesTheSyncAndResyncingIsANoOp()
gettext/gettext treats a falsy translation as absent; the adapter's workaround must hold on the whole...
testAReconcilingWriteRepairsALegacyFlattenedOriginal()
An overlay written by an earlier version has the multi-line original flattened into spaces - a reconc...
testAnUnchangedOverlayWithACorrectMoWritesNothing(bool $refresh)
No-op guard: an unchanged .po next to a .mo equal to what TranslationCatalog::toMoString() compiles w...
testANewOverlayEntryTakesItsLocalChangeDateFromTheGivenDates()
An entry that enters the overlay with a write that knows when it was changed locally (setup update: l...
Fixtures of the MigratedLanguageFileSync tests.
sync(array $entries, bool $refresh=false, ?string $client_data_dir='default')
seedOverlay(array $entries, string $lang_key='de')
seedShipped(array $entries, string $lang_key='de', string $module=self::MODULE)
static writePair(string $base_path, TranslationCatalog $catalog)
Writes "<base>.po" and, like MigratedLanguageFileSync::sync(), its compiled revision named by current...
static catalog(string $module, array $entries)
static readMo(string $file)
static overlayMo(string $base_path)
The compiled catalog of the revision current names; a non-existing path if there is none.
static readPo(string $file)
A directory of language files with a path and a prefix for them.
Interface Observer \BackgroundTasks Contains several chained tasks and infos about them.