xref: /dokuwiki/_test/tests/Parsing/ModeRegistryTest.php (revision 1e28e406b358f79221c515b2a56520d5dbbfb6c8)
1<?php
2
3namespace dokuwiki\test\Parsing;
4
5use dokuwiki\Parsing\ModeRegistry;
6use dokuwiki\Parsing\ParserMode\ModeInterface;
7
8class ModeRegistryTest extends \DokuWikiTest
9{
10    /** @var ModeRegistry */
11    private $registry;
12
13    function setUp(): void
14    {
15        parent::setUp();
16        ModeRegistry::reset();
17        $this->registry = ModeRegistry::getInstance();
18    }
19
20    function tearDown(): void
21    {
22        ModeRegistry::reset();
23        parent::tearDown();
24    }
25
26    function testSingleton()
27    {
28        $this->assertSame(
29            ModeRegistry::getInstance(),
30            ModeRegistry::getInstance()
31        );
32    }
33
34    function testResetCreatesFreshInstance()
35    {
36        $first = ModeRegistry::getInstance();
37        ModeRegistry::reset();
38        $second = ModeRegistry::getInstance();
39        $this->assertNotSame($first, $second);
40    }
41
42    function testConstructorPopulatesGlobal()
43    {
44        global $PARSER_MODES;
45        $this->assertIsArray($PARSER_MODES);
46        $this->assertArrayHasKey('container', $PARSER_MODES);
47        $this->assertArrayHasKey('formatting', $PARSER_MODES);
48        $this->assertArrayHasKey('substition', $PARSER_MODES);
49        $this->assertArrayHasKey('protected', $PARSER_MODES);
50        $this->assertArrayHasKey('disabled', $PARSER_MODES);
51        $this->assertArrayHasKey('paragraphs', $PARSER_MODES);
52        $this->assertArrayHasKey('baseonly', $PARSER_MODES);
53    }
54
55    function testGetCategories()
56    {
57        global $PARSER_MODES;
58        $this->assertSame($PARSER_MODES, $this->registry->getCategories());
59    }
60
61    function testGetModesForSingleCategory()
62    {
63        $modes = $this->registry->getModesForCategories([ModeRegistry::CATEGORY_CONTAINER]);
64        $this->assertContains('listblock', $modes);
65        $this->assertContains('table', $modes);
66        $this->assertContains('quote', $modes);
67        $this->assertContains('hr', $modes);
68    }
69
70    function testGetModesForMultipleCategories()
71    {
72        $modes = $this->registry->getModesForCategories([
73            ModeRegistry::CATEGORY_CONTAINER,
74            ModeRegistry::CATEGORY_BASEONLY,
75        ]);
76        $this->assertContains('listblock', $modes);
77        $this->assertContains('header', $modes);
78    }
79
80    function testGetModesForCategoriesDeduplicates()
81    {
82        $modes = $this->registry->getModesForCategories([
83            ModeRegistry::CATEGORY_CONTAINER,
84            ModeRegistry::CATEGORY_CONTAINER,
85        ]);
86        $counts = array_count_values($modes);
87        foreach ($counts as $count) {
88            $this->assertEquals(1, $count);
89        }
90    }
91
92    function testGetModesForUnknownCategoryReturnsEmpty()
93    {
94        $modes = $this->registry->getModesForCategories(['nonexistent']);
95        $this->assertSame([], $modes);
96    }
97
98    function testRegisterMode()
99    {
100        global $PARSER_MODES;
101        $this->registry->registerMode(ModeRegistry::CATEGORY_CONTAINER, 'testmode');
102        $this->assertContains('testmode', $PARSER_MODES[ModeRegistry::CATEGORY_CONTAINER]);
103        $this->assertContains(
104            'testmode',
105            $this->registry->getModesForCategories([ModeRegistry::CATEGORY_CONTAINER])
106        );
107    }
108
109    function testGlobalModificationsAreVisible()
110    {
111        global $PARSER_MODES;
112        $PARSER_MODES[ModeRegistry::CATEGORY_FORMATTING][] = 'custom_format';
113        $modes = $this->registry->getModesForCategories([ModeRegistry::CATEGORY_FORMATTING]);
114        $this->assertContains('custom_format', $modes);
115    }
116
117    function testGetModesReturnsSortedArray()
118    {
119        $modes = $this->registry->getModes();
120        $this->assertNotEmpty($modes);
121
122        $sortValues = array_column($modes, 'sort');
123        $sorted = $sortValues;
124        sort($sorted);
125        $this->assertSame($sorted, $sortValues);
126    }
127
128    function testGetModesContainsExpectedKeys()
129    {
130        $modes = $this->registry->getModes();
131        foreach ($modes as $entry) {
132            $this->assertArrayHasKey('sort', $entry);
133            $this->assertArrayHasKey('mode', $entry);
134            $this->assertArrayHasKey('obj', $entry);
135            $this->assertIsInt($entry['sort']);
136            $this->assertIsString($entry['mode']);
137            $this->assertInstanceOf(ModeInterface::class, $entry['obj']);
138        }
139    }
140
141    function testGetModesContainsBuiltinModes()
142    {
143        $modes = $this->registry->getModes();
144        $modeNames = array_column($modes, 'mode');
145        $this->assertContains('strong', $modeNames);
146        $this->assertContains('header', $modeNames);
147        $this->assertContains('listblock', $modeNames);
148        $this->assertContains('eol', $modeNames);
149        $this->assertContains('smiley', $modeNames);
150        $this->assertContains('acronym', $modeNames);
151        $this->assertContains('entity', $modeNames);
152    }
153
154    function testSortModes()
155    {
156        $a = ['sort' => 10, 'mode' => 'a'];
157        $b = ['sort' => 20, 'mode' => 'b'];
158        $this->assertLessThan(0, ModeRegistry::sortModes($a, $b));
159        $this->assertGreaterThan(0, ModeRegistry::sortModes($b, $a));
160        $this->assertEquals(0, ModeRegistry::sortModes($a, $a));
161    }
162
163    function testBlockEolModesEmptyByDefault()
164    {
165        $this->assertSame([], $this->registry->getBlockEolModes());
166    }
167
168    function testRegisterBlockEolMode()
169    {
170        $this->registry->registerBlockEolMode('listblock');
171        $this->registry->registerBlockEolMode('table');
172        $this->assertSame(['listblock', 'table'], $this->registry->getBlockEolModes());
173    }
174
175    function testLineStartMarkersEmptyByDefault()
176    {
177        $this->assertSame([], $this->registry->getLineStartMarkers());
178    }
179
180    function testRegisterLineStartMarkers()
181    {
182        $this->registry->registerLineStartMarkers('listblock', ['\\*', '\\-']);
183        $markers = $this->registry->getLineStartMarkers();
184        $this->assertContains('\\*', $markers);
185        $this->assertContains('\\-', $markers);
186    }
187
188    function testLineStartMarkersDeduplicates()
189    {
190        $this->registry->registerLineStartMarkers('mode_a', ['\\*', '\\-']);
191        $this->registry->registerLineStartMarkers('mode_b', ['\\-', '\\+']);
192        $markers = $this->registry->getLineStartMarkers();
193        $this->assertCount(3, $markers);
194        $this->assertContains('\\*', $markers);
195        $this->assertContains('\\-', $markers);
196        $this->assertContains('\\+', $markers);
197    }
198
199    function testBlockEolModesResetWithInstance()
200    {
201        $this->registry->registerBlockEolMode('listblock');
202        ModeRegistry::reset();
203        $fresh = ModeRegistry::getInstance();
204        $this->assertSame([], $fresh->getBlockEolModes());
205    }
206
207    /**
208     * The default syntax setting must produce the exact same mode set as before
209     * the syntax setting was introduced (no-op guarantee).
210     */
211    function testGetModesDefaultSyntaxMatchesLegacy()
212    {
213        global $conf;
214        $conf['syntax'] = 'dokuwiki';
215        ModeRegistry::reset();
216        $registry = ModeRegistry::getInstance();
217        $modes = $registry->getModes();
218        $modeNames = array_column($modes, 'mode');
219
220        // All original built-in modes must be present
221        $expected = [
222            'listblock', 'preformatted', 'notoc', 'nocache',
223            'header', 'table', 'linebreak', 'footnote',
224            'hr', 'unformatted', 'code', 'file', 'quote',
225            'internallink', 'rss', 'media', 'externallink',
226            'emaillink', 'windowssharelink', 'eol',
227            'strong', 'emphasis', 'underline', 'monospace',
228            'subscript', 'superscript', 'deleted',
229            'smiley', 'acronym', 'entity',
230        ];
231        foreach ($expected as $mode) {
232            $this->assertContains($mode, $modeNames, "Mode '$mode' missing in dokuwiki syntax setting");
233        }
234    }
235
236    /** DW-only modes must be absent when syntax is 'markdown' */
237    function testGetModesDwModesSkippedInMarkdownOnly()
238    {
239        global $conf;
240        $conf['syntax'] = 'markdown';
241        ModeRegistry::reset();
242        $registry = ModeRegistry::getInstance();
243        $modes = $registry->getModes();
244        $modeNames = array_column($modes, 'mode');
245
246        $dwOnly = [
247            'emphasis', 'deleted', 'code', 'header', 'hr',
248            'linebreak', 'internallink', 'media', 'listblock', 'table',
249            'monospace', 'unformatted', 'file',
250        ];
251        foreach ($dwOnly as $mode) {
252            $this->assertNotContains($mode, $modeNames, "DW mode '$mode' should not load in markdown-only mode");
253        }
254    }
255
256    /** Always-loaded modes must still be present in markdown-only mode */
257    function testGetModesAlwaysModesPresentInMarkdownOnly()
258    {
259        global $conf;
260        $conf['syntax'] = 'markdown';
261        ModeRegistry::reset();
262        $registry = ModeRegistry::getInstance();
263        $modes = $registry->getModes();
264        $modeNames = array_column($modes, 'mode');
265
266        $always = [
267            'strong', 'subscript', 'superscript',
268            'footnote', 'eol', 'preformatted',
269            'quote', 'externallink', 'emaillink', 'windowssharelink',
270            'notoc', 'nocache', 'rss',
271            'smiley', 'acronym', 'entity',
272        ];
273        foreach ($always as $mode) {
274            $this->assertContains($mode, $modeNames, "Always-loaded mode '$mode' missing in markdown syntax setting");
275        }
276    }
277
278    /** In mixed modes, DW modes must still load */
279    function testGetModesMixedModesLoadDwModes()
280    {
281        $dwOnly = [
282            'emphasis', 'deleted', 'code', 'header', 'hr',
283            'linebreak', 'internallink', 'media', 'listblock', 'table',
284            'monospace', 'unformatted', 'file',
285        ];
286
287        foreach (['dw+md', 'md+dw'] as $syntax) {
288            global $conf;
289            $conf['syntax'] = $syntax;
290            ModeRegistry::reset();
291            $registry = ModeRegistry::getInstance();
292            $modes = $registry->getModes();
293            $modeNames = array_column($modes, 'mode');
294
295            foreach ($dwOnly as $mode) {
296                $this->assertContains($mode, $modeNames, "DW mode '$mode' missing in '$syntax' syntax setting");
297            }
298        }
299    }
300
301    /**
302     * Verifies that each mode is loaded in the expected combinations of
303     * `$conf['syntax']`. One data set per (mode, syntax) pair.
304     *
305     * Add new mode-gating rules to {@see provideModeLoadingCases} — each
306     * entry lists the four syntax settings and whether the mode should be
307     * loaded there.
308     *
309     * @dataProvider provideModeLoadingCases
310     */
311    function testModeLoadingBySyntax(string $mode, string $syntax, bool $shouldLoad): void
312    {
313        global $conf;
314        $conf['syntax'] = $syntax;
315        ModeRegistry::reset();
316        $modeNames = array_column(ModeRegistry::getInstance()->getModes(), 'mode');
317
318        if ($shouldLoad) {
319            $this->assertContains($mode, $modeNames, "$mode must load in '$syntax'");
320        } else {
321            $this->assertNotContains($mode, $modeNames, "$mode must NOT load in '$syntax'");
322        }
323    }
324
325    /**
326     * Data provider for {@see testModeLoadingBySyntax}.
327     *
328     * Declares, per parser mode, whether it should be loaded in each of the
329     * four `$conf['syntax']` settings (`dokuwiki`, `markdown`, `dw+md`,
330     * `md+dw`). Entries are expanded into one data set per (mode, syntax)
331     * pair so PHPUnit reports failures with a specific label.
332     *
333     * Five gating categories are represented:
334     *
335     * - **Always**: loaded unconditionally (no syntax-specific counterpart
336     *   or conflict). Covers core formatting, paragraphs, and data-driven
337     *   modes (smileys, acronyms, entities).
338     * - **DW-always**: loaded whenever DokuWiki is part of the syntax. Used
339     *   for features that have a Markdown counterpart but no delimiter
340     *   conflict (e.g. `**bold**` for emphasis).
341     * - **DW-preferred**: loaded only when DokuWiki is the primary syntax.
342     *   Used when the delimiter conflicts with a Markdown mode in MD-
343     *   preferred settings (e.g. `__` clashes with GFM strong).
344     * - **MD-always**: mirror — loaded whenever Markdown is part of the
345     *   syntax. Used when the delimiter has no DokuWiki counterpart (e.g.
346     *   `*` for emphasis).
347     * - **MD-preferred**: mirror — loaded only when Markdown is primary.
348     *   Used when the delimiter conflicts with a DokuWiki mode in DW-
349     *   preferred settings (e.g. `_`, `__`, `___` clash with Underline).
350     *
351     * Add a new line to the `$rules` table to register additional mode-
352     * gating rules.
353     *
354     * @return array<string, array{0: string, 1: string, 2: bool}> map from
355     *     test-case label to [mode name, syntax setting, should-load]
356     */
357    public static function provideModeLoadingCases(): array
358    {
359        $rules = [
360            // Always-loaded (unconditional — no syntax-specific counterpart)
361            'strong'                         => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
362            'subscript'                      => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
363            'superscript'                    => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
364            'footnote'                       => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
365            'eol'                            => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
366            'preformatted'                   => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
367            'quote'                          => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
368            'externallink'                   => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
369            'emaillink'                      => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
370            'windowssharelink'               => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
371            'notoc'                          => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
372            'nocache'                        => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
373            'rss'                            => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
374            'smiley'                         => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
375            'acronym'                        => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
376            'entity'                         => ['dokuwiki' => true,  'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
377            // DW-always (features with MD counterparts but no delimiter clash)
378            'emphasis'                       => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
379            'deleted'                        => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
380            'code'                           => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
381            'header'                         => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
382            'hr'                             => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
383            'linebreak'                      => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
384            'internallink'                   => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
385            'media'                          => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
386            'listblock'                      => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
387            'table'                          => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
388            'monospace'                      => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
389            'unformatted'                    => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
390            'file'                           => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => true ],
391            // MD-always (`*` / `~~` have no conflicting DW counterpart)
392            'gfm_emphasis'                   => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
393            'gfm_emphasis_strong'            => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
394            'gfm_deleted'                    => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
395            'gfm_backtick_single'            => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
396            'gfm_backtick_double'            => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
397            'gfm_header'                     => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
398            'gfm_link'                       => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
399            'gfm_media'                      => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
400            'gfm_code'                       => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
401            'gfm_file'                       => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => true,  'md+dw' => true ],
402            // MD-preferred (`_`, `__`, `___` clash with Underline in DW)
403            'gfm_emphasis_underscore'        => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => false, 'md+dw' => true ],
404            'gfm_strong_underscore'          => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => false, 'md+dw' => true ],
405            'gfm_emphasis_strong_underscore' => ['dokuwiki' => false, 'markdown' => true,  'dw+md' => false, 'md+dw' => true ],
406            // DW-preferred (Underline's `__` clashes with GFM strong)
407            'underline'                      => ['dokuwiki' => true,  'markdown' => false, 'dw+md' => true,  'md+dw' => false],
408        ];
409
410        $cases = [];
411        foreach ($rules as $mode => $bySyntax) {
412            foreach ($bySyntax as $syntax => $shouldLoad) {
413                $cases["$mode in $syntax"] = [$mode, $syntax, $shouldLoad];
414            }
415        }
416        return $cases;
417    }
418
419}
420