Created
December 19, 2025 09:15
-
-
Save chatcoda/ecc59a089beeafc38dd4d287ade53727 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ### app/Modules/Question/Application/QueryHandler/GetQuestionListHandler.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\QueryHandler; | |
| use App\Modules\Question\Application\Assembler\QuestionDtoAssemblerInterface; | |
| use App\Modules\Question\Application\Dto\QuestionDto; | |
| use App\Modules\Question\Application\Query\GetQuestionListQuery; | |
| use App\Modules\Question\Domain\Repository\QuestionRepositoryInterface; | |
| use App\Shared\Attributes\Service; | |
| #[Service()] | |
| final class GetQuestionListHandler implements GetQuestionListHandlerInterface | |
| { | |
| public function __construct( | |
| private QuestionRepositoryInterface $repository, | |
| private QuestionDtoAssemblerInterface $assembler, | |
| ) {} | |
| /** | |
| * Handle the GetQuestionListQuery and return an array of QuestionDto. | |
| * | |
| * @param GetQuestionListQuery $query | |
| * @return QuestionDto[] | |
| */ | |
| public function handle(GetQuestionListQuery $query): array | |
| { | |
| $entities = []; | |
| if ($query->quizId !== null) { | |
| $entities = $this->repository->findByQuizId($query->quizId); | |
| } else { | |
| // Optional: implement a method to fetch all questions if needed | |
| // $entities = $this->repository->findAll(); | |
| } | |
| /** @var QuestionDto[] */ | |
| $dtos = []; | |
| foreach ($entities as $e) { | |
| $dtos[] = $this->assembler->fromEntity($e); | |
| } | |
| return $dtos; | |
| } | |
| } | |
| app/Modules/Question/Application/QueryHandler/GetQuestionListHandlerInterface.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\QueryHandler; | |
| use App\Modules\Question\Application\Dto\QuestionDto; | |
| use App\Modules\Question\Application\Query\GetQuestionListQuery; | |
| interface GetQuestionListHandlerInterface | |
| { | |
| /** | |
| * Handle the GetQuestionListQuery and return an array of QuestionDto. | |
| * | |
| * @param GetQuestionListQuery $query | |
| * @return QuestionDto[] | |
| */ | |
| public function handle(GetQuestionListQuery $query): array; | |
| } | |
| app/Modules/Question/Application/QueryHandler/GetQuestionDetailsHandler.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\QueryHandler; | |
| use App\Modules\Question\Application\Assembler\QuestionDtoAssemblerInterface; | |
| use App\Modules\Question\Application\Dto\QuestionDto; | |
| use App\Modules\Question\Application\Query\GetQuestionDetailsQuery; | |
| use App\Modules\Question\Domain\Repository\QuestionRepositoryInterface; | |
| use App\Modules\Question\Domain\Exception\QuestionNotFoundException; | |
| use App\Modules\Question\Domain\Entity\Question; | |
| use App\Shared\Attributes\Service; | |
| #[Service()] | |
| final class GetQuestionDetailsHandler | |
| { | |
| public function __construct( | |
| private QuestionRepositoryInterface $repository, | |
| private QuestionDtoAssemblerInterface $assembler | |
| ) {} | |
| /** | |
| * Handle the GetQuestionDetailsQuery and return a QuestionDto. | |
| * | |
| * @param GetQuestionDetailsQuery $query | |
| * @return QuestionDto | |
| * | |
| * @throws QuestionNotFoundException | |
| */ | |
| public function handle(GetQuestionDetailsQuery $query): QuestionDto | |
| { | |
| /** @var Question */ | |
| $entity = $this->repository->findById($query->id); | |
| if ($entity === null) { | |
| throw new QuestionNotFoundException($query->id); | |
| } | |
| return $this->assembler->fromEntity($entity); | |
| } | |
| } | |
| app/Modules/Question/Application/QueryHandler/GetQuestionDetailsHandlerInterface.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\QueryHandler; | |
| use App\Modules\Question\Application\Dto\QuestionDto; | |
| use App\Modules\Question\Application\Query\GetQuestionDetailsQuery; | |
| interface GetQuestionDetailsHandlerInterface | |
| { | |
| /** | |
| * Handle the GetQuestionDetailsQuery and return an array of QuestionDto. | |
| * | |
| * @param GetQuestionDetailsQuery $query | |
| * @return QuestionDto | |
| */ | |
| public function handle(GetQuestionDetailsQuery $query): QuestionDto; | |
| } | |
| app/Modules/Question/Application/Dto/QuestionDto.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\Dto; | |
| final class QuestionDto | |
| { | |
| public function __construct( | |
| public readonly int $id, | |
| public readonly string $text, | |
| public readonly ?string $instruction, | |
| public readonly int $difficulty, | |
| public readonly ?string $createdAt, | |
| public readonly ?string $updatedAt, | |
| public readonly ?string $disabledAt | |
| ) {} | |
| } | |
| app/Modules/Question/Application/Query/GetQuestionListQuery.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\Query; | |
| final class GetQuestionListQuery | |
| { | |
| /** | |
| * Optional quiz filter. | |
| * | |
| * @var int|null | |
| */ | |
| public function __construct( | |
| public readonly ?int $quizId = null | |
| ) {} | |
| } | |
| app/Modules/Question/Application/Query/GetQuestionDetailsQuery.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\Query; | |
| final class GetQuestionDetailsQuery | |
| { | |
| public function __construct( | |
| public readonly int $id | |
| ) {} | |
| } | |
| app/Modules/Question/Application/Service/QuestionEvaluationServiceInterface.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\Service; | |
| use App\Modules\Question\Domain\ValueObject\EvaluationResult; | |
| interface QuestionEvaluationServiceInterface | |
| { | |
| /** | |
| * @param int $questionId | |
| * @param array<string,mixed> $answer | |
| */ | |
| public function evaluate(int $questionId, array $answer): EvaluationResult; | |
| } | |
| app/Modules/Question/Application/Service/QuestionEvaluationService.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\Service; | |
| use App\Modules\Question\Domain\Entity\Question; | |
| use App\Modules\Question\Domain\Repository\QuestionRepositoryInterface; | |
| use App\Modules\Question\Domain\ValueObject\EvaluationResult; | |
| use App\Shared\Attributes\Service; | |
| #[Service] | |
| final class QuestionEvaluationService implements QuestionEvaluationServiceInterface | |
| { | |
| public function __construct( | |
| private QuestionRepositoryInterface $questionRepository, | |
| ) {} | |
| public function evaluate(int $questionId, array $answer): EvaluationResult | |
| { | |
| /** @var Question */ | |
| $question = $this->questionRepository->findById($questionId); | |
| return $question->getBehavior()->evaluateAnswer($answer); | |
| } | |
| } | |
| app/Modules/Question/Application/Assembler/QuestionDtoAssembler.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\Assembler; | |
| use App\Modules\Question\Application\Dto\QuestionDto; | |
| use App\Modules\Question\Domain\Entity\Question; | |
| use App\Shared\Attributes\Service; | |
| #[Service] | |
| final class QuestionDtoAssembler implements QuestionDtoAssemblerInterface | |
| { | |
| public function fromEntity(Question $question): QuestionDto | |
| { | |
| return new QuestionDto( | |
| id: $question->getId(), | |
| text: $question->getText(), | |
| instruction: $question->getInstruction(), | |
| difficulty: $question->getDifficulty(), | |
| createdAt: $question->getCreatedAt()->format('c'), | |
| updatedAt: $question->getUpdatedAt()->format('c'), | |
| disabledAt: $question->getDisabledAt()?->format('c'), | |
| ); | |
| } | |
| } | |
| app/Modules/Question/Application/Assembler/QuestionDtoAssemblerInterface.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Application\Assembler; | |
| use App\Modules\Question\Application\Dto\QuestionDto; | |
| use App\Modules\Question\Domain\Entity\Question; | |
| interface QuestionDtoAssemblerInterface | |
| { | |
| public function fromEntity(Question $question): QuestionDto; | |
| } | |
| app/Modules/Question/Domain/Entity/Question.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Entity; | |
| use App\Modules\Question\Domain\Kind\QuestionKindEnum; | |
| use App\Modules\Question\Domain\Kind\QuestionKindInterface; | |
| use DateTimeImmutable; | |
| /** | |
| * Domain entity representing a Question. | |
| * | |
| * Immutable by design – updates must be done via dedicated domain methods | |
| * returning cloned instances if needed. | |
| */ | |
| final class Question | |
| { | |
| /** | |
| * @param int $id Unique identifier | |
| * @param QuestionKindEnum $kind Domain enum describing the question type | |
| * @param string $text Text of the question | |
| * @param string|null $instruction Optional instruction shown to the learner | |
| * @param QuestionKindInterface $behavior | |
| * @param int $difficulty Difficulty level (0 = default) | |
| * @param DateTimeImmutable|null $createdAt Creation timestamp | |
| * @param DateTimeImmutable|null $updatedAt Update timestamp | |
| * @param DateTimeImmutable|null $disabledAt Disabled timestamp | |
| */ | |
| public function __construct( | |
| private int $id, | |
| private QuestionKindEnum $kind, | |
| private QuestionKindInterface $behavior, | |
| private string $text, | |
| private ?string $instruction, | |
| private int $difficulty, | |
| private DateTimeImmutable $createdAt, | |
| private DateTimeImmutable $updatedAt, | |
| private ?DateTimeImmutable $disabledAt = null | |
| ) {} | |
| /** | |
| * @return int Question ID | |
| */ | |
| public function getId(): int | |
| { | |
| return $this->id; | |
| } | |
| /** | |
| * @return QuestionKindEnum Question type (domain enum) | |
| */ | |
| public function getKind(): QuestionKindEnum | |
| { | |
| return $this->kind; | |
| } | |
| /** | |
| * @return string Question text | |
| */ | |
| public function getText(): string | |
| { | |
| return $this->text; | |
| } | |
| /** | |
| * @return string|null Optional instruction displayed before the question text | |
| */ | |
| public function getInstruction(): ?string | |
| { | |
| return $this->instruction; | |
| } | |
| /** | |
| * @return QuestionKindInterface Strongly typed definition object | |
| */ | |
| public function getBehavior(): QuestionKindInterface | |
| { | |
| return $this->behavior; | |
| } | |
| /** | |
| * @return int Difficulty level | |
| */ | |
| public function getDifficulty(): int | |
| { | |
| return $this->difficulty; | |
| } | |
| /** | |
| * @return bool Whether the question is disabled | |
| */ | |
| public function isDisabled(): bool | |
| { | |
| return $this->disabledAt !== null; | |
| } | |
| /** | |
| * @return DateTimeImmutable|null Creation timestamp | |
| */ | |
| public function getCreatedAt(): ?DateTimeImmutable | |
| { | |
| return $this->createdAt; | |
| } | |
| /** | |
| * @return DateTimeImmutable|null Last update timestamp | |
| */ | |
| public function getUpdatedAt(): ?DateTimeImmutable | |
| { | |
| return $this->updatedAt; | |
| } | |
| /** | |
| * @return DateTimeImmutable|null Disabled timestamp | |
| */ | |
| public function getDisabledAt(): ?DateTimeImmutable | |
| { | |
| return $this->disabledAt; | |
| } | |
| } | |
| app/Modules/Question/Domain/Exception/QuestionNotFoundException.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Exception; | |
| /** | |
| * Exception levée lorsqu'une catégorie n'existe pas. | |
| */ | |
| final class QuestionNotFoundException extends \RuntimeException | |
| { | |
| /** | |
| * @param int $id L'identifiant de la catégorie absente | |
| */ | |
| public function __construct(int $id) | |
| { | |
| // Message simple, interpolation string | |
| parent::__construct("Question with id {$id} not found", 0); | |
| } | |
| } | |
| app/Modules/Question/Domain/Kind/KindMatching.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Kind; | |
| use InvalidArgumentException; | |
| use App\Modules\Question\Domain\ValueObject\EvaluationResult; | |
| /** | |
| * Représente une question de type Matching. | |
| * | |
| * Cette classe contient la logique métier complète : | |
| * - Instruction | |
| * - Validation de la réponse (trois modes configurables via setDefinition) | |
| * - Gestion des items gauche/droite et des paires correctes | |
| * - Mélange possible | |
| */ | |
| final class KindMatching implements QuestionKindInterface | |
| { | |
| /** | |
| * Modes de validation autorisés. | |
| * | |
| * @var string[] | |
| */ | |
| private const VALIDATION_MODES = ['permissive', 'medium', 'strict']; | |
| /** | |
| * Items de gauche. | |
| * | |
| * @var array<int, array{id:int, label:string}> | |
| */ | |
| private array $leftItems = []; | |
| /** | |
| * Items de droite. | |
| * | |
| * @var array<int, array{id:int, label:string}> | |
| */ | |
| private array $rightItems = []; | |
| /** | |
| * Paires correctes. | |
| * | |
| * @var array<int, array{leftId:int, rightId:int}> | |
| */ | |
| private array $correctPairs = []; | |
| /** | |
| * Indique si le mélange est activé. | |
| * | |
| * @var bool | |
| */ | |
| private bool $isShuffleEnabled = false; | |
| /** | |
| * Mode de validation utilisé pour l'évaluation. | |
| * | |
| * Valeurs possibles : 'permissive'|'medium'|'strict' | |
| * | |
| * @var string | |
| */ | |
| private string $validationMode = 'medium'; | |
| /** | |
| * Constructeur vide pour compatibilité avec QuestionKindFactory. | |
| */ | |
| public function __construct( | |
| private readonly int $kindId, | |
| array $definition | |
| ) { | |
| $this->setDefinition($definition); | |
| } | |
| /** | |
| * Injecte la définition complète de la question. | |
| * | |
| * Clés requises : | |
| * - leftItems: array<int, array{id:int, label:string}> | |
| * - rightItems: array<int, array{id:int, label:string}> | |
| * - correctPairs: array<int, array{leftId:int, rightId:int}> | |
| * | |
| * Clés optionnelles : | |
| * - isShuffleEnabled?: bool | |
| * - validationMode?: "permissive"|"medium"|"strict" | |
| * | |
| * @param array{ | |
| * leftItems: array<int, array{id:int, label:string}>, | |
| * rightItems: array<int, array{id:int, label:string}>, | |
| * correctPairs: array<int, array{leftId:int, rightId:int}>, | |
| * isShuffleEnabled?: bool, | |
| * validationMode?: "permissive"|"medium"|"strict" | |
| * } $data Données de définition de la question | |
| * | |
| * @return void | |
| * | |
| * @throws InvalidArgumentException Si les données sont invalides | |
| */ | |
| private function setDefinition(array $data): void | |
| { | |
| // Vérification des clés obligatoires | |
| if (!isset($data['leftItems'], $data['rightItems'], $data['correctPairs'])) { | |
| throw new InvalidArgumentException('Les clés leftItems, rightItems et correctPairs sont requises.'); | |
| } | |
| $leftItems = $data['leftItems']; | |
| $rightItems = $data['rightItems']; | |
| $correctPairs = $data['correctPairs']; | |
| $isShuffleEnabled = $data['isShuffleEnabled'] ?? false; | |
| $validationMode = $data['validationMode'] ?? 'medium'; | |
| // Validations structurelles | |
| $this->validateItemsArray($leftItems, 'leftItems'); | |
| $this->validateItemsArray($rightItems, 'rightItems'); | |
| $this->validateCorrectPairs($correctPairs, $leftItems, $rightItems); | |
| // validation du mode | |
| if (!is_string($validationMode) || !in_array($validationMode, self::VALIDATION_MODES, true)) { | |
| throw new InvalidArgumentException(sprintf('validationMode invalide. Valeurs attendues : %s.', implode(', ', self::VALIDATION_MODES))); | |
| } | |
| $this->leftItems = $leftItems; | |
| $this->rightItems = $rightItems; | |
| $this->correctPairs = $correctPairs; | |
| $this->isShuffleEnabled = (bool) $isShuffleEnabled; | |
| $this->validationMode = $validationMode; | |
| } | |
| /** | |
| * Identifiant du type de question. | |
| * | |
| * @return int | |
| */ | |
| public function getKindId(): int | |
| { | |
| return $this->kindId; | |
| } | |
| /** | |
| * Instruction utilisateur. | |
| * | |
| * @return string | |
| */ | |
| public function getInstruction(): string | |
| { | |
| return 'Relie les paires correspondantes en déplaçant les éléments vers la droite'; | |
| } | |
| /** | |
| * Validation de la structure des items. | |
| * | |
| * @param array<int, mixed> $items | |
| * @param string $fieldName | |
| * | |
| * @throws InvalidArgumentException | |
| */ | |
| private function validateItemsArray(array $items, string $fieldName): void | |
| { | |
| if ($items === []) { | |
| throw new InvalidArgumentException("{$fieldName} ne peut pas être vide."); | |
| } | |
| $seenIds = []; | |
| foreach ($items as $index => $item) { | |
| if (!is_array($item) || !isset($item['id'], $item['label'])) { | |
| throw new InvalidArgumentException("{$fieldName}[{$index}] doit contenir 'id' et 'label'."); | |
| } | |
| if (!is_int($item['id'])) { | |
| throw new InvalidArgumentException("{$fieldName}[{$index}]['id'] doit être un int."); | |
| } | |
| if (!is_string($item['label']) || $item['label'] === '') { | |
| throw new InvalidArgumentException("{$fieldName}[{$index}]['label'] doit être une string non vide."); | |
| } | |
| if (isset($seenIds[$item['id']])) { | |
| throw new InvalidArgumentException("id {$item['id']} dupliqué dans {$fieldName}."); | |
| } | |
| $seenIds[$item['id']] = true; | |
| } | |
| } | |
| /** | |
| * Validation des paires correctes. | |
| * | |
| * @param array<int, mixed> $pairs | |
| * @param array<int, array{id:int, label:string}> $leftItems | |
| * @param array<int, array{id:int, label:string}> $rightItems | |
| * | |
| * @throws InvalidArgumentException | |
| */ | |
| private function validateCorrectPairs(array $pairs, array $leftItems, array $rightItems): void | |
| { | |
| if ($pairs === []) { | |
| throw new InvalidArgumentException('correctPairs ne peut pas être vide.'); | |
| } | |
| $leftIds = array_column($leftItems, 'id'); | |
| $rightIds = array_column($rightItems, 'id'); | |
| $leftSet = array_flip($leftIds); | |
| $rightSet = array_flip($rightIds); | |
| foreach ($pairs as $index => $pair) { | |
| if (!is_array($pair) || !isset($pair['leftId'], $pair['rightId'])) { | |
| throw new InvalidArgumentException("correctPairs[{$index}] doit contenir 'leftId' et 'rightId'."); | |
| } | |
| if (!is_int($pair['leftId']) || !isset($leftSet[$pair['leftId']])) { | |
| throw new InvalidArgumentException("correctPairs[{$index}]['leftId'] invalide."); | |
| } | |
| if (!is_int($pair['rightId']) || !isset($rightSet[$pair['rightId']])) { | |
| throw new InvalidArgumentException("correctPairs[{$index}]['rightId'] invalide."); | |
| } | |
| } | |
| } | |
| /** | |
| * Évalue une réponse utilisateur pour une question de type Matching. | |
| * | |
| * ### Structure attendue de $answer : | |
| * - Variante A (préférée) : tableau de paires | |
| * [ | |
| * ['leftId' => 0, 'rightId' => 1], | |
| * ['leftId' => 1, 'rightId' => 0], | |
| * ... | |
| * ] | |
| * | |
| * - Variante B (mapping) : tableau associatif leftId => rightId | |
| * [ | |
| * 0 => 1, | |
| * 1 => 0, | |
| * ... | |
| * ] | |
| * | |
| * ### Modes de validation disponibles (configurés via setDefinition.validationMode) : | |
| * - 'permissive' : Jaccard sur paires (|S ∩ C| / |S ∪ C|) | |
| * - 'medium' : F1 sur paires (harmonique précision/rappel) | |
| * - 'strict' : max(0, TP - FP) / |C| | |
| * | |
| * @param array<string,mixed> $answer | |
| * Réponse soumise par l'utilisateur. Doit contenir soit : | |
| * - une clé 'pairs' => array<int, array{leftId:int, rightId:int}>, | |
| * - soit une clé 'mapping' => array<int,int> (leftId => rightId). | |
| * | |
| * @return EvaluationResult | |
| * Objet immuable contenant : | |
| * - `isCorrect` : booléen indiquant si la réponse est correcte (score == 1.0) | |
| * - `score` : nombre flottant normalisé entre 0.0 et 1.0 | |
| * - `metadata` : tableau fournissant des détails (tp, fp, fn, mode, scorePercent, message) | |
| * | |
| * @throws InvalidArgumentException | |
| * - Si la définition de la question n'a pas été injectée. | |
| * - Si la structure de $answer est invalide. | |
| */ | |
| public function evaluateAnswer(array $answer): EvaluationResult | |
| { | |
| // Extraire les paires soumises : accepter 'pairs' (liste) ou 'mapping' (assoc) | |
| if (isset($answer['pairs']) && is_array($answer['pairs'])) { | |
| $submittedRaw = $answer['pairs']; | |
| $submittedPairs = []; | |
| foreach ($submittedRaw as $idx => $p) { | |
| if (!is_array($p) || !isset($p['leftId'], $p['rightId'])) { | |
| throw new InvalidArgumentException("submitted pairs invalid at index {$idx}."); | |
| } | |
| if (!is_int($p['leftId']) || !is_int($p['rightId'])) { | |
| throw new InvalidArgumentException("submitted pairs must contain integer leftId and rightId at index {$idx}."); | |
| } | |
| $submittedPairs[] = ['leftId' => $p['leftId'], 'rightId' => $p['rightId']]; | |
| } | |
| } elseif (isset($answer['mapping']) && is_array($answer['mapping'])) { | |
| $submittedPairs = []; | |
| foreach ($answer['mapping'] as $leftId => $rightId) { | |
| if (!is_int($leftId) && !ctype_digit((string) $leftId)) { | |
| throw new InvalidArgumentException('mapping keys must be integer leftIds.'); | |
| } | |
| if (!is_int($rightId) && !ctype_digit((string) $rightId)) { | |
| throw new InvalidArgumentException('mapping values must be integer rightIds.'); | |
| } | |
| $submittedPairs[] = ['leftId' => (int) $leftId, 'rightId' => (int) $rightId]; | |
| } | |
| } else { | |
| throw new InvalidArgumentException('La réponse doit contenir "pairs" (liste) ou "mapping" (assoc) avec des IDs entiers.'); | |
| } | |
| // Normaliser paires en chaînes "L:R" pour comparaison simple et unique | |
| $normalizePairs = static function (array $pairs): array { | |
| $out = []; | |
| foreach ($pairs as $p) { | |
| $l = (int) $p['leftId']; | |
| $r = (int) $p['rightId']; | |
| $out[] = "{$l}:{$r}"; | |
| } | |
| return array_values(array_unique($out)); | |
| }; | |
| $S = $normalizePairs($submittedPairs); | |
| $C = $normalizePairs($this->correctPairs); | |
| // Construire sets pour recherche rapide | |
| $setC = array_flip($C); | |
| $setS = array_flip($S); | |
| // Calcul TP/FP/FN | |
| $tp = 0; | |
| foreach ($S as $s) { | |
| if (isset($setC[$s])) { | |
| $tp++; | |
| } | |
| } | |
| $fp = max(0, count($S) - $tp); | |
| $fn = max(0, count($C) - $tp); | |
| // Calcul du score selon le mode | |
| $mode = $this->validationMode; | |
| $scoreNormalized = 0.0; | |
| // 0.0 .. 1.0 | |
| switch ($mode) { | |
| case 'permissive': | |
| // Jaccard : |S ∩ C| / |S ∪ C| | |
| $unionCount = count(array_unique(array_merge($S, $C))); | |
| $scoreNormalized = $unionCount === 0 ? 0.0 : $tp / $unionCount; | |
| break; | |
| case 'medium': | |
| // F1 : 2 * (P * R) / (P + R) | |
| $precision = count($S) === 0 ? 0.0 : $tp / count($S); | |
| $recall = count($C) === 0 ? 0.0 : $tp / count($C); | |
| if ($precision + $recall === 0.0) { | |
| $scoreNormalized = 0.0; | |
| } else { | |
| $f1 = 2.0 * ($precision * $recall) / ($precision + $recall); | |
| $scoreNormalized = $f1; | |
| } | |
| break; | |
| case 'strict': | |
| // strict : max(0, TP - FP) / |C| | |
| $numerator = max(0, $tp - $fp); | |
| $scoreNormalized = count($C) === 0 ? 0.0 : $numerator / count($C); | |
| break; | |
| default: | |
| // Normalement impossible car validé dans setDefinition | |
| throw new InvalidArgumentException('Mode de validation inconnu.'); | |
| } | |
| // Clamp entre 0 et 1 (sécurité) | |
| if ($scoreNormalized < 0.0) { | |
| $scoreNormalized = 0.0; | |
| } elseif ($scoreNormalized > 1.0) { | |
| $scoreNormalized = 1.0; | |
| } | |
| $scorePercent = round($scoreNormalized * 100.0, 2); | |
| $isCorrect = $scoreNormalized === 1.0; | |
| $metadata = [ | |
| 'tp' => $tp, | |
| 'fp' => $fp, | |
| 'fn' => $fn, | |
| 'mode' => $mode, | |
| 'scorePercent' => $scorePercent, | |
| 'message' => $isCorrect ? 'Réponse entièrement correcte.' : 'Réponse partielle ou incorrecte.' | |
| ]; | |
| // Construction du résultat d'évaluation. | |
| return new EvaluationResult($isCorrect, $scoreNormalized, $metadata); | |
| } | |
| } | |
| app/Modules/Question/Domain/Kind/KindMultipleChoice.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Kind; | |
| use InvalidArgumentException; | |
| use App\Modules\Question\Domain\ValueObject\EvaluationResult; | |
| /** | |
| * Représente une question de type MultipleChoice. | |
| * | |
| * Cette classe contient la logique métier complète : | |
| * - Instruction | |
| * - Validation de la réponse | |
| * - Gestion des options et des IDs corrects | |
| * - Mélange possible | |
| * | |
| * Structure attendue des données : | |
| * [ | |
| * 'options' => [ | |
| * ['id' => 1, 'label' => 'Stylo'], | |
| * ['id' => 2, 'label' => 'Grille-pain'], | |
| * ['id' => 3, 'label' => 'Crayon'], | |
| * ['id' => 4, 'label' => 'Une brosse de toilettes'] | |
| * ], | |
| * 'correctIds' => [2, 4], | |
| * 'isShuffleEnabled' => true, | |
| * 'validationMode' => 'medium' // 'permissive'|'medium'|'strict' | |
| * ] | |
| */ | |
| final class KindMultipleChoice implements QuestionKindInterface | |
| { | |
| /** | |
| * Mode de validation autorisés. | |
| * | |
| * @var string[] | |
| */ | |
| private const VALIDATION_MODES = ['permissive', 'medium', 'strict']; | |
| /** | |
| * @var array<int,array{id:int,label:string}> Options possibles de la question | |
| */ | |
| private array $options = []; | |
| /** | |
| * @var array<int,int> Identifiants des options correctes | |
| */ | |
| private array $correctIds = []; | |
| /** | |
| * @var bool Indique si le mélange est activé | |
| */ | |
| private bool $isShuffleEnabled = false; | |
| /** | |
| * @var string Mode de validation utilisé ('permissive'|'medium'|'strict') | |
| */ | |
| private string $validationMode = 'medium'; | |
| /** | |
| * Constructeur vide pour compatibilité QuestionKindFactory. | |
| * On peut injecter la définition plus tard via setDefinition(). | |
| */ | |
| public function __construct( | |
| private readonly int $kindId, | |
| array $definition | |
| ) { | |
| $this->setDefinition($definition); | |
| } | |
| /** | |
| * Injecte la définition de la question. | |
| * | |
| * @param array<string, mixed> $data Données de définition de la question | |
| * Clés attendues : | |
| * - options: array<int, array{id:int, label:string}> | |
| * - correctIds: array<int,int> | |
| * - isShuffleEnabled?: bool | |
| * - validationMode?: "permissive"|"medium"|"strict" | |
| * | |
| * @return void | |
| * | |
| * @throws InvalidArgumentException Si les champs requis sont manquants ou invalides | |
| */ | |
| private function setDefinition(array $data): void | |
| { | |
| if (!isset($data['options'], $data['correctIds'])) { | |
| throw new InvalidArgumentException('Champs requis manquants pour MultipleChoice.'); | |
| } | |
| // Casts et validations basiques | |
| $options = (array) $data['options']; | |
| $correctIds = (array) $data['correctIds']; | |
| // Vérifier la forme minimale des options | |
| foreach ($options as $opt) { | |
| if (!is_array($opt) || !isset($opt['id'], $opt['label']) || !is_int($opt['id']) || !is_string($opt['label'])) { | |
| throw new InvalidArgumentException('Chaque option doit être un tableau {id:int, label:string}.'); | |
| } | |
| } | |
| // Vérifier que correctIds sont des entiers | |
| foreach ($correctIds as $id) { | |
| if (!is_int($id)) { | |
| throw new InvalidArgumentException('Chaque correctId doit être un entier.'); | |
| } | |
| } | |
| $this->options = $options; | |
| $this->correctIds = array_values(array_unique($correctIds)); | |
| $this->isShuffleEnabled = (bool) ($data['isShuffleEnabled'] ?? false); | |
| $mode = (string) ($data['validationMode'] ?? 'medium'); | |
| if (!in_array($mode, self::VALIDATION_MODES, true)) { | |
| throw new InvalidArgumentException(sprintf('validationMode invalide. Valeurs attendues : %s.', implode(', ', self::VALIDATION_MODES))); | |
| } | |
| $this->validationMode = $mode; | |
| } | |
| /** | |
| * Identifiant du type de question. | |
| * | |
| * @return int | |
| */ | |
| public function getKindId(): int | |
| { | |
| return $this->kindId; | |
| } | |
| /** | |
| * Instruction utilisateur. | |
| * | |
| * @return string | |
| */ | |
| public function getInstruction(): string | |
| { | |
| return 'Coche toutes les réponses correctes'; | |
| } | |
| /** | |
| * Évalue une réponse utilisateur pour une question de type MultipleChoice. | |
| * | |
| * Cette méthode ne modifie pas l'état interne de l'entité : elle prend | |
| * la réponse soumise par l'utilisateur, la compare à la définition | |
| * injectée de la question, et retourne un objet immuable représentant | |
| * le résultat de l'évaluation. | |
| * | |
| * ### Structure attendue de $answer : | |
| * [ | |
| * 'selected' => [2, 4] // tableau d'entiers représentant les IDs choisis | |
| * ] | |
| * | |
| * ### Modes de validation disponibles : | |
| * - 'permissive' : Jaccard (|S ∩ C| / |S ∪ C|) | |
| * - 'medium' : F1 (harmonique précision/rappel) | |
| * - 'strict' : max(0, TP - FP) / |C| | |
| * | |
| * @param array<string,mixed> $answer | |
| * Réponse soumise par l'utilisateur. Doit contenir une clé 'selected' | |
| * associée à un tableau d'entiers représentant les IDs des options cochées. | |
| * | |
| * @return EvaluationResult | |
| * Objet immuable contenant : | |
| * - `isCorrect` : booléen indiquant si la réponse est correcte (score == 1.0) | |
| * - `score` : nombre flottant normalisé entre 0.0 et 1.0 | |
| * - `metadata` : tableau fournissant des détails (tp, fp, fn, mode, scorePercent, message) | |
| * | |
| * @throws InvalidArgumentException | |
| * - Si la définition de la question n'a pas été injectée. | |
| * - Si la structure de $answer est invalide ou incompatible | |
| * avec la définition de la question. | |
| */ | |
| public function evaluateAnswer(array $answer): EvaluationResult | |
| { | |
| if (!isset($answer['selected']) || !is_array($answer['selected'])) { | |
| throw new InvalidArgumentException('La réponse doit contenir une clé "selected" avec un tableau d\'IDs.'); | |
| } | |
| // Normaliser les sélections en entiers uniques | |
| $selectedRaw = $answer['selected']; | |
| $selected = []; | |
| foreach ($selectedRaw as $id) { | |
| if (!is_int($id) && !ctype_digit((string) $id)) { | |
| throw new InvalidArgumentException('Tous les IDs sélectionnés doivent être des entiers.'); | |
| } | |
| $selected[] = (int) $id; | |
| } | |
| $S = array_values(array_unique($selected)); | |
| $C = array_values(array_unique($this->correctIds)); | |
| // Calcul des TP/FP/FN | |
| $tp = count(array_intersect($S, $C)); | |
| $fp = count(array_diff($S, $C)); | |
| $fn = count(array_diff($C, $S)); | |
| $mode = $this->validationMode; | |
| $scoreNormalized = 0.0; | |
| // 0.0 .. 1.0 | |
| switch ($mode) { | |
| case 'permissive': | |
| // Jaccard : |S ∩ C| / |S ∪ C| | |
| $unionCount = count(array_unique(array_merge($S, $C))); | |
| $scoreNormalized = $unionCount === 0 ? 0.0 : $tp / $unionCount; | |
| break; | |
| case 'medium': | |
| // F1 : 2 * (P * R) / (P + R) | |
| $precision = count($S) === 0 ? 0.0 : $tp / count($S); | |
| $recall = count($C) === 0 ? 0.0 : $tp / count($C); | |
| if ($precision + $recall === 0.0) { | |
| $scoreNormalized = 0.0; | |
| } else { | |
| $f1 = 2.0 * ($precision * $recall) / ($precision + $recall); | |
| $scoreNormalized = $f1; | |
| } | |
| break; | |
| case 'strict': | |
| // strict : max(0, TP - FP) / |C| | |
| $numerator = max(0, $tp - $fp); | |
| $scoreNormalized = count($C) === 0 ? 0.0 : $numerator / count($C); | |
| break; | |
| default: | |
| // Normalement impossible car validé dans setDefinition | |
| throw new InvalidArgumentException('Mode de validation inconnu.'); | |
| } | |
| // Clamp entre 0 et 1 (sécurité) | |
| if ($scoreNormalized < 0.0) { | |
| $scoreNormalized = 0.0; | |
| } elseif ($scoreNormalized > 1.0) { | |
| $scoreNormalized = 1.0; | |
| } | |
| $scorePercent = round($scoreNormalized * 100.0, 2); | |
| $isCorrect = $scoreNormalized === 1.0; | |
| $metadata = ['tp' => $tp, 'fp' => $fp, 'fn' => $fn, 'mode' => $mode, 'scorePercent' => $scorePercent, 'message' => $isCorrect ? 'Réponse entièrement correcte.' : 'Réponse partielle ou incorrecte.']; | |
| // Construction du résultat d'évaluation. | |
| // NOTE : j'assume la signature EvaluationResult::__construct(bool, float, array) | |
| return new EvaluationResult($isCorrect, $scoreNormalized, $metadata); | |
| } | |
| } | |
| app/Modules/Question/Domain/Kind/KindShortAnswer.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Kind; | |
| use InvalidArgumentException; | |
| use App\Modules\Question\Domain\ValueObject\EvaluationResult; | |
| /** | |
| * Représente une question de type ShortAnswer. | |
| * | |
| * Cette classe contient la logique métier complète : | |
| * - Instruction | |
| * - Validation de la réponse | |
| * - Gestion de la définition : matchMode, expectedAnswers, isCaseSensitive, normalize | |
| */ | |
| final class KindShortAnswer implements QuestionKindInterface | |
| { | |
| /** | |
| * @var string Mode de correspondance : "exact" | "substring" | "regex" | "tokens" | |
| */ | |
| private string $matchMode = ''; | |
| /** | |
| * @var array<int, string> Réponses attendues | |
| */ | |
| private array $expectedAnswers = []; | |
| /** | |
| * @var bool Respect de la casse | |
| */ | |
| private bool $isCaseSensitive = false; | |
| /** | |
| * @var string|null Normalisation optionnelle | |
| */ | |
| private ?string $normalize = null; | |
| /** | |
| * Constructeur vide pour compatibilité QuestionKindFactory. | |
| * La définition peut être injectée plus tard via setDefinition(). | |
| */ | |
| public function __construct( | |
| private readonly int $kindId, | |
| array $definition | |
| ) { | |
| $this->setDefinition($definition); | |
| } | |
| /** | |
| * Injecte la définition de la question. | |
| * | |
| * @param array<string, mixed> $data | |
| * | |
| * @return void | |
| * | |
| * @throws InvalidArgumentException Si les champs requis sont manquants | |
| */ | |
| private function setDefinition(array $data): void | |
| { | |
| if (!isset($data['matchMode'], $data['expectedAnswers'])) { | |
| throw new InvalidArgumentException('Champs requis manquants pour ShortAnswer.'); | |
| } | |
| $this->matchMode = (string) $data['matchMode']; | |
| $this->expectedAnswers = (array) $data['expectedAnswers']; | |
| $this->isCaseSensitive = (bool) ($data['isCaseSensitive'] ?? false); | |
| $this->normalize = $data['normalize'] ?? null; | |
| } | |
| public function getKindId(): int | |
| { | |
| return $this->kindId; | |
| } | |
| /** | |
| * Instruction utilisateur. | |
| */ | |
| public function getInstruction(): string | |
| { | |
| return 'Saisis ta réponse'; | |
| } | |
| /** | |
| * Évalue une réponse pour une question de type « shortanswer ». | |
| * | |
| * Règles : | |
| * - matchMode = "exact" : | |
| * comparaison directe, sensible ou non à la casse. | |
| * - matchMode = "substring" : | |
| * la réponse de l’utilisateur doit contenir l’une des expectedAnswers. | |
| * - matchMode = "regex" : | |
| * chaque expectedAnswer est interprétée comme une expression régulière. | |
| * - matchMode = "tokens" : | |
| * découpe en mots, normalisation, puis comparaison de jeux de tokens (ordre ignoré). | |
| * | |
| * @param array{ | |
| * answer: string|null | |
| * } $answer Réponse utilisateur brute | |
| * | |
| * @return EvaluationResult | |
| * | |
| * @throws DomainException Si la définition de la question n’est pas prête | |
| */ | |
| public function evaluateAnswer(array $answer): EvaluationResult | |
| { | |
| $input = $answer['answer'] ?? ''; | |
| if (!is_string($input)) { | |
| return new EvaluationResult( | |
| isCorrect: false, | |
| score: 0.0, | |
| metadata: ['Réponse invalide'] | |
| ); | |
| } | |
| /** @var string $inputNorm Réponse utilisateur normalisée */ | |
| $inputNorm = $this->applyNormalization(value: $input); | |
| /** @var array<int,string> $expectedNorm Liste des réponses attendues normalisées */ | |
| $expectedNorm = array_map(fn(string $v): string => $this->applyNormalization(value: $v), $this->expectedAnswers); | |
| $isCorrect = false; | |
| $points = 0.0; | |
| $feedback = []; | |
| switch ($this->matchMode) { | |
| case 'exact': | |
| foreach ($expectedNorm as $exp) { | |
| if ($this->compare(a: $inputNorm, b: $exp)) { | |
| $isCorrect = true; | |
| break; | |
| } | |
| } | |
| break; | |
| case 'substring': | |
| foreach ($expectedNorm as $exp) { | |
| if ($exp !== '' && str_contains($inputNorm, $exp)) { | |
| $isCorrect = true; | |
| break; | |
| } | |
| } | |
| break; | |
| case 'regex': | |
| foreach ($this->expectedAnswers as $pattern) { | |
| $flags = $this->isCaseSensitive ? '' : 'i'; | |
| if (@preg_match(pattern: '/' . $pattern . '/' . $flags, subject: $input) === 1) { | |
| $isCorrect = true; | |
| break; | |
| } | |
| } | |
| break; | |
| case 'tokens': | |
| $inputTokens = $this->tokenize(value: $inputNorm); | |
| foreach ($expectedNorm as $exp) { | |
| $expTokens = $this->tokenize(value: $exp); | |
| if ($inputTokens === $expTokens) { | |
| $isCorrect = true; | |
| break; | |
| } | |
| } | |
| break; | |
| default: | |
| $feedback[] = 'Mode de correspondance inconnu'; | |
| } | |
| if ($isCorrect) { | |
| $points = 1.0; | |
| $feedback[] = 'Bonne réponse'; | |
| } else { | |
| $feedback[] = 'Mauvaise réponse'; | |
| } | |
| return new EvaluationResult(isCorrect: $isCorrect, score: $points, metadata: $feedback); | |
| } | |
| /* ============================================================ | |
| * MÉTHODES UTILITAIRES | |
| * ============================================================ | |
| */ | |
| /** | |
| * Applique la normalisation configurée sur une chaîne. | |
| * | |
| * @param string $value Valeur d’entrée brute | |
| * @return string Valeur normalisée | |
| */ | |
| private function applyNormalization(string $value): string | |
| { | |
| if ($this->normalize === 'trim') { | |
| $value = trim($value); | |
| } elseif ($this->normalize === 'lower') { | |
| $value = mb_strtolower($value); | |
| } elseif ($this->normalize === 'upper') { | |
| $value = mb_strtoupper($value); | |
| } elseif ($this->normalize === 'alnum') { | |
| $value = preg_replace(pattern: '/[^[:alnum:]]+/u', replacement: '', subject: $value) ?? $value; | |
| } | |
| if (!$this->isCaseSensitive) { | |
| $value = mb_strtolower($value); | |
| } | |
| return $value; | |
| } | |
| /** | |
| * Compare deux chaînes. | |
| * | |
| * @param string $a | |
| * @param string $b | |
| * @return bool | |
| */ | |
| private function compare(string $a, string $b): bool | |
| { | |
| return $a === $b; | |
| } | |
| /** | |
| * Transforme une chaîne en liste triée de tokens nettoyés. | |
| * | |
| * @param string $value | |
| * @return array<int,string> | |
| */ | |
| private function tokenize(string $value): array | |
| { | |
| $tokens = preg_split(pattern: '/\s+/u', subject: trim($value)) ?: []; | |
| sort($tokens, SORT_STRING); | |
| return $tokens; | |
| } | |
| } | |
| app/Modules/Question/Domain/Kind/KindSingleChoice.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Kind; | |
| use InvalidArgumentException; | |
| use App\Modules\Question\Domain\ValueObject\EvaluationResult; | |
| /** | |
| * Représente une question de type SingleChoice. | |
| * | |
| * Cette classe contient la logique métier complète : | |
| * - Instruction | |
| * - Validation de la réponse | |
| * - Gestion des options et de l’ID correct | |
| * - Mélange possible | |
| * | |
| * Structure attendue des données : | |
| * [ | |
| * 'options' => [ | |
| * ['id' => 1, 'label' => 'Bleu'], | |
| * ['id' => 2, 'label' => 'Blanc'], | |
| * ['id' => 3, 'label' => 'Rouge'] | |
| * ], | |
| * 'correctId' => 2, | |
| * 'isShuffleEnabled' => true | |
| * ] | |
| */ | |
| final class KindSingleChoice implements QuestionKindInterface | |
| { | |
| /** | |
| * @var array<int,array{id:int,label:string}> | |
| */ | |
| private array $options = []; | |
| /** | |
| * @var int Identifiant de l’option correcte | |
| */ | |
| private int $correctId = 0; | |
| /** | |
| * @var bool Indique si le mélange est activé | |
| */ | |
| private bool $isShuffleEnabled = false; | |
| /** | |
| * @var bool Indique si la définition a été injectée | |
| */ | |
| private bool $isDefinitionSet = false; | |
| /** | |
| * Constructeur vide pour compatibilité QuestionKindFactory. | |
| * La définition peut être injectée plus tard via setDefinition(). | |
| */ | |
| public function __construct( | |
| private readonly int $kindId, | |
| array $definition | |
| ) { | |
| $this->setDefinition($definition); | |
| } | |
| /** | |
| * Injecte la définition de la question. | |
| * | |
| * @param array<string, mixed> $data Données de définition de la question | |
| * | |
| * @return void | |
| * | |
| * @throws InvalidArgumentException Si les champs requis sont manquants | |
| */ | |
| private function setDefinition(array $data): void | |
| { | |
| if (!isset($data['options'], $data['correctId'])) { | |
| throw new InvalidArgumentException('Champs requis manquants pour SingleChoice.'); | |
| } | |
| $this->options = (array) $data['options']; | |
| $this->correctId = (int) $data['correctId']; | |
| $this->isShuffleEnabled = (bool) ($data['isShuffleEnabled'] ?? false); | |
| $this->isDefinitionSet = true; | |
| } | |
| /** | |
| * Vérifie que la définition a été injectée. | |
| * | |
| * @return void | |
| * | |
| * @throws InvalidArgumentException Si la définition n'a pas été injectée | |
| */ | |
| private function ensureDefinition(): void | |
| { | |
| if (!$this->isDefinitionSet) { | |
| throw new InvalidArgumentException('La définition de la question n\'a pas été injectée.'); | |
| } | |
| } | |
| /** | |
| * Identifiant du type de question. | |
| * | |
| * @return int | |
| */ | |
| public function getKindId(): int | |
| { | |
| return $this->kindId; | |
| } | |
| /** | |
| * Instruction utilisateur. | |
| * | |
| * @return string | |
| */ | |
| public function getInstruction(): string | |
| { | |
| return 'Choisis la réponse correcte'; | |
| } | |
| /** | |
| * Évalue une réponse utilisateur pour ce type de question. | |
| * | |
| * Cette méthode **ne modifie pas l'état interne** de l'entité. | |
| * Elle prend la réponse soumise par l'utilisateur et retourne | |
| * un objet immuable représentant le résultat de l'évaluation. | |
| * | |
| * @param array<string, mixed> $answer Réponse soumise par l'utilisateur. | |
| * Exemple : ['selected' => 2] | |
| * | |
| * @return EvaluationResult Objet contenant le score, | |
| * la validité, les erreurs et | |
| * tout feedback nécessaire. | |
| * | |
| * @throws InvalidArgumentException Si la structure de $answer est invalide | |
| * ou incompatible avec la définition de la question. | |
| */ | |
| public function evaluateAnswer(array $answer): EvaluationResult | |
| { | |
| $this->ensureDefinition(); | |
| $selected = $answer['selected'] ?? null; | |
| $isCorrect = false; | |
| $points = 0.0; | |
| $feedback = []; | |
| if (is_int($selected)) { | |
| if ($selected === $this->correctId) { | |
| $isCorrect = true; | |
| $points = 1.0; | |
| $feedback[] = 'Bonne réponse'; | |
| } else { | |
| $feedback[] = 'Mauvaise réponse'; | |
| } | |
| } else { | |
| $feedback[] = 'Réponse invalide'; | |
| } | |
| return new EvaluationResult(isCorrect: $isCorrect, score: $points, metadata: $feedback); | |
| } | |
| } | |
| app/Modules/Question/Domain/Kind/QuestionKindFactory.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Kind; | |
| /** | |
| * Factory pour créer des instances de Kind* | |
| * | |
| * @package App\Domain\Question\Kinds | |
| */ | |
| final class QuestionKindFactory | |
| { | |
| /** | |
| * Crée une instance de Kind* selon son identifiant numérique | |
| * | |
| * @param int $kindId | |
| * @return QuestionKindInterface | |
| * @throws \InvalidArgumentException Si l’ID n’est pas reconnu | |
| */ | |
| public static function create(int $kindId, array $definition): QuestionKindInterface | |
| { | |
| return match ($kindId) { | |
| 1 => new KindShortAnswer(kindId: $kindId, definition: $definition), | |
| 2 => new KindSingleChoice(kindId: $kindId, definition: $definition), | |
| 3 => new KindMultipleChoice(kindId: $kindId, definition: $definition), | |
| 4 => new KindMatching(kindId: $kindId, definition: $definition), | |
| default => throw new \InvalidArgumentException("ID de type de question inconnu : {$kindId}"), | |
| }; | |
| } | |
| } | |
| app/Modules/Question/Domain/Kind/QuestionKindInterface.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Kind; | |
| use App\Modules\Question\Domain\ValueObject\EvaluationResult; | |
| /** | |
| * Interface commune à tous les types de questions. | |
| * Chaque implémentation représente un type de question spécifique | |
| * et encapsule : | |
| * - l’instruction par défaut | |
| * - la logique de validation | |
| * - la présentation (rendering HTML ou autre) | |
| */ | |
| interface QuestionKindInterface | |
| { | |
| /** | |
| * Retourne l’identifiant logique du type de question. | |
| * | |
| * | |
| * @return int | |
| */ | |
| public function getKindId(): int; | |
| /** | |
| * Retourne l’instruction utilisateur par défaut pour ce type de question. | |
| * | |
| * @return string | |
| */ | |
| public function getInstruction(): string; | |
| /** | |
| * Évalue une réponse utilisateur pour ce type de question. | |
| * | |
| * @param array<string, mixed> $answer Réponse soumise par l'utilisateur. | |
| * | |
| * @return EvaluationResult Objet contenant le score, | |
| * la validité, les erreurs et | |
| * tout feedback nécessaire. | |
| * | |
| * @throws InvalidArgumentException Si la structure de $answer est invalide | |
| * ou incompatible avec la définition de la question. | |
| */ | |
| public function evaluateAnswer(array $answer): EvaluationResult; | |
| } | |
| app/Modules/Question/Domain/Kind/QuestionKindEnum.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Kind; | |
| enum QuestionKindEnum: int | |
| { | |
| case ShortAnswer = 1; | |
| case SingleChoice = 2; | |
| case MultipleChoice = 3; | |
| case Matching = 4; | |
| } | |
| app/Modules/Question/Domain/Repository/QuestionRepositoryInterface.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Domain\Repository; | |
| use App\Modules\Question\Domain\Entity\Question; | |
| interface QuestionRepositoryInterface | |
| { | |
| public function findById(int $id): ?Question; | |
| /** @return Question[] */ | |
| public function findByQuizId(int $quizId): array; | |
| } | |
| app/Modules/Question/Domain/ValueObject/EvaluationResult.php | |
| <?php | |
| declare (strict_types=1); | |
| namespace App\Modules\Question\Domain\ValueObject; | |
| /** | |
| * Value Object représentant le résultat de l'évaluation d'une réponse | |
| * | |
| * @package App\Domain\Question | |
| */ | |
| final class EvaluationResult | |
| { | |
| /** | |
| * @param bool $isCorrect Indique si la réponse est correcte | |
| * @param float $score Score obtenu pour la question, typiquement entre 0 et 1 | |
| * @param array<string,mixed> $metadata Données supplémentaires optionnelles (feedback, erreurs, etc.) | |
| */ | |
| public function __construct(private bool $isCorrect, private float $score, private array $metadata = []) | |
| { | |
| } | |
| /** | |
| * @return bool | |
| */ | |
| public function isCorrect(): bool | |
| { | |
| return $this->isCorrect; | |
| } | |
| /** | |
| * @return float | |
| */ | |
| public function getScore(): float | |
| { | |
| return $this->score; | |
| } | |
| /** | |
| * Retourne les métadonnées associées à l'évaluation | |
| * | |
| * @return array<string, mixed> | |
| */ | |
| public function getMetadata(): array | |
| { | |
| return $this->metadata; | |
| } | |
| }app/Modules/Question/Infrastructure/Pdo/PdoQuestionRepository.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\Infrastructure\Pdo; | |
| use App\Modules\Question\Domain\Entity\Question; | |
| use App\Modules\Question\Domain\Kind\QuestionKindEnum; | |
| use App\Modules\Question\Domain\Kind\QuestionKindFactory; | |
| use App\Modules\Question\Domain\Kind\QuestionKindInterface; | |
| use App\Modules\Question\Domain\Repository\QuestionRepositoryInterface; | |
| use App\Shared\Attributes\Service; | |
| use App\Shared\Utils\DateTimeConverter; | |
| use PDO; | |
| use Psr\Log\LoggerInterface; | |
| #[Service()] | |
| final class PdoQuestionRepository implements QuestionRepositoryInterface | |
| { | |
| public function __construct( | |
| private readonly PDO $pdo, | |
| private readonly LoggerInterface $logger | |
| ) {} | |
| /** | |
| * Retrieve a question by its ID. | |
| */ | |
| public function findById(int $id): ?Question | |
| { | |
| $sql = <<<SQL | |
| SELECT id, | |
| kind_id, | |
| text, | |
| definition, | |
| difficulty, | |
| created_at, | |
| updated_at, | |
| disabled_at | |
| FROM questions | |
| WHERE disabled_at IS NULL | |
| AND id = :id | |
| LIMIT 1 | |
| SQL; | |
| $stmt = $this->pdo->prepare($sql); | |
| $stmt->bindValue(':id', $id, PDO::PARAM_INT); | |
| $stmt->execute(); | |
| $row = $stmt->fetch(PDO::FETCH_ASSOC); | |
| if ($row === false) { | |
| return null; | |
| } | |
| return $this->hydrateEntity($row); | |
| } | |
| /** | |
| * Retrieve all questions for a given quiz. | |
| * | |
| * @return Question[] | |
| */ | |
| public function findByQuizId(int $quizId): array | |
| { | |
| $sql = <<<SQL | |
| SELECT q.id, | |
| q.kind_id, | |
| q.text, | |
| q.definition, | |
| q.difficulty, | |
| q.created_at, | |
| q.updated_at, | |
| q.disabled_at | |
| FROM questions q | |
| INNER JOIN quiz__question qq ON qq.question_id = q.id | |
| WHERE qq.quiz_id = :qid | |
| AND q.disabled_at IS NULL | |
| ORDER BY qq.position ASC, q.id ASC | |
| SQL; | |
| $stmt = $this->pdo->prepare($sql); | |
| $stmt->bindValue(':qid', $quizId, PDO::PARAM_INT); | |
| $stmt->execute(); | |
| $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| return array_map( | |
| fn(array $row): Question => $this->hydrateEntity($row), | |
| $rows | |
| ); | |
| } | |
| /** | |
| * Hydrate a Question entity from a database row. | |
| */ | |
| private function hydrateEntity(array $row): Question | |
| { | |
| $definitionJson = $row['definition'] ?? null; | |
| $definitionArray = json_decode((string) $definitionJson, true, 512, JSON_THROW_ON_ERROR); | |
| $kindId = (int) $row['kind_id']; | |
| $kind = QuestionKindEnum::from(value: $kindId); | |
| /** @var QuestionKindInterface */ | |
| $behavior = QuestionKindFactory::create(kindId: $kindId, definition: $definitionArray); | |
| // $definition = QuestionDefinitionFactory::fromArray($kind, $definitionArray); | |
| return new Question( | |
| id: (int) $row['id'], | |
| text: (string) $row['text'], | |
| kind: $kind, | |
| behavior: $behavior, | |
| difficulty: (int) $row['difficulty'], | |
| instruction: null, | |
| createdAt: DateTimeConverter::fromString(datetime: $row['created_at']), | |
| updatedAt: DateTimeConverter::fromString(datetime: $row['updated_at']), | |
| disabledAt: DateTimeConverter::fromString(datetime: $row['disabled_at']) | |
| ); | |
| } | |
| } | |
| app/Modules/Question/UI/Web/Controller/QuestionController.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\UI\Web\Controller; | |
| use App\Modules\Question\Application\Dto\QuestionDto; | |
| use App\Modules\Question\Application\Query\GetQuestionDetailsQuery; | |
| use App\Modules\Question\Application\Query\GetQuestionListQuery; | |
| use App\Modules\Question\Application\QueryHandler\GetQuestionDetailsHandler; | |
| use App\Modules\Question\Application\QueryHandler\GetQuestionListHandler; | |
| use App\Modules\Question\Domain\Exception\QuestionNotFoundException; | |
| use App\Modules\Question\UI\Web\Renderer\QuestionRenderer; | |
| use App\Modules\Question\UI\Web\Route\QuestionRoutes; | |
| use App\Modules\Http\Infrastructure\Message\Response; | |
| use App\Modules\Http\Infrastructure\Message\SimpleStream; | |
| use App\Shared\Attributes\Route; | |
| use App\Shared\Attributes\Service; | |
| use Psr\Http\Message\RequestInterface; | |
| use Psr\Http\Message\ResponseInterface; | |
| use Psr\Http\Message\ServerRequestInterface; | |
| /** | |
| * Controller Web pour Health — responsabilité unique : adaptation HTTP -> UseCase | |
| */ | |
| #[Service] | |
| final class QuestionController | |
| { | |
| public function __construct( | |
| private GetQuestionListHandler $listHandler, | |
| private GetQuestionDetailsHandler $getHandler, | |
| private QuestionRenderer $renderer, | |
| ) {} | |
| /** | |
| * Action HTTP GET /health | |
| * | |
| * @param RequestInterface $request | |
| * @param ResponseInterface $response | |
| * @return ResponseInterface | |
| */ | |
| #[Route(method: 'GET', path: [QuestionRoutes::LIST])] | |
| public function index(ServerRequestInterface $request): ResponseInterface | |
| { | |
| $query = new GetQuestionListQuery(); | |
| /** @var QuestionDto[] $dtos Liste de DTOs de catégories */ | |
| $dtos = $this->listHandler->handle($query); | |
| $html = $this->renderer->renderQuestionList($dtos); | |
| return new Response( | |
| statusCode: 200, | |
| headers: ['Content-Type' => 'text/html; charset=utf-8'], | |
| body: new SimpleStream($html) | |
| ); | |
| } | |
| /** | |
| * Action : GET /questions/{id} | |
| * | |
| * Récupère une catégorie par son identifiant. | |
| * - Si l'id est invalide → 400 Bad Request JSON | |
| * - Si la catégorie n'existe pas → 404 Not Found JSON | |
| * - Sinon → 200 OK JSON avec le DTO de la catégorie | |
| * | |
| * @param ServerRequestInterface $request Requête HTTP entrante (contenant les attributs de route) | |
| * | |
| * @return ResponseInterface Réponse JSON (200, 400 ou 404) | |
| */ | |
| #[Route(method: 'GET', path: QuestionRoutes::DETAILS, requirements: ['id' => '\d+'])] | |
| public function show(ServerRequestInterface $request): ResponseInterface | |
| { | |
| /** @var array<string,mixed> $routeParams Paramètres extraits de la route */ | |
| $routeParams = $request->getAttribute('route_params', []); | |
| $raw = $routeParams['id'] ?? null; | |
| if (!is_string($raw) || $raw === '' || !ctype_digit($raw)) { | |
| // Paramètre invalide → 400 Bad Request | |
| $payload = json_encode(['error' => 'Invalid Id'], JSON_THROW_ON_ERROR); | |
| return (new Response())->withStatus(400)->withHeader('Content-Type', 'application/json')->withBody(new SimpleStream($payload)); | |
| } | |
| $id = (int) $raw; | |
| try { | |
| $query = new GetQuestionDetailsQuery($id); | |
| /** @var QuestionDto */ | |
| $dto = $this->getHandler->handle($query); | |
| $html = $this->renderer->renderQuestionDetails($dto); | |
| return new Response( | |
| statusCode: 200, | |
| headers: ['Content-Type' => 'text/html; charset=utf-8'], | |
| body: new SimpleStream($html) | |
| ); | |
| } catch (QuestionNotFoundException $e) { | |
| // Catégorie non trouvée → 404 Not Found | |
| $payload = json_encode(['error' => 'not_found'], JSON_THROW_ON_ERROR); | |
| return (new Response()) | |
| ->withStatus(404) | |
| ->withHeader('Content-Type', 'application/json') | |
| ->withBody(new SimpleStream($payload)); | |
| } | |
| } | |
| } | |
| app/Modules/Question/UI/Web/Renderer/QuestionRenderer.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\UI\Web\Renderer; | |
| use App\Modules\Question\Application\Dto\QuestionDto; | |
| use App\Modules\Question\UI\Web\ViewModel\QuestionViewModel; | |
| use App\Modules\Web\UI\Web\Renderer\PageRendererInterface; | |
| use App\Modules\Shared\UI\Web\Helper\ViewHelperInterface; | |
| use App\Modules\Shared\UI\Web\ViewModel\Factory\WebPageParamsProviderInterface; | |
| use App\Shared\Attributes\Service; | |
| use App\Modules\Question\UI\Web\Route\QuestionRoutes; | |
| #[Service] | |
| final class QuestionRenderer implements QuestionRendererInterface | |
| { | |
| public function __construct( | |
| private readonly PageRendererInterface $pageRenderer, | |
| private readonly WebPageParamsProviderInterface $pageParamsFactory, | |
| private readonly ViewHelperInterface $viewHelper, | |
| ) {} | |
| /** | |
| * Render question list | |
| * | |
| * @param QuestionDto[] $dtoList | |
| */ | |
| public function renderQuestionList(array $dtoList, bool $isPartialOnly = false): string | |
| { | |
| // Transformer chaque DTO en ViewModel UI | |
| $viewModelList = array_map(fn(QuestionDto $dto) => new QuestionViewModel( | |
| id: $dto->id, | |
| label: $this->viewHelper::esc($dto->text), | |
| instruction: $this->viewHelper::esc($dto->instruction), | |
| difficulty: $dto->difficulty, | |
| route: QuestionRoutes::LIST . '/' . $dto->id, | |
| createdAt: $this->viewHelper::esc($dto->createdAt), | |
| ), $dtoList); | |
| if ($isPartialOnly) { | |
| return $this->pageRenderer->renderPartial( | |
| partial: __DIR__ . '/../Partial/list.php', | |
| partialParams: [ | |
| 'questionList' => $viewModelList, | |
| ], | |
| ); | |
| } else { | |
| // Appel du PageRenderer avec la partial | |
| return $this->pageRenderer->renderPage( | |
| layout: 'main.php', | |
| partial: __DIR__ . '/../Partial/list.php', | |
| partialParams: [ | |
| 'questionList' => $viewModelList, | |
| ], | |
| pageParams: $this->pageParamsFactory | |
| ->create() | |
| ->withTitle('Liste des question'), | |
| ); | |
| } | |
| } | |
| /** | |
| * Render the details of a question. | |
| * | |
| * @param QuestionDto $dto | |
| * @param \App\Modules\Question\Application\Dto\QuestionDto[] $questions | |
| */ | |
| public function renderQuestionDetails(QuestionDto $dto, bool $isPartialOnly = false): string | |
| { | |
| // Transformer le DTO en ViewModel UI | |
| $vm = new QuestionViewModel( | |
| id: $dto->id, | |
| label: $this->viewHelper::esc($dto->text), | |
| instruction: $this->viewHelper::esc($dto->instruction), | |
| difficulty: $dto->difficulty, | |
| route: QuestionRoutes::LIST . '/' . $dto->id, | |
| createdAt: $this->viewHelper::esc($dto->createdAt), | |
| ); | |
| if ($isPartialOnly) { | |
| return $this->pageRenderer->renderPartial( | |
| partial: __DIR__ . '/../Partial/details.php', | |
| partialParams: [ | |
| 'question' => $vm, | |
| ], | |
| ); | |
| } else { | |
| // Rendu complet via PageRenderer | |
| return $this->pageRenderer->renderPage( | |
| layout: 'main.php', | |
| partial: __DIR__ . '/../Partial/details.php', | |
| partialParams: [ | |
| 'question' => $vm | |
| ], | |
| pageParams: $this->pageParamsFactory | |
| ->create() | |
| ); | |
| } | |
| } | |
| } | |
| app/Modules/Question/UI/Web/Renderer/QuestionRendererInterface.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\UI\Web\Renderer; | |
| interface QuestionRendererInterface | |
| { | |
| public function renderQuestionList(array $dtoList, bool $isPartialOnly = false): string; | |
| } | |
| app/Modules/Question/UI/Web/ViewModel/QuestionViewModel.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\UI\Web\ViewModel; | |
| final class QuestionViewModel | |
| { | |
| public function __construct( | |
| public readonly int $id, | |
| public readonly string $label, | |
| public readonly ?string $instruction, | |
| public readonly int $difficulty, | |
| public readonly string $route, | |
| public readonly ?string $description = null, | |
| public readonly ?string $createdAt = null, | |
| ) {} | |
| } | |
| app/Modules/Question/UI/Web/Route/QuestionRoutes.php | |
| <?php | |
| declare(strict_types=1); | |
| namespace App\Modules\Question\UI\Web\Route; | |
| final class QuestionRoutes | |
| { | |
| public const LIST = '/questions'; | |
| public const DETAILS = '/questions/{id}'; | |
| } | |
| app/Modules/Question/UI/Web/Partial/details.php | |
| <?php | |
| /** @var \App\Modules\Question\UI\Web\ViewModel\QuestionViewModel $question */ | |
| ?> | |
| <?php if ($question->label !== null): ?> | |
| <p class="mb-1"><?= $question->label ?></p> | |
| <?php endif; ?>app/Modules/Question/UI/Web/Partial/list.php | |
| <?php | |
| /** @var \App\Modules\Question\UI\Web\ViewModel\QuestionViewModel[] $questionList */ | |
| ?> | |
| <!-- <div class="container my-4"> | |
| <h3 class="h3 mb-4 text-center"><?= $question_h3 ?? 'Question'; ?></h3> | |
| --> | |
| <ul class="list-group d-flex flex-column gap-1"> | |
| <?php foreach ($questionList as $question): ?> | |
| <li class="list-group-item list-group-item-action d-flex flex-column flex-md-row justify-content-between align-items-start" | |
| onclick="location.href='<?= $question->route ?>'" | |
| style="cursor: pointer;"> | |
| <div class="flex-grow-1"> | |
| <h5 class="mb-1"><?= $question->label ?></h5> | |
| <?php if ($question->description !== null): ?> | |
| <p class="mb-1"><?= $question->description ?></p> | |
| <?php endif; ?> | |
| </div> | |
| </li> | |
| <?php endforeach; ?> | |
| </ul> | |
| <!-- </div> --> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment