buildMetaDescription($synopsis); } public function testResolveOgImage(array $files): string { return $this->resolveOgImage($files); } public function testSplitJuryByRole(array $jury): array { return $this->splitJuryByRole($jury); } public function testCollectCaptionPaths(array $files): array { return $this->collectCaptionPaths($files); } } class ThesisCreateControllerTestable extends ThesisCreateController { public function testDetectFileType(string $mimeType, string $ext): string { return $this->detectFileType($mimeType, $ext); } public function testGenerateAuthorSlug(string $name): string { return $this->generateAuthorSlug($name); } } // ── Setup ───────────────────────────────────────────────────────────────────── echo "Pure Logic Unit Test\n"; echo "====================\n\n"; $db = Database::getInstance(); $tfe = new TfeControllerTestable($db); $createCtrl = new ThesisCreateControllerTestable($db); try { // ========================================================================= // SECTION A: TfeController helpers // ========================================================================= // ── A1: buildMetaDescription ────────────────────────────────────────────── echo "A1: buildMetaDescription — normal synopsis\n"; $desc = $tfe->testBuildMetaDescription('A short synopsis.'); plAssertEq('A short synopsis.', $desc, 'short synopsis returned as-is'); echo "\n"; echo "A2: buildMetaDescription — synopsis over 160 chars truncated\n"; $long = str_repeat('a', 200); $desc = $tfe->testBuildMetaDescription($long); plAssert(strlen($desc) <= 160, 'length <= 160'); plAssert(str_ends_with($desc, '…'), 'ends with ellipsis'); echo "\n"; echo "A3: buildMetaDescription — exactly 160 chars not truncated\n"; $exact = str_repeat('b', 160); $desc = $tfe->testBuildMetaDescription($exact); plAssertEq($exact, $desc, '160-char synopsis not truncated'); echo "\n"; echo "A4: buildMetaDescription — empty synopsis returns fallback\n"; $desc = $tfe->testBuildMetaDescription(''); plAssert($desc !== '', 'non-empty fallback returned'); plAssert(strlen($desc) <= 160, 'fallback length <= 160'); echo "\n"; echo "A5: buildMetaDescription — HTML tags stripped\n"; $desc = $tfe->testBuildMetaDescription('

Hello world

