diff --git a/.gitignore b/.gitignore index e7b351e..c3d49a6 100644 --- a/.gitignore +++ b/.gitignore @@ -72,10 +72,8 @@ Icon /.idea/ /.vscode/ -# Junie -!/.junie/ -/.junie/memory/ -/.junie/plans/ +# Claude Code +/.claude/settings.local.json # Windows Thumbs.db diff --git a/.idea/TijdVoorDeTest.iml b/.idea/TijdVoorDeTest.iml index 09c1969..486f93d 100644 --- a/.idea/TijdVoorDeTest.iml +++ b/.idea/TijdVoorDeTest.iml @@ -169,6 +169,7 @@ + diff --git a/.idea/php.xml b/.idea/php.xml index 1ff9fb0..0946da5 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -41,170 +41,169 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - + - - - - - - - - - - - - - + + + + + + + + + + + + + - + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.junie/AGENTS.md b/.junie/AGENTS.md deleted file mode 100644 index 94ba767..0000000 --- a/.junie/AGENTS.md +++ /dev/null @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c4e0c8d --- /dev/null +++ b/CLAUDE.md @@ -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 # 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 diff --git a/Makefile b/Makefile deleted file mode 100644 index 618c977..0000000 --- a/Makefile +++ /dev/null @@ -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}' diff --git a/assets/styles/backoffice.scss b/assets/styles/backoffice.scss index e69de29..4ab8c9f 100644 --- a/assets/styles/backoffice.scss +++ b/assets/styles/backoffice.scss @@ -0,0 +1,3 @@ +.col-result-xs { width: 10%; } +.col-result-sm { width: 15%; } +.col-result-md { width: 20%; } diff --git a/assets/styles/quiz.scss b/assets/styles/quiz.scss index 4fb76ee..d815297 100644 --- a/assets/styles/quiz.scss +++ b/assets/styles/quiz.scss @@ -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 { diff --git a/compose.override.yaml b/compose.override.yaml index 1927cbc..2efd489 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -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 ### diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml index 7f32f21..e3bb2a5 100644 --- a/config/packages/web_profiler.yaml +++ b/config/packages/web_profiler.yaml @@ -6,7 +6,6 @@ when@dev: framework: profiler: only_exceptions: false - collect_serializer_data: true when@test: web_profiler: diff --git a/config/reference.php b/config/reference.php index e0ea2b9..0fe774d 100644 --- a/config/reference.php +++ b/config/reference.php @@ -127,7 +127,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * } * @psalm-type ServicesConfig = array{ * _defaults?: DefaultsType, - * _instanceof?: InstanceofType, + * _instanceof?: array, * ... * } * @psalm-type ExtensionType = array @@ -727,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. @@ -773,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. @@ -852,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, * }>, diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 5feec8b..0bc9c1d 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -8,7 +8,7 @@ failOnNotice="true" failOnWarning="true" bootstrap="tests/bootstrap.php" - cacheDirectory=".phpunit.cache" + cacheDirectory="/tmp/phpunit.cache" > diff --git a/src/Controller/Backoffice/BackofficeController.php b/src/Controller/Backoffice/BackofficeController.php index 833d351..4c0a0b6 100644 --- a/src/Controller/Backoffice/BackofficeController.php +++ b/src/Controller/Backoffice/BackofficeController.php @@ -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; + } } diff --git a/src/Entity/GivenAnswer.php b/src/Entity/GivenAnswer.php index 64387da..bd778de 100644 --- a/src/Entity/GivenAnswer.php +++ b/src/Entity/GivenAnswer.php @@ -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, ) {} } diff --git a/src/Service/QuizSpreadsheetService.php b/src/Service/QuizSpreadsheetService.php index 92e9eab..152995c 100644 --- a/src/Service/QuizSpreadsheetService.php +++ b/src/Service/QuizSpreadsheetService.php @@ -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); + $identiteit = new Question(); + $identiteit->question = 'Wie is de mol?'; + $identiteit->ordering = 2; + foreach ([ + ['Emma', false], + ['Jan', false], + ['Sara', false], + ['Piet', false], + ['Lisa', true], + ['Kees', false], + ['Anna', false], + ['Henk', false], + ['Nina', false], + ['Joost', false], + ] as $i => [$name, $correct]) { + $answer = new Answer($name, $correct); + $answer->ordering = $i + 1; + $identiteit->addAnswer($answer); + } - $sheet->setCellValue('A2', 'Is de mol een man of een vrouw?'); + $quiz->addQuestion($identiteit); } - return $this->toXlsx($spreadsheet); + return $this->quizToXlsx($quiz); } /** @throws SpreadsheetDataException */ @@ -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 diff --git a/templates/backoffice/quiz/tab_overview.html.twig b/templates/backoffice/quiz/tab_overview.html.twig index 3ab04c2..adf01be 100644 --- a/templates/backoffice/quiz/tab_overview.html.twig +++ b/templates/backoffice/quiz/tab_overview.html.twig @@ -1,3 +1,27 @@ +{% macro confirm_modal(id, target, body, formAction, csrfToken) %} + +{% endmacro %} +

