fix: unify CSV import/export columns, fix import JS error, fix createThesis VALUES

- Fix createThesis SQL: extra ? placeholder in VALUES (27 values for 26 columns)
- Add createThesis integration tests to catch column/value mismatches
- Add CSV_COLUMNS as single source of truth in ExportController, deriving
  csvHeaders() and positional fallback from it
- Add missing columns to export query + CSV: duration_pages, duration_minutes,
  has_annexes, license_custom, contact_visible, objet
- Add missing columns to import INSERT: license_id, license_custom, cc2r,
  exemplaire_baiu, exemplaire_erg, objet, contact_visible, duration_pages,
  duration_minutes, has_annexes
- Resolve license name → license_id during import
- Fix XamxamInitFilePonds: add file-upload-filepond.js to admin-entry.js
  so FilePond initialization code is available on the admin list page
- Add FilePond vendor CSS to admin.min.css bundle (import dialog styling)
- Generate .admin-file-hint from csvHeaders() instead of hardcoded stale list
- Use positional fallback from CSV_COLUMNS for import cell parsing
This commit is contained in:
Pontoporeia
2026-07-10 11:56:27 +02:00
parent ce936e3b71
commit 22a49f7cb6
11 changed files with 355 additions and 42 deletions
File diff suppressed because one or more lines are too long
+38
View File
@@ -3,3 +3,41 @@
- [x] Simplify `tfe.php` HTML structure (remove `.tfe-layout` wrapper, remove `.tfe-synopsis-column` wrapper)
- [x] Simplify `tfe.css` — replace flex-column with grid where possible, merge `.tfe-layout` into `.tfe-content-row`, remove dead wrappers
- [x] Commit changes
- [x] Remove "Mo" option from duration — keep only minutes and pages
- [x] Combine pages and minutes as separate fields (both can be set simultaneously)
- [x] Fix minutes input visibility (can't see what's typed)
- [x] Add `has_annexes` checkbox to form + DB column
- [x] Display duration on admin backoffice recap page
- [x] Update public TFE page duration display for new combined format
- [x] Fix `handleAutosaveResponse is not defined` — load `autosave-handler.js` on about_page edit view
- [x] Fix `handleAutosaveResponse is not defined` in admin add/edit — function was scoped inside IIFE bundle, not on window
- [x] Fix search result cards: title and author always below image/gradient, remove text overlay on placeholder gradient
- [x] Change search results to 10 per page with HTMX pagination (no-JS fallback preserved)
- [x] Bump to 15 results/page, make results area vertically scrollable, sticky pagination at viewport bottom
- [x] Fix HTMX targetError: move #search-results wrapper inside fragment so it survives outerHTML swaps
- [x] Add range counter to results header (e.g. "115 sur 112 résultats")
- [x] Collapsible filter controls with <details>/<summary> + active filter badge on mobile
- [x] Responsive paginator: hide first/last buttons on mobile, smaller touch targets
- [x] Compact range counter on mobile: "115 / 112" instead of full "115 sur 112 résultats"
- [x] Remove orientation display from search result card meta
- [x] Add bulk select/delete to tmp cleanup dialog (like admin/index.php pattern)
- [x] Move cleanup UI from modal to dedicated page (like add.php/edit.php)
- [x] Replace <details> with <fieldset>/<legend> in cleanup fragments (like contenus.php)
- [x] Standardise test group recipes: lint-php, lint-css, lint-js, test + add fix recipe + make lint pass clean
- [x] Remove Mot de passe column from acces.php, add key.svg icon button in Actions to copy password to clipboard, reorder actions (URL/password/edit/toggle/archive), switch URL icon to link-simple.svg
- [x] Fix CC2r: removed hidden seeds (were stale/overriding user input), render initial licence-choice from \$formData; Interne want_license unchecked clears CC2r+licence, switching from other modes preserves values; CC2r independent toggle; licence dropdown has no-licence placeholder
- [x] Dans le formulaire admin, le contact privé ne se sauvegarde pas.
- [x] Remove "Rendre le contact visible publiquement sur la fiche du TFE" from admin add/edit forms
- [x] Auto-show contact on public TFE page when contact_visible is filled (no checkbox needed)
- [x] Fix file size limits: align all layers (JS/FilepondHandler/ThesisFileHandler/validate-fragment) — video/audio 5 GB, default 500 MB, PDF 100 MB; fix JS fileValidateSizeFilter gate bug that rejected AV files >1 GB despite 8 GB per-ext cap
- [x] Fix: deleting contact groups in apropos form not persisted (no change event after DOM removal, autosave never triggered)
- [x] Improve h1, h2, h3 size difference in base.css heading scale
- [x] Rework TFE page desktop layout: two columns 35vw/1fr, left = author/title/meta/synopsis, right = files, media 100% width; columns scroll independently, content fits 100vw; no bottom padding on main; add &scrollbar=0 to PDF iframe URLs
- [x] 'Promoteur·ice ULB' remplacé par 'Promoteur·ice université' (changement juste l'étiquette)
- [x] PDF viewer: fix page navigation buttons (prev/next) — build toolbar after canvases exist & query canvases fresh in scroll handler
- [x] PDF viewer: render pages smaller by default (0.85× scale)
- [x] PDF viewer: add dark transparent violet background behind pages when expanded
- [x] pdf-viewer: expand to fill right column (not whole page), center pages, freeze column on expand
- [x] pdf-viewer: fix toolbar width (align-self:stretch), hide sibling file items when expanded
- [x] Fix createThesis SQL: extra `?` placeholder in VALUES (27 values for 26 columns) + add integration tests
- [x] CSV import/export: single source of truth (CSV_COLUMNS), add missing columns (duration, annexes, license_custom, contact_visible, objet, cc2r, exemplaires), fix XamxamInitFilePonds → window.n, generate import hint from headers
+1 -1
View File
@@ -27,7 +27,7 @@ echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
// Column headers
fputcsv($out, ExportController::CSV_HEADERS, ',', '"', '');
fputcsv($out, ExportController::csvHeaders(), ',', '"', '');
// Data rows
$rows = $controller->exportAllTheses();
+102 -35
View File
@@ -37,14 +37,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
// Build colIdx[name] → position map; fall back to positional if header not found.
// Matching uses prefix + variant logic so "contact.visible" matches "contact",
// "promoteur·ice(s)" matches "promoteur", "Licence" matches "license", etc.
require_once APP_ROOT . '/src/Controllers/ExportController.php';
$csvCols = ExportController::CSV_COLUMNS;
$csvHeaders = ExportController::csvHeaders();
$colCount = count($csvCols);
$colIdx = null;
$headerRowNum = 0;
$knownHeaders = [
'identifiant', 'titre', 'sous-titre', 'auteur', 'contact',
'promoteur', 'lecteur', 'ulb', 'externe', 'format', 'année', 'ap', 'orientation', 'finalité',
'mots-clés', 'synopsis', 'contexte', 'remarques', 'langue',
'autorisation', 'licence', 'license', 'points', 'lien baiu',
];
// Build knownHeaders from CSV_COLUMNS import_key column (index 2)
$knownHeaders = array_unique(array_column($csvCols, 2));
// Add licence/license cross-match aliases
$knownHeaders[] = 'license';
for ($scan = 0; $scan < 8; $scan++) {
$hrow = fgetcsv($handle, 0, ',', '"', '');
if ($hrow === false) break;
@@ -101,10 +104,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
}
}
// Build positional fallback from CSV_COLUMNS (keyed by import_key → position)
$fallbackPositions = [];
foreach ($csvCols as $pos => $col) {
$key = $col[2];
if (!isset($fallbackPositions[$key])) {
$fallbackPositions[$key] = $pos;
}
}
// Helper: get cell value by column name.
// When header was found: only use mapped column (returns '' if missing from header).
// When no header found: use positional fallback index.
$cell = function(array $row, string $name, int $fallbackPos) use ($colIdx): string {
// When header was found: use mapped column.
// When header was found but key is missing: use positional fallback.
// When no header found: use positional fallback.
$cell = function(array $row, string $name) use ($colIdx, $fallbackPositions): string {
if ($colIdx !== null) {
$pos = $colIdx[$name] ?? null;
if ($pos === null) {
@@ -112,13 +125,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
if ($name === 'license') $pos = $colIdx['licence'] ?? null;
elseif ($name === 'licence') $pos = $colIdx['license'] ?? null;
}
// Fall back to positional if header didn't have this key
if ($pos === null) {
$pos = $fallbackPositions[$name] ?? null;
}
if ($pos === null) return '';
} else {
$pos = $fallbackPos;
$pos = $fallbackPositions[$name] ?? null;
if ($pos === null) return '';
}
return isset($row[$pos]) ? trim((string)$row[$pos]) : '';
};
// Helper: parse "Oui"/"Non"/1/0 strings to int
$parseBool = function(string $raw): int {
$raw = strtolower(trim($raw));
return ($raw === 'oui' || $raw === '1' || $raw === 'yes' || $raw === 'true') ? 1 : 0;
};
// Code → canonical name (legacy short-code CSV format)
$orientationCodeMap = [
'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation",
@@ -232,39 +256,55 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
try {
$importDb->beginTransaction();
$identifier = $cell($row, 'identifiant', 0);
$title = $cell($row, 'titre', 1);
$subtitle = $cell($row, 'sous-titre', 2);
$authorsRaw = $cell($row, 'auteur', 3);
$contact = $cell($row, 'contact', 4);
$identifier = $cell($row, 'identifiant');
$title = $cell($row, 'titre');
$subtitle = $cell($row, 'sous-titre');
$authorsRaw = $cell($row, 'auteur');
$contact = $cell($row, 'contact');
// Normalise CSV artefacts: OUI/NON → empty (not a valid email)
if ($contact !== '' && in_array(strtoupper(trim($contact)), ['NON', 'OUI'], true)) {
$contact = '';
}
$supervisorsRaw = $cell($row, 'promoteur', 5);
$lecteursInternesRaw = $cell($row, 'lecteur', 6); // first "lecteur" col = interne
$lecteursExternesRaw = $cell($row, 'externe', 7); // contains "externe"
$promoteursUlbRaw = $cell($row, 'ulb', 8); // contains "ulb"
$formatsRaw = $cell($row, 'format', 9);
$yearRaw = $cell($row, 'année', 10);
$supervisorsRaw = $cell($row, 'promoteur');
$lecteursInternesRaw = $cell($row, 'lecteur'); // first "lecteur" col = interne
$lecteursExternesRaw = $cell($row, 'externe'); // contains "externe"
$promoteursUlbRaw = $cell($row, 'ulb'); // contains "ulb"
$formatsRaw = $cell($row, 'format');
$yearRaw = $cell($row, 'année');
$year = $yearRaw !== '' ? intval($yearRaw) : 0;
// Fallback: derive year from identifier (e.g. "2024-003" → 2024)
if ($year === 0 && $identifier !== '' && preg_match('/^(\d{4})-/', $identifier, $m)) {
$year = (int)$m[1];
}
$apCode = $cell($row, 'ap', 11);
$orientationCode = $cell($row, 'orientation', 12);
$finalityName = $cell($row, 'finalité', 13);
$keywordsRaw = $cell($row, 'mots-clés', 14);
$synopsis = $cell($row, 'synopsis', 15);
$context = $cell($row, 'contexte', 16);
$remarks = $cell($row, 'remarques', 17);
$languageRaw = $cell($row, 'langue', 18);
$access = $cell($row, 'autorisation', 19);
$license = $cell($row, 'license', 20);
$juryPointsRaw = $cell($row, 'points', 21);
$apCode = $cell($row, 'ap');
$orientationCode = $cell($row, 'orientation');
$finalityName = $cell($row, 'finalité');
$keywordsRaw = $cell($row, 'mots-clés');
$synopsis = $cell($row, 'synopsis');
$context = $cell($row, 'contexte');
$remarks = $cell($row, 'remarques');
$languageRaw = $cell($row, 'langue');
$access = $cell($row, 'autorisation');
$license = $cell($row, 'licence');
$juryPointsRaw = $cell($row, 'points');
$juryPoints = $juryPointsRaw !== '' ? floatval($juryPointsRaw) : null;
$baiuLink = $cell($row, 'lien baiu', 22);
$baiuLink = $cell($row, 'lien baiu');
$cc2rRaw = $cell($row, 'cc2r');
$cc2r = $parseBool($cc2rRaw);
$exempBaiuRaw = $cell($row, 'exemplaire'); // first "exemplaire" col = BAIU
$exempBaiu = $parseBool($exempBaiuRaw);
$exempErgRaw = $cell($row, 'exemplaire-erg'); // second "exemplaire", key won't match header → positional
$exempErg = $parseBool($exempErgRaw);
$durationPagesRaw = $cell($row, 'pages');
$durationPages = $durationPagesRaw !== '' ? (int)$durationPagesRaw : null;
$durationMinutesRaw = $cell($row, 'durée');
$durationMinutes = $durationMinutesRaw !== '' ? (int)$durationMinutesRaw : null;
$hasAnnexesRaw = $cell($row, 'annexes');
$hasAnnexes = $parseBool($hasAnnexesRaw);
$licenseCustom = $cell($row, 'licence-perso'); // second "licence", key won't match header → positional
$contactVisible = $cell($row, 'contact-visible'); // second "contact", key won't match header → positional
$objetRaw = $cell($row, 'objet');
$objet = in_array($objetRaw, ['tfe', 'thèse', 'frart'], true) ? $objetRaw : 'tfe';
if ($title === '' || $year === 0) {
$missing = [];
@@ -303,14 +343,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
}
}
// Resolve license name → license_id
$licenseId = null;
if (!empty($license)) {
$ls = $importPdo->prepare("SELECT id FROM license_types WHERE LOWER(name) = LOWER(?)");
$ls->execute([trim($license)]);
$lr = $ls->fetch();
$licenseId = $lr ? (int)$lr['id'] : null;
}
// If a custom license string is provided and no DB license matched, store as custom
if ($licenseId === null && !empty($license) && empty($licenseCustom)) {
$licenseCustom = $license;
}
$s = $importPdo->prepare("
INSERT INTO theses (
identifier, title, subtitle, year,
orientation_id, ap_program_id, finality_id,
synopsis, context_note, remarks,
jury_points, baiu_link,
access_type_id, is_published, submitted_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,0,CURRENT_TIMESTAMP)
access_type_id, license_id, license_custom,
cc2r, exemplaire_baiu, exemplaire_erg,
objet, contact_visible,
duration_pages, duration_minutes, has_annexes,
is_published, status, submitted_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,'active',CURRENT_TIMESTAMP)
");
$s->execute([
!empty($identifier) ? $identifier : null, $title,
@@ -322,6 +379,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$juryPoints,
!empty($baiuLink) ? $baiuLink : null,
$accessTypeId,
$licenseId,
!empty($licenseCustom) ? $licenseCustom : null,
$cc2r,
$exempBaiu,
$exempErg,
$objet,
!empty($contactVisible) ? $contactVisible : null,
$durationPages,
$durationMinutes,
$hasAnnexes,
]);
$thesisId = $importPdo->lastInsertId();
+24
View File
@@ -2508,3 +2508,27 @@ th.admin-ap-col {
padding: var(--space-3xs) 0;
border: 0;
}
/* ── CSV import file input (admin list page) ──────────────────────────── */
.admin-file-input {
display: flex;
flex-direction: column;
gap: var(--space-3xs);
}
/* FilePond drop-zone border (replaces the raw input border from form-base.css) */
.admin-file-input .filepond--root {
border: 1px dashed var(--border-primary, #ccc);
border-radius: var(--radius, 8px);
background: var(--bg-elevated, #fafafa);
}
.admin-file-input .filepond--root:hover {
border-color: var(--accent-primary, #5b2d8e);
}
.admin-file-hint {
display: block;
margin-top: var(--space-2xs);
}
+1
View File
@@ -15,6 +15,7 @@ import "./smtp-error-focus.js";
import "./htmx-global-setup.js";
// Admin features
import "./file-upload-filepond.js";
import "./admin-index-bulk.js";
import "./admin-cleanup-bulk.js";
import "./admin-tags.js";
+65
View File
@@ -250,6 +250,59 @@ class ExportController
/**
* Column headers matching the import format.
*/
/**
* Single source of truth for CSV column definitions.
* Each entry: [header_label, field_key, import_search_key]
*
* field_key = key in the SELECT row and export data array
* import_key = lowercase token used to match header rows during import
*/
public const CSV_COLUMNS = [
['Identifiant', 'identifier', 'identifiant'],
['Titre', 'title', 'titre'],
['Sous-titre', 'subtitle', 'sous-titre'],
['Auteur·ice(s)', 'authors', 'auteur'],
['Contact', 'contact', 'contact'],
['Promoteur·ice(s) interne', 'promoteurs_internes', 'promoteur'],
['Lecteur·ice(s) interne', 'lecteurs_internes', 'lecteur'],
['Lecteur·ice(s) externe', 'lecteurs_externes', 'externe'],
['Promoteur·ice(s) université', 'promoteurs_ulb', 'ulb'],
['Format(s)', 'formats', 'format'],
['Année', 'year', 'année'],
['AP', 'ap_program', 'ap'],
['Orientation', 'orientation', 'orientation'],
['Finalité', 'finality', 'finalité'],
['Mots-clés', 'keywords', 'mots-clés'],
['Synopsis', 'synopsis', 'synopsis'],
['Contexte', 'context', 'contexte'],
['Remarques', 'remarks', 'remarques'],
['Langue', 'languages', 'langue'],
['Autorisation', 'access_type', 'autorisation'],
['Licence', 'license', 'licence'],
['Points sur 20', 'jury_points', 'points'],
['Lien BAIU', 'baiu_link', 'lien baiu'],
['CC2r', 'cc2r', 'cc2r'],
['Exemplaire BAIU', 'exemplaire_baiu', 'exemplaire'],
['Exemplaire ERG', 'exemplaire_erg', 'exemplaire-erg'],
['Pages', 'duration_pages', 'pages'],
['Durée (minutes)', 'duration_minutes', 'durée'],
['Annexes', 'has_annexes', 'annexes'],
['Licence personnalisée', 'license_custom', 'licence-perso'],
['Contact visible', 'contact_visible', 'contact-visible'],
['Objet', 'objet', 'objet'],
];
/**
* Derived from CSV_COLUMNS for backward compatibility.
*/
public static function csvHeaders(): array
{
return array_column(self::CSV_COLUMNS, 0);
}
/**
* @deprecated Use csvHeaders() instead.
*/
public const CSV_HEADERS = [
'Identifiant',
'Titre',
@@ -277,6 +330,12 @@ class ExportController
'CC2r',
'Exemplaire BAIU',
'Exemplaire ERG',
'Pages',
'Durée (minutes)',
'Annexes',
'Licence personnalisée',
'Contact visible',
'Objet',
];
/**
@@ -396,6 +455,12 @@ class ExportController
!empty($t['cc2r']) ? 'Oui' : 'Non',
!empty($t['exemplaire_baiu']) ? 'Oui' : 'Non',
!empty($t['exemplaire_erg']) ? 'Oui' : 'Non',
isset($t['duration_pages']) && $t['duration_pages'] !== null ? (string) $t['duration_pages'] : '',
isset($t['duration_minutes']) && $t['duration_minutes'] !== null ? (string) $t['duration_minutes'] : '',
!empty($t['has_annexes']) ? 'Oui' : 'Non',
$t['license_custom'] ?? '',
$t['contact_visible'] ?? '',
$t['objet'] ?? '',
];
}
+8 -2
View File
@@ -2266,7 +2266,7 @@ class Database
duration_pages, duration_minutes,
has_annexes,
submitted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, "draft", ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
');
$validObjet = ['tfe', 'thèse', 'frart'];
@@ -2696,7 +2696,13 @@ class Database
t.baiu_link,
t.exemplaire_baiu,
t.exemplaire_erg,
t.cc2r
t.cc2r,
t.license_custom,
t.contact_visible,
t.duration_pages,
t.duration_minutes,
t.has_annexes,
t.objet
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
@@ -1,3 +1,9 @@
<?php
// Ensure ExportController is available for the CSV column hint
if (!class_exists('ExportController')) {
require_once APP_ROOT . '/src/Controllers/ExportController.php';
}
?>
<dialog id="import-dialog" class="admin-dialog" aria-labelledby="import-dialog-title">
<div class="admin-dialog__header">
<h3 id="import-dialog-title">Importer une liste de TFE</h3>
@@ -51,7 +57,7 @@
data-queue-type="csv_import"
required>
<small class="admin-file-hint">
Colonnes : Identifiant, Titre, Sous-titre, Auteur·ice(s), Contact, Promoteur·ice(s), Format, Année, AP, Orientation, Finalité, Mots-clés, Synopsis, Contexte, Remarques, Langue, Autorisation, License, taille, Points sur 20, lien BAIU<br>
Colonnes : <?= htmlspecialchars(implode(', ', ExportController::csvHeaders())) ?><br>
Quatre premières lignes ignorées Séparateur : virgule UTF-8
</small>
</div>
+9 -2
View File
@@ -102,8 +102,15 @@ async function main() {
// 1. Base bundle: resolve style.css @import chain
total += await bundleCss("style.css", "base.min.css");
// 2. Admin bundle: just minify admin.css (standalone)
total += await bundleCss("admin.css", "admin.min.css");
// 2. Admin bundle: admin.css + filepond vendor CSS (for CSV import dialog)
total += await concatBundle(
[
"admin.css",
"filepond.min.css",
"filepond-plugin-image-preview.min.css",
],
"admin.min.css"
);
// 3. Form bundle: concat + minify form-base.css + form-admin.css + filepond vendor CSS
total += await concatBundle(
+99
View File
@@ -345,4 +345,103 @@ class DatabaseExtendedTest extends TestCase
$deleted = $pdo->query("SELECT deleted_at FROM tags WHERE id = $tagB")->fetchColumn();
$this->assertNotNull($deleted);
}
// ── createThesis ────────────────────────────────────────────────────────
public function testCreateThesisMinimalFields(): void
{
$id = $this->db->createThesis([
'title' => 'Minimal Thesis',
'year' => 2025,
'synopsis' => 'A minimal synopsis.',
]);
$this->assertGreaterThan(0, $id);
$row = TestDatabase::getPDO()
->query("SELECT * FROM theses WHERE id = $id")
->fetch(PDO::FETCH_ASSOC);
$this->assertSame('Minimal Thesis', $row['title']);
$this->assertSame(2025, (int)$row['year']);
$this->assertSame('tfe', $row['objet']);
$this->assertSame('draft', $row['status']);
$this->assertSame(0, (int)$row['is_published']);
$this->assertSame(0, (int)$row['exemplaire_baiu']);
$this->assertSame(0, (int)$row['exemplaire_erg']);
$this->assertSame(0, (int)$row['cc2r']);
$this->assertSame(0, (int)$row['has_annexes']);
$this->assertStringStartsWith('2025-', $row['identifier']);
$this->assertNotNull($row['submitted_at']);
}
public function testCreateThesisAllOptionalFields(): void
{
$id = $this->db->createThesis([
'title' => 'Full Thesis',
'subtitle' => 'With a subtitle',
'year' => 2024,
'synopsis' => 'Full synopsis.',
'context_note' => 'Some context.',
'contact_visible' => 'Visible info',
'baiu_link' => 'https://example.com/thesis',
'license_id' => null,
'license_custom' => 'CC BY 4.0',
'access_type_id' => 1,
'objet' => 'tfe',
'remarks' => 'No remarks.',
'jury_points' => '16.5',
'exemplaire_baiu' => '1',
'exemplaire_erg' => '1',
'cc2r' => '1',
'duration_pages' => '42',
'duration_minutes' => '30',
'has_annexes' => '1',
]);
$this->assertGreaterThan(0, $id);
$row = TestDatabase::getPDO()
->query("SELECT * FROM theses WHERE id = $id")
->fetch(PDO::FETCH_ASSOC);
$this->assertSame('Full Thesis', $row['title']);
$this->assertSame('With a subtitle', $row['subtitle']);
$this->assertSame(2024, (int)$row['year']);
$this->assertSame('Full synopsis.', $row['synopsis']);
$this->assertSame('Some context.', $row['context_note']);
$this->assertSame('Visible info', $row['contact_visible']);
$this->assertSame('https://example.com/thesis', $row['baiu_link']);
$this->assertSame('CC BY 4.0', $row['license_custom']);
$this->assertSame(1, (int)$row['exemplaire_baiu']);
$this->assertSame(1, (int)$row['exemplaire_erg']);
$this->assertSame(1, (int)$row['cc2r']);
$this->assertSame(42, (int)$row['duration_pages']);
$this->assertSame(30, (int)$row['duration_minutes']);
$this->assertSame(1, (int)$row['has_annexes']);
$this->assertSame('No remarks.', $row['remarks']);
$this->assertEquals(16.5, (float)$row['jury_points']);
}
public function testCreateThesisGeneratesUniqueIdentifiers(): void
{
$id1 = $this->db->createThesis([
'title' => 'First',
'year' => 2025,
'synopsis' => 'One.',
]);
$id2 = $this->db->createThesis([
'title' => 'Second',
'year' => 2025,
'synopsis' => 'Two.',
]);
$pdo = TestDatabase::getPDO();
$ident1 = $pdo->query("SELECT identifier FROM theses WHERE id = $id1")->fetchColumn();
$ident2 = $pdo->query("SELECT identifier FROM theses WHERE id = $id2")->fetchColumn();
$this->assertNotSame($ident1, $ident2);
$this->assertSame('2025-001', $ident1);
$this->assertSame('2025-002', $ident2);
}
}