packages/sql-catalog/tests/Unit/Reporter/Html/Page/NamespacePageTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html\Page;
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\NamespacePage;
31use SqlCatalog\Reporter\Html\Palette;
32use SqlCatalog\Reporter\Html\ReportSite;
33use SqlCatalog\Reporter\Html\Scope;
34use SqlCatalog\Reporter\Html\SqlFormatter;
35use SqlCatalog\Reporter\Html\SqlHighlighter;
36use SqlCatalog\Reporter\Html\StatementList;
37use SqlCatalog\Reporter\Html\StatementRow;
38use SqlCatalog\Reporter\Html\TableName;
39
40#[CoversClass(NamespacePage::class)]
41#[UsesClass(CallSite::class)]
42#[UsesClass(Catalog::class)]
43#[UsesClass(CatalogEntry::class)]
44#[UsesClass(CatalogIndex::class)]
45#[UsesClass(CatalogStatistics::class)]
46#[UsesClass(HtmlText::class)]
47#[UsesClass(ReportSite::class)]
48#[UsesClass(Scope::class)]
49#[UsesClass(Severity::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(StatementPart::class)]
60#[UsesClass(Palette::class)]
61#[UsesClass(SqlFormatter::class)]
62#[UsesClass(SqlHighlighter::class)]
63#[UsesClass(StatementList::class)]
64#[UsesClass(StatementRow::class)]
65#[UsesClass(TableName::class)]
66#[UsesClass(Origin::class)]
67#[UsesClass(TextHole::class)]
68#[UsesClass(TypeShape::class)]
69final class NamespacePageTest extends TestCase
70{
71    public function testRenderListsEveryNamespaceWithWhatIsDeclaredInIt(): void
72    {
73        $catalog = new Catalog([
74            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'App\\R::find', 'pdo.query'), []),
75            new CatalogEntry('a2', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('a.php', 2, 'helper', 'pdo.query'), []),
76        ]);
77        $page = (new NamespacePage())->render(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()));
78
79        self::assertStringContainsString('<h1>Namespaces<span class="count">2 namespaces</span></h1>', $page);
80        self::assertStringContainsString('<h2 id="ns-global-namespace"><code>(global namespace)</code>', $page);
81        self::assertStringContainsString('<h2 id="ns-app"><code>App</code>', $page);
82        self::assertStringContainsString('No statement was found.', (new NamespacePage())->render(new ReportSite(new Catalog(), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
83    }
84
85    public function testSectionLeadsFromAClassToItsPageAndFromAFunctionToItsFile(): void
86    {
87        $entries = [
88            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users'], [], new CallSite('src/a.php', 1, 'R::find', 'pdo.query'), []),
89            new CatalogEntry('a2', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('src/b.php', 2, 'helper', 'pdo.query'), []),
90            new CatalogEntry('a3', StatementKind::Select, TextPattern::fromText('SELECT 3'), [], [], new CallSite('src/c.php', 2, '{main}', 'pdo.query'), []),
91        ];
92        $section = (new NamespacePage())->section(new ReportSite(new Catalog($entries), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), '', $entries);
93
94        self::assertStringContainsString('<a class="anchor" href="statements.html?namespace=">All statements</a>', $section);
95        self::assertStringContainsString('<tr><td><a class="mono" href="classes/r.html">R</a></td><td class="tight muted">class</td><td><a class="muted" href="files/src-a-php.html">src/a.php</'
96            . 'a></td><td class="num">1</td><td class="num">1</td>', $section);
97        self::assertStringContainsString('<a class="mono" href="files/src-b-php.html#fn-helper">helper</a></td><td class="tight muted">function</td>', $section);
98        self::assertStringContainsString('<a class="mono" href="statements.html?function=%7Bmain%7D">top-level code</a></td><td class="tight muted">top-level code</td>', $section);
99    }
100
101    public function testMembersListClassesBeforeFunctions(): void
102    {
103        $entries = [
104            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'alpha', 'pdo.query'), []),
105            new CatalogEntry('a2', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('a.php', 2, 'Zed::run', 'pdo.query'), []),
106            new CatalogEntry('a3', StatementKind::Select, TextPattern::fromText('SELECT 3'), [], [], new CallSite('a.php', 3, 'Zed::go', 'pdo.query'), []),
107        ];
108
109        self::assertSame(['Zed', 'alpha'], array_keys((new NamespacePage())->members($entries)));
110    }
111
112    public function testAnchorsNameEveryNamespace(): void
113    {
114        $catalog = new Catalog([
115            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'App\\R::find', 'pdo.query'), []),
116        ]);
117
118        self::assertSame([['App', 'ns-app']], (new NamespacePage())->anchors(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter())));
119    }
120
121
122    public function testMembersOrderNamesWithoutRegardToCase(): void
123    {
124        $entries = [
125            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'B::f', 'pdo.query'), []),
126            new CatalogEntry('a2', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('a.php', 2, 'a::f', 'pdo.query'), []),
127            new CatalogEntry('a3', StatementKind::Select, TextPattern::fromText('SELECT 3'), [], [], new CallSite('a.php', 3, 'Zed', 'pdo.query'), []),
128            new CatalogEntry('a4', StatementKind::Select, TextPattern::fromText('SELECT 4'), [], [], new CallSite('a.php', 4, 'beta', 'pdo.query'), []),
129        ];
130
131        self::assertSame(['a', 'B', 'beta', 'Zed'], array_keys((new NamespacePage())->members($entries)));
132    }
133
134    public function testRenderIsWrittenExactly(): void
135    {
136        $entries = [
137            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromSegments([
138                new LiteralText('SELECT id FROM posts WHERE slug = '),
139                new TextHole(Origin::External, TypeShape::unknown(), '$_GET["s"]'),
140            ]), ['posts'], [new Placeholder('?', 0, null, new ValueDomain('int', [7], true, []))], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), [
141                Finding::of(FindingRule::ExternalInput, 'spliced'),
142            ], false, ['App\\R::run', 'App\\R::find']),
143            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'), []),
144            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'), [
145                Finding::of(FindingRule::PlaceholderCountMismatch, 'one bound'),
146            ]),
147            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'), []),
148            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'), []),
149            new CatalogEntry('c1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('lib/c.php', 2, 'helper', 'mysqli.query'), [
150                Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
151            ], true, [], true),
152            new CatalogEntry('c2', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('lib/c.php', 6, '{main}', 'unmatched'), [
153                Finding::of(FindingRule::CallNotAnalyzed, 'unseen'),
154            ]),
155            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'), [
156                Finding::of(FindingRule::DynamicSql, 'dyn'),
157            ]),
158            new CatalogEntry('e1', StatementKind::Show, TextPattern::fromText('SHOW TABLES'), [], [], new CallSite('src/e.php', 1, 'App\\R::find', 'pdo.query'), []),
159            new CatalogEntry('f1', StatementKind::Select, TextPattern::fromText('SELECT 1 FROM t1'), ['t1'], [], new CallSite('src/f.php', 1, 'App\\F::a', 'pdo.query'), []),
160            new CatalogEntry('f2', StatementKind::Select, TextPattern::fromText('SELECT 2 FROM t2'), ['t2'], [], new CallSite('src/f.php', 2, 'App\\F::b', 'pdo.query'), []),
161            new CatalogEntry('f3', StatementKind::Select, TextPattern::fromText('SELECT 3 FROM t3'), ['t3'], [], new CallSite('src/f.php', 3, 'App\\G::a', 'pdo.query'), []),
162            new CatalogEntry('f4', StatementKind::Select, TextPattern::fromText('SELECT 4 FROM t4'), ['t4'], [], new CallSite('src/f.php', 4, 'App\\H::a', 'pdo.query'), []),
163            new CatalogEntry('f5', StatementKind::Select, TextPattern::fromText('SELECT 5 FROM t5'), ['t5'], [], new CallSite('src/g.php', 1, 'App\\I::a', 'pdo.query'), []),
164            new CatalogEntry('f6', StatementKind::Select, TextPattern::fromText('SELECT 6 FROM t6'), ['t6'], [], new CallSite('src/h.php', 1, 'App\\J::a', 'pdo.query'), []),
165            new CatalogEntry('f7', StatementKind::Select, TextPattern::fromText('SELECT 7 FROM t7'), ['t7'], [], new CallSite('src/i.php', 1, 'App\\K::a', 'pdo.query'), [
166                Finding::of(FindingRule::DynamicSql, 'dyn'),
167            ]),
168            new CatalogEntry('f8', StatementKind::Select, TextPattern::fromText('SELECT 8 FROM t8'), ['t8'], [], new CallSite('src/j.php', 1, 'App\\L::a', 'pdo.query'), [
169                Finding::of(FindingRule::DynamicSql, 'dyn'),
170            ]),
171            new CatalogEntry('f9', StatementKind::Select, TextPattern::fromText('SELECT 9 FROM t9'), ['t9'], [], new CallSite('src/k.php', 1, 'App\\M::a', 'pdo.query'), [
172                Finding::of(FindingRule::DynamicSql, 'dyn'),
173            ]),
174        ];
175        $catalog = new Catalog($entries, [new AnalysisProblem('src/broken.php', 'broken')]);
176        $site = new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
177
178        self::assertSame(
179            '<h1>Namespaces<span class="count">3 namespaces</span></h1><p class="lede">The classes and functions that issue statements, under the namespace each is'
180                . ' declared in. Open a class to read its statements method by method.</p><input type="search" name="filter" class="input input-block" data-filter-rows p'
181                . 'laceholder="Narrow by class or function name…" aria-label="Narrow by class or function name" autocomplete="off" spellcheck="false"><section class="gro'
182                . 'up"><h2 id="ns-global-namespace"><code>(global namespace)</code><span class="count">2 statements</span><a class="anchor" href="statements.html?namespa'
183                . 'ce=">All statements</a></h2><div class="table-wrap"><table class="sortable filter-target" data-dd-sortable><thead><tr><th scope="col" data-dd-sort="te'
184                . 'xt">Name</th><th scope="col" class="tight">Kind</th><th scope="col" data-dd-sort="text">File</th><th scope="col" class="num" data-dd-sort="number">Sta'
185                . 'tements</th><th scope="col" class="num" data-dd-sort="number">Tables</th><th scope="col" class="num" data-dd-sort="number">Attention</th></tr></thead>'
186                . '<tbody><tr><td><a class="mono" href="files/lib-c-php.html#fn-helper">helper</a></td><td class="tight muted">function</td><td><a class="muted" href="fi'
187                . 'les/lib-c-php.html">lib/c.php</a></td><td class="num">1</td><td class="num">0</td><td class="num"><span class="none">0</span></td></tr><tr><td><a clas'
188                . 's="mono" href="statements.html?function=%7Bmain%7D">top-level code</a></td><td class="tight muted">top-level code</td><td><a class="muted" href="files'
189                . '/lib-c-php.html">lib/c.php</a></td><td class="num">1</td><td class="num">0</td><td class="num"><span class="none">0</span></td></tr></tbody></table></'
190                . 'div></section><section class="group"><h2 id="ns-app"><code>App</code><span class="count">14 statements</span><a class="anchor" href="statements.html?n'
191                . 'amespace=App">All statements</a></h2><div class="table-wrap"><table class="sortable filter-target" data-dd-sortable><thead><tr><th scope="col" data-dd'
192                . '-sort="text">Name</th><th scope="col" class="tight">Kind</th><th scope="col" data-dd-sort="text">File</th><th scope="col" class="num" data-dd-sort="nu'
193                . 'mber">Statements</th><th scope="col" class="num" data-dd-sort="number">Tables</th><th scope="col" class="num" data-dd-sort="number">Attention</th></tr'
194                . '></thead><tbody><tr><td><a class="mono" href="classes/app-f.html">App\\F</a></td><td class="tight muted">class</td><td><a class="muted" href="files/sr'
195                . 'c-f-php.html">src/f.php</a></td><td class="num">2</td><td class="num">2</td><td class="num"><span class="none">0</span></td></tr><tr><td><a class="mon'
196                . 'o" href="classes/app-g.html">App\\G</a></td><td class="tight muted">class</td><td><a class="muted" href="files/src-f-php.html">src/f.php</a></td><td c'
197                . 'lass="num">1</td><td class="num">1</td><td class="num"><span class="none">0</span></td></tr><tr><td><a class="mono" href="classes/app-h.html">App\\H</'
198                . 'a></td><td class="tight muted">class</td><td><a class="muted" href="files/src-f-php.html">src/f.php</a></td><td class="num">1</td><td class="num">1</t'
199                . 'd><td class="num"><span class="none">0</span></td></tr><tr><td><a class="mono" href="classes/app-i.html">App\\I</a></td><td class="tight muted">class<'
200                . '/td><td><a class="muted" href="files/src-g-php.html">src/g.php</a></td><td class="num">1</td><td class="num">1</td><td class="num"><span class="none">'
201                . '0</span></td></tr><tr><td><a class="mono" href="classes/app-j.html">App\\J</a></td><td class="tight muted">class</td><td><a class="muted" href="files/'
202                . 'src-h-php.html">src/h.php</a></td><td class="num">1</td><td class="num">1</td><td class="num"><span class="none">0</span></td></tr><tr><td><a class="m'
203                . 'ono" href="classes/app-k.html">App\\K</a></td><td class="tight muted">class</td><td><a class="muted" href="files/src-i-php.html">src/i.php</a></td><td'
204                . ' class="num">1</td><td class="num">1</td><td class="num">1</td></tr><tr><td><a class="mono" href="classes/app-l.html">App\\L</a></td><td class="tight '
205                . 'muted">class</td><td><a class="muted" href="files/src-j-php.html">src/j.php</a></td><td class="num">1</td><td class="num">1</td><td class="num">1</td>'
206                . '</tr><tr><td><a class="mono" href="classes/app-m.html">App\\M</a></td><td class="tight muted">class</td><td><a class="muted" href="files/src-k-php.htm'
207                . 'l">src/k.php</a></td><td class="num">1</td><td class="num">1</td><td class="num">1</td></tr><tr><td><a class="mono" href="classes/app-r.html">App\\R</'
208                . 'a></td><td class="tight muted">class</td><td><a class="muted" href="files/src-a-php.html">src/a.php</a></td><td class="num">5</td><td class="num">2</t'
209                . 'd><td class="num">3</td></tr></tbody></table></div></section><section class="group"><h2 id="ns-app-admin"><code>App\\Admin</code><span class="count">2'
210                . ' statements</span><a class="anchor" href="statements.html?namespace=App%5CAdmin">All statements</a></h2><div class="table-wrap"><table class="sortable'
211                . ' filter-target" data-dd-sortable><thead><tr><th scope="col" data-dd-sort="text">Name</th><th scope="col" class="tight">Kind</th><th scope="col" data-d'
212                . 'd-sort="text">File</th><th scope="col" class="num" data-dd-sort="number">Statements</th><th scope="col" class="num" data-dd-sort="number">Tables</th><'
213                . 'th scope="col" class="num" data-dd-sort="number">Attention</th></tr></thead><tbody><tr><td><a class="mono" href="classes/app-admin-u.html">App\\Admin\\U'
214                . '</a></td><td class="tight muted">class</td><td><a class="muted" href="files/src-b-php.html">src/b.php</a></td><td class="num">2</td><td class="num">1<'
215                . '/td><td class="num"><span class="none">0</span></td></tr></tbody></table></div></section>',
216            (new NamespacePage())->render($site),
217        );
218    }
219}
220