'); plAssertEq('Hello world', $desc, 'HTML tags stripped'); echo "\n"; // ── A6: resolveOgImage ──────────────────────────────────────────────────── echo "A6: resolveOgImage — cover preferred over image files\n"; $files = [ ['file_type' => 'image', 'file_path' => 'theses/2025/img.jpg'], ['file_type' => 'cover', 'file_path' => 'covers/cover.jpg'], ['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'], ]; $url = $tfe->testResolveOgImage($files); plAssert(str_contains($url, rawurlencode('covers/cover.jpg')), 'cover used when available'); echo "\n"; echo "A7: resolveOgImage — falls back to first image when no cover\n"; $files = [ ['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'], ['file_type' => 'image', 'file_path' => 'theses/2025/img.jpg'], ['file_type' => 'image', 'file_path' => 'theses/2025/img2.png'], ]; $url = $tfe->testResolveOgImage($files); plAssert(str_contains($url, rawurlencode('theses/2025/img.jpg')), 'first image file used as fallback'); plAssert(!str_contains($url, rawurlencode('img2.png')), 'second image not used'); echo "\n"; echo "A8: resolveOgImage — empty string when no image at all\n"; $files = [ ['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'], ['file_type' => 'audio', 'file_path' => 'theses/2025/audio.mp3'], ]; $url = $tfe->testResolveOgImage($files); plAssertEq('', $url, 'empty string when no image'); echo "\n"; echo "A9: resolveOgImage — empty files array returns empty string\n"; plAssertEq('', $tfe->testResolveOgImage([]), 'empty array → empty string'); echo "\n"; // ── A10: splitJuryByRole ────────────────────────────────────────────────── echo "A10: splitJuryByRole — all roles correctly binned\n"; $jury = [ ['name' => 'Alice', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0], ['name' => 'Bob', 'role' => 'promoteur', 'is_external' => 0, 'is_ulb' => 0], ['name' => 'Carol', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 1], ['name' => 'Dave', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 0], ['name' => 'Eve', 'role' => 'lecteur', 'is_external' => 0, 'is_ulb' => 0], ['name' => 'Frank', 'role' => 'lecteur', 'is_external' => 1, 'is_ulb' => 0], ]; $split = $tfe->testSplitJuryByRole($jury); plAssertEq(['Alice'], $split['presidents'], 'president'); plAssertEq(['Bob'], $split['internes'], 'interne promoteur'); plAssertEq(['Carol'], $split['ulb'], 'ulb promoteur'); plAssertEq(['Dave'], $split['externes'], 'externe promoteur (non-ULB)'); plAssertEq(['Eve'], $split['lecteurs_internes'], 'lecteur interne'); plAssertEq(['Frank'], $split['lecteurs_externes'], 'lecteur externe'); echo "\n"; echo "A11: splitJuryByRole — empty name skipped\n"; $jury = [['name' => '', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0]]; $split = $tfe->testSplitJuryByRole($jury); plAssertEq([], $split['presidents'], 'empty name not added'); echo "\n"; echo "A12: splitJuryByRole — empty jury returns all-empty arrays\n"; $split = $tfe->testSplitJuryByRole([]); plAssertEq([], $split['presidents'], 'presidents empty'); plAssertEq([], $split['internes'], 'internes empty'); plAssertEq([], $split['ulb'], 'ulb empty'); plAssertEq([], $split['externes'], 'externes empty'); plAssertEq([], $split['lecteurs_internes'], 'lecteurs_internes empty'); plAssertEq([], $split['lecteurs_externes'], 'lecteurs_externes empty'); echo "\n"; // ── A13: collectCaptionPaths ────────────────────────────────────────────── echo "A13: collectCaptionPaths — VTT files extracted in order\n"; $files = [ ['mime_type' => 'application/pdf', 'file_path' => 'main.pdf'], ['mime_type' => 'text/vtt', 'file_path' => 'captions1.vtt'], ['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'], ['mime_type' => 'text/plain', 'file_path' => 'captions2.vtt'], ]; $captions = $tfe->testCollectCaptionPaths($files); plAssertEq(['captions1.vtt', 'captions2.vtt'], $captions, 'both VTT files returned in order'); echo "\n"; echo "A14: collectCaptionPaths — .vtt extension without mime match\n"; $files = [ ['mime_type' => 'application/octet-stream', 'file_path' => 'sub.vtt'], ['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'], ]; $captions = $tfe->testCollectCaptionPaths($files); plAssertEq(['sub.vtt'], $captions, '.vtt extension matches even with generic mime'); echo "\n"; echo "A15: collectCaptionPaths — no VTT returns empty array\n"; $files = [ ['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'], ]; plAssertEq([], $tfe->testCollectCaptionPaths($files), 'empty array when no VTT'); echo "\n"; // ========================================================================= // SECTION B: ThesisCreateController helpers // ========================================================================= // ── B1: autofocusFieldForError ──────────────────────────────────────────── echo "B1: autofocusFieldForError — known error messages map to fields\n"; $cases = [ ["Titre du mémoire", 'titre'], ["Nom/Prénom/Pseudo", 'auteurice'], ["Synopsis", 'synopsis'], ["Année invalide", 'année'], ["orientation", 'orientation'], ["Atelier Pratique", 'ap'], ["finalité", 'finality'], ["langue", 'languages'], ["promoteur", 'jury_promoteur'], ["lecteur·ice interne", 'jury_lecteur_interne[]'], ["lecteur·ice externe", 'jury_lecteur_externe[]'], ["format", 'formats'], ["licence", 'license_id'], ["Lien URL", 'lien'], ]; foreach ($cases as [$message, $expected]) { $actual = ThesisCreateController::autofocusFieldForError($message); plAssertEq($expected, $actual, "\"$message\" → $expected"); } echo "\n"; echo "B2: autofocusFieldForError — unknown message returns null\n"; plAssertEq(null, ThesisCreateController::autofocusFieldForError('completely unknown'), 'null for unknown'); echo "\n"; // ── B3: detectFileType ──────────────────────────────────────────────────── echo "B3: detectFileType — mime-based detection\n"; $cases = [ ['text/vtt', 'vtt', 'caption'], ['audio/mpeg', 'mp3', 'audio'], ['audio/ogg', 'ogg', 'audio'], ['video/mp4', 'mp4', 'video'], ['video/webm', 'webm', 'video'], ['application/pdf', 'pdf', 'main'], ['image/jpeg', 'jpg', 'image'], ['image/png', 'png', 'image'], ['application/zip', 'zip', 'other'], ]; foreach ($cases as [$mime, $ext, $expected]) { $actual = $createCtrl->testDetectFileType($mime, $ext); plAssertEq($expected, $actual, "$mime / $ext → $expected"); } echo "\n"; echo "B4: detectFileType — extension-based fallback\n"; // application/octet-stream with known extensions $cases = [ ['application/octet-stream', 'mp3', 'audio'], ['application/octet-stream', 'mp4', 'video'], ['application/octet-stream', 'pdf', 'main'], ['application/octet-stream', 'webp', 'image'], ['application/octet-stream', 'vtt', 'caption'], ]; foreach ($cases as [$mime, $ext, $expected]) { $actual = $createCtrl->testDetectFileType($mime, $ext); plAssertEq($expected, $actual, "octet-stream + .$ext → $expected"); } echo "\n"; // ── B5: generateAuthorSlug ──────────────────────────────────────────────── echo "B5: generateAuthorSlug — basic ASCII\n"; plAssertEq('JANE_DOE', $createCtrl->testGenerateAuthorSlug('Jane Doe'), 'spaces to underscores, uppercase'); plAssertEq('AUTHOR', $createCtrl->testGenerateAuthorSlug(''), 'empty → AUTHOR'); echo "\n"; echo "B6: generateAuthorSlug — French accents stripped\n"; plAssertEq('ELEONORE_DUPONT', $createCtrl->testGenerateAuthorSlug('Éléonore Dupont'), 'accents stripped'); plAssertEq('FRANCOISE', $createCtrl->testGenerateAuthorSlug('Françoise'), 'ç → C'); echo "\n"; echo "B7: generateAuthorSlug — multiple authors comma-separated\n"; $slug = $createCtrl->testGenerateAuthorSlug('Alice Martin, Bob Durand'); plAssert(str_contains($slug, 'ALICE'), 'contains ALICE'); plAssert(str_contains($slug, 'BOB'), 'contains BOB'); echo "\n"; // ========================================================================= // SECTION C: ExportController — CSV column count consistency // ========================================================================= echo "C1: ExportController — CSV_HEADERS count matches row column count\n"; $export = new ExportController($db); $rows = $export->exportAllTheses(); $headerCount = count(ExportController::CSV_HEADERS); plAssert($headerCount > 0, 'CSV_HEADERS is non-empty'); if (!empty($rows)) { foreach ($rows as $i => $row) { if (count($row) !== $headerCount) { throw new RuntimeException( "FAIL: row $i has " . count($row) . " columns, expected $headerCount" ); } } echo " ✓ all " . count($rows) . " rows have $headerCount columns matching CSV_HEADERS\n"; } else { echo " ✓ no rows to check (empty export) — header count is $headerCount\n"; } echo "\n"; // ========================================================================= // SECTION D: SearchController — coverMap key always present (regression) // ========================================================================= echo "D1: SearchController::handleSearch() — coverMap key always in return array\n"; // Simulate $_GET for the method (it reads from $_GET directly via collectSearchParams) $_GET = ['query' => '']; $rateLimit = new RateLimit(1000, 60); $searchCtrl = new SearchController($db, $rateLimit); $vars = $searchCtrl->handleSearch(); plAssert(array_key_exists('coverMap', $vars), 'coverMap key present in handleSearch() return'); plAssert(is_array($vars['coverMap']), 'coverMap is an array'); $_GET = []; echo "\n"; echo "✅ All pure logic tests passed!\n"; $result = true; } catch (Exception $e) { echo '❌ FAIL: ' . $e->getMessage() . "\n"; $result = false; } return $result ?? false;