perf(admin): infinite-scroll the contenus langues/mots-clés tables

The contenus page loaded both lookup tables in full via HTMX on page load:
760 tag rows + 216 language rows, ~2.4MB of HTML including ~1960 inline
SVG icons and per-row CSRF forms. The DB queries were already fast (~6ms);
the cost was pure client-side payload and DOM.

- Add paged lookups: getTagsPage/getLanguagesPage + countTagsWithCount/
  countLanguagesWithCount, and optional limit/offset on the existing
  unpaged variants (backward compatible).
- Fragments serve 25 rows per request. A sentinel <tr> with
  hx-trigger="intersect once root:#<wrap>" appends the next page when it
  scrolls into the table's own scroll container (htmx 'revealed' only
  checks the window viewport, not nested scrollers).
- Search forms still swap the whole wrapper and reset to offset 0.
- Style the load-more row; add PagedLanguagesTagsTest coverage.

Initial DOM for the page drops from ~2.4MB to ~160KB; scrolling reaches the
full totals (736 tags, 216 languages) and stops cleanly.
This commit is contained in:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent 8016712741
commit 25c5133086
6 changed files with 475 additions and 62 deletions
+174
View File
@@ -0,0 +1,174 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* PagedLanguagesTagsTest — covers the paged lookups that back the admin
* contenus page "load more" infinite scroll (getTagsPage / getLanguagesPage
* plus their count helpers and the optional limit/offset on the unpaged
* variants).
*/
class PagedLanguagesTagsTest extends TestCase
{
private Database $db;
protected function setUp(): void
{
TestDatabase::resetData();
$this->db = TestDatabase::getInstance();
}
protected function tearDown(): void
{
// Languages are seeded by the schema and not cleared by resetData(),
// so remove the test-specific rows we inserted.
TestDatabase::getPDO()
->exec("DELETE FROM languages WHERE name LIKE 'PagLang%'");
}
private function seedTags(array $names): void
{
$pdo = TestDatabase::getPDO();
$stmt = $pdo->prepare('INSERT INTO tags (name) VALUES (?)');
foreach ($names as $n) {
$stmt->execute([$n]);
}
}
private function seedLanguages(array $names): void
{
$pdo = TestDatabase::getPDO();
$stmt = $pdo->prepare('INSERT INTO languages (name) VALUES (?)');
foreach ($names as $n) {
$stmt->execute([$n]);
}
}
// ── Tags ──────────────────────────────────────────────────────────────
public function testGetTagsPageRespectsLimitAndOffset(): void
{
$this->seedTags(['alpha', 'bravo', 'charlie', 'delta', 'echo']);
$page1 = $this->db->getTagsPage('', 2, 0);
$page2 = $this->db->getTagsPage('', 2, 2);
$page3 = $this->db->getTagsPage('', 2, 4);
$this->assertSame(['alpha', 'bravo'], array_column($page1, 'name'));
$this->assertSame(['charlie', 'delta'], array_column($page2, 'name'));
$this->assertSame(['echo'], array_column($page3, 'name'));
}
public function testGetTagsPageFiltersByPrefix(): void
{
$this->seedTags(['alpha', 'alpine', 'bravo']);
$rows = $this->db->getTagsPage('al', 100, 0);
$this->assertSame(['alpha', 'alpine'], array_column($rows, 'name'));
}
public function testCountTagsWithCount(): void
{
$this->seedTags(['alpha', 'alpine', 'bravo']);
$this->assertSame(3, $this->db->countTagsWithCount(''));
$this->assertSame(2, $this->db->countTagsWithCount('al'));
$this->assertSame(0, $this->db->countTagsWithCount('zzz'));
}
public function testCountTagsIgnoresSoftDeleted(): void
{
$this->seedTags(['alpha', 'bravo']);
$id = (int) TestDatabase::getPDO()
->query("SELECT id FROM tags WHERE name = 'bravo'")->fetchColumn();
$this->db->deleteTag($id);
$this->assertSame(1, $this->db->countTagsWithCount(''));
$this->assertSame(['alpha'], array_column($this->db->getTagsPage('', 100, 0), 'name'));
}
public function testGetAllTagsWithCountOptionalPaging(): void
{
$this->seedTags(['alpha', 'bravo', 'charlie']);
$this->assertCount(3, $this->db->getAllTagsWithCount());
$this->assertSame(
['alpha', 'bravo'],
array_column($this->db->getAllTagsWithCount(2, 0), 'name')
);
$this->assertSame(
['charlie'],
array_column($this->db->getAllTagsWithCount(2, 2), 'name')
);
}
// ── Languages ─────────────────────────────────────────────────────────
/**
* The schema seeds a few languages, so language tests must not assume an
* empty table. Query a unique prefix ("PagLang") and account for the
* seeded rows in the unpaged assertions.
*/
public function testGetLanguagesPageRespectsLimitAndOffset(): void
{
$this->seedLanguages(['PagLang A', 'PagLang B', 'PagLang C', 'PagLang D']);
$this->assertSame(
['PagLang A', 'PagLang B'],
array_column($this->db->getLanguagesPage('PagLang', 2, 0), 'name')
);
$this->assertSame(
['PagLang C', 'PagLang D'],
array_column($this->db->getLanguagesPage('PagLang', 2, 2), 'name')
);
}
public function testGetLanguagesPageDeduplicatesCaseInsensitively(): void
{
$this->seedLanguages(['PagLangDup', 'paglanguedup']);
// Grouped by LOWER(name): two distinct lowercase names.
$names = array_column($this->db->getLanguagesPage('PagLang', 100, 0), 'name');
$lowered = array_map('strtolower', $names);
$this->assertSame(['paglangdup', 'paglanguedup'], $lowered);
$this->assertSame(2, $this->db->countLanguagesWithCount('PagLang'));
}
public function testGetLanguagesPageFiltersCaseInsensitively(): void
{
$this->seedLanguages(['PagLangX', 'PagLangY']);
// Lowercase query must still match the capitalised names.
$rows = $this->db->getLanguagesPage('pagl', 100, 0);
$this->assertSame(['PagLangX', 'PagLangY'], array_column($rows, 'name'));
}
public function testCountLanguagesWithCount(): void
{
$this->seedLanguages(['PagLangA', 'PagLangB', 'PagLangC']);
$this->assertSame(3, $this->db->countLanguagesWithCount('PagLang'));
$this->assertSame(1, $this->db->countLanguagesWithCount('PagLangA'));
$this->assertSame(0, $this->db->countLanguagesWithCount('zzz'));
}
public function testGetAllLanguagesWithCountOptionalPaging(): void
{
$this->seedLanguages(['PagLangA', 'PagLangB', 'PagLangC']);
$unpaged = array_map('strtolower', array_column($this->db->getAllLanguagesWithCount(), 'name'));
$this->assertContains('paglanga', $unpaged);
$this->assertContains('paglangb', $unpaged);
$this->assertContains('paglangc', $unpaged);
$page1 = array_column($this->db->getAllLanguagesWithCount(2, 0), 'name');
$page2 = array_column($this->db->getAllLanguagesWithCount(2, 2), 'name');
$this->assertCount(2, $page1);
$this->assertCount(2, $page2);
// Pages must not overlap.
$this->assertSame([], array_intersect($page1, $page2));
}
}