Skip to content

Instantly share code, notes, and snippets.

@chatcoda
Created December 19, 2025 09:15
Show Gist options
  • Select an option

  • Save chatcoda/ecc59a089beeafc38dd4d287ade53727 to your computer and use it in GitHub Desktop.

Select an option

Save chatcoda/ecc59a089beeafc38dd4d287ade53727 to your computer and use it in GitHub Desktop.
### 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