mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
378 lines
16 KiB
PHP
378 lines
16 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Pure Logic Unit Test
|
|
*
|
|
* Tests deterministic, I/O-free logic extracted from controllers:
|
|
*
|
|
* TfeController (via subclass to access protected methods):
|
|
* - buildMetaDescription() — truncation at 160 chars, empty synopsis fallback
|
|
* - resolveOgImage() — cover preferred over image, fallback chain
|
|
* - splitJuryByRole() — correct binning of promoteur/lecteur/president/ulb
|
|
* - collectCaptionPaths() — VTT files extracted in order
|
|
*
|
|
* ThesisCreateController (static / public helpers):
|
|
* - autofocusFieldForError()
|
|
* - detectFileType() — via subclass to access private
|
|
* - generateAuthorSlug() — accent stripping, uppercase, underscore
|
|
*
|
|
* ExportController:
|
|
* - CSV_HEADERS count matches exportAllTheses() row width
|
|
*
|
|
* SearchController:
|
|
* - handleSearch() return array always contains 'coverMap' key (regression for undefined var bug)
|
|
*/
|
|
|
|
putenv('DB_ENV=test');
|
|
|
|
if (!defined('APP_ROOT')) {
|
|
define('APP_ROOT', dirname(__DIR__, 2));
|
|
}
|
|
if (!defined('STORAGE_ROOT')) {
|
|
define('STORAGE_ROOT', APP_ROOT . '/storage');
|
|
}
|
|
|
|
require_once APP_ROOT . '/src/Database.php';
|
|
require_once APP_ROOT . '/src/RateLimit.php';
|
|
require_once APP_ROOT . '/src/Controllers/TfeController.php';
|
|
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
|
|
require_once APP_ROOT . '/src/Controllers/SearchController.php';
|
|
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
|
|
|
// ── Test harness helpers ──────────────────────────────────────────────────────
|
|
|
|
function plAssert(bool $cond, string $label): void
|
|
{
|
|
if ($cond) {
|
|
echo " ✓ $label\n";
|
|
} else {
|
|
throw new RuntimeException("FAIL: $label");
|
|
}
|
|
}
|
|
|
|
function plAssertEq(mixed $expected, mixed $actual, string $label): void
|
|
{
|
|
if ($expected === $actual) {
|
|
echo " ✓ $label\n";
|
|
} else {
|
|
$e = var_export($expected, true);
|
|
$a = var_export($actual, true);
|
|
throw new RuntimeException("FAIL $label\n expected: $e\n actual: $a");
|
|
}
|
|
}
|
|
|
|
// ── Subclasses to expose protected / private methods ─────────────────────────
|
|
|
|
class TfeControllerTestable extends TfeController
|
|
{
|
|
public function testBuildMetaDescription(string $synopsis): string
|
|
{
|
|
return $this->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('<p>Hello <strong>world</strong></p>');
|
|
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 TFE', 'titre'],
|
|
["Le champ 'Auteur·ice(s)' est requis.", '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;
|