mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-05 15:10:16 +02:00
Compare commits
32 Commits
0.0.22
..
815e7b17be
| Author | SHA1 | Date | |
|---|---|---|---|
| 815e7b17be | |||
| 764f59e6a7 | |||
| 404c0dcc26 | |||
| 685544ffff | |||
| 6b616465b2 | |||
| 381c2119e7 | |||
| 228a36be95 | |||
| 05ad1ebf2e | |||
| 102b894134 | |||
| 8609fc3552 | |||
| 8cec9faec0 | |||
| bf36b1a6ac | |||
| 0b5c535ed9 | |||
| dcb1f40b7e | |||
| 368790c073 | |||
| 078f838700 | |||
| 841bd5b00d | |||
| 2aea98a839 | |||
| 54668521f0 | |||
| 56d89e42d6 | |||
| 2997323f0e | |||
| 15e2a57943 | |||
| 7fdfe59224 | |||
| dc592ab54a | |||
| bd387828ba | |||
| 3ab85b47ca | |||
| 89455997de | |||
| d127e16ada | |||
| dba70cd75b | |||
| 6a085ae656 | |||
| b689b81d61 | |||
| 88fafe85f7 |
+60
-15
@@ -17,12 +17,11 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
quality:
|
||||
name: Code Quality
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -40,26 +39,68 @@ jobs:
|
||||
compose.yaml
|
||||
compose.override.yaml
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=${{github.ref}}
|
||||
*.cache-from=type=gha,scope=${{github.ref}}-quality
|
||||
*.cache-from=type=gha,scope=refs/heads/main
|
||||
*.cache-to=type=gha,scope=${{github.ref}},mode=max
|
||||
*.cache-to=type=gha,scope=${{github.ref}}-quality,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
|
||||
- name: Start services
|
||||
run: docker compose up php database --wait --no-build
|
||||
- name: Warm up dev cache
|
||||
run: docker compose exec -T php bin/console cache:warmup --env=dev
|
||||
- name: Lint Twig templates
|
||||
id: twig_lint
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php bin/console lint:twig --format=github templates
|
||||
- name: Coding Style
|
||||
id: cs
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none
|
||||
- name: Twig Coding Style
|
||||
id: twig_cs
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php vendor/bin/twig-cs-fixer check
|
||||
- name: Static Analysis (PHPStan)
|
||||
id: phpstan
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php vendor/bin/phpstan analyse --no-progress --no-ansi --error-format=github
|
||||
- name: Rector
|
||||
id: rector
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
|
||||
- name: Check HTTP reachability
|
||||
run: curl -v --fail-with-body http://localhost
|
||||
- name: Check Mercure reachability
|
||||
if: false
|
||||
run: curl -vkI --fail-with-body https://localhost/.well-known/mercure?topic=test
|
||||
- name: Assert all checks passed
|
||||
if: always()
|
||||
run: |
|
||||
outcomes="${{ steps.twig_lint.outcome }} ${{ steps.cs.outcome }} ${{ steps.twig_cs.outcome }} ${{ steps.phpstan.outcome }} ${{ steps.rector.outcome }}"
|
||||
if echo "$outcomes" | grep -q "failure"; then exit 1; fi
|
||||
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build Docker images
|
||||
uses: docker/bake-action@v5
|
||||
with:
|
||||
pull: true
|
||||
load: true
|
||||
files: |
|
||||
compose.yaml
|
||||
compose.override.yaml
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=${{github.ref}}-tests
|
||||
*.cache-from=type=gha,scope=refs/heads/main
|
||||
*.cache-to=type=gha,scope=${{github.ref}}-tests,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }}
|
||||
- name: Start services
|
||||
run: docker compose up php database --wait --no-build
|
||||
- name: Create test database
|
||||
run: docker compose exec -T php bin/console -e test doctrine:database:create
|
||||
- name: Run migrations
|
||||
@@ -78,15 +119,16 @@ jobs:
|
||||
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
||||
|
||||
build-deploy:
|
||||
name: Build and deploy to ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
|
||||
name: Build and Deploy
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
environment:
|
||||
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
|
||||
name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
|
||||
url: ${{ vars.URL }}
|
||||
needs: tests
|
||||
needs: [quality, tests]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
if: (github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -106,14 +148,17 @@ jobs:
|
||||
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
SENTRY_VERSION="${TAG#v}"
|
||||
{
|
||||
echo "tag=$TAG"
|
||||
echo "sentry_version=$SENTRY_VERSION"
|
||||
echo "full_name=ghcr.io/${REPO_LOWER}:$TAG"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
{
|
||||
echo "tag=$SHORT_SHA"
|
||||
echo "sentry_version=$SHORT_SHA"
|
||||
echo "full_name=ghcr.io/${REPO_LOWER}:$SHORT_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
@@ -139,12 +184,12 @@ jobs:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
release: ${{steps.meta.outputs.tag}}
|
||||
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || (github.ref == 'refs/heads/main' && 'acceptance' || '') }}
|
||||
release: ${{steps.meta.outputs.sentry_version}}
|
||||
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }}
|
||||
|
||||
- name: Trigger Portainer Deployment
|
||||
shell: bash
|
||||
env:
|
||||
PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}}
|
||||
run: |
|
||||
curl -v -X POST "$PORTAINER_WEBHOOK"?IMAGE_TAG=${{steps.meta.outputs.tag}} --fail-with-body
|
||||
curl -v -X POST "${PORTAINER_WEBHOOK}?IMAGE_TAG=${{steps.meta.outputs.tag}}" --fail-with-body
|
||||
|
||||
+2
-4
@@ -72,10 +72,8 @@ Icon
|
||||
/.idea/
|
||||
/.vscode/
|
||||
|
||||
# Junie
|
||||
!/.junie/
|
||||
/.junie/memory/
|
||||
/.junie/plans/
|
||||
# Claude Code
|
||||
/.claude/settings.local.json
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
|
||||
Generated
+3
@@ -4,6 +4,7 @@
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="Tvdt\" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="Tvdt\Tests\" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/vendor/maennchen/zipstream-php/test" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/clue/ndjson-react" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/composer" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/collections" />
|
||||
@@ -167,6 +168,8 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/stof/doctrine-extensions-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/git-state" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/ergebnis/agent-detector" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
Generated
+157
-157
@@ -41,169 +41,169 @@
|
||||
</component>
|
||||
<component name="PhpIncludePathManager">
|
||||
<include_path>
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/form" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/brevo-mailer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/phpunit-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset-mapper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<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/doctrine-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<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/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
||||
<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/maennchen/zipstream-php" />
|
||||
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
||||
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpoffice/phpspreadsheet" />
|
||||
<path value="$PROJECT_DIR$/vendor/markbaker/matrix" />
|
||||
<path value="$PROJECT_DIR$/vendor/markbaker/complex" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<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/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/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/fidry/cpu-core-counter" />
|
||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||
<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/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/myclabs/deep-copy" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
||||
<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/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/asset" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/css-selector" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dom-crawler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/psr-http-message-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
||||
<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/inflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/file-filter" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/git-state" />
|
||||
<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/complexity" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/phpstan-safe-rule" />
|
||||
<path value="$PROJECT_DIR$/vendor/vincentlanglet/twig-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
|
||||
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
|
||||
<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/composer" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpInterpreters">
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,232 @@
|
||||
# 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
|
||||
@@ -1,16 +0,0 @@
|
||||
.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,7 +3,10 @@
|
||||
"@symfony/ux-turbo": {
|
||||
"turbo-core": {
|
||||
"enabled": false,
|
||||
"fetch": "eager"
|
||||
"fetch": "eager",
|
||||
"autoimport": {
|
||||
"@symfony/ux-turbo/dist/mercure_stream_source_element.js": true
|
||||
}
|
||||
},
|
||||
"mercure-turbo-stream": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.col-result-xs { width: 10%; }
|
||||
.col-result-sm { width: 15%; }
|
||||
.col-result-md { width: 20%; }
|
||||
|
||||
+87
-4
@@ -1,14 +1,97 @@
|
||||
html, body {
|
||||
html {
|
||||
height: 100%;
|
||||
background-image: url("../img/background.png");
|
||||
background-position: center center;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-color: black;
|
||||
color: white;
|
||||
|
||||
display: grid;
|
||||
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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
target: frankenphp_dev
|
||||
volumes:
|
||||
- ./:/app
|
||||
- ~/.composer/cache:/root/.composer/cache
|
||||
- ./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/data:/data
|
||||
@@ -46,6 +47,8 @@ services:
|
||||
- sass:build
|
||||
- --watch
|
||||
- -v
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
###> symfony/mercure-bundle ###
|
||||
###< symfony/mercure-bundle ###
|
||||
|
||||
+29
-29
@@ -19,33 +19,33 @@
|
||||
"phpstan/phpdoc-parser": "^2.3.2",
|
||||
"sentry/sentry-symfony": "^5.9.0",
|
||||
"stof/doctrine-extensions-bundle": "^1.15.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/asset-mapper": "8.0.*",
|
||||
"symfony/brevo-mailer": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
"symfony/dotenv": "8.0.*",
|
||||
"symfony/flex": "^2.10.0",
|
||||
"symfony/form": "8.0.*",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/mailer": "8.0.*",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/security-csrf": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
"symfony/translation": "8.0.*",
|
||||
"symfony/twig-bundle": "8.0.*",
|
||||
"symfony/uid": "8.0.*",
|
||||
"symfony/ux-turbo": "^2.35.0",
|
||||
"symfony/validator": "8.0.*",
|
||||
"symfony/yaml": "8.0.*",
|
||||
"symfony/asset": "8.1.*",
|
||||
"symfony/asset-mapper": "8.1.*",
|
||||
"symfony/brevo-mailer": "8.1.*",
|
||||
"symfony/console": "8.1.*",
|
||||
"symfony/dotenv": "8.1.*",
|
||||
"symfony/flex": "^2.11.0",
|
||||
"symfony/form": "8.1.*",
|
||||
"symfony/framework-bundle": "8.1.*",
|
||||
"symfony/mailer": "8.1.*",
|
||||
"symfony/property-access": "8.1.*",
|
||||
"symfony/property-info": "8.1.*",
|
||||
"symfony/runtime": "8.1.*",
|
||||
"symfony/security-bundle": "8.1.*",
|
||||
"symfony/security-csrf": "8.1.*",
|
||||
"symfony/serializer": "8.1.*",
|
||||
"symfony/translation": "8.1.*",
|
||||
"symfony/twig-bundle": "8.1.*",
|
||||
"symfony/uid": "8.1.*",
|
||||
"symfony/ux-turbo": "^3.1",
|
||||
"symfony/validator": "8.1.*",
|
||||
"symfony/yaml": "8.1.*",
|
||||
"symfonycasts/sass-bundle": "^0.10",
|
||||
"symfonycasts/verify-email-bundle": "^1.18.0",
|
||||
"thecodingmachine/safe": "^3.4.0",
|
||||
"twig/extra-bundle": "^3.24.0",
|
||||
"twig/intl-extra": "^3.24.0",
|
||||
"twig/twig": "^3.24.0"
|
||||
"twig/twig": "^3.27.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"dama/doctrine-test-bundle": "^8.6",
|
||||
@@ -58,14 +58,14 @@
|
||||
"phpstan/phpstan-symfony": "^2.0.15",
|
||||
"phpunit/phpunit": "^13.0.5",
|
||||
"rector/rector": "^2.3.9",
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/css-selector": "8.0.*",
|
||||
"symfony/browser-kit": "8.1.*",
|
||||
"symfony/css-selector": "8.1.*",
|
||||
"symfony/maker-bundle": "^1.67.0",
|
||||
"symfony/phpunit-bridge": "8.0.*",
|
||||
"symfony/stopwatch": "8.0.*",
|
||||
"symfony/web-profiler-bundle": "8.0.*",
|
||||
"symfony/phpunit-bridge": "8.1.*",
|
||||
"symfony/stopwatch": "8.1.*",
|
||||
"symfony/web-profiler-bundle": "8.1.*",
|
||||
"thecodingmachine/phpstan-safe-rule": "^1.4.3",
|
||||
"vincentlanglet/twig-cs-fixer": "^3.14.0"
|
||||
"vincentlanglet/twig-cs-fixer": "^4.0.0"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "8.0.*",
|
||||
"require": "8.1.*",
|
||||
"docker": true
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+820
-617
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ when@dev:
|
||||
framework:
|
||||
profiler:
|
||||
only_exceptions: false
|
||||
collect_serializer_data: true
|
||||
|
||||
when@test:
|
||||
web_profiler:
|
||||
|
||||
Generated
+33
-14
@@ -78,6 +78,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* tags?: TagsType,
|
||||
* resource_tags?: TagsType,
|
||||
* decorates?: string,
|
||||
* decorates_tag?: string,
|
||||
* decoration_inner_name?: string,
|
||||
* decoration_priority?: int,
|
||||
* decoration_on_invalid?: 'exception'|'ignore'|null,
|
||||
@@ -118,10 +119,15 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* stack: list<DefinitionType|AliasType|PrototypeType|array<class-string, ArgumentsType|null>>,
|
||||
* public?: bool,
|
||||
* deprecated?: DeprecationType,
|
||||
* decorates?: string,
|
||||
* decorates_tag?: string,
|
||||
* decoration_inner_name?: string,
|
||||
* decoration_priority?: int,
|
||||
* decoration_on_invalid?: 'exception'|'ignore'|null,
|
||||
* }
|
||||
* @psalm-type ServicesConfig = array{
|
||||
* _defaults?: DefaultsType,
|
||||
* _instanceof?: InstanceofType,
|
||||
* _instanceof?: array<class-string, InstanceofType>,
|
||||
* ...<string, DefinitionType|AliasType|PrototypeType|StackType|ArgumentsType|null>
|
||||
* }
|
||||
* @psalm-type ExtensionType = array<string, mixed>
|
||||
@@ -168,7 +174,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* allow_revalidate?: bool|Param,
|
||||
* stale_while_revalidate?: int|Param,
|
||||
* stale_if_error?: int|Param,
|
||||
* terminate_on_cache_hit?: bool|Param,
|
||||
* terminate_on_cache_hit?: bool|Param, // Deprecated: Setting the "framework.http_cache.terminate_on_cache_hit.terminate_on_cache_hit" configuration option is deprecated. It will be removed in version 9.0.
|
||||
* },
|
||||
* esi?: bool|array{ // ESI configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
@@ -188,7 +194,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* only_exceptions?: bool|Param, // Default: false
|
||||
* only_main_requests?: bool|Param, // Default: false
|
||||
* dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler"
|
||||
* collect_serializer_data?: true|Param, // Default: true
|
||||
* collect_serializer_data?: true|Param, // Deprecated: Setting the "framework.profiler.collect_serializer_data.collect_serializer_data" configuration option is deprecated. It will be removed in version 9.0. // Default: true
|
||||
* },
|
||||
* workflows?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
@@ -340,6 +346,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null
|
||||
* },
|
||||
* disable_translation?: bool|Param, // Default: false
|
||||
* property_metadata_existence_check?: bool|Param, // When enabled, validateProperty() and validatePropertyValue() throw an exception if no metadata is found for the given property. // Default: false
|
||||
* auto_mapping?: array<string, array{ // Default: []
|
||||
* services?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
@@ -396,6 +403,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter.
|
||||
* early_expiration_message_bus?: scalar|Param|null,
|
||||
* clearer?: scalar|Param|null,
|
||||
* marshaller?: scalar|Param|null, // The marshaller service to use for this pool.
|
||||
* }>,
|
||||
* },
|
||||
* php_errors?: array{ // PHP errors handling configuration
|
||||
@@ -420,9 +428,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* routing?: array<string, string|array{ // Default: []
|
||||
* senders?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* routing?: array<string, string|list<scalar|Param|null>>,
|
||||
* serializer?: array{
|
||||
* default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer"
|
||||
* symfony_serializer?: array{
|
||||
@@ -498,7 +504,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. // Default: 86400
|
||||
* },
|
||||
* retry_failed?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
@@ -514,7 +520,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1
|
||||
* },
|
||||
* },
|
||||
* mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable.
|
||||
* mock_response_factory?: scalar|Param|null, // `true` to always return empty 200 responses, or the id of the service to use to generate mock responses - which should be either an invokable or an iterable.
|
||||
* scoped_clients?: array<string, string|array{ // Default: []
|
||||
* scope?: scalar|Param|null, // The regular expression that the request URL must match before adding the other options. When none is provided, the base URI is used instead.
|
||||
* base_uri?: scalar|Param|null, // The URI to resolve relative URLs, following rules in RFC 3985, section 2.
|
||||
@@ -545,13 +551,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* md5?: mixed,
|
||||
* },
|
||||
* crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants.
|
||||
* mock_response_factory?: scalar|Param|null, // `true` to always return empty 200 responses, `false` to disable mocking, or the id of the service to use to generate mock responses (invokable or iterable).
|
||||
* extra?: array<string, mixed>,
|
||||
* rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null
|
||||
* caching?: bool|array{ // Caching configuration.
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client"
|
||||
* shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true
|
||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null
|
||||
* max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. // Default: 86400
|
||||
* },
|
||||
* retry_failed?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
@@ -635,6 +642,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
|
||||
* amount?: int|Param, // Amount of tokens to add each interval. // Default: 1
|
||||
* },
|
||||
* anchor_at?: scalar|Param|null, // Aligns the "fixed_window" policy to a calendar (e.g. "2024-01-05 00:00:00 UTC" combined with `interval: 1 month` resets the counter on the 5th of each month). UTC if not specified. // Default: null
|
||||
* }>,
|
||||
* },
|
||||
* uid?: bool|array{ // Uid configuration
|
||||
@@ -644,10 +652,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* name_based_uuid_namespace?: scalar|Param|null,
|
||||
* time_based_uuid_version?: 7|6|1|Param, // Default: 7
|
||||
* time_based_uuid_node?: scalar|Param|null,
|
||||
* uuid47_secret?: scalar|Param|null, // A high-entropy secret used by the "uuid47_transformer" service. Defaults to "kernel.secret". // Default: null
|
||||
* },
|
||||
* html_sanitizer?: bool|array{ // HtmlSanitizer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* sanitizers?: array<string, array{ // Default: []
|
||||
* default_action?: "drop"|"block"|"allow"|Param, // Defines how the sanitizer must behave by default.
|
||||
* allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false
|
||||
* allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false
|
||||
* allow_elements?: array<string, mixed>,
|
||||
@@ -671,6 +681,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* webhook?: bool|array{ // Webhook configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||
* event_header_name?: scalar|Param|null, // Default: "Webhook-Event"
|
||||
* id_header_name?: scalar|Param|null, // Default: "Webhook-Id"
|
||||
* signature_header_name?: scalar|Param|null, // Default: "Webhook-Signature"
|
||||
* signing_algorithm?: scalar|Param|null, // Default: "sha256"
|
||||
* routing?: array<string, array{ // Default: []
|
||||
* service?: scalar|Param|null,
|
||||
* secret?: scalar|Param|null, // Default: ""
|
||||
@@ -681,6 +695,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* json_streamer?: bool|array{ // JSON streamer configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* default_options?: array{
|
||||
* include_null_properties?: bool|Param, // Encode the properties with null value // Default: false
|
||||
* ...<string, mixed>
|
||||
* },
|
||||
* },
|
||||
* }
|
||||
* @psalm-type DoctrineConfig = array{
|
||||
@@ -709,7 +727,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
|
||||
* sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver
|
||||
* server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.
|
||||
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.
|
||||
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection.
|
||||
* sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
|
||||
* sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
|
||||
* sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.
|
||||
@@ -755,7 +773,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* servicename?: scalar|Param|null, // Overrules dbname parameter if given and used as SERVICE_NAME or SID connection parameter for Oracle depending on the service parameter.
|
||||
* sessionMode?: scalar|Param|null, // The session mode to use for the oci8 driver
|
||||
* server?: scalar|Param|null, // The name of a running database server to connect to for SQL Anywhere.
|
||||
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connexion.
|
||||
* default_dbname?: scalar|Param|null, // Override the default database (postgres) to connect to for PostgreSQL connection.
|
||||
* sslmode?: scalar|Param|null, // Determines whether or with what priority a SSL TCP/IP connection will be negotiated with the server for PostgreSQL.
|
||||
* sslrootcert?: scalar|Param|null, // The name of a file containing SSL certificate authority (CA) certificate(s). If the file exists, the server's certificate will be verified to be signed by one of these authorities.
|
||||
* sslcert?: scalar|Param|null, // The path to the SSL client certificate file for PostgreSQL.
|
||||
@@ -834,7 +852,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* lock_path?: scalar|Param|null, // Default: "%kernel.cache_dir%/doctrine/orm/slc/filelock"
|
||||
* lock_lifetime?: scalar|Param|null, // Default: 60
|
||||
* type?: scalar|Param|null, // Default: "default"
|
||||
* lifetime?: scalar|Param|null, // Default: 0
|
||||
* lifetime?: scalar|Param|null, // Default: null
|
||||
* service?: scalar|Param|null,
|
||||
* name?: scalar|Param|null,
|
||||
* }>,
|
||||
@@ -932,7 +950,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* access_denied_url?: scalar|Param|null, // Default: null
|
||||
* session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate"
|
||||
* expose_security_errors?: \Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::None|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::AccountStatus|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::All|Param, // Default: "none"
|
||||
* erase_credentials?: bool|Param, // Default: true
|
||||
* erase_credentials?: bool|Param, // Deprecated: Setting the "security.erase_credentials.erase_credentials" configuration option is deprecated. It will be removed in Symfony 9.0, as the "eraseCredentials()" method was removed in Symfony 8.0. // Default: true
|
||||
* access_decision_manager?: array{
|
||||
* strategy?: "affirmative"|"consensus"|"unanimous"|"priority"|Param,
|
||||
* service?: scalar|Param|null,
|
||||
@@ -1004,7 +1022,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* path?: scalar|Param|null, // Default: "/logout"
|
||||
* target?: scalar|Param|null, // Default: "/"
|
||||
* invalidate_session?: bool|Param, // Default: true
|
||||
* clear_site_data?: string|list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>,
|
||||
* clear_site_data?: string|list<"*"|"cache"|"cookies"|"storage"|"clientHints"|"executionContexts"|"prefetchCache"|"prerenderCache"|Param>,
|
||||
* delete_cookies?: string|array<string, array{ // Default: []
|
||||
* path?: scalar|Param|null, // Default: null
|
||||
* domain?: scalar|Param|null, // Default: null
|
||||
@@ -1162,6 +1180,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* cache?: array{
|
||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||
* },
|
||||
* enforce_key_usage_verification?: bool|Param, // When enabled (default), only keys explicitly designated for signature (via "use":"sig" or a "key_ops" entry containing "sign"/"verify") are accepted. When disabled, keys without any usage designation are also accepted; keys explicitly restricted to encryption are still rejected. // Default: true
|
||||
* },
|
||||
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
|
||||
* audience?: scalar|Param|null, // Audience set in the token, for validation purpose.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
cacheDirectory="/tmp/phpunit.cache"
|
||||
>
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
|
||||
@@ -6,17 +6,21 @@ namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Requirement\Requirement;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Form\CreateSeasonFormType;
|
||||
use Tvdt\Repository\SeasonRepository;
|
||||
use Tvdt\Security\Voter\SeasonVoter;
|
||||
use Tvdt\Service\QuizSpreadsheetService;
|
||||
|
||||
#[AsController]
|
||||
@@ -78,4 +82,20 @@ final class BackofficeController extends AbstractController
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,14 +31,14 @@ class GivenAnswer
|
||||
public function __construct(
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]
|
||||
private(set) Candidate $candidate,
|
||||
public private(set) Candidate $candidate,
|
||||
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[ORM\ManyToOne]
|
||||
private(set) Quiz $quiz,
|
||||
public private(set) Quiz $quiz,
|
||||
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[ORM\ManyToOne(inversedBy: 'givenAnswers')]
|
||||
private(set) Answer $answer,
|
||||
public private(set) Answer $answer,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Service;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Reader;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer;
|
||||
@@ -17,39 +18,40 @@ class QuizSpreadsheetService
|
||||
{
|
||||
public function generateTemplate(bool $fillExample = true): \Closure
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
|
||||
$sheet->setCellValue('A1', 'Question');
|
||||
$sheet->getColumnDimension('A')->setWidth(30);
|
||||
$sheet->getStyle('A:A')->getAlignment()->setWrapText(true);
|
||||
|
||||
$counter = 1;
|
||||
foreach (range('B', 'L', 2) as $column) {
|
||||
$sheet->setCellValue($column.'1', 'Answer '.$counter++);
|
||||
$sheet->getColumnDimension($column)->setWidth(30);
|
||||
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
|
||||
}
|
||||
|
||||
foreach (range('C', 'M', 2) as $column) {
|
||||
$sheet->setCellValue($column.'1', 'Correct');
|
||||
$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||
}
|
||||
$quiz = new Quiz();
|
||||
|
||||
if ($fillExample) {
|
||||
$sheet->setCellValue('B2', 'Man');
|
||||
$sheet->setCellValue('C2', true);
|
||||
$geslacht = new Question();
|
||||
$geslacht->question = 'Is de mol een man of een vrouw?';
|
||||
$geslacht->ordering = 1;
|
||||
$geslacht->addAnswer(new Answer('Man', true));
|
||||
$geslacht->addAnswer(new Answer('Vrouw'));
|
||||
$quiz->addQuestion($geslacht);
|
||||
|
||||
$sheet->setCellValue('D2', 'Vrouw');
|
||||
$sheet->setCellValue('E2', false);
|
||||
|
||||
$sheet->setCellValue('A2', 'Is de mol een man of een vrouw?');
|
||||
$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);
|
||||
}
|
||||
|
||||
return $this->toXlsx($spreadsheet);
|
||||
$quiz->addQuestion($identiteit);
|
||||
}
|
||||
|
||||
return $this->quizToXlsx($quiz);
|
||||
}
|
||||
|
||||
/** @throws SpreadsheetDataException */
|
||||
@@ -94,24 +96,16 @@ class QuizSpreadsheetService
|
||||
$answerCounter = 1;
|
||||
$arrCounter = 1;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
if (null === $questionArr[$arrCounter]) {
|
||||
if (1 === $answerCounter) {
|
||||
$errors[] = \sprintf('Question %d has no answers', $answerCounter);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
} catch (\ErrorException) {
|
||||
break;
|
||||
}
|
||||
|
||||
while (\array_key_exists($arrCounter, $questionArr) && null !== $questionArr[$arrCounter]) {
|
||||
$answer = new Answer((string) $questionArr[$arrCounter++], (bool) $questionArr[$arrCounter++]);
|
||||
$answer->ordering = $answerCounter++;
|
||||
$question->addAnswer($answer);
|
||||
}
|
||||
|
||||
if (1 === $answerCounter) {
|
||||
$errors[] = \sprintf('Question %d has no answers', $question->ordering);
|
||||
}
|
||||
|
||||
$quiz->addQuestion($question);
|
||||
}
|
||||
|
||||
@@ -120,9 +114,47 @@ class QuizSpreadsheetService
|
||||
}
|
||||
}
|
||||
|
||||
public function quizToXlsx(Quiz $quiz): void
|
||||
public function quizToXlsx(Quiz $quiz): \Closure
|
||||
{
|
||||
throw new \Exception('Not implemented');
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
// Write data rows first so we know the maximum answer count.
|
||||
$maxAnswers = 0;
|
||||
$row = 2;
|
||||
foreach ($quiz->questions as $question) {
|
||||
$sheet->setCellValue('A'.$row, $question->question);
|
||||
|
||||
$col = 0;
|
||||
foreach ($question->answers as $answer) {
|
||||
$sheet->setCellValue(Coordinate::stringFromColumnIndex(2 + 2 * $col).$row, $answer->text);
|
||||
$sheet->setCellValue(Coordinate::stringFromColumnIndex(3 + 2 * $col).$row, $answer->isRightAnswer);
|
||||
++$col;
|
||||
}
|
||||
|
||||
$maxAnswers = max($maxAnswers, $col);
|
||||
++$row;
|
||||
}
|
||||
|
||||
// Write headers last, sized to the widest question.
|
||||
$sheet->getStyle('1:1')->getFont()->setBold(true);
|
||||
$sheet->setCellValue('A1', 'Question');
|
||||
$sheet->getColumnDimension('A')->setWidth(30);
|
||||
$sheet->getStyle('A:A')->getAlignment()->setWrapText(true);
|
||||
|
||||
for ($i = 0; $i < $maxAnswers; ++$i) {
|
||||
$answerCol = Coordinate::stringFromColumnIndex(2 + 2 * $i);
|
||||
$correctCol = Coordinate::stringFromColumnIndex(3 + 2 * $i);
|
||||
|
||||
$sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1));
|
||||
$sheet->getColumnDimension($answerCol)->setWidth(30);
|
||||
$sheet->getStyle($answerCol.':'.$answerCol)->getAlignment()->setWrapText(true);
|
||||
|
||||
$sheet->setCellValue($correctCol.'1', 'Correct');
|
||||
$sheet->getColumnDimension($correctCol)->setAutoSize(true);
|
||||
}
|
||||
|
||||
return $this->toXlsx($spreadsheet);
|
||||
}
|
||||
|
||||
private function toXlsx(Spreadsheet $spreadsheet): \Closure
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
{% 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">
|
||||
<h4 class="mb-3">{{ 'Quick actions'|trans }}</h4>
|
||||
<div class="mb-3 btn-group">
|
||||
@@ -25,6 +49,11 @@
|
||||
</button>
|
||||
</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>
|
||||
<div class="accordion">
|
||||
{%~ for question in quiz.questions ~%}
|
||||
@@ -66,53 +95,19 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Modal Clear #}
|
||||
<div class="modal fade" id="clearQuizModal" data-bs-backdrop="static"
|
||||
tabindex="-1"
|
||||
data-bo--quiz-target="clearModal"
|
||||
aria-labelledby="staticBackdropLabel" aria-hidden="true">
|
||||
<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(
|
||||
'clearQuizModal',
|
||||
'clearModal',
|
||||
'Are you sure you want to clear all the results? This will also delete all the eliminations.'|trans,
|
||||
path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}),
|
||||
csrf_token('clear_quiz'),
|
||||
) }}
|
||||
|
||||
{# Modal Delete #}
|
||||
<div class="modal fade" id="deleteQuizModal" data-bs-backdrop="static"
|
||||
tabindex="-1"
|
||||
data-bo--quiz-target="deleteModal"
|
||||
aria-labelledby="staticBackdropLabel" aria-hidden="true">
|
||||
<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>
|
||||
{{ _self.confirm_modal(
|
||||
'deleteQuizModal',
|
||||
'deleteModal',
|
||||
'Are you sure you want to delete this quiz?'|trans,
|
||||
path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}),
|
||||
csrf_token('delete_quiz'),
|
||||
) }}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<h4 class="mb-3">{{ 'Score'|trans }}</h4>
|
||||
<div class="btn-toolbar mb-3" role="toolbar">
|
||||
<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">
|
||||
<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>
|
||||
@@ -20,15 +19,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-3">{{ 'Number of dropouts:'|trans }} {{ quiz.dropouts }} </p>
|
||||
<table class="table table-hover mb-3">
|
||||
<table class="table table-hover table-result mb-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ 'Candidate'|trans }}</th>
|
||||
<th style="width: 15%" scope="col">{{ 'Correct Answers'|trans }}</th>
|
||||
<th style="width: 20%" scope="col">{{ 'Corrections'|trans }}</th>
|
||||
<th style="width: 20%" scope="col">{{ 'Penalty'|trans }}</th>
|
||||
<th style="width: 10%" scope="col">{{ 'Score'|trans }}</th>
|
||||
<th style="width: 20%" scope="col">{{ 'Time'|trans }}</th>
|
||||
<th class="col-result-sm" scope="col">{{ 'Correct Answers'|trans }}</th>
|
||||
<th class="col-result-md" scope="col">{{ 'Corrections'|trans }}</th>
|
||||
<th class="col-result-md" scope="col">{{ 'Penalty'|trans }}</th>
|
||||
<th class="col-result-xs" scope="col">{{ 'Score'|trans }}</th>
|
||||
<th class="col-result-md" scope="col">{{ 'Time'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -40,32 +39,24 @@
|
||||
<form method="post"
|
||||
action="{{ path('tvdt_backoffice_modify_correction', {quiz: quiz.id, candidate: candidate.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('candidate_correction') }}">
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<div class="d-flex gap-1">
|
||||
<input class="form-control form-control-sm" type="number"
|
||||
value="{{ candidate.corrections }}" step="0.5"
|
||||
name="corrections">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post"
|
||||
action="{{ path('tvdt_backoffice_modify_penalty', {quiz: quiz.id, candidate: candidate.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('candidate_penalty') }}">
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<div class="d-flex gap-1">
|
||||
<input class="form-control form-control-sm" type="number"
|
||||
value="{{ candidate.penaltySeconds }}" step="1"
|
||||
name="penalty">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button class="btn btn-sm btn-primary" type="submit">{{ 'Save'|trans }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>{{ candidate.score|default('x') }}</td>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<h2 class="mb-3">{{ 'Season'|trans }}: {{ season.name }}</h2>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="d-flex flex-row align-items-center mb-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0 pe-2">{{ 'Quizzes'|trans }}</h4>
|
||||
<a class="link"
|
||||
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Add'|trans }}</a>
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-12">
|
||||
<div class="d-flex flex-row align-items-center mb-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0 pe-2">{{ 'Candidates'|trans }}</h4>
|
||||
<a class="link"
|
||||
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}
|
||||
@@ -40,11 +40,10 @@
|
||||
<li>{{ candidate.name }}</li>{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="d-flex flex-row align-items-center mb-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h4 class="mb-0 pe-2">{{ 'Settings'|trans }}</h4>
|
||||
</div>
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
<div class="col-12 col-md-3"></div>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block importmap %}{{ importmap('quiz') }}{% endblock %}
|
||||
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{% extends 'quiz/base.html.twig' %}
|
||||
{% block body %}
|
||||
<div class="quiz-form-narrow">
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{% if is_granted('IS_AUTHENTICATED') or app.current_route() == 'tvdt_quiz_select_season' %}
|
||||
<div class="quiz-topbar">
|
||||
{% if is_granted('IS_AUTHENTICATED') %}
|
||||
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
{{ 'Backoffice'|trans }}
|
||||
</a>
|
||||
<a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
|
||||
{{ 'Logout'|trans }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
{{ 'Manage Quiz'|trans }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'quiz/base.html.twig' %}
|
||||
{% block body %}
|
||||
<div class="quiz-content">
|
||||
<h4>
|
||||
{% if season.settings.showNumbers %}
|
||||
({{ question.ordering }}/{{ question.quiz.questions.count }})
|
||||
@@ -7,24 +8,29 @@
|
||||
</h4>
|
||||
<form method="post">
|
||||
<input type="hidden" name="token" value="{{ csrf_token('question') }}">
|
||||
{% set twoCol = question.answers|length >= 6 %}
|
||||
{% if season.settings.confirmAnswers == false %}
|
||||
<div class="{{ twoCol ? 'quiz-answers-grid' : '' }}">
|
||||
{% for answer in question.answers %}
|
||||
<div class="py-2">
|
||||
<button class="btn btn-outline-success"
|
||||
<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="btn btn-outline-secondary" for="answer-{{ loop.index0 }}">{{ answer.text }}</label>
|
||||
<label class="answer-btn" for="answer-{{ loop.index0 }}">{{ answer.text }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<button class="btn btn-success"
|
||||
type="submit"
|
||||
@@ -33,4 +39,5 @@
|
||||
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'quiz/base.html.twig' %}
|
||||
{% block body %}
|
||||
<div class="quiz-form-narrow">
|
||||
{{ form(form) }}
|
||||
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary">{{ 'Manage Quiz'|trans }}</a>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Twig;
|
||||
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use function Safe\file_get_contents;
|
||||
use function Safe\preg_match_all;
|
||||
|
||||
final class TemplateReferencesTest extends TestCase
|
||||
{
|
||||
private static string $templatesDir;
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
self::$templatesDir = \dirname(__DIR__, 2).'/templates';
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function templateReferenceProvider(): iterable
|
||||
{
|
||||
$templatesDir = \dirname(__DIR__, 2).'/templates';
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($templatesDir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
Assert::assertInstanceOf(\SplFileInfo::class, $file);
|
||||
if ('twig' !== $file->getExtension()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
$sourceFile = str_replace($templatesDir.'/', '', $file->getPathname());
|
||||
|
||||
// Match extends, include(), and embed tags — capture the quoted template name
|
||||
preg_match_all(
|
||||
'/(?:extends|include|embed)\s*\(?[\'"]([^\'"]+)[\'"]\)?/',
|
||||
$content,
|
||||
$matches,
|
||||
);
|
||||
|
||||
foreach ($matches[1] as $referencedTemplate) {
|
||||
yield \sprintf('%s → %s', $sourceFile, $referencedTemplate) => [$sourceFile, $referencedTemplate];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[DataProvider('templateReferenceProvider')]
|
||||
public function testReferencedTemplateExists(string $sourceFile, string $referencedTemplate): void
|
||||
{
|
||||
$absolutePath = self::$templatesDir.'/'.$referencedTemplate;
|
||||
|
||||
$this->assertFileExists(
|
||||
$absolutePath,
|
||||
\sprintf("Template '%s' references '%s' which does not exist at '%s'.", $sourceFile, $referencedTemplate, $absolutePath),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user