Files
TijdVoorDeTest/tests/Service/QuizSpreadsheetServiceTest.php
Marijn 404c0dcc26 Summer cleanup: XLSX export, WIDM-style quiz UI, CSS fixes (#162)
* Add CLAUDE.md, replace Makefile with Justfile, remove .junie

- Add CLAUDE.md with project overview, commands, architecture, and domain entity docs
- Remove Makefile in favour of the existing Justfile
- Remove .junie/AGENTS.md (knowledge transferred to CLAUDE.md)
- Update .gitignore: drop .junie/ entries, add .claude/settings.local.json
- Minor doc fixes in config/reference.php (typo, type correction)

* Clean up templates and CSS

- season.html.twig: remove dead empty column, drop redundant flex-row
- tab_overview.html.twig: extract Twig macro for confirm modals, fix duplicate aria-labelledby IDs
- tab_result.html.twig: remove dead comment, replace inline widths with CSS classes, simplify nested row/col forms to d-flex gap-1
- backoffice.scss: add col-result-xs/sm/md column width classes
- quiz.scss: replace broken display:grid + justify-self:center with flexbox centering

* Implement quizToXlsx() export and add export button

- QuizSpreadsheetService: implement quizToXlsx() as the inverse of
  fillQuizFromArray() — writes quiz questions and answers to XLSX using
  the same column layout as the import template
- BackofficeController: add exportQuiz() action at GET /backoffice/quiz/{quiz}/export
- tab_overview.html.twig: add Export to XLSX button in Quick actions

* Add unit tests for QuizSpreadsheetService

7 tests covering generateTemplate(), quizToXlsx(), and xlsxToQuiz():
- valid XLSX output and MIME type
- template without example reimports as empty
- template example data survives a reimport
- round-trip (export → reimport) preserves questions, answers, and correct flags
- empty quiz exports and reimports cleanly
- invalid MIME type throws InvalidArgumentException
- question with no answers throws SpreadsheetDataException with error list

* Fix quiz page vertical centering regression

The CSS cleanup broke vertical centering: flex on body causes main to
stretch full-width; place-items:center on a grid body only centers
items within their auto-sized track (not the track within body).

Fix: move background/color to html (full-viewport grid that centers
body), give body height:100% + display:grid + align-content:center
(centers the content track within full-height body) + justify-self:center
(shrink-wraps body width). Matches production behavior exactly.

* Improve quiz page layouts: WIDM-style answers and responsive centering

- Add green square answer buttons styled after the TV show
- Two-column answer grid for 6+ answers, single column on mobile
- fit-content centering for question pages so block matches question width
- Narrow fixed-width centering for form pages (enter name, select season)

* Use HeaderUtils::makeDisposition() for safe Content-Disposition filename

* Fix quizToXlsx to support unlimited answers and add header count tests

- Replace hardcoded 6-column arrays with dynamic Coordinate arithmetic
- Write data rows first to determine max answer count, write headers last
- Replace try/catch ErrorException in fillQuizFromArray with array_key_exists
- Add data-provider test covering 2, 6, 7, and 10 answers
- Add cross-question max-header and 7-answer round-trip tests

* Fix Sass healthcheck

* Improve quiz layout: add fixed topbar, include navigation, and clean up unused elements

- Add `.quiz-topbar` with fixed positioning and spacing in `quiz.scss`
- Update `base.html.twig` to include `quiz/nav.html.twig` in a new `nav` block
- Remove unused "Manage Quiz" button from `select_season.html.twig`

* Refactor generateTemplate to reuse quizToXlsx and add second example question

- generateTemplate now builds an in-memory Quiz entity and delegates to
  quizToXlsx, eliminating duplicate spreadsheet-building logic
- Adds a second example question "Wie is de mol?" with 10 Dutch names
  (5 male, 5 female) to better illustrate the import format
- Updates tests to assert both example questions and adds a test for the
  blank-row halt behaviour in fillQuizFromArray (achieving 100% coverage)

* Move PHPUnit cache to /tmp to avoid writing into the mounted volume

* Update src/Service/QuizSpreadsheetService.php

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-02 20:25:02 +00:00

309 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\File\File;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Exception\SpreadsheetDataException;
use Tvdt\Service\QuizSpreadsheetService;
use function Safe\file_put_contents;
use function Safe\ob_get_clean;
use function Safe\ob_start;
use function Safe\unlink;
#[CoversClass(QuizSpreadsheetService::class)]
final class QuizSpreadsheetServiceTest extends TestCase
{
private QuizSpreadsheetService $subject;
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
$this->subject = new QuizSpreadsheetService();
}
protected function tearDown(): void
{
foreach ($this->tempFiles as $path) {
if (file_exists($path)) {
unlink($path);
}
}
}
public function testGenerateTemplateProducesValidXlsx(): void
{
$path = $this->captureXlsx($this->subject->generateTemplate());
$this->assertSame(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
new File($path)->getMimeType(),
);
}
public function testGenerateTemplateWithoutExampleHasNoQuestions(): void
{
$path = $this->captureXlsx($this->subject->generateTemplate(fillExample: false));
$quiz = new Quiz();
$this->subject->xlsxToQuiz($quiz, new File($path));
$this->assertCount(0, $quiz->questions);
}
public function testGenerateTemplateExampleCanBeReimported(): void
{
$path = $this->captureXlsx($this->subject->generateTemplate(fillExample: true));
$quiz = new Quiz();
$this->subject->xlsxToQuiz($quiz, new File($path));
$this->assertCount(2, $quiz->questions);
/** @var Question $first */
$first = $quiz->questions->first();
$this->assertSame('Is de mol een man of een vrouw?', $first->question);
$this->assertCount(2, $first->answers);
/** @var Question $second */
$second = $quiz->questions->last();
$this->assertSame('Wie is de mol?', $second->question);
$this->assertCount(10, $second->answers);
}
public function testQuizToXlsxEmptyQuizImportsWithNoQuestions(): void
{
$path = $this->captureXlsx($this->subject->quizToXlsx(new Quiz()));
$imported = new Quiz();
$this->subject->xlsxToQuiz($imported, new File($path));
$this->assertCount(0, $imported->questions);
}
public function testQuizToXlsxRoundTrip(): void
{
$original = $this->makeQuiz();
$path = $this->captureXlsx($this->subject->quizToXlsx($original));
$imported = new Quiz();
$this->subject->xlsxToQuiz($imported, new File($path));
$this->assertCount(2, $imported->questions);
/** @var Question $first */
$first = $imported->questions->first();
$this->assertSame('Who is de Mol?', $first->question);
$this->assertCount(2, $first->answers);
$answers = $first->answers->toArray();
$this->assertSame('Alice', $answers[0]->text);
$this->assertFalse($answers[0]->isRightAnswer);
$this->assertSame('Bob', $answers[1]->text);
$this->assertTrue($answers[1]->isRightAnswer);
/** @var Question $second */
$second = $imported->questions->last();
$this->assertSame('What did de Mol sabotage?', $second->question);
$this->assertCount(3, $second->answers);
}
public function testXlsxToQuizThrowsOnInvalidMimeType(): void
{
$path = $this->createTempPath('.txt');
file_put_contents($path, 'not a spreadsheet');
$this->expectException(\InvalidArgumentException::class);
$this->subject->xlsxToQuiz(new Quiz(), new File($path));
}
public function testXlsxToQuizThrowsOnQuestionWithNoAnswers(): void
{
$quiz = new Quiz();
$question = new Question();
$question->question = 'Unanswered question';
$question->ordering = 1;
$quiz->addQuestion($question);
$path = $this->captureXlsx($this->subject->quizToXlsx($quiz));
try {
$this->subject->xlsxToQuiz(new Quiz(), new File($path));
$this->fail('Expected SpreadsheetDataException to be thrown');
} catch (SpreadsheetDataException $spreadsheetDataException) {
$this->assertNotEmpty($spreadsheetDataException->errors);
}
}
public function testXlsxToQuizStopsAtBlankRow(): void
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setCellValue('A1', 'Question');
$sheet->setCellValue('B1', 'Answer 1');
$sheet->setCellValue('C1', 'Correct');
$sheet->setCellValue('A2', 'First question');
$sheet->setCellValue('B2', 'Yes');
$sheet->setCellValue('C2', true);
// Row 3 intentionally blank — should halt parsing
$sheet->setCellValue('A4', 'Second question');
$sheet->setCellValue('B4', 'No');
$sheet->setCellValue('C4', false);
$path = $this->createTempPath('.xlsx');
ob_start();
new Writer\Xlsx($spreadsheet)->save('php://output');
file_put_contents($path, ob_get_clean());
$quiz = new Quiz();
$this->subject->xlsxToQuiz($quiz, new File($path));
$this->assertCount(1, $quiz->questions);
/** @var Question $first */
$first = $quiz->questions->first();
$this->assertSame('First question', $first->question);
}
/** @return \Iterator<string, array{int, string, int, string, int, int}> */
public static function answerCountHeaderProvider(): \Iterator
{
// Columns (0-based): Question=0, Answer1=1, Correct=2, Answer2=3, Correct=4, …
// Answer N is at index 1+2*(N-1) = 2N-1, Correct N at 2+2*(N-1) = 2N.
yield '2 answers → 2 header pairs' => [2, 'Answer 2', 3, 'Correct', 4, 5];
yield '6 answers → 6 header pairs' => [6, 'Answer 6', 11, 'Correct', 12, 13];
yield '7 answers → 7 header pairs' => [7, 'Answer 7', 13, 'Correct', 14, 15];
yield '10 answers → 10 header pairs' => [10, 'Answer 10', 19, 'Correct', 20, 21];
}
#[DataProvider('answerCountHeaderProvider')]
public function testQuizToXlsxHeaderCountMatchesAnswerCount(
int $answerCount,
string $lastAnswerHeader,
int $lastAnswerIndex,
string $lastCorrectHeader,
int $lastCorrectIndex,
int $absentIndex,
): void {
$path = $this->captureXlsx($this->subject->quizToXlsx($this->makeQuizWithAnswerCounts($answerCount)));
$headers = $this->readFirstRow($path);
$this->assertSame($lastAnswerHeader, $headers[$lastAnswerIndex]);
$this->assertSame($lastCorrectHeader, $headers[$lastCorrectIndex]);
$this->assertArrayNotHasKey($absentIndex, $headers);
}
public function testQuizToXlsxHeadersMatchMaxAnswersAcrossQuestions(): void
{
$quiz = new Quiz();
$quiz->addQuestion($this->makeQuestion('Short', 3));
$quiz->addQuestion($this->makeQuestion('Long', 7));
$quiz->addQuestion($this->makeQuestion('Medium', 5));
$path = $this->captureXlsx($this->subject->quizToXlsx($quiz));
$headers = $this->readFirstRow($path);
$this->assertSame('Answer 7', $headers[13]);
$this->assertSame('Correct', $headers[14]);
$this->assertArrayNotHasKey(15, $headers);
}
public function testQuizToXlsxRoundTripWithSevenAnswers(): void
{
$original = $this->makeQuizWithAnswerCounts(7);
$path = $this->captureXlsx($this->subject->quizToXlsx($original));
$imported = new Quiz();
$this->subject->xlsxToQuiz($imported, new File($path));
$this->assertCount(1, $imported->questions);
/** @var Question $question */
$question = $imported->questions->first();
$this->assertCount(7, $question->answers);
}
private function makeQuiz(): Quiz
{
$quiz = new Quiz();
$q1 = new Question();
$q1->question = 'Who is de Mol?';
$q1->ordering = 1;
$q1->addAnswer(new Answer('Alice', isRightAnswer: false));
$q1->addAnswer(new Answer('Bob', isRightAnswer: true));
$q2 = new Question();
$q2->question = 'What did de Mol sabotage?';
$q2->ordering = 2;
$q2->addAnswer(new Answer('The boat', isRightAnswer: true));
$q2->addAnswer(new Answer('The car', isRightAnswer: false));
$q2->addAnswer(new Answer('Nothing', isRightAnswer: false));
$quiz->addQuestion($q1);
$quiz->addQuestion($q2);
return $quiz;
}
private function makeQuizWithAnswerCounts(int ...$counts): Quiz
{
$quiz = new Quiz();
foreach ($counts as $i => $count) {
$quiz->addQuestion($this->makeQuestion('Question '.$i, $count));
}
return $quiz;
}
private function makeQuestion(string $text, int $answerCount): Question
{
$question = new Question();
$question->question = $text;
$question->ordering = 1;
for ($i = 1; $i <= $answerCount; ++$i) {
$question->addAnswer(new Answer('Answer '.$i, isRightAnswer: false));
}
return $question;
}
/** @return array<int, string|null> */
private function readFirstRow(string $path): array
{
$rows = new Reader\Xlsx()->setReadDataOnly(true)->load($path)->getActiveSheet()->toArray(formatData: false);
return $rows[0] ?? [];
}
private function captureXlsx(\Closure $closure): string
{
$path = $this->createTempPath('.xlsx');
ob_start();
$closure();
file_put_contents($path, ob_get_clean());
return $path;
}
private function createTempPath(string $suffix): string
{
$path = sys_get_temp_dir().\DIRECTORY_SEPARATOR.uniqid('tvdt_test_', more_entropy: true).$suffix;
$this->tempFiles[] = $path;
return $path;
}
}