diff --git a/.idea/TijdVoorDeTest.iml b/.idea/TijdVoorDeTest.iml index f0ec1db..b6ef210 100644 --- a/.idea/TijdVoorDeTest.iml +++ b/.idea/TijdVoorDeTest.iml @@ -165,7 +165,6 @@ - diff --git a/.idea/php.xml b/.idea/php.xml index 3261d69..ced0bf0 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -36,170 +36,168 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + - - - - - + + - - - - - - - - + + + + + - - - - - + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - @@ -293,7 +291,7 @@ - + diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 0b5a012..c673940 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -24,7 +24,6 @@ return new Config() 'no_unreachable_default_argument_value' => true, 'no_useless_else' => true, 'no_useless_return' => true, - 'php_unit_strict' => true, 'phpdoc_line_span' => ['const' => 'single', 'method' => 'single', 'property' => 'single'], 'phpdoc_order' => true, 'single_line_empty_body' => true, diff --git a/Justfile b/Justfile index a22d394..750a6a6 100644 --- a/Justfile +++ b/Justfile @@ -43,5 +43,5 @@ clean: reload-tests: @docker compose exec php bin/console --env=test doctrine:database:drop --if-exists --force @docker compose exec php bin/console --env=test doctrine:database:create - @docker compose exec php bin/console --env=test doctrine:schema:create --quiet - @docker compose exec php bin/console --env=test doctrine:fixtures:load --no-interaction --group=test + @docker compose exec php bin/console --env=test doctrine:migrations:migrate -n + @docker compose exec php bin/console --env=test doctrine:fixtures:load -n --group=test diff --git a/composer.json b/composer.json index 598566f..250061c 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,6 @@ "twig/twig": "^3.21.1" }, "require-dev": { - "brianium/paratest": "^7.14", "dama/doctrine-test-bundle": "^8.4", "doctrine/doctrine-fixtures-bundle": "^4.3", "friendsofphp/php-cs-fixer": "^3.89.0", diff --git a/composer.lock b/composer.lock index ae599f0..b22d2e4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "944e218741f3c7a1f04072e075255c2c", + "content-hash": "752576f663ccc67d22525a025afc01df", "packages": [ { "name": "composer/pcre", @@ -8750,99 +8750,6 @@ } ], "packages-dev": [ - { - "name": "brianium/paratest", - "version": "v7.14.2", - "source": { - "type": "git", - "url": "https://github.com/paratestphp/paratest.git", - "reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/de06de1ae1203b11976c6ca01d6a9081c8b33d45", - "reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-simplexml": "*", - "fidry/cpu-core-counter": "^1.3.0", - "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.4.0", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.4.1", - "sebastian/environment": "^8.0.3", - "symfony/console": "^6.4.20 || ^7.3.4", - "symfony/process": "^6.4.20 || ^7.3.4" - }, - "require-dev": { - "doctrine/coding-standard": "^14.0.0", - "ext-pcntl": "*", - "ext-pcov": "*", - "ext-posix": "*", - "phpstan/phpstan": "^2.1.31", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.7", - "phpstan/phpstan-strict-rules": "^2.0.7", - "symfony/filesystem": "^6.4.13 || ^7.3.2" - }, - "bin": [ - "bin/paratest", - "bin/paratest_for_phpstorm" - ], - "type": "library", - "autoload": { - "psr-4": { - "ParaTest\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Brian Scaturro", - "email": "scaturrob@gmail.com", - "role": "Developer" - }, - { - "name": "Filippo Tessarotto", - "email": "zoeslam@gmail.com", - "role": "Developer" - } - ], - "description": "Parallel testing for PHP", - "homepage": "https://github.com/paratestphp/paratest", - "keywords": [ - "concurrent", - "parallel", - "phpunit", - "testing" - ], - "support": { - "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.14.2" - }, - "funding": [ - { - "url": "https://github.com/sponsors/Slamdunk", - "type": "github" - }, - { - "url": "https://paypal.me/filippotessarotto", - "type": "paypal" - } - ], - "time": "2025-10-24T07:20:53+00:00" - }, { "name": "clue/ndjson-react", "version": "v1.3.0", diff --git a/src/Controller/QuizController.php b/src/Controller/QuizController.php index 49db665..a14fab6 100644 --- a/src/Controller/QuizController.php +++ b/src/Controller/QuizController.php @@ -108,6 +108,7 @@ final class QuizController extends AbstractController if (!$answer instanceof Answer) { throw new BadRequestHttpException('Invalid Answer ID'); } + $givenAnswer = new GivenAnswer($candidate, $answer->question->quiz, $answer); $this->entityManager->persist($givenAnswer); $this->entityManager->flush(); diff --git a/src/Repository/QuizRepository.php b/src/Repository/QuizRepository.php index 6b42a4c..807badf 100644 --- a/src/Repository/QuizRepository.php +++ b/src/Repository/QuizRepository.php @@ -49,11 +49,14 @@ class QuizRepository extends ServiceEntityRepository DQL) ->setParameter('quiz', $quiz) ->execute(); - } catch (\Throwable $throwable) { + } + // @codeCoverageIgnoreStart + catch (\Throwable $throwable) { $this->logger->error($throwable->getMessage()); $em->rollback(); throw new ErrorClearingQuizException(previous: $throwable); } + // @codeCoverageIgnoreEnd $em->commit(); } @@ -76,17 +79,17 @@ class QuizRepository extends ServiceEntityRepository c.id, c.name, sum(case when a.isRightAnswer = true then 1 else 0 end) as correct, - qc.corrections, + qd.corrections, max(ga.created) as end_time, - qc.created as start_time, - (sum(case when a.isRightAnswer = true then 1 else 0 end) + qc.corrections) as score + qd.created as start_time, + (sum(case when a.isRightAnswer = true then 1 else 0 end) + qd.corrections) as score from Tvdt\Entity\Candidate c join c.givenAnswers ga join ga.answer a - join c.quizData qc - where qc.quiz = :quiz and ga.quiz = :quiz - group by ga.quiz, c.id, qc.id - order by score desc, max(ga.created) - qc.created asc + join c.quizData qd + where qd.quiz = :quiz and ga.quiz = :quiz + group by ga.quiz, c.id, qd.id + order by score desc, max(ga.created) - qd.created asc DQL )->setParameter('quiz', $quiz)->getResult(); @@ -95,7 +98,7 @@ class QuizRepository extends ServiceEntityRepository name: $row['name'], correct: (int) $row['correct'], corrections: $row['corrections'], - time: new DateTimeImmutable($row['end_time'])->diff($row['start_time']), + time: $row['start_time']->diff(new DateTimeImmutable($row['end_time'])), score: $row['score'], ), $result); } diff --git a/tests/Repository/QuizRepositoryTest.php b/tests/Repository/QuizRepositoryTest.php index 02bea19..79f0ad9 100644 --- a/tests/Repository/QuizRepositoryTest.php +++ b/tests/Repository/QuizRepositoryTest.php @@ -7,6 +7,9 @@ namespace Tvdt\Tests\Repository; use PHPUnit\Framework\Attributes\CoversClass; use Psr\Clock\ClockInterface; use Symfony\Component\Clock\MockClock; +use Tvdt\Entity\Answer; +use Tvdt\Entity\GivenAnswer; +use Tvdt\Entity\Question; use Tvdt\Entity\Quiz; use Tvdt\Entity\QuizCandidate; use Tvdt\Repository\GivenAnswerRepository; @@ -46,20 +49,166 @@ final class QuizRepositoryTest extends DatabaseTestCase $this->assertCount(1, $krtekSeason->quizzes); } - public function testGetScores(): void + public function testTimeForCandidate(): void { $clock = new MockClock('2025-11-01 16:00:00'); self::getContainer()->set(ClockInterface::class, $clock); $krtekSeason = $this->getSeasonByCode('krtek'); $candidate = $this->getCandidateBySeasonAndName($krtekSeason, 'Iris'); + $quiz = $krtekSeason->activeQuiz; + $this->assertInstanceOf(Quiz::class, $quiz); + // Start Quiz - $qc = new QuizCandidate($krtekSeason->activeQuiz, $candidate); + $qc = new QuizCandidate($quiz, $candidate); $this->entityManager->persist($qc); $this->entityManager->flush(); - dump($qc->created); + for ($i = 0; $i < 15; ++$i) { + $question = $this->questionRepository->findNextQuestionForCandidate($candidate); + $this->assertInstanceOf(Question::class, $question); - $this->markTestIncomplete('TODO: Make fixtures first and write good test.'); + $answer = $question->answers->first(); + $this->assertInstanceOf(Answer::class, $answer); + + $clock->sleep(10 + $i); + $qa = new GivenAnswer($candidate, $quiz, $answer); + $this->entityManager->persist($qa); + $this->entityManager->flush(); + } + + $result = $this->quizRepository->getScores($quiz); + + $this->assertSame('Iris', $result[0]->name); + $this->assertSame(5, $result[0]->correct); + $this->assertEqualsWithDelta(5.0, $result[0]->score, \PHP_FLOAT_EPSILON); + + $this->assertSame(4, $result[0]->time->i); + $this->assertSame(15, $result[0]->time->s); + } + + public function testScoresAreCalculatedCorrectly(): void + { + $clock = new MockClock('2025-11-01 16:00:00'); + self::getContainer()->set(ClockInterface::class, $clock); + $krtekSeason = $this->getSeasonByCode('krtek'); + $candidate1 = $this->getCandidateBySeasonAndName($krtekSeason, 'Iris'); + $candidate2 = $this->getCandidateBySeasonAndName($krtekSeason, 'Philine'); + + $quiz = $krtekSeason->activeQuiz; + $this->assertInstanceOf(Quiz::class, $quiz); + + $qc1 = new QuizCandidate($quiz, $candidate1); + $qc2 = new QuizCandidate($quiz, $candidate2); + $this->entityManager->persist($qc1); + $this->entityManager->persist($qc2); + $this->entityManager->flush(); + + for ($i = 0; $i < 15; ++$i) { + $question = $this->questionRepository->findNextQuestionForCandidate($candidate1); + $this->assertInstanceOf(Question::class, $question); + + $answer1 = $question->answers->first(); + $answer2 = $question->answers[intdiv(\count($question->answers), 2)]; + $this->assertInstanceOf(Answer::class, $answer1); + $this->assertInstanceOf(Answer::class, $answer2); + + $clock->sleep(10); + + $qa = new GivenAnswer($candidate1, $quiz, $answer1); + $this->entityManager->persist($qa); + $qa = new GivenAnswer($candidate2, $quiz, $answer2); + $this->entityManager->persist($qa); + + $this->entityManager->flush(); + } + + $scores = $this->quizRepository->getScores($quiz); + $this->assertCount(2, $scores); + $this->assertSame('Iris', $scores[0]->name); + $this->assertSame('Philine', $scores[1]->name); + $this->assertEqualsWithDelta(5.0, $scores[0]->score, \PHP_FLOAT_EPSILON); + $this->assertEqualsWithDelta(4.0, $scores[1]->score, \PHP_FLOAT_EPSILON); + } + + public function testCorrectionsCalculatedCorrectly(): void + { + $clock = new MockClock('2025-11-01 16:00:00'); + self::getContainer()->set(ClockInterface::class, $clock); + $krtekSeason = $this->getSeasonByCode('krtek'); + $candidate = $this->getCandidateBySeasonAndName($krtekSeason, 'Iris'); + + $quiz = $krtekSeason->activeQuiz; + $this->assertInstanceOf(Quiz::class, $quiz); + + $qc = new QuizCandidate($quiz, $candidate); + $this->entityManager->persist($qc); + $this->entityManager->flush(); + + for ($i = 0; $i < 15; ++$i) { + $question = $this->questionRepository->findNextQuestionForCandidate($candidate); + $this->assertInstanceOf(Question::class, $question); + + $answer = $question->answers->first(); + $this->assertInstanceOf(Answer::class, $answer); + + $clock->sleep(10); + $qa = new GivenAnswer($candidate, $quiz, $answer); + $this->entityManager->persist($qa); + $this->entityManager->flush(); + } + + $qc->corrections = 2; + $this->entityManager->flush(); + + $result = $this->quizRepository->getScores($quiz); + + $this->assertEqualsWithDelta(7.0, $result[0]->score, \PHP_FLOAT_EPSILON); + } + + public function testCandidatesWithSameScoreAreSortedCorrectlyByTime(): void + { + $clock = new MockClock('2025-11-01 16:00:00'); + self::getContainer()->set(ClockInterface::class, $clock); + $krtekSeason = $this->getSeasonByCode('krtek'); + $candidate1 = $this->getCandidateBySeasonAndName($krtekSeason, 'Iris'); + $candidate2 = $this->getCandidateBySeasonAndName($krtekSeason, 'Philine'); + + $quiz = $krtekSeason->activeQuiz; + $this->assertInstanceOf(Quiz::class, $quiz); + + $qc1 = new QuizCandidate($quiz, $candidate1); + $this->entityManager->persist($qc1); + $clock->sleep(10); + $qc2 = new QuizCandidate($quiz, $candidate2); + $this->entityManager->persist($qc2); + $this->entityManager->flush(); + + for ($i = 0; $i < 15; ++$i) { + $question = $this->questionRepository->findNextQuestionForCandidate($candidate1); + $this->assertInstanceOf(Question::class, $question); + + $answer1 = $question->answers->first(); + $answer2 = $question->answers->last(); + $this->assertInstanceOf(Answer::class, $answer1); + $this->assertInstanceOf(Answer::class, $answer2); + + $clock->sleep(10); + + $qa = new GivenAnswer($candidate1, $quiz, $answer1); + $this->entityManager->persist($qa); + + $qa = new GivenAnswer($candidate2, $quiz, $answer2); + $this->entityManager->persist($qa); + + $this->entityManager->flush(); + } + + $result = $this->quizRepository->getScores($quiz); + + $this->assertEqualsWithDelta(5.0, $result[0]->score, \PHP_FLOAT_EPSILON); + $this->assertEqualsWithDelta(5.0, $result[1]->score, \PHP_FLOAT_EPSILON); + $this->assertSame('Philine', $result[0]->name); + $this->assertSame('Iris', $result[1]->name); } } diff --git a/tests/Security/Voter/SeasonVoterTest.php b/tests/Security/Voter/SeasonVoterTest.php index 2941091..e882af7 100644 --- a/tests/Security/Voter/SeasonVoterTest.php +++ b/tests/Security/Voter/SeasonVoterTest.php @@ -90,6 +90,7 @@ final class SeasonVoterTest extends TestCase { $user = new User(); $user->roles = ['ROLE_ADMIN']; + $token = $this->createStub(TokenInterface::class); $token->method('getUser')->willReturn($user);