Compare commits

..

10 Commits

Author SHA1 Message Date
Marijn 466f0aeb54 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`
2026-07-02 21:48:37 +02:00
Marijn d55cced5f7 Fix Sass healthcheck 2026-07-02 20:48:16 +02:00
Marijn 9c22345d2d 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
2026-07-01 22:48:28 +02:00
Marijn 0f18e4afe5 Use HeaderUtils::makeDisposition() for safe Content-Disposition filename 2026-07-01 22:33:16 +02:00
Marijn 20c97d9cb5 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)
2026-07-01 22:25:43 +02:00
Marijn e5198507ae 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.
2026-07-01 21:35:45 +02:00
Marijn d94eeced8c 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
2026-07-01 21:11:23 +02:00
Marijn 7e09fcdafb 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
2026-07-01 20:27:48 +02:00
Marijn d8b671046b 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
2026-07-01 18:32:57 +02:00
Marijn cd63ef339f 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)
2026-07-01 17:47:45 +02:00
11 changed files with 167 additions and 423 deletions
-4
View File
@@ -28,7 +28,3 @@ updates:
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+39 -172
View File
@@ -17,130 +17,49 @@ permissions:
contents: read
jobs:
build:
name: Build Dev Image
runs-on: ubuntu-latest
timeout-minutes: 15
if: "!startsWith(github.ref, 'refs/tags/')"
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Lint Dockerfile
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Build Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
with:
pull: true
files: |
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
*.cache-from=type=gha,scope=refs/heads/main-devbuild
*.cache-to=type=gha,scope=${{github.ref}}-devbuild,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
quality:
name: Code Quality
runs-on: ubuntu-latest
timeout-minutes: 20
needs: build
if: "!startsWith(github.ref, 'refs/tags/')"
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Load Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
with:
load: true
files: |
compose.yaml
compose.override.yaml
set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
- 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: Assert all checks passed
if: always()
run: |
failed=0
check() {
local name="$1" outcome="$2"
if [[ "$outcome" == "failure" ]]; then
echo "::error::$name failed"
failed=1
fi
}
check "Twig Lint" "${{ steps.twig_lint.outcome }}"
check "Coding Style" "${{ steps.cs.outcome }}"
check "Twig Coding Style" "${{ steps.twig_cs.outcome }}"
check "PHPStan" "${{ steps.phpstan.outcome }}"
check "Rector" "${{ steps.rector.outcome }}"
exit $failed
tests:
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 20
needs: build
if: "!startsWith(github.ref, 'refs/tags/')"
permissions:
checks: write
pull-requests: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Lint Dockerfile
uses: hadolint/hadolint-action@v3.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Load Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
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}}-devbuild
*.cache-from=type=gha,scope=${{github.ref}}
*.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}},mode=max
- name: Start services
run: docker compose up php database --wait --no-build
- name: Lint Twig templates
run: docker compose exec -T php bin/console lint:twig --format=github templates
- name: Coding Style
run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none
- name: Twig Coding Style
run: docker compose exec -T php vendor/bin/twig-cs-fixer check
- name: Static Analysis (PHPStan)
run: docker compose exec -T php vendor/bin/phpstan analyse --no-progress --no-ansi --error-format=github
- name: Rector
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: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations
@@ -151,78 +70,31 @@ jobs:
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
- name: Publish PHPUnit test results
if: always()
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
uses: mikepenz/action-junit-report@v5
with:
report_paths: var/phpunit/junit.xml
check_name: PHPUnit
- name: Doctrine Schema Validator
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
verify-prior-run:
name: Verify Prior CI Run
runs-on: ubuntu-latest
timeout-minutes: 20
if: startsWith(github.ref, 'refs/tags/')
permissions:
actions: read
steps:
- name: Wait for and verify successful CI run on this commit
env:
GH_TOKEN: ${{ github.token }}
run: |
max_attempts=30
attempt=0
while [[ $attempt -lt $max_attempts ]]; do
attempt=$((attempt + 1))
success_count=$(gh api \
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&status=success&per_page=5" \
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }})] | length")
if [[ "$success_count" -gt 0 ]]; then
echo "Found $success_count prior successful CI run(s) for ${{ github.sha }}."
exit 0
fi
in_progress_count=$(gh api \
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&per_page=10" \
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"in_progress\" or .status == \"queued\" or .status == \"waiting\" or .status == \"requested\" or .status == \"pending\")] | length")
if [[ "$in_progress_count" -gt 0 ]]; then
echo "CI still in progress (attempt $attempt/$max_attempts), waiting 30s..."
sleep 30
else
echo "::error::No prior successful CI run found for ${{ github.sha }}. Only tag commits that have passed CI on main."
exit 1
fi
done
echo "::error::Timed out waiting for CI run to complete for ${{ github.sha }}."
exit 1
build-deploy:
name: Build and Deploy
name: Build and deploy to ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
permissions:
contents: read
packages: write
environment:
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
url: ${{ vars.URL }}
needs: [quality, tests, verify-prior-run]
needs: tests
runs-on: ubuntu-latest
timeout-minutes: 15
if: >-
always() && !cancelled() && !failure() &&
((github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/'))
if: (github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -234,23 +106,20 @@ 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
- name: Build and Push Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7
uses: docker/bake-action@v5
with:
pull: true
push: true
@@ -264,20 +133,18 @@ jobs:
*.tags=${{ steps.meta.outputs.full_name }}
- name: Create Sentry release
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
release: ${{steps.meta.outputs.sentry_version}}
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
release: ${{steps.meta.outputs.tag}}
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
- name: Trigger Portainer Deployment
shell: bash
env:
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
IMAGE_TAG: ${{steps.meta.outputs.tag}}
SENTRY_RELEASE: ${{steps.meta.outputs.sentry_version}}
run: |
curl -v -X POST "${PORTAINER_WEBHOOK}?IMAGE_TAG=${IMAGE_TAG}&SENTRY_RELEASE=${SENTRY_RELEASE}" --fail-with-body
curl -v -X POST "$PORTAINER_WEBHOOK"?IMAGE_TAG=${{steps.meta.outputs.tag}} --fail-with-body
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3
uses: dependabot/fetch-metadata@v2
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for Dependabot PRs
+1 -1
View File
@@ -10,7 +10,7 @@ services:
MAILER_DSN: ${MAILER_DSN}
MAILER_SENDER: ${MAILER_SENDER}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_RELEASE: ${SENTRY_RELEASE}
SENTRY_RELEASE: ${IMAGE_TAG}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT}
labels:
- "traefik.enable=true"
Generated
+79 -81
View File
@@ -1839,16 +1839,16 @@
},
{
"name": "martin-georgiev/postgresql-for-doctrine",
"version": "v4.7.0",
"version": "v4.6.0",
"source": {
"type": "git",
"url": "https://github.com/martin-georgiev/postgresql-for-doctrine.git",
"reference": "23b5c2694083355ab87eaa913b43a0cddd8c64bb"
"reference": "59841c7e53f8339b13bc0cb0ee9931b7b9bbb139"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/martin-georgiev/postgresql-for-doctrine/zipball/23b5c2694083355ab87eaa913b43a0cddd8c64bb",
"reference": "23b5c2694083355ab87eaa913b43a0cddd8c64bb",
"url": "https://api.github.com/repos/martin-georgiev/postgresql-for-doctrine/zipball/59841c7e53f8339b13bc0cb0ee9931b7b9bbb139",
"reference": "59841c7e53f8339b13bc0cb0ee9931b7b9bbb139",
"shasum": ""
},
"require": {
@@ -1863,13 +1863,13 @@
"deptrac/deptrac": "^4.0",
"doctrine/orm": "~2.14||~3.0",
"ekino/phpstan-banned-code": "^3.2.0",
"friendsofphp/php-cs-fixer": "^3.95.11",
"phpstan/phpstan": "^2.2.2",
"friendsofphp/php-cs-fixer": "^3.95.2",
"phpstan/phpstan": "^2.1.55",
"phpstan/phpstan-deprecation-rules": "^2.0.4",
"phpstan/phpstan-doctrine": "^2.0.27",
"phpstan/phpstan-doctrine": "^2.0.22",
"phpstan/phpstan-phpunit": "^2.0.16",
"phpunit/phpunit": "^10.5.63||^11.5",
"rector/rector": "^2.5.2",
"rector/rector": "^2.4.4",
"symfony/cache": "^6.4||^7.0",
"symfony/var-exporter": "^6.4||^7.0"
},
@@ -1952,7 +1952,7 @@
],
"support": {
"issues": "https://github.com/martin-georgiev/postgresql-for-doctrine/issues",
"source": "https://github.com/martin-georgiev/postgresql-for-doctrine/tree/v4.7.0"
"source": "https://github.com/martin-georgiev/postgresql-for-doctrine/tree/v4.6.0"
},
"funding": [
{
@@ -1964,7 +1964,7 @@
"type": "github"
}
],
"time": "2026-07-01T18:17:39+00:00"
"time": "2026-05-29T19:11:20+00:00"
},
{
"name": "phpdocumentor/reflection-common",
@@ -9287,16 +9287,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.95.11",
"version": "v3.95.8",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5"
"reference": "4140023f552ff02346df9b1329742532166f677f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5",
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/4140023f552ff02346df9b1329742532166f677f",
"reference": "4140023f552ff02346df9b1329742532166f677f",
"shasum": ""
},
"require": {
@@ -9330,7 +9330,7 @@
"require-dev": {
"facile-it/paraunit": "^1.3.1 || ^2.11.0",
"infection/infection": "^0.32.7",
"justinrainbow/json-schema": "^6.10.0",
"justinrainbow/json-schema": "^6.9.0",
"keradus/cli-executor": "^2.3",
"mikey179/vfsstream": "^1.6.12",
"php-coveralls/php-coveralls": "^2.9.1",
@@ -9380,7 +9380,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.11"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.8"
},
"funding": [
{
@@ -9388,7 +9388,7 @@
"type": "github"
}
],
"time": "2026-06-25T14:17:04+00:00"
"time": "2026-06-16T09:52:26+00:00"
},
{
"name": "myclabs/deep-copy",
@@ -9676,11 +9676,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.2.4",
"version": "2.2.2",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27",
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
"reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
"shasum": ""
},
"require": {
@@ -9736,7 +9736,7 @@
"type": "github"
}
],
"time": "2026-07-03T07:00:23+00:00"
"time": "2026-06-05T09:00:01+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
@@ -9817,22 +9817,21 @@
},
{
"name": "phpstan/phpstan-phpunit",
"version": "2.0.17",
"version": "2.0.16",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-phpunit.git",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f"
"reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
"reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
"shasum": ""
},
"require": {
"phar-io/version": "^3.2",
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.2.3"
"phpstan/phpstan": "^2.1.32"
},
"conflict": {
"phpunit/phpunit": "<7.0"
@@ -9842,8 +9841,7 @@
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6",
"shipmonk/name-collision-detector": "^2.1"
"phpunit/phpunit": "^9.6"
},
"type": "phpstan-extension",
"extra": {
@@ -9869,9 +9867,9 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-phpunit/issues",
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.17"
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16"
},
"time": "2026-06-29T05:32:23+00:00"
"time": "2026-02-14T09:05:21+00:00"
},
{
"name": "phpstan/phpstan-symfony",
@@ -10332,16 +10330,16 @@
},
{
"name": "phpunit/phpunit",
"version": "13.2.2",
"version": "13.2.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "492c067e618de7b3c76105082c90f9d2833401b7"
"reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7",
"reference": "492c067e618de7b3c76105082c90f9d2833401b7",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/60da0ff1e10a0f72ee18a24117ec3b613a346bba",
"reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba",
"shasum": ""
},
"require": {
@@ -10412,7 +10410,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.2"
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.1"
},
"funding": [
{
@@ -10420,7 +10418,7 @@
"type": "other"
}
],
"time": "2026-06-29T13:36:29+00:00"
"time": "2026-06-15T13:14:22+00:00"
},
{
"name": "react/cache",
@@ -10950,16 +10948,16 @@
},
{
"name": "rector/rector",
"version": "2.5.2",
"version": "2.4.6",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24"
"reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/9b9e5c76618e4d359f65b54ca2eabcad3d1761ee",
"reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee",
"shasum": ""
},
"require": {
@@ -10998,7 +10996,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.5.2"
"source": "https://github.com/rectorphp/rector/tree/2.4.6"
},
"funding": [
{
@@ -11006,7 +11004,7 @@
"type": "github"
}
],
"time": "2026-06-22T11:39:33+00:00"
"time": "2026-06-17T11:56:28+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -12169,16 +12167,16 @@
},
{
"name": "symfony/browser-kit",
"version": "v8.1.1",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/browser-kit.git",
"reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5"
"reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5",
"reference": "f2ac86001ca9f487e8c6d0e11c8e33e6a9b8b2d5",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/74e18e582cdda0eca35f7c74e1e48e62f0ede853",
"reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853",
"shasum": ""
},
"require": {
@@ -12217,7 +12215,7 @@
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/browser-kit/tree/v8.1.1"
"source": "https://github.com/symfony/browser-kit/tree/v8.1.0"
},
"funding": [
{
@@ -12237,7 +12235,7 @@
"type": "tidelift"
}
],
"time": "2026-06-09T10:54:51+00:00"
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/css-selector",
@@ -12310,16 +12308,16 @@
},
{
"name": "symfony/dom-crawler",
"version": "v8.1.1",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/dom-crawler.git",
"reference": "1dfadd25537c8fcb6752cce5775f24647d976bdc"
"reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/1dfadd25537c8fcb6752cce5775f24647d976bdc",
"reference": "1dfadd25537c8fcb6752cce5775f24647d976bdc",
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/77ca351474ea018daba5f2e473cbf1b9b8e72ac6",
"reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6",
"shasum": ""
},
"require": {
@@ -12356,7 +12354,7 @@
"description": "Eases DOM navigation for HTML and XML documents",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dom-crawler/tree/v8.1.1"
"source": "https://github.com/symfony/dom-crawler/tree/v8.1.0"
},
"funding": [
{
@@ -12376,7 +12374,7 @@
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/maker-bundle",
@@ -12479,16 +12477,16 @@
},
{
"name": "symfony/phpunit-bridge",
"version": "v8.1.1",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/phpunit-bridge.git",
"reference": "3e1c9a9167e07474ec115555b632f0ffadb0f94d"
"reference": "1fed488f8033f2dece371e60a1c66f2add274916"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/3e1c9a9167e07474ec115555b632f0ffadb0f94d",
"reference": "3e1c9a9167e07474ec115555b632f0ffadb0f94d",
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/1fed488f8033f2dece371e60a1c66f2add274916",
"reference": "1fed488f8033f2dece371e60a1c66f2add274916",
"shasum": ""
},
"require": {
@@ -12540,7 +12538,7 @@
"testing"
],
"support": {
"source": "https://github.com/symfony/phpunit-bridge/tree/v8.1.1"
"source": "https://github.com/symfony/phpunit-bridge/tree/v8.1.0"
},
"funding": [
{
@@ -12560,20 +12558,20 @@
"type": "tidelift"
}
],
"time": "2026-06-09T10:54:51+00:00"
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/web-profiler-bundle",
"version": "v8.1.1",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/web-profiler-bundle.git",
"reference": "eb4cf71d8fc496d790ec85b1b684a7ac30d57a96"
"reference": "f8ccea08797a511b85a698b0da40e1b9e6461086"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/eb4cf71d8fc496d790ec85b1b684a7ac30d57a96",
"reference": "eb4cf71d8fc496d790ec85b1b684a7ac30d57a96",
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f8ccea08797a511b85a698b0da40e1b9e6461086",
"reference": "f8ccea08797a511b85a698b0da40e1b9e6461086",
"shasum": ""
},
"require": {
@@ -12625,7 +12623,7 @@
"dev"
],
"support": {
"source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.1"
"source": "https://github.com/symfony/web-profiler-bundle/tree/v8.1.0"
},
"funding": [
{
@@ -12645,27 +12643,27 @@
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "thecodingmachine/phpstan-safe-rule",
"version": "v1.4.7",
"version": "v1.4.3",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/phpstan-safe-rule.git",
"reference": "51fa2a35a270f683fc9ea53384a03e892b4d7b51"
"reference": "5c804889253ce9498ef185e108e9f94b6023208e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/phpstan-safe-rule/zipball/51fa2a35a270f683fc9ea53384a03e892b4d7b51",
"reference": "51fa2a35a270f683fc9ea53384a03e892b4d7b51",
"url": "https://api.github.com/repos/thecodingmachine/phpstan-safe-rule/zipball/5c804889253ce9498ef185e108e9f94b6023208e",
"reference": "5c804889253ce9498ef185e108e9f94b6023208e",
"shasum": ""
},
"require": {
"nikic/php-parser": "^5",
"php": "^8.1",
"phpstan/phpstan": "^2.2.2",
"thecodingmachine/safe": "^3.1"
"phpstan/phpstan": "^2.1.11",
"thecodingmachine/safe": "^1.2 || ^2.0 || ^3.0"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
@@ -12701,9 +12699,9 @@
"description": "A PHPStan rule to detect safety issues. Must be used in conjunction with thecodingmachine/safe",
"support": {
"issues": "https://github.com/thecodingmachine/phpstan-safe-rule/issues",
"source": "https://github.com/thecodingmachine/phpstan-safe-rule/tree/v1.4.7"
"source": "https://github.com/thecodingmachine/phpstan-safe-rule/tree/v1.4.3"
},
"time": "2026-06-21T07:55:55+00:00"
"time": "2025-11-21T09:41:49+00:00"
},
{
"name": "theseer/tokenizer",
@@ -12757,16 +12755,16 @@
},
{
"name": "vincentlanglet/twig-cs-fixer",
"version": "4.0.2",
"version": "4.0.1",
"source": {
"type": "git",
"url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git",
"reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a"
"reference": "366f7cca494a6f95c5f410ae542aef9c164d329e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a",
"reference": "1cb75618f7dd0f9bf51924aa6d3aa8c588f51d5a",
"url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/366f7cca494a6f95c5f410ae542aef9c164d329e",
"reference": "366f7cca494a6f95c5f410ae542aef9c164d329e",
"shasum": ""
},
"require": {
@@ -12822,7 +12820,7 @@
"homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer",
"support": {
"issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues",
"source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/4.0.2"
"source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/4.0.1"
},
"funding": [
{
@@ -12830,7 +12828,7 @@
"type": "github"
}
],
"time": "2026-06-29T15:22:14+00:00"
"time": "2026-06-18T15:31:27+00:00"
}
],
"aliases": [],
+1 -1
View File
@@ -8,7 +8,7 @@
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory="/tmp/phpunit.cache"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
+1 -4
View File
@@ -3,7 +3,6 @@
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\PHPUnit\CodeQuality\Rector\Class_\AddSeeTestAnnotationRector;
use Rector\Symfony\Bridge\Symfony\Routing\SymfonyRoutesProvider;
use Rector\Symfony\Contract\Bridge\Symfony\Routing\SymfonyRoutesProviderInterface;
@@ -14,7 +13,7 @@ return RectorConfig::configure()
__DIR__.'/src',
__DIR__.'/tests',
])
->withSkipPath(__DIR__.'/config/reference.php')
->withSkip([__DIR__.'/config/reference.php'])
->withSymfonyContainerXml(__DIR__.'/var/cache/dev/Tvdt_KernelDevDebugContainer.xml')
->withSymfonyContainerPhp(__DIR__.'/tests/symfony-container.php')
->registerService(SymfonyRoutesProvider::class, SymfonyRoutesProviderInterface::class)
@@ -35,6 +34,4 @@ return RectorConfig::configure()
)
->withAttributesSets(all: true)
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)
->withSkip([AddSeeTestAnnotationRector::class])
;
+29 -30
View File
@@ -18,40 +18,39 @@ class QuizSpreadsheetService
{
public function generateTemplate(bool $fillExample = true): \Closure
{
$quiz = new Quiz();
$spreadsheet = new Spreadsheet();
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);
$sheet = $spreadsheet->getActiveSheet();
$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->getStyle('1:1')->getFont()->setBold(true);
$quiz->addQuestion($identiteit);
$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);
}
return $this->quizToXlsx($quiz);
foreach (range('C', 'M', 2) as $column) {
$sheet->setCellValue($column.'1', 'Correct');
$sheet->getColumnDimension($column)->setAutoSize(true);
}
if ($fillExample) {
$sheet->setCellValue('B2', 'Man');
$sheet->setCellValue('C2', true);
$sheet->setCellValue('D2', 'Vrouw');
$sheet->setCellValue('E2', false);
$sheet->setCellValue('A2', 'Is de mol een man of een vrouw?');
}
return $this->toXlsx($spreadsheet);
}
/** @throws SpreadsheetDataException */
@@ -103,7 +102,7 @@ class QuizSpreadsheetService
}
if (1 === $answerCounter) {
$errors[] = \sprintf('Question %d has no answers', $question->ordering);
$errors[] = \sprintf('Question %d has no answers', $answerCounter);
}
$quiz->addQuestion($question);
-16
View File
@@ -1,16 +0,0 @@
{% 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 %}
+16 -50
View File
@@ -5,8 +5,6 @@ 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;
@@ -71,17 +69,12 @@ final class QuizSpreadsheetServiceTest extends TestCase
$quiz = new Quiz();
$this->subject->xlsxToQuiz($quiz, new File($path));
$this->assertCount(2, $quiz->questions);
$this->assertCount(1, $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);
/** @var Question $question */
$question = $quiz->questions->first();
$this->assertSame('Is de mol een man of een vrouw?', $question->question);
$this->assertCount(2, $question->answers);
}
public function testQuizToXlsxEmptyQuizImportsWithNoQuestions(): void
@@ -149,44 +142,17 @@ final class QuizSpreadsheetServiceTest extends TestCase
}
}
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
/** @return array<string, array{int, string, int, string, int, int}> */
public static function answerCountHeaderProvider(): array
{
// 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];
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],
];
}
#[DataProvider('answerCountHeaderProvider')]
@@ -262,7 +228,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;
@@ -274,7 +240,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;
@@ -283,7 +249,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
@@ -1,63 +0,0 @@
<?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),
);
}
}