xref: /plugin/dw2pdf/_test/EndToEndTest.php (revision c124fc539f0c5c773f84bc64bb897525f386e8f8)
1<?php
2
3namespace dokuwiki\plugin\dw2pdf\test;
4
5use dokuwiki\plugin\dw2pdf\src\BookCreatorLiveSelectionCollector;
6use dokuwiki\plugin\dw2pdf\src\Cache;
7use dokuwiki\plugin\dw2pdf\src\Config;
8use dokuwiki\plugin\dw2pdf\src\PageCollector;
9use dokuwiki\plugin\dw2pdf\src\PdfExportService;
10use DOMWrap\Document;
11use DOMWrap\Element;
12
13/**
14 * End-to-end tests for the dw2pdf plugin
15 *
16 * @group plugin_dw2pdf
17 * @group plugins
18 */
19class EndToEndTest extends \DokuWikiTest
20{
21    protected $pluginsEnabled = ['dw2pdf'];
22
23    public function setUp(): void
24    {
25        parent::setUp();
26        $_REQUEST = [];
27    }
28
29
30    /**
31     * Create the pages, render them through the PdfExportService in debug mode and return the resulting HTML.
32     *
33     * @param string|string[] $pages One or more pages to be included in the export
34     * @return string Rendered HTML output
35     */
36    protected function getDebugHTML($pages, $conf = []): string
37    {
38        $pages = (array)$pages;
39
40        foreach ($pages as $page) {
41            $this->prepareFixturePage($page);
42        }
43
44        $config = new Config(array_merge(
45            $conf,
46            [
47                'debug' => 1,
48                'liveselection' => json_encode($pages)
49            ]
50        ));
51        $collector = new BookCreatorLiveSelectionCollector($config);
52        $cache = new Cache($config, $collector);
53        $service = new PdfExportService($config, $collector, $cache, 'Contents', 'tester');
54        return $service->getDebugHtml();
55    }
56
57    /**
58     * Test that numbered headers are rendered correctly
59     */
60    public function testNumberedHeaders(): void
61    {
62        $html = $this->getDebugHTML('headers', ['headernumber' => 1]);
63
64        $dom = (new Document())->html($html);
65
66        $dom->find('h1')->each(function ($h) {
67            $this->assertMatchesRegularExpression('/^1\. Header/', $h->text());
68        });
69
70        $dom->find('h2')->each(function ($h) {
71            $this->assertMatchesRegularExpression('/^\d\.\d\. Header/', $h->text());
72        });
73
74        $dom->find('h3')->each(function ($h) {
75            $this->assertMatchesRegularExpression('/^\d\.\d\.\d\. Header/', $h->text());
76        });
77    }
78
79    /**
80     * Test that numbered headers are rendered correctly across multiple pages
81     *
82     * Each new page should increase the top-level header number
83     */
84    public function testNumberedHeadersMultipage(): void
85    {
86        $html = $this->getDebugHTML(['headers', 'simple'], ['headernumber' => 1]);
87
88        $dom = (new Document())->html($html);
89
90        $count = 1;
91        $dom->find('h1')->each(function ($h) use (&$count) {
92            $this->assertMatchesRegularExpression('/^' . ($count++) . '\. /', $h->text());
93        });
94    }
95
96    /**
97     * Ensure each rendered page begins with an anchor that namespaces intra-page links.
98     */
99    public function testDocumentStartCreatesPageAnchors(): void
100    {
101        $html = $this->getDebugHTML(['renderer_features', 'target']);
102
103        $dom = (new Document())->html($html);
104
105        foreach (['renderer_features', 'target'] as $pageId) {
106            $anchors = $dom->find('a[name="' . $pageId . '__"]');
107            $this->assertSame(
108                1,
109                count($anchors),
110                'Missing document_start anchor for ' . $pageId
111            );
112        }
113    }
114
115    /**
116     * Bookmarks should only be produced up to the configured level and include numbering.
117     */
118    public function testBookmarksRespectConfiguredLevels(): void
119    {
120        $html = $this->getDebugHTML('headers', ['headernumber' => 1, 'maxbookmarks' => 2]);
121
122        $dom = (new Document())->html($html);
123        $bookmarks = $dom->find('bookmark');
124
125        $this->assertGreaterThan(0, count($bookmarks));
126
127        foreach ($bookmarks as $bookmark) {
128            $this->assertLessThanOrEqual(
129                1,
130                (int)$bookmark->attr('level'),
131                'Bookmark level exceeded configured maximum'
132            );
133
134            $content = trim((string)$bookmark->attr('content'));
135            $this->assertMatchesRegularExpression('/^\d+(?:\.\d+)*\.\s+Header/', $content);
136        }
137
138        $this->assertSame(count($dom->find('h1, h2')), count($bookmarks));
139    }
140
141    /**
142     * Local section links should include the page-specific prefix.
143     */
144    public function testLocallinksArePrefixedWithPageId(): void
145    {
146        $html = $this->getDebugHTML(['renderer_features', 'target']);
147        $dom = (new Document())->html($html);
148
149        $link = $this->findLinkByText($dom, 'Jump to Remote Section');
150        $this->assertNotNull($link, 'Local section link missing');
151
152        $this->assertSame('#renderer_features__remote_section', $link->attr('href'));
153    }
154
155    /**
156     * Internal links must expose dw2pdf data attributes so the writer can retarget them.
157     */
158    public function testInternalLinksExposeDw2pdfMetadata(): void
159    {
160        $html = $this->getRawRendererHtml('renderer_features', [], ['target']);
161        $dom = (new Document())->html($html);
162
163        $pageLink = $this->findLinkByText($dom, 'Target page link');
164        $this->assertNotNull($pageLink, 'Page link missing');
165        $this->assertSame('target', $pageLink->attr('data-dw2pdf-target'));
166        $this->assertSame('', $pageLink->attr('data-dw2pdf-hash'));
167
168        $sectionLink = $this->findLinkByText($dom, 'Target section link');
169        $this->assertNotNull($sectionLink, 'Section link missing');
170        $this->assertSame('target', $sectionLink->attr('data-dw2pdf-target'));
171        $this->assertSame('sub_section', $sectionLink->attr('data-dw2pdf-hash'));
172    }
173
174    /**
175     * Centered media needs to be wrapped so CSS centering survives inside mPDF.
176     */
177    public function testCenteredMediaIsWrapped(): void
178    {
179        $html = $this->getDebugHTML('renderer_features');
180        $dom = (new Document())->html($html);
181
182        $wrappers = $dom->find('div[align="center"][style*="text-align: center"]');
183        $this->assertGreaterThan(0, count($wrappers), 'Centered media wrapper missing');
184        $this->assertGreaterThan(0, count($wrappers->first()->find('img')));
185    }
186
187    /**
188     * Acronyms should render as plain text to avoid useless hover hints in PDFs.
189     */
190    public function testAcronymOutputDropsHover(): void
191    {
192        $html = $this->getDebugHTML('renderer_features');
193        $dom = (new Document())->html($html);
194
195        $this->assertSame(0, count($dom->find('acronym')));
196        $this->assertStringContainsString('FAQ', $html);
197        $this->assertStringNotContainsString('Frequently Asked Questions', $html);
198    }
199
200    /**
201     * Email addresses must not be obfuscated so that mailto links remain readable.
202     */
203    public function testEmailLinksStayReadable(): void
204    {
205        $html = $this->getDebugHTML('renderer_features');
206        $dom = (new Document())->html($html);
207
208        $link = $this->findLinkByText($dom, 'test@example.com');
209        $this->assertNotNull($link, 'Email link missing');
210        $this->assertSame('mailto:test@example.com', $link->attr('href'));
211    }
212
213    /**
214     * Interwiki links should be prefixed with the respective icon.
215     */
216    public function testInterwikiLinksArePrefixedWithIcon(): void
217    {
218        $html = $this->getDebugHTML('renderer_features');
219        $dom = (new Document())->html($html);
220
221        $link = $dom->find('a.interwiki')->first();
222        $this->assertNotNull($link, 'Interwiki link missing');
223
224        $icon = $link->children()->first();
225        $this->assertNotNull($icon, 'Interwiki icon missing');
226        $this->assertSame('img', strtolower($icon->nodeName));
227        $this->assertStringContainsString('iw_doku', $icon->attr('class'));
228    }
229
230    /**
231     * Render a single page through the dw2pdf renderer without writer post-processing.
232     *
233     * @param string $pageId
234     * @param array $conf
235     * @param string[] $additionalPages
236     * @return string
237     */
238    protected function getRawRendererHtml(string $pageId, array $conf = [], array $additionalPages = []): string
239    {
240        $this->prepareFixturePage($pageId);
241        foreach ($additionalPages as $related) {
242            $this->prepareFixturePage($related);
243        }
244
245        /** @var \renderer_plugin_dw2pdf $renderer */
246        $renderer = plugin_load('renderer', 'dw2pdf', true);
247        $renderer->setConfig(new Config($conf));
248
249        global $ID;
250        $keep = $ID;
251        $ID = $pageId;
252
253        $file = wikiFN($pageId);
254        $instructions = p_get_instructions(io_readWikiPage($file, $pageId));
255        $info = [];
256        $html = p_render('dw2pdf', $instructions, $info);
257
258        $ID = $keep;
259
260        return $html;
261    }
262
263    /**
264     * Persist the given fixture page into the wiki so that it can be rendered.
265     *
266     * @param string $pageId
267     * @return void
268     */
269    protected function prepareFixturePage(string $pageId): void
270    {
271        $data = file_get_contents(__DIR__ . '/pages/' . $pageId . '.txt');
272        saveWikiText($pageId, $data, 'dw2pdf renderer test');
273    }
274
275    /**
276     * Locate the first hyperlink whose trimmed text matches the expected label.
277     *
278     * @param Document $dom
279     * @param string $text
280     * @return Element|null
281     */
282    protected function findLinkByText(Document $dom, string $text): ?Element
283    {
284        foreach ($dom->find('a') as $anchor) {
285            if (trim($anchor->text()) === $text) {
286                return $anchor;
287            }
288        }
289
290        return null;
291    }
292}
293