Compare commits

..

3 Commits

Author SHA1 Message Date
Marijn 815e7b17be Add missing quiz/nav.html.twig and test for template reference integrity (#166)
Adds the previously uncommitted quiz nav partial that broke main, and
introduces TemplateReferencesTest which scans all Twig templates for
extends/include/embed references and asserts each target file exists —
preventing this class of missing-template mistake from reaching CI undetected.
2026-07-02 21:08:21 +00:00
Marijn 764f59e6a7 Improve GitHub Actions CI: parallelise jobs, continue-on-error, timeouts, cache optimisation (#165)
* Strip v-prefix from version tag before passing to Sentry

GitHub tags follow the v1.2.3 convention, but Sentry requires bare
semver (1.2.3) to recognise releases as valid semver. Extract a
sentry_version output in the meta step that strips the leading v.

* Parallelize CI: split quality and tests jobs, add continue-on-error

- Split the single tests job into parallel quality and tests jobs,
  saving ~4 min wall-clock time per run
- Quality checks (lint, CS, PHPStan, Rector) now all run with
  continue-on-error so every failure is visible in one pass; a
  final Assert step fails the job if any check failed
- Add cache:warmup before PHPStan so the Symfony dev container XML
  exists and the Symfony extension has full type information
- Use per-job GHA cache scopes to avoid parallel cache write races
- Use cache mode=min on PRs, mode=max on main/tags
- Add timeout-minutes (20/20/15) to all jobs
- Remove dead if:false Mercure reachability step
- Fix Portainer webhook URL quoting
- build-deploy now needs: [quality, tests]

* Simplify build-deploy job name and environment expressions

* Use static name for build-deploy job (expressions not evaluated when skipped)

* build-deploy only needs tests, not quality (quality is informational)

* Revert: build-deploy needs both quality and tests
2026-07-02 23:06:23 +02:00
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
6 changed files with 220 additions and 61 deletions
+61 -16
View File
@@ -17,12 +17,11 @@ permissions:
contents: read
jobs:
tests:
name: Tests
quality:
name: Code Quality
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
checks: write
pull-requests: write
contents: read
steps:
- name: Checkout
@@ -40,26 +39,68 @@ jobs:
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}
*.cache-from=type=gha,scope=${{github.ref}}-quality
*.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}},mode=max
*.cache-to=type=gha,scope=${{github.ref}}-quality,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
- name: Start services
run: docker compose up php database --wait --no-build
- name: Warm up dev cache
run: docker compose exec -T php bin/console cache:warmup --env=dev
- name: Lint Twig templates
id: twig_lint
continue-on-error: true
run: docker compose exec -T php bin/console lint:twig --format=github templates
- name: Coding Style
id: cs
continue-on-error: true
run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none
- name: Twig Coding Style
id: twig_cs
continue-on-error: true
run: docker compose exec -T php vendor/bin/twig-cs-fixer check
- name: Static Analysis (PHPStan)
id: phpstan
continue-on-error: true
run: docker compose exec -T php vendor/bin/phpstan analyse --no-progress --no-ansi --error-format=github
- name: Rector
id: rector
continue-on-error: true
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
- name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost
- name: Check Mercure reachability
if: false
run: curl -vkI --fail-with-body https://localhost/.well-known/mercure?topic=test
- name: Assert all checks passed
if: always()
run: |
outcomes="${{ steps.twig_lint.outcome }} ${{ steps.cs.outcome }} ${{ steps.twig_cs.outcome }} ${{ steps.phpstan.outcome }} ${{ steps.rector.outcome }}"
if echo "$outcomes" | grep -q "failure"; then exit 1; fi
tests:
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
checks: write
pull-requests: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker images
uses: docker/bake-action@v5
with:
pull: true
load: true
files: |
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}-tests
*.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}}-tests,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
- name: Start services
run: docker compose up php database --wait --no-build
- name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations
@@ -76,17 +117,18 @@ jobs:
check_name: PHPUnit
- name: Doctrine Schema Validator
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
build-deploy:
name: Build and deploy to ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
name: Build and Deploy
permissions:
contents: read
packages: write
environment:
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
url: ${{ vars.URL }}
needs: tests
needs: [quality, tests]
runs-on: ubuntu-latest
timeout-minutes: 15
if: (github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout
@@ -106,14 +148,17 @@ jobs:
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
SENTRY_VERSION="${TAG#v}"
{
echo "tag=$TAG"
echo "sentry_version=$SENTRY_VERSION"
echo "full_name=ghcr.io/${REPO_LOWER}:$TAG"
} >> "$GITHUB_OUTPUT"
else
SHORT_SHA=$(git rev-parse --short HEAD)
{
echo "tag=$SHORT_SHA"
echo "sentry_version=$SHORT_SHA"
echo "full_name=ghcr.io/${REPO_LOWER}:$SHORT_SHA"
} >> "$GITHUB_OUTPUT"
fi
@@ -139,12 +184,12 @@ jobs:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
release: ${{steps.meta.outputs.tag}}
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
release: ${{steps.meta.outputs.sentry_version}}
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
- name: Trigger Portainer Deployment
shell: bash
env:
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
run: |
curl -v -X POST "$PORTAINER_WEBHOOK"?IMAGE_TAG=${{steps.meta.outputs.tag}} --fail-with-body
curl -v -X POST "${PORTAINER_WEBHOOK}?IMAGE_TAG=${{steps.meta.outputs.tag}}" --fail-with-body
+1 -1
View File
@@ -8,7 +8,7 @@
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
cacheDirectory="/tmp/phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
+29 -28
View File
@@ -18,39 +18,40 @@ class QuizSpreadsheetService
{
public function generateTemplate(bool $fillExample = true): \Closure
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->getStyle('1:1')->getFont()->setBold(true);
$sheet->setCellValue('A1', 'Question');
$sheet->getColumnDimension('A')->setWidth(30);
$sheet->getStyle('A:A')->getAlignment()->setWrapText(true);
$counter = 1;
foreach (range('B', 'L', 2) as $column) {
$sheet->setCellValue($column.'1', 'Answer '.$counter++);
$sheet->getColumnDimension($column)->setWidth(30);
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
}
foreach (range('C', 'M', 2) as $column) {
$sheet->setCellValue($column.'1', 'Correct');
$sheet->getColumnDimension($column)->setAutoSize(true);
}
$quiz = new Quiz();
if ($fillExample) {
$sheet->setCellValue('B2', 'Man');
$sheet->setCellValue('C2', true);
$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);
$sheet->setCellValue('D2', 'Vrouw');
$sheet->setCellValue('E2', false);
$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);
}
$sheet->setCellValue('A2', 'Is de mol een man of een vrouw?');
$quiz->addQuestion($identiteit);
}
return $this->toXlsx($spreadsheet);
return $this->quizToXlsx($quiz);
}
/** @throws SpreadsheetDataException */
@@ -102,7 +103,7 @@ class QuizSpreadsheetService
}
if (1 === $answerCounter) {
$errors[] = \sprintf('Question %d has no answers', $answerCounter);
$errors[] = \sprintf('Question %d has no answers', $question->ordering);
}
$quiz->addQuestion($question);
+16
View File
@@ -0,0 +1,16 @@
{% if is_granted('IS_AUTHENTICATED') or app.current_route() == 'tvdt_quiz_select_season' %}
<div class="quiz-topbar">
{% if is_granted('IS_AUTHENTICATED') %}
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Backoffice'|trans }}
</a>
<a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Logout'|trans }}
</a>
{% else %}
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Manage Quiz'|trans }}
</a>
{% endif %}
</div>
{% endif %}
+50 -16
View File
@@ -5,6 +5,8 @@ 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;
@@ -69,12 +71,17 @@ final class QuizSpreadsheetServiceTest extends TestCase
$quiz = new Quiz();
$this->subject->xlsxToQuiz($quiz, new File($path));
$this->assertCount(1, $quiz->questions);
$this->assertCount(2, $quiz->questions);
/** @var Question $question */
$question = $quiz->questions->first();
$this->assertSame('Is de mol een man of een vrouw?', $question->question);
$this->assertCount(2, $question->answers);
/** @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
@@ -142,17 +149,44 @@ final class QuizSpreadsheetServiceTest extends TestCase
}
}
/** @return array<string, array{int, string, int, string, int, int}> */
public static function answerCountHeaderProvider(): array
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.
return [
'2 answers → 2 header pairs' => [2, 'Answer 2', 3, 'Correct', 4, 5],
'6 answers → 6 header pairs' => [6, 'Answer 6', 11, 'Correct', 12, 13],
'7 answers → 7 header pairs' => [7, 'Answer 7', 13, 'Correct', 14, 15],
'10 answers → 10 header pairs' => [10, 'Answer 10', 19, 'Correct', 20, 21],
];
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')]
@@ -228,7 +262,7 @@ final class QuizSpreadsheetServiceTest extends TestCase
{
$quiz = new Quiz();
foreach ($counts as $i => $count) {
$quiz->addQuestion($this->makeQuestion("Question $i", $count));
$quiz->addQuestion($this->makeQuestion('Question '.$i, $count));
}
return $quiz;
@@ -240,7 +274,7 @@ final class QuizSpreadsheetServiceTest extends TestCase
$question->question = $text;
$question->ordering = 1;
for ($i = 1; $i <= $answerCount; ++$i) {
$question->addAnswer(new Answer("Answer $i", isRightAnswer: false));
$question->addAnswer(new Answer('Answer '.$i, isRightAnswer: false));
}
return $question;
@@ -249,7 +283,7 @@ final class QuizSpreadsheetServiceTest extends TestCase
/** @return array<int, string|null> */
private function readFirstRow(string $path): array
{
$rows = (new Reader\Xlsx())->setReadDataOnly(true)->load($path)->getActiveSheet()->toArray(formatData: false);
$rows = new Reader\Xlsx()->setReadDataOnly(true)->load($path)->getActiveSheet()->toArray(formatData: false);
return $rows[0] ?? [];
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Twig;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use function Safe\file_get_contents;
use function Safe\preg_match_all;
final class TemplateReferencesTest extends TestCase
{
private static string $templatesDir;
public static function setUpBeforeClass(): void
{
self::$templatesDir = \dirname(__DIR__, 2).'/templates';
}
/** @return iterable<string, array{string, string}> */
public static function templateReferenceProvider(): iterable
{
$templatesDir = \dirname(__DIR__, 2).'/templates';
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($templatesDir, \RecursiveDirectoryIterator::SKIP_DOTS),
);
foreach ($iterator as $file) {
Assert::assertInstanceOf(\SplFileInfo::class, $file);
if ('twig' !== $file->getExtension()) {
continue;
}
$content = file_get_contents($file->getPathname());
$sourceFile = str_replace($templatesDir.'/', '', $file->getPathname());
// Match extends, include(), and embed tags — capture the quoted template name
preg_match_all(
'/(?:extends|include|embed)\s*\(?[\'"]([^\'"]+)[\'"]\)?/',
$content,
$matches,
);
foreach ($matches[1] as $referencedTemplate) {
yield \sprintf('%s → %s', $sourceFile, $referencedTemplate) => [$sourceFile, $referencedTemplate];
}
}
}
#[DataProvider('templateReferenceProvider')]
public function testReferencedTemplateExists(string $sourceFile, string $referencedTemplate): void
{
$absolutePath = self::$templatesDir.'/'.$referencedTemplate;
$this->assertFileExists(
$absolutePath,
\sprintf("Template '%s' references '%s' which does not exist at '%s'.", $sourceFile, $referencedTemplate, $absolutePath),
);
}
}