trial app

This commit is contained in:
2024-03-31 18:48:05 +03:00
commit aadc3c50d5
59 changed files with 13017 additions and 0 deletions
View File
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Controller;
use App\Entity\Test;
use App\Entity\UserAnswer;
use App\Form\Type\UserAnswerType;
use App\Service\QuestionService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Factory\UuidFactory;
class IndexController extends AbstractController
{
public function __construct(
private readonly QuestionService $questionService,
private readonly UuidFactory $uuidFactory,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em
)
{
}
#[Route('/', name: 'index')]
public function index(Request $request): Response
{
$test = $this->em->getRepository(Test::class)->find(1);
$session = $request->getSession();
if (!$session->has('step')) {
$step = $this->getStep($test, $this->getUuid());
$session->set('step', $step);
}
$step = $session->get('step');
$form = $this->getForm($test, $step);
if ($request->getMethod() === 'POST') {
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->flush();
if ($step = $this->getStep($test, $this->getUuid())) {
$session->set('step', $step);
$form = $this->getForm($test, $step);
} else {
$session->set('test_' . $test->getId(), true);
$session->remove('step');
$oldUuid = $this->getUuid();
$this->getUuid(true);
return $this->redirectToRoute('test_results', ['test_id' => $test->getId(), 'uuid' => $oldUuid]);
}
}
}
return $this->render('index/index.html.twig', ['form' => $form, 'test' => $test, 'step' => $step - 1]);
}
#[Route('/test_results/{test_id}/{uuid}', name: 'test_results', requirements: ['test_id' => '\d+'])]
public function testResults(EntityManagerInterface $em, int $test_id, string $uuid): Response
{
$test = $em->getRepository(Test::class)->find($test_id);
if (!$test) {
$this->redirectToRoute('index');
}
$existingAnswers = $this->em->getRepository(UserAnswer::class)->findBy(['uuid' => $uuid]);
if (count($existingAnswers) != $test->getQuestions()->count()) {
$this->redirectToRoute('index');
}
return $this->render('index/results.html.twig',[
'test' => $test,
'existingAnswers' => $existingAnswers,
'service' => $this->questionService,
'uuid' => $uuid,
]);
}
private function getForm(Test $test, $step): FormInterface
{
$question = $test->getQuestions()->findFirst(fn($i, $q) => $q->getId() === $step);
$questionModel = $this->questionService->getQuestionModel($question);
$userAnswer = (new UserAnswer())->setQuestion($question)->setUuid($this->getUuid());
$this->em->persist($userAnswer);
return $this->createForm(UserAnswerType::class, $userAnswer, [
'questionModel' => $questionModel,
'question' => $question,
'test' => $test,
]);
}
private function getUuid($forceNew = false)
{
$session = $this->requestStack->getCurrentRequest()->getSession();
if (!$session->has('uuid') || $forceNew) {
$uuid = $this->uuidFactory->create();
$session->set('uuid', $uuid);
} else {
$uuid = $session->get('uuid');
}
return $uuid;
}
private function getStep(Test $test, string $uuid): int
{
$existingAnswers = array_map(fn($a) => $a->getQuestion()->getId(), $this->em->getRepository(UserAnswer::class)->findBy(['uuid' => $uuid]));
$allSteps = $test->getQuestions()->map(fn($q) => $q->getId())->toArray();
$availableSteps = array_diff($allSteps, $existingAnswers);
shuffle($availableSteps);
return current($availableSteps);
}
}
View File
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Entity;
use App\Repository\QuestionRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: QuestionRepository::class)]
class Question
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column]
private array $prompt = [];
#[ORM\Column]
private array $answers = [];
#[ORM\ManyToOne(inversedBy: 'questions')]
#[ORM\JoinColumn(nullable: false)]
private Test $test;
public function getId(): ?int
{
return $this->id;
}
public function getPrompt(): array
{
return $this->prompt;
}
public function setPrompt(array $prompt): static
{
$this->prompt = $prompt;
return $this;
}
public function getAnswers(): array
{
return $this->answers;
}
public function setAnswers(array $answers): static
{
$this->answers = $answers;
return $this;
}
public function getTest(): Test
{
return $this->test;
}
public function setTest(Test $test): static
{
$this->test = $test;
return $this;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Entity;
use App\Repository\TestRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TestRepository::class)]
class Test
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 60)]
private ?string $name = null;
#[ORM\OneToMany(targetEntity: Question::class, mappedBy: 'test', orphanRemoval: true)]
private Collection $questions;
public function __construct()
{
$this->questions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
/**
* @return Collection<int, Question>
*/
public function getQuestions(): Collection
{
return $this->questions;
}
public function addQuestion(Question $question): static
{
if (!$this->questions->contains($question)) {
$this->questions->add($question);
$question->setTest($this);
}
return $this;
}
public function removeQuestion(Question $question): static
{
if ($this->questions->removeElement($question)) {
// set the owning side to null (unless already changed)
if ($question->getTest() === $this) {
$question->setTest(null);
}
}
return $this;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Entity;
use App\Model\AnswerModel;
use App\Repository\UserAnswerRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserAnswerRepository::class)]
class UserAnswer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Question $question = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $date_created = null;
#[ORM\Column(type: Types::BOOLEAN)]
private bool $is_correct = false;
#[ORM\Column]
private array $answer = [];
#[ORM\Column(length: 60)]
private ?string $uuid;
public function getId(): ?int
{
return $this->id;
}
public function getQuestion(): ?Question
{
return $this->question;
}
public function setQuestion(?Question $question): static
{
$this->question = $question;
return $this;
}
public function getDateCreated(): ?\DateTimeInterface
{
return $this->date_created;
}
public function setDateCreated(\DateTimeInterface $date_created): static
{
$this->date_created = $date_created;
return $this;
}
public function isCorrect(): bool
{
return $this->is_correct;
}
public function setIsCorrect(bool $isCorrect): static
{
$this->is_correct = $isCorrect;
return $this;
}
/**
* @return array<AnswerModel>
*/
public function getAnswer(): array
{
return $this->answer;
}
public function setAnswer(array $answer): static
{
$this->answer = $answer;
return $this;
}
/**
* @return string
*/
public function getUuid(): string
{
return $this->uuid;
}
public function setUuid(string $uuid): static
{
$this->uuid = $uuid;
return $this;
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enum;
enum OperatorsEnum: string
{
case ADD = '+';
case SUB = '-';
case MUL = '*';
case DIV = '/';
case EQL = '=';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Enum;
enum QuestionPartTypeEnum: string
{
case NUMBER = 'number';
case OPERATOR = 'op';
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Form\Type;
use App\Entity\Question;
use App\Entity\Test;
use App\Entity\UserAnswer;
use App\Model\AnswerModel;
use App\Model\QuestionModel;
use App\Service\QuestionService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PostSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class UserAnswerType extends AbstractType
{
public function __construct(private readonly QuestionService $questionService)
{
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var QuestionModel $questionModel */
$questionModel = $options['questionModel'];
$answers = $questionModel->getAnswers();
shuffle($answers);
$builder
->add('answer', ChoiceType::class, [
'choices' => $answers,
'choice_label' => function (?AnswerModel $answerModel): string {
return $answerModel->getName();
},
'label' => sprintf('Pick up answer(s) for %s', $questionModel->getPrompt()),
'required' => true,
'multiple' => true,
'expanded' => true,
'constraints' => [
new NotBlank()
]
])
->add('save', SubmitType::class, ['label' => 'Answer'])
->addEventListener(FormEvents::POST_SUBMIT, [$this, 'checkAnswer'])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setRequired(['test', 'question', 'questionModel'])
->setDefaults([
'data_class' => UserAnswer::class,
])
->setAllowedTypes('test', Test::class)
->setAllowedTypes('questionModel', QuestionModel::class)
->setAllowedTypes('question', Question::class)
;
}
public function checkAnswer(PostSubmitEvent $event): void
{
/** @var UserAnswer $userAnswer */
$userAnswer = $event->getData();
/** @var Question $question */
$question = $event->getForm()->getConfig()->getOption('question');
$answer = $userAnswer
->setDateCreated(new \DateTime())
->setQuestion($question)
->getAnswer();
$userAnswer->setIsCorrect($this->questionService->isValidAnswer($question, $answer));
$userAnswer->setAnswer(array_map(fn($a) => $a->getConfig(), $answer));
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Model;
final class AnswerModel
{
public function __construct(
private readonly string $name,
private readonly string $value,
private readonly array $config
)
{
}
public function getName(): string
{
return $this->name;
}
public function getValue(): string
{
return $this->value;
}
public function getConfig(): array
{
return $this->config;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Model;
final class QuestionModel
{
public function __construct(
private readonly string $prompt,
private readonly array $answers
)
{
}
public function getPrompt(): string
{
return $this->prompt;
}
/**
* @return
*/
public function getAnswers(): array
{
return $this->answers;
}
}
View File
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Repository;
use App\Entity\Question;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Question>
*
* @method Question|null find($id, $lockMode = null, $lockVersion = null)
* @method Question|null findOneBy(array $criteria, array $orderBy = null)
* @method Question[] findAll()
* @method Question[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class QuestionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Question::class);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Repository;
use App\Entity\Test;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Test>
*
* @method Test|null find($id, $lockMode = null, $lockVersion = null)
* @method Test|null findOneBy(array $criteria, array $orderBy = null)
* @method Test[] findAll()
* @method Test[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class TestRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Test::class);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Repository;
use App\Entity\UserAnswer;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserAnswer>
*
* @method UserAnswer|null find($id, $lockMode = null, $lockVersion = null)
* @method UserAnswer|null findOneBy(array $criteria, array $orderBy = null)
* @method UserAnswer[] findAll()
* @method UserAnswer[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserAnswerRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserAnswer::class);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Service;
use App\Entity\Question;
use App\Enum\OperatorsEnum;
use App\Enum\QuestionPartTypeEnum;
use App\Model\AnswerModel;
use App\Model\QuestionModel;
class QuestionService
{
public function getQuestionModel(Question $question): QuestionModel
{
$questionAnswers = $question->getAnswers();
$answers = array_map(
fn($answer, $i) => new AnswerModel($this->assembleString($answer), $i, $answer),
$questionAnswers,
array_keys($questionAnswers)
);
return new QuestionModel(
$this->assembleString($question->getPrompt(), true),
$answers
);
}
/**
* @param Question $question
* @param array<AnswerModel> $answer
* @return boolean
*/
public function isValidAnswer(Question $question, array $answer): bool
{
$prompt = $question->getPrompt();
$result = $this->calculate($prompt);
$answerResults = array_map(fn($a) => $this->calculate($a->getConfig()), $answer);
foreach($answerResults as $answerResult) {
if($answerResult !== $result) {
return false;
}
}
return true;
}
private function calculate(array $entries): float
{
$result = 0;
$count = count($entries);
$prev = null;
for($i=0; $i < $count; $i++) {
$entry = $entries[$i];
$curr = QuestionPartTypeEnum::tryFrom($entry['type']);
if(!$curr) {
throw new \RuntimeException(sprintf('Invalid entry type: %s', $entry['type']));
}
if ($curr === QuestionPartTypeEnum::OPERATOR && $prev !== QuestionPartTypeEnum::NUMBER) {
throw new \RuntimeException('Unexpected part, got OPERATOR expected NUMBER');
}
if ($curr === QuestionPartTypeEnum::NUMBER && $prev !== null && $prev !== QuestionPartTypeEnum::OPERATOR) {
throw new \RuntimeException('Unexpected part, got NUMBER expected OPERATOR');
}
if ($curr === QuestionPartTypeEnum::NUMBER && $prev === null) {
$prev = $curr;
$result += (float) $entry['value'];
}
if ($curr === QuestionPartTypeEnum::OPERATOR && $prev === QuestionPartTypeEnum::NUMBER) {
$nextEntry = $entries[$i + 1];
$next = QuestionPartTypeEnum::tryFrom($nextEntry['type']);
if(!$next) {
throw new \RuntimeException(sprintf('Invalid entry type: %s', $entry['type']));
};
$curr = OperatorsEnum::tryFrom($entry['value']);
switch ($curr) {
case OperatorsEnum::ADD:
$result += (float) $nextEntry['value'];
break;
case OperatorsEnum::SUB:
$result -= (float) $nextEntry['value'];
break;
case OperatorsEnum::MUL:
$result *= (float) $nextEntry['value'];
break;
case OperatorsEnum::DIV:
if((double) $nextEntry['value'] === 0.0) {
throw new \RuntimeException('Division by 0');
}
$result /= (float) $nextEntry['value'];
break;
case OperatorsEnum::EQL:
throw new \RuntimeException('Unexpected operator');
}
$prev = $curr;
$i++;
}
}
return $result;
}
public function assembleString(array $entries, $isPrompt = false): string
{
$ret = [];
foreach($entries as $entry) {
$type = QuestionPartTypeEnum::tryFrom($entry['type']);
$ret[] = match($type) {
QuestionPartTypeEnum::OPERATOR => OperatorsEnum::tryFrom($entry['value'])->value,
QuestionPartTypeEnum::NUMBER => $entry['value'],
default => null
};
}
if ($isPrompt) {
$ret[] = OperatorsEnum::EQL->value;
}
return implode(' ', array_filter($ret, fn($r) => $r !== null));
}
}