{{ 'Quick actions'|trans }}

@@ -25,6 +49,11 @@
+ + {{ 'Export to XLSX'|trans }} + +

{{ 'Questions'|trans }}

{%~ for question in quiz.questions ~%} @@ -66,53 +95,19 @@ {% endfor %}
- {# Modal Clear #} - + {{ _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 #} - + {{ _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'), + ) }}
diff --git a/templates/backoffice/quiz/tab_result.html.twig b/templates/backoffice/quiz/tab_result.html.twig index d1c9101..cef5e6e 100644 --- a/templates/backoffice/quiz/tab_result.html.twig +++ b/templates/backoffice/quiz/tab_result.html.twig @@ -1,7 +1,6 @@

{{ 'Score'|trans }}

{{ 'Number of dropouts:'|trans }} {{ quiz.dropouts }}

- +
- - - - - + + + + + @@ -40,15 +39,11 @@ -
-
- -
-
- -
+
+ +
@@ -56,15 +51,11 @@
-
-
- -
-
- -
+
+ +
diff --git a/templates/backoffice/season.html.twig b/templates/backoffice/season.html.twig index 9b5d88f..166931d 100644 --- a/templates/backoffice/season.html.twig +++ b/templates/backoffice/season.html.twig @@ -14,7 +14,7 @@

{{ 'Season'|trans }}: {{ season.name }}

-
+

{{ 'Quizzes'|trans }}

{{ 'Add'|trans }} @@ -29,7 +29,7 @@
-
+ {% endblock body %} diff --git a/templates/quiz/base.html.twig b/templates/quiz/base.html.twig index ed13ec7..5603f9c 100644 --- a/templates/quiz/base.html.twig +++ b/templates/quiz/base.html.twig @@ -1,2 +1,3 @@ {% extends 'base.html.twig' %} {% block importmap %}{{ importmap('quiz') }}{% endblock %} +{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %} diff --git a/templates/quiz/enter_name.twig b/templates/quiz/enter_name.twig index bc91c82..78a41ea 100644 --- a/templates/quiz/enter_name.twig +++ b/templates/quiz/enter_name.twig @@ -1,4 +1,6 @@ {% extends 'quiz/base.html.twig' %} {% block body %} - {{ form(form) }} +
+ {{ form(form) }} +
{% endblock body %} diff --git a/templates/quiz/question.twig b/templates/quiz/question.twig index 8c200c3..c2e616b 100644 --- a/templates/quiz/question.twig +++ b/templates/quiz/question.twig @@ -1,36 +1,43 @@ {% extends 'quiz/base.html.twig' %} {% block body %} -

- {% if season.settings.showNumbers %} - ({{ question.ordering }}/{{ question.quiz.questions.count }}) - {% endif %}{{ question.question }}
-

-
- - {% if season.settings.confirmAnswers == false %} - {% for answer in question.answers %} +
+

+ {% if season.settings.showNumbers %} + ({{ question.ordering }}/{{ question.quiz.questions.count }}) + {% endif %}{{ question.question }}
+

+ + + {% set twoCol = question.answers|length >= 6 %} + {% if season.settings.confirmAnswers == false %} +
+ {% for answer in question.answers %} +
+ +
+ {% endfor %} +
+ {% else %} +
+ {% for answer in question.answers %} +
+ + +
+ {% endfor %} +
- + >{{ 'Next'|trans }}
- {% endfor %} - {% else %} - {% for answer in question.answers %} -
- - -
- {% endfor %} -
- -
- {% endif %} - + {% endif %} + +
{% endblock body %} diff --git a/templates/quiz/select_season.html.twig b/templates/quiz/select_season.html.twig index d1c0e48..78a41ea 100644 --- a/templates/quiz/select_season.html.twig +++ b/templates/quiz/select_season.html.twig @@ -1,5 +1,6 @@ {% extends 'quiz/base.html.twig' %} {% block body %} - {{ form(form) }} -
{{ 'Manage Quiz'|trans }} +
+ {{ form(form) }} +
{% endblock body %} diff --git a/tests/Service/QuizSpreadsheetServiceTest.php b/tests/Service/QuizSpreadsheetServiceTest.php new file mode 100644 index 0000000..0503f7f --- /dev/null +++ b/tests/Service/QuizSpreadsheetServiceTest.php @@ -0,0 +1,308 @@ + */ + 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 */ + 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 */ + 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; + } +}
{{ 'Candidate'|trans }}{{ 'Correct Answers'|trans }}{{ 'Corrections'|trans }}{{ 'Penalty'|trans }}{{ 'Score'|trans }}{{ 'Time'|trans }}{{ 'Correct Answers'|trans }}{{ 'Corrections'|trans }}{{ 'Penalty'|trans }}{{ 'Score'|trans }}{{ 'Time'|trans }}