mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-05 07:00:14 +02:00
404c0dcc26
* 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>
172 lines
5.5 KiB
PHP
172 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tvdt\Service;
|
|
|
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
|
use PhpOffice\PhpSpreadsheet\Reader;
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
use PhpOffice\PhpSpreadsheet\Writer;
|
|
use Symfony\Component\HttpFoundation\File\File;
|
|
use Tvdt\Entity\Answer;
|
|
use Tvdt\Entity\Question;
|
|
use Tvdt\Entity\Quiz;
|
|
use Tvdt\Exception\SpreadsheetDataException;
|
|
|
|
class QuizSpreadsheetService
|
|
{
|
|
public function generateTemplate(bool $fillExample = true): \Closure
|
|
{
|
|
$quiz = new Quiz();
|
|
|
|
if ($fillExample) {
|
|
$geslacht = new Question();
|
|
$geslacht->question = 'Is de mol een man of een vrouw?';
|
|
$geslacht->ordering = 1;
|
|
$geslacht->addAnswer(new Answer('Man', true));
|
|
$geslacht->addAnswer(new Answer('Vrouw'));
|
|
$quiz->addQuestion($geslacht);
|
|
|
|
$identiteit = new Question();
|
|
$identiteit->question = 'Wie is de mol?';
|
|
$identiteit->ordering = 2;
|
|
foreach ([
|
|
['Emma', false],
|
|
['Jan', false],
|
|
['Sara', false],
|
|
['Piet', false],
|
|
['Lisa', true],
|
|
['Kees', false],
|
|
['Anna', false],
|
|
['Henk', false],
|
|
['Nina', false],
|
|
['Joost', false],
|
|
] as $i => [$name, $correct]) {
|
|
$answer = new Answer($name, $correct);
|
|
$answer->ordering = $i + 1;
|
|
$identiteit->addAnswer($answer);
|
|
}
|
|
|
|
$quiz->addQuestion($identiteit);
|
|
}
|
|
|
|
return $this->quizToXlsx($quiz);
|
|
}
|
|
|
|
/** @throws SpreadsheetDataException */
|
|
public function xlsxToQuiz(Quiz $quiz, File $file): void
|
|
{
|
|
if (!$this->isSpreadsheetFile($file)) {
|
|
throw new \InvalidArgumentException('File must be a valid XLSX spreadsheet');
|
|
}
|
|
|
|
$spreadsheet = $this->readSheet($file);
|
|
$sheet = $spreadsheet->getSheet($spreadsheet->getFirstSheetIndex());
|
|
|
|
$answerLines = \array_slice($sheet->toArray(formatData: false), 1);
|
|
|
|
$this->fillQuizFromArray($quiz, $answerLines);
|
|
}
|
|
|
|
private function readSheet(File $file): Spreadsheet
|
|
{
|
|
return new Reader\Xlsx()->setReadDataOnly(true)->load($file->getRealPath());
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<int, string|bool|null>> $sheet
|
|
*
|
|
* @throws SpreadsheetDataException
|
|
*/
|
|
private function fillQuizFromArray(Quiz $quiz, array $sheet): void
|
|
{
|
|
$errors = [];
|
|
|
|
$questionCounter = 1;
|
|
foreach ($sheet as $questionArr) {
|
|
if (null === $questionArr[0]) {
|
|
break;
|
|
}
|
|
|
|
$question = new Question();
|
|
$question->question = (string) $questionArr[0];
|
|
$question->ordering = $questionCounter++;
|
|
|
|
$answerCounter = 1;
|
|
$arrCounter = 1;
|
|
|
|
while (\array_key_exists($arrCounter, $questionArr) && null !== $questionArr[$arrCounter]) {
|
|
$answer = new Answer((string) $questionArr[$arrCounter++], (bool) $questionArr[$arrCounter++]);
|
|
$answer->ordering = $answerCounter++;
|
|
$question->addAnswer($answer);
|
|
}
|
|
|
|
if (1 === $answerCounter) {
|
|
$errors[] = \sprintf('Question %d has no answers', $question->ordering);
|
|
}
|
|
|
|
$quiz->addQuestion($question);
|
|
}
|
|
|
|
if ([] !== $errors) {
|
|
throw new SpreadsheetDataException($errors);
|
|
}
|
|
}
|
|
|
|
public function quizToXlsx(Quiz $quiz): \Closure
|
|
{
|
|
$spreadsheet = new Spreadsheet();
|
|
$sheet = $spreadsheet->getActiveSheet();
|
|
|
|
// Write data rows first so we know the maximum answer count.
|
|
$maxAnswers = 0;
|
|
$row = 2;
|
|
foreach ($quiz->questions as $question) {
|
|
$sheet->setCellValue('A'.$row, $question->question);
|
|
|
|
$col = 0;
|
|
foreach ($question->answers as $answer) {
|
|
$sheet->setCellValue(Coordinate::stringFromColumnIndex(2 + 2 * $col).$row, $answer->text);
|
|
$sheet->setCellValue(Coordinate::stringFromColumnIndex(3 + 2 * $col).$row, $answer->isRightAnswer);
|
|
++$col;
|
|
}
|
|
|
|
$maxAnswers = max($maxAnswers, $col);
|
|
++$row;
|
|
}
|
|
|
|
// Write headers last, sized to the widest question.
|
|
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
|
$sheet->setCellValue('A1', 'Question');
|
|
$sheet->getColumnDimension('A')->setWidth(30);
|
|
$sheet->getStyle('A:A')->getAlignment()->setWrapText(true);
|
|
|
|
for ($i = 0; $i < $maxAnswers; ++$i) {
|
|
$answerCol = Coordinate::stringFromColumnIndex(2 + 2 * $i);
|
|
$correctCol = Coordinate::stringFromColumnIndex(3 + 2 * $i);
|
|
|
|
$sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1));
|
|
$sheet->getColumnDimension($answerCol)->setWidth(30);
|
|
$sheet->getStyle($answerCol.':'.$answerCol)->getAlignment()->setWrapText(true);
|
|
|
|
$sheet->setCellValue($correctCol.'1', 'Correct');
|
|
$sheet->getColumnDimension($correctCol)->setAutoSize(true);
|
|
}
|
|
|
|
return $this->toXlsx($spreadsheet);
|
|
}
|
|
|
|
private function toXlsx(Spreadsheet $spreadsheet): \Closure
|
|
{
|
|
$writer = new Writer\Xlsx($spreadsheet);
|
|
|
|
return static fn () => $writer->save('php://output');
|
|
}
|
|
|
|
private function isSpreadsheetFile(File $file): bool
|
|
{
|
|
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' === $file->getMimeType();
|
|
}
|
|
}
|