Compare commits

..

3 Commits

Author SHA1 Message Date
Marijn baef8a5667 Merge branch 'main' into dependabot/composer/dev-dependencies-98b83477af 2026-07-01 17:26:48 +02:00
Marijn fa042a70c2 Fix CS 2026-07-01 17:26:22 +02:00
dependabot[bot] 54242e53ce Bump the dev-dependencies group across 1 directory with 9 updates
---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpunit/phpunit
  dependency-version: 13.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: rector/rector
  dependency-version: 2.5.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: symfony/browser-kit
  dependency-version: 8.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: symfony/phpunit-bridge
  dependency-version: 8.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: symfony/web-profiler-bundle
  dependency-version: 8.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: thecodingmachine/phpstan-safe-rule
  dependency-version: 1.4.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vincentlanglet/twig-cs-fixer
  dependency-version: 4.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 23:53:13 +00:00
29 changed files with 476 additions and 1274 deletions
-4
View File
@@ -28,7 +28,3 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+39 -172
View File
@@ -17,130 +17,49 @@ permissions:
contents: read contents: read
jobs: 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: tests:
name: Tests name: Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
needs: build
if: "!startsWith(github.ref, 'refs/tags/')"
permissions: permissions:
checks: write checks: write
pull-requests: write pull-requests: write
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@v4
with: - name: Lint Dockerfile
persist-credentials: false uses: hadolint/hadolint-action@v3.1.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@v3
- name: Load Docker images - name: Build Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7 uses: docker/bake-action@v5
with: with:
pull: true
load: true load: true
files: | files: |
compose.yaml compose.yaml
compose.override.yaml compose.override.yaml
set: | 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 - name: Start services
run: docker compose up php database --wait --no-build 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 - name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations - name: Run migrations
@@ -151,78 +70,31 @@ jobs:
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
- name: Publish PHPUnit test results - name: Publish PHPUnit test results
if: always() if: always()
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6 uses: mikepenz/action-junit-report@v5
with: with:
report_paths: var/phpunit/junit.xml report_paths: var/phpunit/junit.xml
check_name: PHPUnit check_name: PHPUnit
- name: Doctrine Schema Validator - name: Doctrine Schema Validator
run: docker compose exec -T php bin/console -e test doctrine:schema:validate 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: build-deploy:
name: Build and Deploy name: Build and deploy to ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
environment: 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 }} url: ${{ vars.URL }}
needs: [quality, tests, verify-prior-run] needs: tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 if: (github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/')
if: >-
always() && !cancelled() && !failure() &&
((github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/'))
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Docker Buildx - 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 - name: Log in to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -234,23 +106,20 @@ jobs:
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
if [[ "${{ github.ref }}" == refs/tags/* ]]; then if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}" TAG="${GITHUB_REF#refs/tags/}"
SENTRY_VERSION="${TAG#v}"
{ {
echo "tag=$TAG" echo "tag=$TAG"
echo "sentry_version=$SENTRY_VERSION"
echo "full_name=ghcr.io/${REPO_LOWER}:$TAG" echo "full_name=ghcr.io/${REPO_LOWER}:$TAG"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
else else
SHORT_SHA=$(git rev-parse --short HEAD) SHORT_SHA=$(git rev-parse --short HEAD)
{ {
echo "tag=$SHORT_SHA" echo "tag=$SHORT_SHA"
echo "sentry_version=$SHORT_SHA"
echo "full_name=ghcr.io/${REPO_LOWER}:$SHORT_SHA" echo "full_name=ghcr.io/${REPO_LOWER}:$SHORT_SHA"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
fi fi
- name: Build and Push Docker images - name: Build and Push Docker images
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7 uses: docker/bake-action@v5
with: with:
pull: true pull: true
push: true push: true
@@ -264,20 +133,18 @@ jobs:
*.tags=${{ steps.meta.outputs.full_name }} *.tags=${{ steps.meta.outputs.full_name }}
- name: Create Sentry release - name: Create Sentry release
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3 uses: getsentry/action-release@v3
env: env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with: with:
release: ${{steps.meta.outputs.sentry_version}} release: ${{steps.meta.outputs.tag}}
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }} environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
- name: Trigger Portainer Deployment - name: Trigger Portainer Deployment
shell: bash shell: bash
env: env:
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}} PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
IMAGE_TAG: ${{steps.meta.outputs.tag}}
SENTRY_RELEASE: ${{steps.meta.outputs.sentry_version}}
run: | 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: steps:
- name: Dependabot metadata - name: Dependabot metadata
id: metadata id: metadata
uses: dependabot/fetch-metadata@v3 uses: dependabot/fetch-metadata@v2
with: with:
github-token: "${{ secrets.GITHUB_TOKEN }}" github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for Dependabot PRs - name: Enable auto-merge for Dependabot PRs
+4 -2
View File
@@ -72,8 +72,10 @@ Icon
/.idea/ /.idea/
/.vscode/ /.vscode/
# Claude Code # Junie
/.claude/settings.local.json !/.junie/
/.junie/memory/
/.junie/plans/
# Windows # Windows
Thumbs.db Thumbs.db
-1
View File
@@ -169,7 +169,6 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/git-state" /> <excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/git-state" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ergebnis/agent-detector" /> <excludeFolder url="file://$MODULE_DIR$/vendor/ergebnis/agent-detector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
Generated
+146 -145
View File
@@ -41,169 +41,170 @@
</component> </component>
<component name="PhpIncludePathManager"> <component name="PhpIncludePathManager">
<include_path> <include_path>
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/gedmo/doctrine-extensions" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
<path value="$PROJECT_DIR$/vendor/react/cache" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/twig/intl-extra" />
<path value="$PROJECT_DIR$/vendor/dama/doctrine-test-bundle" />
<path value="$PROJECT_DIR$/vendor/stof/doctrine-extensions-bundle" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-phpunit" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
<path value="$PROJECT_DIR$/vendor/react/child-process" />
<path value="$PROJECT_DIR$/vendor/jean85/pretty-package-versions" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/rector/rector" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry-symfony" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/form" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-deepclone" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/phpstan/extension-installer" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/symfony/asset-mapper" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/brevo-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" /> <path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" /> <path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/config" /> <path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" /> <path value="$PROJECT_DIR$/vendor/symfony/intl" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" /> <path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" /> <path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" /> <path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" /> <path value="$PROJECT_DIR$/vendor/symfony/form" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" /> <path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" /> <path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" /> <path value="$PROJECT_DIR$/vendor/symfony/brevo-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" /> <path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" /> <path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" /> <path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" /> <path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" /> <path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" /> <path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" /> <path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" /> <path value="$PROJECT_DIR$/vendor/symfony/asset-mapper" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" /> <path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" /> <path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" /> <path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/string" /> <path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" /> <path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" /> <path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" /> <path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" /> <path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" /> <path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/symfony/process" /> <path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/console" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" /> <path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" /> <path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" /> <path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" /> <path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" /> <path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" /> <path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" /> <path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" /> <path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/ergebnis/agent-detector" />
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
<path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" /> <path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" /> <path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" /> <path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" /> <path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" /> <path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" /> <path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" /> <path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" /> <path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" /> <path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" /> <path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/sebastian/file-filter" /> <path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" /> <path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/sebastian/git-state" /> <path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" /> <path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" /> <path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" /> <path value="$PROJECT_DIR$/vendor/maennchen/zipstream-php" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" /> <path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" /> <path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" /> <path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" /> <path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" /> <path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" /> <path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" /> <path value="$PROJECT_DIR$/vendor/markbaker/complex" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" /> <path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/sass-bundle" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" /> <path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/sass-bundle" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" />
<path value="$PROJECT_DIR$/vendor/composer" /> <path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/psr/simple-cache" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/react/cache" />
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
<path value="$PROJECT_DIR$/vendor/jean85/pretty-package-versions" />
<path value="$PROJECT_DIR$/vendor/react/child-process" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/twig/intl-extra" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/stof/doctrine-extensions-bundle" />
<path value="$PROJECT_DIR$/vendor/dama/doctrine-test-bundle" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/gedmo/doctrine-extensions" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-phpunit" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/phpstan/extension-installer" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry" />
<path value="$PROJECT_DIR$/vendor/sentry/sentry-symfony" />
<path value="$PROJECT_DIR$/vendor/rector/rector" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/sebastian/git-state" />
<path value="$PROJECT_DIR$/vendor/ergebnis/agent-detector" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-deepclone" />
</include_path> </include_path>
</component> </component>
<component name="PhpInterpreters"> <component name="PhpInterpreters">
+82
View File
@@ -0,0 +1,82 @@
# Agent Guide: Tijd Voor De Test (Tvdt)
This document provides essential context and instructions for AI agents working on the **Tijd Voor De Test** project.
## Project Overview
A web application for managing "Wie is de Mol?" style tests, including seasons, quizzes, candidates, and eliminations.
- **Namespace**: `Tvdt`
- **PHP Version**: 8.5+
- **Framework**: Symfony 8.0
## Tech Stack
- **Server**: FrankenPHP (Caddy-based PHP server)
- **Database**: PostgreSQL
- **Frontend**: Symfony Asset Mapper (no Node.js/Webpack), Stimulus, Turbo
- **Styling**: Sass (via `symfonycasts/sass-bundle`)
- **Persistence**: Doctrine ORM 3.x
## Core Domain Entities
- **Season**: Groups quizzes and candidates for a specific period.
- **SeasonSettings**: Configuration for a season.
- **Quiz**: A test within a season containing multiple questions.
- **Question**: Questions belonging to a quiz.
- **Answer**: Possible answers for a question.
- **Candidate**: A participant in the season.
- **QuizCandidate**: Represents a candidate's attempt at a specific quiz (tracking start/end time).
- **GivenAnswer**: The specific answer a candidate selected during a quiz.
- **Elimination**: Records of red/green screens and forced results.
- **User**: Administrative accounts for managing the system.
## Development Workflow
The project uses `just` as the primary task runner. Always prefer `just` commands over manual docker calls.
### Common Commands
- `just up`: Start the environment.
- `just down`: Stop the environment.
- `just shell`: Enter the PHP container.
- `just migrate`: Run database migrations.
- `just fixtures`: Load development fixtures.
- `just fix-cs`: Run `php-cs-fixer` and `twig-cs-fixer`.
- `just phpstan`: Run static analysis.
- `just rector`: Run Rector for automated refactorings.
- `just reload-tests`: Reset the test database and load test fixtures.
## Coding Standards
- **PSR-12**: Follow standard PHP coding styles.
- **Strict Typing**: Use strict types in all PHP files.
- **Doctrine ORM 3**: Be aware of ORM 3 changes (e.g., lazy loading behavior, attribute-based mapping).
- **Symfony 8**: Use modern Symfony features (Attributes, Type-hinting).
- **Safe Functions**: Use `thecodingmachine/safe` for standard PHP functions that throw exceptions instead of returning false.
## Testing
- **Framework**: PHPUnit
- **Bundle**: `dama/doctrine-test-bundle` is used to wrap tests in transactions.
- **Location**: `tests/` directory mirroring `src/`.
- **Execution**: Run via `bin/phpunit` inside the container or `just reload-tests` to prepare the environment.
## Frontend Development
- JavaScript is managed via **Import Maps**.
- Stimulus controllers are located in `assets/controllers/`.
- CSS/Sass is in `assets/styles/`.
- Assets are compiled on-the-fly or mapped; do not look for a `node_modules` folder.
## Key Components
### Controllers
- **Backoffice**: Located in `src/Controller/Backoffice`, handles season and quiz management.
- **Quiz**: `src/Controller/QuizController` handles the candidate-facing side of quizzes.
- **Elimination**: `src/Controller/EliminationController` handles elimination screens.
### Services
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files.
### Base Classes & Enums
- **AbstractController**: Base class for all controllers, containing common regexes and flash helpers.
- **FlashType Enum**: Used for consistent flash messaging (`FlashType::Success`, `FlashType::Danger`, etc.).
## Key Files
- `composer.json`: Dependency management.
- `importmap.php`: JavaScript module mapping.
- `Justfile`: Automation shortcuts.
- `config/`: Application configuration.
- `templates/`: Twig templates.
-232
View File
@@ -1,232 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is eliminated. This app replicates that quiz format with:
- Test creation with variable question counts
- Season management with active test controls
- Candidate answer tracking with automatic timing
- Elimination tracking with joker adjustments
- Backoffice management for quiz administration and statistics
Tech Stack:
- **Framework**: Symfony 8.1
- **PHP**: 8.5+
- **Database**: PostgreSQL 16
- **ORM**: Doctrine
- **Server**: FrankenPHP with Caddy
- **Container**: Docker Compose
- **Frontend**: Twig templates with SASS (via asset mapper)
- **Testing**: PHPUnit 13 with DAMA Doctrine test bundle
## Build & Development Commands
All commands assume Docker is running. The project uses a [Justfile](https://just.systems) as the primary interface.
### Essential Commands
```bash
just up # Start Docker services (PHP, PostgreSQL)
just stop # Stop services
just down # Stop and remove containers/orphans
just shell # Interactive shell inside the PHP container
just shell-run # Shell in a fresh one-off container
```
### Database
```bash
just migrate # Run Doctrine migrations (starts services first)
just fixtures # Load dev fixtures (truncates first)
just reload-tests # Drop/recreate test DB, migrate, load test fixtures
```
### Testing
```bash
just test # Run full PHPUnit suite
just test tests/Path/To/TestFile.php # Run a specific test file
just test --coverage-html var/coverage # Generate HTML coverage report
```
### Code Quality & Linting
```bash
just fix-cs # Auto-fix PHP-CS-Fixer + Twig-CS-Fixer
just phpstan # PHPStan static analysis (level 9)
just phpstan --no-progress # Without progress output
just rector # Apply Rector modernizations
just rector --dry-run # Preview Rector changes
```
### Other
```bash
just translations # Extract/update nl translation strings
just clean # Nuke containers (volumes) + all generated files (prompts for confirmation)
just trust-cert # Trust the local Caddy TLS certificate (macOS)
just exec <cmd> # Run any command inside the PHP container
```
All code quality checks run in CI/CD (.github/workflows/ci.yml) and should pass before merging.
## Project Structure
```
src/
Controller/ # HTTP request handlers (attribute-routed)
Backoffice/ # Admin panel controllers
Entity/ # Doctrine ORM entities
Repository/ # Database queries
Service/ # Business logic
Command/ # CLI commands
Form/ # Symfony form types
Dto/ # Data transfer objects
Enum/ # Enumerations (FlashType, etc.)
Exception/ # Custom exceptions
Factory/ # Object factories
Helpers/ # Utility functions
Security/ # Auth and voter classes
Voter/ # Authorization voters
DataFixtures/ # Test data loaders
config/
packages/ # Symfony bundle configurations
routes/ # Route definitions
services.yaml # Service container configuration
routes.yaml # Main route entry point
templates/
backoffice/ # Admin UI templates
quiz/ # Public quiz UI templates
base.html.twig # Main layout
tests/
Command/ # Command tests
Controller/ # Controller/integration tests
Repository/ # Repository tests
Security/ # Auth tests
Helpers/ # Utility tests
bootstrap.php # PHPUnit bootstrap with test container setup
```
## Core Domain Entities
- **Season**: Groups quizzes and candidates for a specific period, with a linked `SeasonSettings`.
- **Quiz**: A test within a season containing multiple `Question`s, each with multiple `Answer`s.
- **Candidate**: A participant in the season.
- **QuizCandidate**: Represents a candidate's attempt at a specific quiz (tracks start/end time).
- **GivenAnswer**: The specific answer a candidate selected during a quiz.
- **Elimination**: Records red/green screens and forced results with joker adjustments.
- **User**: Administrative accounts for managing the system.
## Architecture Notes
### Routing
- Routes are **attribute-based** (PHP 8 attributes in controller methods)
- Configured in `config/routes/attributes.yaml` for automatic discovery
- Main entry point: `config/routes.yaml`
### Service Container & Dependency Injection
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
- Exclusions: Entity, DependencyInjection, Kernel classes
- Autowiring and autoconfiguration enabled by default
- Service definitions in `config/services.yaml`
### Database & Migrations
- PostgreSQL-based with Doctrine ORM
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration`
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
- Test database configured separately via `.env.test`
### Testing Infrastructure
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test kernel/container (used by Rector)
- Symfony test utilities (BrowserKit, CSS selectors) available
- Coverage excluded from: `src/DataFixtures/`
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
### Code Style & Standards
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
- Strict types declaration required
- Trailing commas in multiline structures
- No else-only blocks
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit)
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony
- **Twig-CS-Fixer**: Template style enforcement
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead
### Environment Configuration
- `.env` - Local development defaults (uncommitted in .env.local)
- `.env.dev` - Development overrides
- `.env.test` - Test environment configuration
- Production uses `composer dump-env prod` for compiled configuration
- Key variables:
- `APP_ENV` - Environment (dev/test/prod)
- `DATABASE_URL` - PostgreSQL connection string
- `MAILER_SENDER` - From address for emails
### Frontend Build
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
- Production: Assets precompiled during Docker build
- Development: Watch mode enabled in FrankenPHP container
## CI/CD Pipeline
GitHub Actions workflow (`.github/workflows/ci.yml`):
1. **Linting**: Dockerfile (hadolint), Twig templates
2. **Code Quality**:
- PHP-CS-Fixer style check
- Twig-CS-Fixer style check
- PHPStan static analysis
- Rector dry-run
3. **Integration Tests**:
- Docker image build and start services
- Database creation and migration
- Fixture loading
- Full PHPUnit test suite with JUnit XML output
- Doctrine schema validation
4. **Build & Deploy** (on tags or main, disabled currently):
- Docker image push to GitHub Container Registry
- Sentry release creation
- Portainer webhook trigger for production deployment
Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits.
## Important Files & Conventions
- **Kernel**: `src/Kernel.php` - Symfony kernel class
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers
- **Flash Messages**: Use `FlashType` enum instead of string literals
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
- **.gitignore**: Excludes var/, vendor/, .env.local, .phpunit.cache
- **Dockerfile**: Multi-stage build with dev/prod separation, FrankenPHP-based
- **Docker Compose**: PHP service with Caddy, PostgreSQL database, persistent volumes
## Security & Authorization
- Doctrine extensions enabled (timestamps, slugs, etc.)
- Voter-based authorization in `src/Security/Voter/`
- User entity with security encoding configured
- CSRF protection enabled
- Email verification available via SymfonyCasts bundle
## Composer Scripts
Auto-executed scripts on install/update:
- `cache:clear` - Symfony cache clear
- `assets:install` - Copy public assets
- `importmap:install` - JS import map setup
## Notes for Future Work
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
- Quiz timing logic starts on candidate start click and stops on final answer selection
- Background music feature noted but not yet implemented (requirements only)
- Statistics functionality is marked TBD in README
+16
View File
@@ -0,0 +1,16 @@
.DEFAULT_GOAL := help
.PHONY: up
up: ## Start application
@docker compose up -d
stop: ## Stop application
@docker compose stop
.PHONY: shell
shell: ## Start a shell inside the container
@docker compose exec php bash
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-10s\033[0m %s\n", $$1, $$2}'
-3
View File
@@ -1,3 +0,0 @@
.col-result-xs { width: 10%; }
.col-result-sm { width: 15%; }
.col-result-md { width: 20%; }
+4 -87
View File
@@ -1,97 +1,14 @@
html { html, body {
height: 100%; height: 100%;
background-image: url("../img/background.png"); background-image: url("../img/background.png");
background-position: center; background-position: center center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-color: black; background-color: black;
color: white; color: white;
display: grid; display: grid;
align-items: center; align-items: center;
} justify-self: center;
body {
height: 100%;
background-image: url("../img/background.png");
background-position: center;
background-repeat: no-repeat;
background-color: black;
display: grid;
align-content: center;
}
.quiz-form-narrow {
max-width: 320px;
width: 100%;
margin: 0 auto;
}
.quiz-content {
width: fit-content;
max-width: min(760px, 100%);
margin: 0 auto;
}
.quiz-answers-grid {
display: grid;
grid-template-columns: repeat(2, auto);
column-gap: 2rem;
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
}
.answer-btn {
display: flex;
align-items: center;
gap: 0.8rem;
background: transparent;
border: none;
color: white;
font-size: 1.1rem;
padding: 0.4rem 0;
cursor: pointer;
text-align: left;
line-height: 1.3;
&::before {
content: '';
display: block;
flex-shrink: 0;
width: 2rem;
height: 2rem;
background: radial-gradient(ellipse at 35% 30%, #6abf4b, #2d7a1f);
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.25), 0 2px 6px rgba(0, 0, 0, 0.6);
}
&:hover,
&:focus {
color: #7ed45a;
outline: none;
&::before {
background: radial-gradient(ellipse at 35% 30%, #86d45f, #3d9c2a);
}
}
}
input.btn-check:checked + label.answer-btn {
color: #7ed45a;
&::before {
background: radial-gradient(ellipse at 35% 30%, #86d45f, #3d9c2a);
box-shadow: 0 0 10px rgba(106, 191, 75, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.3);
}
}
.quiz-topbar {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1030;
display: flex;
gap: 0.5rem;
} }
.elimination-screen { .elimination-screen {
-3
View File
@@ -7,7 +7,6 @@ services:
target: frankenphp_dev target: frankenphp_dev
volumes: volumes:
- ./:/app - ./:/app
- ~/.composer/cache:/root/.composer/cache
- ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro - ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
- ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro - ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro
- ./frankenphp/data:/data - ./frankenphp/data:/data
@@ -47,8 +46,6 @@ services:
- sass:build - sass:build
- --watch - --watch
- -v - -v
healthcheck:
disable: true
###> symfony/mercure-bundle ### ###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ### ###< symfony/mercure-bundle ###
+1 -1
View File
@@ -10,7 +10,7 @@ services:
MAILER_DSN: ${MAILER_DSN} MAILER_DSN: ${MAILER_DSN}
MAILER_SENDER: ${MAILER_SENDER} MAILER_SENDER: ${MAILER_SENDER}
SENTRY_DSN: ${SENTRY_DSN} SENTRY_DSN: ${SENTRY_DSN}
SENTRY_RELEASE: ${SENTRY_RELEASE} SENTRY_RELEASE: ${IMAGE_TAG}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT} SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
Generated
+28 -31
View File
@@ -1839,16 +1839,16 @@
}, },
{ {
"name": "martin-georgiev/postgresql-for-doctrine", "name": "martin-georgiev/postgresql-for-doctrine",
"version": "v4.7.0", "version": "v4.6.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/martin-georgiev/postgresql-for-doctrine.git", "url": "https://github.com/martin-georgiev/postgresql-for-doctrine.git",
"reference": "23b5c2694083355ab87eaa913b43a0cddd8c64bb" "reference": "59841c7e53f8339b13bc0cb0ee9931b7b9bbb139"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/martin-georgiev/postgresql-for-doctrine/zipball/23b5c2694083355ab87eaa913b43a0cddd8c64bb", "url": "https://api.github.com/repos/martin-georgiev/postgresql-for-doctrine/zipball/59841c7e53f8339b13bc0cb0ee9931b7b9bbb139",
"reference": "23b5c2694083355ab87eaa913b43a0cddd8c64bb", "reference": "59841c7e53f8339b13bc0cb0ee9931b7b9bbb139",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1863,13 +1863,13 @@
"deptrac/deptrac": "^4.0", "deptrac/deptrac": "^4.0",
"doctrine/orm": "~2.14||~3.0", "doctrine/orm": "~2.14||~3.0",
"ekino/phpstan-banned-code": "^3.2.0", "ekino/phpstan-banned-code": "^3.2.0",
"friendsofphp/php-cs-fixer": "^3.95.11", "friendsofphp/php-cs-fixer": "^3.95.2",
"phpstan/phpstan": "^2.2.2", "phpstan/phpstan": "^2.1.55",
"phpstan/phpstan-deprecation-rules": "^2.0.4", "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", "phpstan/phpstan-phpunit": "^2.0.16",
"phpunit/phpunit": "^10.5.63||^11.5", "phpunit/phpunit": "^10.5.63||^11.5",
"rector/rector": "^2.5.2", "rector/rector": "^2.4.4",
"symfony/cache": "^6.4||^7.0", "symfony/cache": "^6.4||^7.0",
"symfony/var-exporter": "^6.4||^7.0" "symfony/var-exporter": "^6.4||^7.0"
}, },
@@ -1952,7 +1952,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/martin-georgiev/postgresql-for-doctrine/issues", "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": [ "funding": [
{ {
@@ -1964,7 +1964,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-07-01T18:17:39+00:00" "time": "2026-05-29T19:11:20+00:00"
}, },
{ {
"name": "phpdocumentor/reflection-common", "name": "phpdocumentor/reflection-common",
@@ -2753,16 +2753,16 @@
}, },
{ {
"name": "sentry/sentry", "name": "sentry/sentry",
"version": "4.29.0", "version": "4.28.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/getsentry/sentry-php.git", "url": "https://github.com/getsentry/sentry-php.git",
"reference": "d732a4da195f231cedb2a2a78ae16dd73082afa3" "reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/d732a4da195f231cedb2a2a78ae16dd73082afa3", "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/662cb7a01a342a7f33780fc955ff4a028d8b785a",
"reference": "d732a4da195f231cedb2a2a78ae16dd73082afa3", "reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2787,7 +2787,6 @@
"open-telemetry/api": "^1.0", "open-telemetry/api": "^1.0",
"open-telemetry/exporter-otlp": "^1.0", "open-telemetry/exporter-otlp": "^1.0",
"open-telemetry/sdk": "^1.0", "open-telemetry/sdk": "^1.0",
"open-telemetry/sem-conv": "^1.27",
"phpstan/phpstan": "^1.3", "phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^8.5.52|^9.6.34", "phpunit/phpunit": "^8.5.52|^9.6.34",
"spiral/roadrunner-http": "^3.6", "spiral/roadrunner-http": "^3.6",
@@ -2831,7 +2830,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/getsentry/sentry-php/issues", "issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.29.0" "source": "https://github.com/getsentry/sentry-php/tree/4.28.0"
}, },
"funding": [ "funding": [
{ {
@@ -2843,7 +2842,7 @@
"type": "custom" "type": "custom"
} }
], ],
"time": "2026-06-29T14:47:44+00:00" "time": "2026-06-11T12:22:38+00:00"
}, },
{ {
"name": "sentry/sentry-symfony", "name": "sentry/sentry-symfony",
@@ -9676,11 +9675,11 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.2.4", "version": "2.2.3",
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4048833dd47b377287818841877fb3087289509c",
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27", "reference": "4048833dd47b377287818841877fb3087289509c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9736,7 +9735,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-07-03T07:00:23+00:00" "time": "2026-06-30T21:15:26+00:00"
}, },
{ {
"name": "phpstan/phpstan-doctrine", "name": "phpstan/phpstan-doctrine",
@@ -9817,22 +9816,21 @@
}, },
{ {
"name": "phpstan/phpstan-phpunit", "name": "phpstan/phpstan-phpunit",
"version": "2.0.17", "version": "2.0.16",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan-phpunit.git", "url": "https://github.com/phpstan/phpstan-phpunit.git",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f" "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f", "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f", "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"phar-io/version": "^3.2",
"php": "^7.4 || ^8.0", "php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.2.3" "phpstan/phpstan": "^2.1.32"
}, },
"conflict": { "conflict": {
"phpunit/phpunit": "<7.0" "phpunit/phpunit": "<7.0"
@@ -9842,8 +9840,7 @@
"php-parallel-lint/php-parallel-lint": "^1.2", "php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6", "phpunit/phpunit": "^9.6"
"shipmonk/name-collision-detector": "^2.1"
}, },
"type": "phpstan-extension", "type": "phpstan-extension",
"extra": { "extra": {
@@ -9869,9 +9866,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/phpstan/phpstan-phpunit/issues", "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", "name": "phpstan/phpstan-symfony",
+1
View File
@@ -6,6 +6,7 @@ when@dev:
framework: framework:
profiler: profiler:
only_exceptions: false only_exceptions: false
collect_serializer_data: true
when@test: when@test:
web_profiler: web_profiler:
+1 -1
View File
@@ -8,7 +8,7 @@
failOnNotice="true" failOnNotice="true"
failOnWarning="true" failOnWarning="true"
bootstrap="tests/bootstrap.php" bootstrap="tests/bootstrap.php"
cacheDirectory="/tmp/phpunit.cache" cacheDirectory=".phpunit.cache"
> >
<php> <php>
<ini name="display_errors" value="1" /> <ini name="display_errors" value="1" />
+1 -4
View File
@@ -3,7 +3,6 @@
declare(strict_types=1); declare(strict_types=1);
use Rector\Config\RectorConfig; use Rector\Config\RectorConfig;
use Rector\PHPUnit\CodeQuality\Rector\Class_\AddSeeTestAnnotationRector;
use Rector\Symfony\Bridge\Symfony\Routing\SymfonyRoutesProvider; use Rector\Symfony\Bridge\Symfony\Routing\SymfonyRoutesProvider;
use Rector\Symfony\Contract\Bridge\Symfony\Routing\SymfonyRoutesProviderInterface; use Rector\Symfony\Contract\Bridge\Symfony\Routing\SymfonyRoutesProviderInterface;
@@ -14,7 +13,7 @@ return RectorConfig::configure()
__DIR__.'/src', __DIR__.'/src',
__DIR__.'/tests', __DIR__.'/tests',
]) ])
->withSkipPath(__DIR__.'/config/reference.php') ->withSkip([__DIR__.'/config/reference.php'])
->withSymfonyContainerXml(__DIR__.'/var/cache/dev/Tvdt_KernelDevDebugContainer.xml') ->withSymfonyContainerXml(__DIR__.'/var/cache/dev/Tvdt_KernelDevDebugContainer.xml')
->withSymfonyContainerPhp(__DIR__.'/tests/symfony-container.php') ->withSymfonyContainerPhp(__DIR__.'/tests/symfony-container.php')
->registerService(SymfonyRoutesProvider::class, SymfonyRoutesProviderInterface::class) ->registerService(SymfonyRoutesProvider::class, SymfonyRoutesProviderInterface::class)
@@ -35,6 +34,4 @@ return RectorConfig::configure()
) )
->withAttributesSets(all: true) ->withAttributesSets(all: true)
->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true) ->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)
->withSkip([AddSeeTestAnnotationRector::class])
; ;
@@ -6,21 +6,17 @@ namespace Tvdt\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Tvdt\Controller\AbstractController; use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Entity\User;
use Tvdt\Form\CreateSeasonFormType; use Tvdt\Form\CreateSeasonFormType;
use Tvdt\Repository\SeasonRepository; use Tvdt\Repository\SeasonRepository;
use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
#[AsController] #[AsController]
@@ -82,20 +78,4 @@ final class BackofficeController extends AbstractController
return $response; return $response;
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/export',
name: 'tvdt_backoffice_quiz_export',
requirements: ['quiz' => Requirement::UUID],
methods: ['GET'],
)]
public function exportQuiz(Quiz $quiz): StreamedResponse
{
$response = new StreamedResponse($this->excel->quizToXlsx($quiz));
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $quiz->name.'.xlsx'));
return $response;
}
} }
+43 -75
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tvdt\Service; namespace Tvdt\Service;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer; use PhpOffice\PhpSpreadsheet\Writer;
@@ -18,40 +17,39 @@ class QuizSpreadsheetService
{ {
public function generateTemplate(bool $fillExample = true): \Closure public function generateTemplate(bool $fillExample = true): \Closure
{ {
$quiz = new Quiz(); $spreadsheet = new Spreadsheet();
if ($fillExample) { $sheet = $spreadsheet->getActiveSheet();
$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(); $sheet->getStyle('1:1')->getFont()->setBold(true);
$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); $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 */ /** @throws SpreadsheetDataException */
@@ -96,16 +94,24 @@ class QuizSpreadsheetService
$answerCounter = 1; $answerCounter = 1;
$arrCounter = 1; $arrCounter = 1;
while (\array_key_exists($arrCounter, $questionArr) && null !== $questionArr[$arrCounter]) { while (true) {
try {
if (null === $questionArr[$arrCounter]) {
if (1 === $answerCounter) {
$errors[] = \sprintf('Question %d has no answers', $answerCounter);
}
break;
}
} catch (\ErrorException) {
break;
}
$answer = new Answer((string) $questionArr[$arrCounter++], (bool) $questionArr[$arrCounter++]); $answer = new Answer((string) $questionArr[$arrCounter++], (bool) $questionArr[$arrCounter++]);
$answer->ordering = $answerCounter++; $answer->ordering = $answerCounter++;
$question->addAnswer($answer); $question->addAnswer($answer);
} }
if (1 === $answerCounter) {
$errors[] = \sprintf('Question %d has no answers', $question->ordering);
}
$quiz->addQuestion($question); $quiz->addQuestion($question);
} }
@@ -114,47 +120,9 @@ class QuizSpreadsheetService
} }
} }
public function quizToXlsx(Quiz $quiz): \Closure public function quizToXlsx(Quiz $quiz): void
{ {
$spreadsheet = new Spreadsheet(); throw new \Exception('Not implemented');
$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 private function toXlsx(Spreadsheet $spreadsheet): \Closure
@@ -1,27 +1,3 @@
{% macro confirm_modal(id, target, body, formAction, csrfToken) %}
<div class="modal fade" id="{{ id }}" data-bs-backdrop="static"
tabindex="-1"
data-bo--quiz-target="{{ target }}"
aria-labelledby="{{ id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="{{ id }}Label">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">{{ body }}</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ formAction }}" method="POST">
<input type="hidden" name="_token" value="{{ csrfToken }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
{% endmacro %}
<div data-controller="bo--quiz"> <div data-controller="bo--quiz">
<h4 class="mb-3">{{ 'Quick actions'|trans }}</h4> <h4 class="mb-3">{{ 'Quick actions'|trans }}</h4>
<div class="mb-3 btn-group"> <div class="mb-3 btn-group">
@@ -49,11 +25,6 @@
</button> </button>
</div> </div>
<a class="btn btn-outline-secondary mb-3"
href="{{ path('tvdt_backoffice_quiz_export', {quiz: quiz.id}) }}">
{{ 'Export to XLSX'|trans }}
</a>
<h4 class="mb-3">{{ 'Questions'|trans }}</h4> <h4 class="mb-3">{{ 'Questions'|trans }}</h4>
<div class="accordion"> <div class="accordion">
{%~ for question in quiz.questions ~%} {%~ for question in quiz.questions ~%}
@@ -95,19 +66,53 @@
{% endfor %} {% endfor %}
</div> </div>
{{ _self.confirm_modal( {# Modal Clear #}
'clearQuizModal', <div class="modal fade" id="clearQuizModal" data-bs-backdrop="static"
'clearModal', tabindex="-1"
'Are you sure you want to clear all the results? This will also delete all the eliminations.'|trans, data-bo--quiz-target="clearModal"
path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}), aria-labelledby="staticBackdropLabel" aria-hidden="true">
csrf_token('clear_quiz'), <div class="modal-dialog">
) }} <div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ 'Are you sure you want to clear all the results? This will also delete all the eliminations.'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('clear_quiz') }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
{{ _self.confirm_modal( {# Modal Delete #}
'deleteQuizModal', <div class="modal fade" id="deleteQuizModal" data-bs-backdrop="static"
'deleteModal', tabindex="-1"
'Are you sure you want to delete this quiz?'|trans, data-bo--quiz-target="deleteModal"
path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}), aria-labelledby="staticBackdropLabel" aria-hidden="true">
csrf_token('delete_quiz'), <div class="modal-dialog">
) }} <div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ 'Are you sure you want to delete this quiz?'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('delete_quiz') }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
</div> </div>
+25 -16
View File
@@ -1,6 +1,7 @@
<h4 class="mb-3">{{ 'Score'|trans }}</h4> <h4 class="mb-3">{{ 'Score'|trans }}</h4>
<div class="btn-toolbar mb-3" role="toolbar"> <div class="btn-toolbar mb-3" role="toolbar">
<div class="btn-group me-2"> <div class="btn-group me-2">
{# <a class="btn btn-primary">{{ 'Start Elimination'|trans }}</a> #}
<form action="{{ path('tvdt_prepare_elimination', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST"> <form action="{{ path('tvdt_prepare_elimination', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('prepare_elimination') }}"> <input type="hidden" name="_token" value="{{ csrf_token('prepare_elimination') }}">
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">{{ 'Prepare Custom Elimination'|trans }}</button> <button type="submit" class="btn btn-secondary rounded-0 rounded-start">{{ 'Prepare Custom Elimination'|trans }}</button>
@@ -19,15 +20,15 @@
</div> </div>
</div> </div>
<p class="mb-3">{{ 'Number of dropouts:'|trans }} {{ quiz.dropouts }} </p> <p class="mb-3">{{ 'Number of dropouts:'|trans }} {{ quiz.dropouts }} </p>
<table class="table table-hover table-result mb-3"> <table class="table table-hover mb-3">
<thead> <thead>
<tr> <tr>
<th scope="col">{{ 'Candidate'|trans }}</th> <th scope="col">{{ 'Candidate'|trans }}</th>
<th class="col-result-sm" scope="col">{{ 'Correct Answers'|trans }}</th> <th style="width: 15%" scope="col">{{ 'Correct Answers'|trans }}</th>
<th class="col-result-md" scope="col">{{ 'Corrections'|trans }}</th> <th style="width: 20%" scope="col">{{ 'Corrections'|trans }}</th>
<th class="col-result-md" scope="col">{{ 'Penalty'|trans }}</th> <th style="width: 20%" scope="col">{{ 'Penalty'|trans }}</th>
<th class="col-result-xs" scope="col">{{ 'Score'|trans }}</th> <th style="width: 10%" scope="col">{{ 'Score'|trans }}</th>
<th class="col-result-md" scope="col">{{ 'Time'|trans }}</th> <th style="width: 20%" scope="col">{{ 'Time'|trans }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -39,11 +40,15 @@
<form method="post" <form method="post"
action="{{ path('tvdt_backoffice_modify_correction', {quiz: quiz.id, candidate: candidate.id}) }}"> action="{{ path('tvdt_backoffice_modify_correction', {quiz: quiz.id, candidate: candidate.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('candidate_correction') }}"> <input type="hidden" name="_token" value="{{ csrf_token('candidate_correction') }}">
<div class="d-flex gap-1"> <div class="row">
<input class="form-control form-control-sm" type="number" <div class="col-8">
value="{{ candidate.corrections }}" step="0.5" <input class="form-control form-control-sm" type="number"
name="corrections"> value="{{ candidate.corrections }}" step="0.5"
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button> name="corrections">
</div>
<div class="col-2">
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
</div>
</div> </div>
</form> </form>
</td> </td>
@@ -51,11 +56,15 @@
<form method="post" <form method="post"
action="{{ path('tvdt_backoffice_modify_penalty', {quiz: quiz.id, candidate: candidate.id}) }}"> action="{{ path('tvdt_backoffice_modify_penalty', {quiz: quiz.id, candidate: candidate.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('candidate_penalty') }}"> <input type="hidden" name="_token" value="{{ csrf_token('candidate_penalty') }}">
<div class="d-flex gap-1"> <div class="row">
<input class="form-control form-control-sm" type="number" <div class="col-8">
value="{{ candidate.penaltySeconds }}" step="1" <input class="form-control form-control-sm" type="number"
name="penalty"> value="{{ candidate.penaltySeconds }}" step="1"
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button> name="penalty">
</div>
<div class="col-2">
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
</div>
</div> </div>
</form> </form>
</td> </td>
+4 -3
View File
@@ -14,7 +14,7 @@
<h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2> <h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2>
<div class="row"> <div class="row">
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<div class="d-flex align-items-center mb-3"> <div class="d-flex flex-row align-items-center mb-3">
<h4 class="mb-0 pe-2">{{ 'Quizzes'|trans }}</h4> <h4 class="mb-0 pe-2">{{ 'Quizzes'|trans }}</h4>
<a class="link" <a class="link"
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Add'|trans }}</a> href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Add'|trans }}</a>
@@ -29,7 +29,7 @@
</div> </div>
</div> </div>
<div class="col-md-3 col-12"> <div class="col-md-3 col-12">
<div class="d-flex align-items-center mb-3"> <div class="d-flex flex-row align-items-center mb-3">
<h4 class="mb-0 pe-2">{{ 'Candidates'|trans }}</h4> <h4 class="mb-0 pe-2">{{ 'Candidates'|trans }}</h4>
<a class="link" <a class="link"
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }} href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}
@@ -40,10 +40,11 @@
<li>{{ candidate.name }}</li>{% endfor %} <li>{{ candidate.name }}</li>{% endfor %}
</ul> </ul>
<div class="d-flex align-items-center mb-3"> <div class="d-flex flex-row align-items-center mb-3">
<h4 class="mb-0 pe-2">{{ 'Settings'|trans }}</h4> <h4 class="mb-0 pe-2">{{ 'Settings'|trans }}</h4>
</div> </div>
{{ form(form) }} {{ form(form) }}
</div> </div>
<div class="col-12 col-md-3"></div>
</div> </div>
{% endblock body %} {% endblock body %}
-1
View File
@@ -1,3 +1,2 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('quiz') }}{% endblock %} {% block importmap %}{{ importmap('quiz') }}{% endblock %}
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
+1 -3
View File
@@ -1,6 +1,4 @@
{% extends 'quiz/base.html.twig' %} {% extends 'quiz/base.html.twig' %}
{% block body %} {% block body %}
<div class="quiz-form-narrow"> {{ form(form) }}
{{ form(form) }}
</div>
{% endblock body %} {% endblock body %}
-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 %}
+29 -36
View File
@@ -1,43 +1,36 @@
{% extends 'quiz/base.html.twig' %} {% extends 'quiz/base.html.twig' %}
{% block body %} {% block body %}
<div class="quiz-content"> <h4>
<h4> {% if season.settings.showNumbers %}
{% if season.settings.showNumbers %} ({{ question.ordering }}/{{ question.quiz.questions.count }})
({{ question.ordering }}/{{ question.quiz.questions.count }}) {% endif %}{{ question.question }}<br/>
{% endif %}{{ question.question }}<br/> </h4>
</h4> <form method="post">
<form method="post"> <input type="hidden" name="token" value="{{ csrf_token('question') }}">
<input type="hidden" name="token" value="{{ csrf_token('question') }}"> {% if season.settings.confirmAnswers == false %}
{% set twoCol = question.answers|length >= 6 %} {% for answer in question.answers %}
{% if season.settings.confirmAnswers == false %}
<div class="{{ twoCol ? 'quiz-answers-grid' : '' }}">
{% for answer in question.answers %}
<div class="py-2">
<button class="answer-btn"
type="submit"
name="answer"
value="{{ answer.id }}">{{ answer.text }}</button>
</div>
{% endfor %}
</div>
{% else %}
<div class="{{ twoCol ? 'quiz-answers-grid' : '' }}">
{% for answer in question.answers %}
<div class="py-1">
<input required="required" type="radio" class="btn-check" name="answer"
id="answer-{{ loop.index0 }}" autocomplete="off"
value="{{ answer.id }}">
<label class="answer-btn" for="answer-{{ loop.index0 }}">{{ answer.text }}</label>
</div>
{% endfor %}
</div>
<div class="py-2"> <div class="py-2">
<button class="btn btn-success" <button class="btn btn-outline-success"
type="submit" type="submit"
>{{ 'Next'|trans }}</button> name="answer"
value="{{ answer.id }}">{{ answer.text }}</button>
</div> </div>
{% endfor %}
{% else %}
{% for answer in question.answers %}
<div class="py-1">
<input required="required" type="radio" class="btn-check" name="answer"
id="answer-{{ loop.index0 }}" autocomplete="off"
value="{{ answer.id }}">
<label class="btn btn-outline-secondary" for="answer-{{ loop.index0 }}">{{ answer.text }}</label>
</div>
{% endfor %}
<div class="py-2">
<button class="btn btn-success"
type="submit"
>{{ 'Next'|trans }}</button>
</div>
{% endif %} {% endif %}
</form> </form>
</div>
{% endblock body %} {% endblock body %}
+2 -3
View File
@@ -1,6 +1,5 @@
{% extends 'quiz/base.html.twig' %} {% extends 'quiz/base.html.twig' %}
{% block body %} {% block body %}
<div class="quiz-form-narrow"> {{ form(form) }}
{{ form(form) }} <a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary">{{ 'Manage Quiz'|trans }}</a>
</div>
{% endblock body %} {% endblock body %}
@@ -1,308 +0,0 @@
<?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;
}
}
-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),
);
}
}