packages/sql-catalog/tests/Unit/Reporter/Html/PageShellTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Catalog\AnalysisProblem;
11use SqlCatalog\Core\Catalog\CallSite;
12use SqlCatalog\Core\Catalog\Catalog;
13use SqlCatalog\Core\Catalog\CatalogEntry;
14use SqlCatalog\Core\Catalog\Finding;
15use SqlCatalog\Core\Catalog\FindingRule;
16use SqlCatalog\Core\Catalog\Placeholder;
17use SqlCatalog\Core\Catalog\Resolution;
18use SqlCatalog\Core\Catalog\Severity;
19use SqlCatalog\Core\Catalog\StatementPart;
20use SqlCatalog\Core\Catalog\ValueDomain;
21use SqlCatalog\Core\Sql\StatementKind;
22use SqlCatalog\Core\Text\LiteralText;
23use SqlCatalog\Core\Text\Origin;
24use SqlCatalog\Core\Text\TextHole;
25use SqlCatalog\Core\Text\TextPattern;
26use SqlCatalog\Core\Type\TypeShape;
27use SqlCatalog\Reporter\Html\CatalogIndex;
28use SqlCatalog\Reporter\Html\CatalogStatistics;
29use SqlCatalog\Reporter\Html\HtmlText;
30use SqlCatalog\Reporter\Html\Page\TablePage;
31use SqlCatalog\Reporter\Html\PageShell;
32use SqlCatalog\Reporter\Html\Palette;
33use SqlCatalog\Reporter\Html\ReportSite;
34use SqlCatalog\Reporter\Html\Scope;
35use SqlCatalog\Reporter\Html\SqlFormatter;
36use SqlCatalog\Reporter\Html\SqlHighlighter;
37use SqlCatalog\Reporter\Html\StatementList;
38use SqlCatalog\Reporter\Html\StatementRow;
39use SqlCatalog\Reporter\Html\TableName;
40
41#[CoversClass(PageShell::class)]
42#[UsesClass(CallSite::class)]
43#[UsesClass(Catalog::class)]
44#[UsesClass(CatalogEntry::class)]
45#[UsesClass(CatalogIndex::class)]
46#[UsesClass(CatalogStatistics::class)]
47#[UsesClass(HtmlText::class)]
48#[UsesClass(ReportSite::class)]
49#[UsesClass(Scope::class)]
50#[UsesClass(StatementKind::class)]
51#[UsesClass(LiteralText::class)]
52#[UsesClass(TextPattern::class)]
53#[UsesClass(AnalysisProblem::class)]
54#[UsesClass(Finding::class)]
55#[UsesClass(FindingRule::class)]
56#[UsesClass(Placeholder::class)]
57#[UsesClass(ValueDomain::class)]
58#[UsesClass(Resolution::class)]
59#[UsesClass(Severity::class)]
60#[UsesClass(StatementPart::class)]
61#[UsesClass(Palette::class)]
62#[UsesClass(SqlFormatter::class)]
63#[UsesClass(SqlHighlighter::class)]
64#[UsesClass(StatementList::class)]
65#[UsesClass(StatementRow::class)]
66#[UsesClass(TableName::class)]
67#[UsesClass(Origin::class)]
68#[UsesClass(TextHole::class)]
69#[UsesClass(TypeShape::class)]
70#[UsesClass(TablePage::class)]
71final class PageShellTest extends TestCase
72{
73    public function testRenderWritesOneCompleteDocumentWithLinksRelativeToTheRoot(): void
74    {
75        $page = (new PageShell())->render(new ReportSite(new Catalog(), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'tables/users.html', 'users', [['Overview', 'index.html'], ['users', null]], '<p>body</p>', [['On this page', [['Reads', '#reads', null, false]], null]]);
76
77        self::assertStringStartsWith('<!DOCTYPE html>' . "\n" . '<html lang="en" data-dd-theme-key="sql-catalog-theme">', $page);
78        self::assertStringContainsString('<title>users</title>', $page);
79        self::assertStringContainsString('<link rel="stylesheet" href="../assets/document-design-v1.0.0.css">', $page);
80        self::assertStringContainsString('<body data-root="../">', $page);
81        self::assertStringContainsString('<div class="doc">' . "\n" . '<nav class="sidebar" id="navigation" aria-label="Report navigation">', $page);
82        self::assertStringContainsString('<main class="content" id="content">' . "\n" . '<p>body</p></main>', $page);
83        self::assertStringContainsString('<script src="../assets/search-index.js" defer></script>' . "\n" . '<script src="../assets/report.js" defer></script>' . "\n" . '<script src="../assets/document-design-v1.0.0.js" defer></script>', $page);
84        self::assertStringContainsString('<ul class="sidebar-list sidebar-context" data-dd-toc><li><a href="#reads" title="Reads">Reads</a></li></ul>', $page);
85    }
86
87    public function testCrumbsWriteTheTrailToThePage(): void
88    {
89        self::assertSame(
90            '<a href="../index.html">Overview</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">Tables</span>',
91            (new PageShell())->crumbs([['Overview', 'index.html'], ['Tables', null]], '../'),
92        );
93    }
94
95    public function testSidebarWritesTheRoutesAndThenWhatThePageIsLeftFrom(): void
96    {
97        $catalog = new Catalog([
98            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
99        ]);
100        $sidebar = (new PageShell())->sidebar(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'tables/t.html', [['Tables', [['t', 'tables/t.html', 1, true]], 'tables.html']]);
101
102        self::assertStringStartsWith('<div class="sidebar-section"><p class="sidebar-title">Browse</p>', $sidebar);
103        self::assertStringEndsWith('<div class="sidebar-section"><p class="sidebar-title">Tables</p><ul class="sidebar-list sidebar-context"><li class="is-active"><a href="../tables/t.ht'
104            . 'ml" title="t" aria-current="page">t</a><span class="sidebar-count">1</span></li></ul></div>', $sidebar);
105        self::assertStringNotContainsString('SQL catalog', $sidebar);
106    }
107
108    public function testRoutesMarkTheRouteBeingReadAndCountWhatEachHolds(): void
109    {
110        $catalog = new Catalog([
111            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users'], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
112        ]);
113        $routes = (new PageShell())->routes(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'tables/users.html');
114
115        self::assertStringContainsString('<li class="is-active"><a href="../tables.html" aria-current="page">Tables</a><span class="sidebar-count">1</span></li>', $routes);
116        self::assertStringContainsString('<li><a href="../index.html">Overview</a></li>', $routes);
117        self::assertStringContainsString('<li><a href="../findings.html">Findings</a><span class="sidebar-count">0</span></li>', $routes);
118    }
119
120    public function testBlockWritesSectionsAsTheyAreAndPagesRelativeToTheRoot(): void
121    {
122        $shell = new PageShell();
123
124        self::assertSame('', $shell->block('Empty', [], null, '../'));
125        self::assertSame(
126            '<div class="sidebar-section"><p class="sidebar-title">On this page</p><ul class="sidebar-list sidebar-context"><li><a href="#a" title="A &amp; B">A &a'
127                . 'mp; B</a></li><li class="is-active"><a href="../tables/t.html" title="t" aria-current="page">t</a><span class="sidebar-count">3</span></li></ul></div>',
128            $shell->block('On this page', [['A & B', '#a', null, false], ['t', 'tables/t.html', 3, true]], null, '../'),
129        );
130        self::assertSame(
131            '<div class="sidebar-section"><p class="sidebar-title">On this page</p><ul class="sidebar-list sidebar-context" data-dd-toc><li><a href="#a" title="A">A</a></li></ul></div>',
132            $shell->block('On this page', [['A', '#a', null, false]], null, '../'),
133        );
134    }
135
136    public function testBlockCutsALongListAndSaysWhereTheRestAre(): void
137    {
138        $items = array_map(static fn (int $n): array => ['t' . $n, 'tables/t' . $n . '.html', $n, false], range(1, 41));
139        $block = (new PageShell())->block('Tables', $items, 'tables.html', '');
140
141        self::assertStringContainsString('title="t40"', $block);
142        self::assertStringNotContainsString('title="t41"', $block);
143        self::assertStringEndsWith('<li class="sidebar-more"><a href="tables.html">All 41…</a></li></ul></div>', $block);
144        self::assertStringNotContainsString('sidebar-more', (new PageShell())->block('Tables', array_slice($items, 0, 40), 'tables.html', ''));
145    }
146
147    public function testOnThisPageTurnsAnchorsIntoABlock(): void
148    {
149        self::assertSame([], (new PageShell())->onThisPage([]));
150        self::assertSame([['On this page', [['Reads', '#reads', null, false]], null]], (new PageShell())->onThisPage([['Reads', 'reads']]));
151    }
152
153    public function testHeadLoadsOnlyTheDocUiStylesheet(): void
154    {
155        $head = (new PageShell())->head('../', 'A & B');
156
157        self::assertStringStartsWith('<!DOCTYPE html>' . "\n" . '<html lang="en" data-dd-theme-key="sql-catalog-theme">' . "\n" . '<head>', $head);
158        self::assertStringContainsString('<meta name="color-scheme" content="light dark">', $head);
159        self::assertStringContainsString('<title>A &amp; B</title>', $head);
160        self::assertSame(1, substr_count($head, 'rel="stylesheet"'));
161        self::assertStringNotContainsString('<style', $head);
162        self::assertStringContainsString('<link rel="stylesheet" href="../assets/document-design-v1.0.0.css">', $head);
163        self::assertStringEndsWith((new PageShell())->bootstrap() . "\n" . '</head>' . "\n", $head);
164    }
165
166    public function testTopbarCarriesTheTrailAndTheControlsTheScriptReveals(): void
167    {
168        $topbar = (new PageShell())->topbar([['Overview', 'index.html'], ['Tables', null]], '../');
169
170        self::assertStringStartsWith('<header class="topbar">' . "\n" . '<button class="btn nav-toggle" type="button" data-dd-nav-toggle aria-controls="navigation" aria-expanded="false"', $topbar);
171        self::assertStringContainsString('<nav class="breadcrumbs" aria-label="Breadcrumb"><a href="../index.html">Overview</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">Tables</span></nav>', $topbar);
172        self::assertStringContainsString('<input type="search" id="search" class="input input-search" data-dd-search data-dd-enhance hidden', $topbar);
173        self::assertStringContainsString('<button class="btn" type="button" data-dd-theme-toggle data-dd-enhance hidden aria-label="Switch theme">◐</button>', $topbar);
174        self::assertStringEndsWith('</header>' . "\n", $topbar);
175    }
176
177    public function testBootstrapRestoresTheChosenTheme(): void
178    {
179        self::assertSame('<script>try{var t=localStorage.getItem("sql-catalog-theme");if(t){document.documentElement.dataset.ddTheme=t}}catch(e){}</script>', (new PageShell())->bootstrap());
180    }
181
182    public function testRenderIsWrittenExactly(): void
183    {
184        $entries = [
185            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromSegments([
186                new LiteralText('SELECT id FROM posts WHERE slug = '),
187                new TextHole(Origin::External, TypeShape::unknown(), '$_GET["s"]'),
188            ]), ['posts'], [new Placeholder('?', 0, null, new ValueDomain('int', [7], true, []))], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), [
189                Finding::of(FindingRule::ExternalInput, 'spliced'),
190            ], false, ['App\\R::run', 'App\\R::find']),
191            new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT INTO posts (id) VALUES (1)'), ['posts'], [], new CallSite('src/a.php', 9, 'App\\R::add', 'pdo.query'), []),
192            new CatalogEntry('a3', StatementKind::Update, TextPattern::fromText('UPDATE posts SET title = ? WHERE id = ?'), ['posts'], [], new CallSite('src/a.php', 14, 'App\\R::add', 'pdo.prepare'), [
193                Finding::of(FindingRule::PlaceholderCountMismatch, 'one bound'),
194            ]),
195            new CatalogEntry('b1', StatementKind::Delete, TextPattern::fromText('DELETE FROM users WHERE id = 1'), ['users'], [], new CallSite('src/b.php', 3, 'App\\Admin\\U::drop', 'pdo.query'), []),
196            new CatalogEntry('b2', StatementKind::Alter, TextPattern::fromText('ALTER TABLE users ADD x INT'), ['users'], [], new CallSite('src/b.php', 8, 'App\\Admin\\U::migrate', 'pdo.exec'), []),
197            new CatalogEntry('c1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('lib/c.php', 2, 'helper', 'mysqli.query'), [
198                Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
199            ], true, [], true),
200            new CatalogEntry('c2', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('lib/c.php', 6, '{main}', 'unmatched'), [
201                Finding::of(FindingRule::CallNotAnalyzed, 'unseen'),
202            ]),
203            new CatalogEntry('d1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts p JOIN users u ON u.id = p.author'), ['posts', 'users'], [], new CallSite('src/d.php', 1, 'App\\R::find', 'pdo.query'), [
204                Finding::of(FindingRule::DynamicSql, 'dyn'),
205            ]),
206            new CatalogEntry('e1', StatementKind::Show, TextPattern::fromText('SHOW TABLES'), [], [], new CallSite('src/e.php', 1, 'App\\R::find', 'pdo.query'), []),
207            new CatalogEntry('f1', StatementKind::Select, TextPattern::fromText('SELECT 1 FROM t1'), ['t1'], [], new CallSite('src/f.php', 1, 'App\\F::a', 'pdo.query'), []),
208            new CatalogEntry('f2', StatementKind::Select, TextPattern::fromText('SELECT 2 FROM t2'), ['t2'], [], new CallSite('src/f.php', 2, 'App\\F::b', 'pdo.query'), []),
209            new CatalogEntry('f3', StatementKind::Select, TextPattern::fromText('SELECT 3 FROM t3'), ['t3'], [], new CallSite('src/f.php', 3, 'App\\G::a', 'pdo.query'), []),
210            new CatalogEntry('f4', StatementKind::Select, TextPattern::fromText('SELECT 4 FROM t4'), ['t4'], [], new CallSite('src/f.php', 4, 'App\\H::a', 'pdo.query'), []),
211            new CatalogEntry('f5', StatementKind::Select, TextPattern::fromText('SELECT 5 FROM t5'), ['t5'], [], new CallSite('src/g.php', 1, 'App\\I::a', 'pdo.query'), []),
212            new CatalogEntry('f6', StatementKind::Select, TextPattern::fromText('SELECT 6 FROM t6'), ['t6'], [], new CallSite('src/h.php', 1, 'App\\J::a', 'pdo.query'), []),
213            new CatalogEntry('f7', StatementKind::Select, TextPattern::fromText('SELECT 7 FROM t7'), ['t7'], [], new CallSite('src/i.php', 1, 'App\\K::a', 'pdo.query'), [
214                Finding::of(FindingRule::DynamicSql, 'dyn'),
215            ]),
216            new CatalogEntry('f8', StatementKind::Select, TextPattern::fromText('SELECT 8 FROM t8'), ['t8'], [], new CallSite('src/j.php', 1, 'App\\L::a', 'pdo.query'), [
217                Finding::of(FindingRule::DynamicSql, 'dyn'),
218            ]),
219            new CatalogEntry('f9', StatementKind::Select, TextPattern::fromText('SELECT 9 FROM t9'), ['t9'], [], new CallSite('src/k.php', 1, 'App\\M::a', 'pdo.query'), [
220                Finding::of(FindingRule::DynamicSql, 'dyn'),
221            ]),
222        ];
223        $catalog = new Catalog($entries, [new AnalysisProblem('src/broken.php', 'broken')]);
224        $site = new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
225
226        self::assertSame(
227            '<!DOCTYPE html>
228<html lang="en" data-dd-theme-key="sql-catalog-theme">
229<head>
230<meta charset="utf-8">
231<meta name="viewport" content="width=device-width'
232                . ', initial-scale=1">
233<meta name="color-scheme" content="light dark">
234<title>posts</title>
235<link rel="stylesheet" href="../assets/document-design-v1.0.0'
236                . '.css">
237<script>try{var t=localStorage.getItem("sql-catalog-theme");if(t){document.documentElement.'
238                . 'dataset.ddTheme=t}}catch(e){}</script>
239</head>
240<body data-root="../">
241<a class="skip" href="#content">Skip to content</a>
242<div class="doc">
243<nav class'
244                . '="sidebar" id="navigation" aria-label="Report navigation"><div class="sidebar-section"><p class="sidebar-title">Browse</p><ul class="sidebar-list"><li'
245                . '><a href="../index.html">Overview</a></li><li><a href="../statements.html">Statements</a><span class="sidebar-count">18</span></li><li class="is-activ'
246                . 'e"><a href="../tables.html" aria-current="page">Tables</a><span class="sidebar-count">11</span></li><li><a href="../namespaces.html">Namespaces</a><sp'
247                . 'an class="sidebar-count">3</span></li><li><a href="../files.html">Files</a><span class="sidebar-count">11</span></li><li><a href="../findings.html">Fi'
248                . 'ndings</a><span class="sidebar-count">8</span></li></ul></div><div class="sidebar-section"><p class="sidebar-title">On this page</p><ul class="sidebar'
249                . '-list sidebar-context" data-dd-toc><li><a href="#used-from" title="Used from">Used from</a></li><li><a href="#alongside" title="Named alongside">Named'
250                . ' alongside</a></li><li><a href="#writes" title="Writes">Writes</a></li><li><a href="#reads" title="Reads">Reads</a></li></ul></div><div class="sidebar'
251                . '-section"><p class="sidebar-title">Tables</p><ul class="sidebar-list sidebar-context"><li class="is-active"><a href="../tables/posts.html" title="post'
252                . 's" aria-current="page">posts</a><span class="sidebar-count">4</span></li><li><a href="../tables/users.html" title="users">users</a><span class="sideba'
253                . 'r-count">3</span></li><li><a href="../tables/t1.html" title="t1">t1</a><span class="sidebar-count">1</span></li><li><a href="../tables/t2.html" title='
254                . '"t2">t2</a><span class="sidebar-count">1</span></li><li><a href="../tables/t3.html" title="t3">t3</a><span class="sidebar-count">1</span></li><li><a h'
255                . 'ref="../tables/t4.html" title="t4">t4</a><span class="sidebar-count">1</span></li><li><a href="../tables/t5.html" title="t5">t5</a><span class="sideba'
256                . 'r-count">1</span></li><li><a href="../tables/t6.html" title="t6">t6</a><span class="sidebar-count">1</span></li><li><a href="../tables/t7.html" title='
257                . '"t7">t7</a><span class="sidebar-count">1</span></li><li><a href="../tables/t8.html" title="t8">t8</a><span class="sidebar-count">1</span></li><li><a h'
258                . 'ref="../tables/t9.html" title="t9">t9</a><span class="sidebar-count">1</span></li></ul></div></nav>
259<div class="main">
260<header class="topbar">
261<button'
262                . ' class="btn nav-toggle" type="button" data-dd-nav-toggle aria-controls="navigation" aria-expanded="false" aria-label="Open navigation">☰</button>
263<nav'
264                . ' class="breadcrumbs" aria-label="Breadcrumb"><a href="../index.html">Overview</a><span class="breadcrumb-sep">/</span><a href="../tables.html">Tables<'
265                . '/a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">posts</span></nav>
266<div class="topbar-tools">
267<input type="search" id="search'
268                . '" class="input input-search" data-dd-search data-dd-enhance hidden placeholder="Find a statement… ( / )" aria-label="Search by SQL text, table, functi'
269                . 'on or file" autocomplete="off" spellcheck="false">
270<button class="btn" type="button" data-dd-theme-toggle data-dd-enhance hidden aria-label="Switch th'
271                . 'eme">◐</button>
272</div>
273</header>
274<div class="search-results" data-dd-search-results hidden></div>
275<main class="content" id="content">
276<p>body</p></mai'
277                . 'n>
278<footer class="doc-footer">Written by <a href="https://github.com/k-kinzal/ztd-query-php/tree/main/packages/sql-catalog">sql-catalog</a>.</footer>
279'
280                . '</div>
281</div>
282<script src="../assets/search-index.js" defer></script>
283<script src="../assets/report.js" defer></script>
284<script src="../assets/documen'
285                . 't-design-v1.0.0.js" defer></script>
286</body>
287</html>
288',
289            (new PageShell())->render($site, 'tables/posts.html', 'posts', [['Overview', 'index.html'], ['Tables', 'tables.html'], ['posts', null]], '<p>body</p>', (new TablePage())->context($site, 'posts')),
290        );
291    }
292}
293