From ff18850a4302a8b59a3495bdc9447946ffae1395 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sat, 7 Feb 2026 01:06:55 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Configuration=20du=20mode=20de=20notati?= =?UTF-8?q?on=20par=20=C3=A9tablissement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les établissements scolaires utilisent des systèmes d'évaluation variés (notes /20, /10, lettres, compétences, sans notes). Jusqu'ici l'application imposait implicitement le mode notes /20, ce qui ne correspondait pas à la réalité pédagogique de nombreuses écoles. Cette configuration permet à chaque établissement de choisir son mode de notation par année scolaire, avec verrouillage automatique dès que des notes ont été saisies pour éviter les incohérences. Le Score Sérénité adapte ses pondérations selon le mode choisi (les compétences sont converties via un mapping, le mode sans notes exclut la composante notes). --- backend/Dockerfile | 2 +- backend/config/services.yaml | 4 + backend/migrations/Version20260206100000.php | 47 ++ .../ConfigureGradingModeCommand.php | 16 + .../ConfigureGradingModeHandler.php | 67 ++ .../Port/GradeExistenceChecker.php | 13 + .../HasGradesForYearHandler.php | 35 + .../HasGradesForYearQuery.php | 21 + .../Domain/Event/ModeNotationConfigure.php | 43 ++ ...GradingModeWithExistingGradesException.php | 17 + .../GradingConfigurationNotFoundException.php | 21 + .../GradingConfiguration.php | 40 ++ .../GradingConfiguration/GradingMode.php | 73 ++ .../SchoolGradingConfiguration.php | 145 ++++ .../SchoolGradingConfigurationId.php | 11 + .../GradingConfigurationRepository.php | 24 + .../ConfigureGradingModeProcessor.php | 103 +++ .../Api/Processor/UpdatePeriodProcessor.php | 3 + .../Api/Provider/GradingModeProvider.php | 87 +++ .../Api/Resource/GradingModeResource.php | 109 +++ .../CreateTestActivationTokenCommand.php | 5 + ...DoctrineGradingConfigurationRepository.php | 121 ++++ ...InMemoryGradingConfigurationRepository.php | 59 ++ .../Security/GradingModeVoter.php | 100 +++ .../Service/NoOpGradeExistenceChecker.php | 10 + .../Scolarite/Domain/Model/GradingMode.php | 20 + .../Domain/Service/SerenityScoreWeights.php | 71 ++ .../Api/GradingModeEndpointsTest.php | 151 ++++ .../ConfigureGradingModeHandlerTest.php | 226 ++++++ .../UpdatePeriod/UpdatePeriodHandlerTest.php | 36 +- .../HasGradesForYearHandlerTest.php | 71 ++ .../GradingConfigurationTest.php | 79 +++ .../GradingConfiguration/GradingModeTest.php | 97 +++ .../SchoolGradingConfigurationTest.php | 186 +++++ .../ConfigureGradingModeProcessorTest.php | 254 +++++++ .../Api/Provider/GradingModeProviderTest.php | 226 ++++++ .../Api/Resource/GradingModeResourceTest.php | 124 ++++ .../Security/GradingModeVoterTest.php | 141 ++++ .../Service/SerenityScoreWeightsTest.php | 93 +++ frontend/e2e/classes.spec.ts | 24 +- frontend/e2e/pedagogy.spec.ts | 284 ++++++++ frontend/e2e/periods.spec.ts | 24 +- frontend/e2e/subjects.spec.ts | 24 +- .../SerenityScoreExplainer.svelte | 14 +- .../organisms/Dashboard/DashboardAdmin.svelte | 5 + frontend/src/lib/monitoring/webVitals.ts | 1 + frontend/src/routes/admin/+layout.svelte | 2 + .../admin/academic-year/periods/+page.svelte | 32 +- .../src/routes/admin/classes/+page.svelte | 10 +- .../src/routes/admin/pedagogy/+page.svelte | 658 ++++++++++++++++++ .../src/routes/admin/subjects/+page.svelte | 13 +- 51 files changed, 3963 insertions(+), 79 deletions(-) create mode 100644 backend/migrations/Version20260206100000.php create mode 100644 backend/src/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeCommand.php create mode 100644 backend/src/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeHandler.php create mode 100644 backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandler.php create mode 100644 backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearQuery.php create mode 100644 backend/src/Administration/Domain/Event/ModeNotationConfigure.php create mode 100644 backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php create mode 100644 backend/src/Administration/Domain/Exception/GradingConfigurationNotFoundException.php create mode 100644 backend/src/Administration/Domain/Model/GradingConfiguration/GradingConfiguration.php create mode 100644 backend/src/Administration/Domain/Model/GradingConfiguration/GradingMode.php create mode 100644 backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfiguration.php create mode 100644 backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationId.php create mode 100644 backend/src/Administration/Domain/Repository/GradingConfigurationRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/ConfigureGradingModeProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/GradingModeProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/GradingModeResource.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineGradingConfigurationRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryGradingConfigurationRepository.php create mode 100644 backend/src/Administration/Infrastructure/Security/GradingModeVoter.php create mode 100644 backend/src/Scolarite/Domain/Model/GradingMode.php create mode 100644 backend/src/Scolarite/Domain/Service/SerenityScoreWeights.php create mode 100644 backend/tests/Functional/Administration/Api/GradingModeEndpointsTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingConfigurationTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingModeTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigureGradingModeProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Provider/GradingModeProviderTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Resource/GradingModeResourceTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Security/GradingModeVoterTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Service/SerenityScoreWeightsTest.php create mode 100644 frontend/e2e/pedagogy.spec.ts create mode 100644 frontend/src/routes/admin/pedagogy/+page.svelte diff --git a/backend/Dockerfile b/backend/Dockerfile index 697fbe0..c768efa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,7 +21,7 @@ RUN apk add --no-cache \ $PHPIZE_DEPS # Install PHP extensions (opcache is pre-installed in FrankenPHP) -RUN docker-php-ext-install intl pdo_pgsql zip sockets +RUN docker-php-ext-install intl pcntl pdo_pgsql zip sockets # Install AMQP extension for RabbitMQ RUN pecl install amqp && docker-php-ext-enable amqp diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 3e2a3fc..88c7faf 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -138,6 +138,10 @@ services: App\Administration\Domain\Repository\PeriodConfigurationRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrinePeriodConfigurationRepository + # Grading Configuration Repository (Story 2.4 - Mode de notation) + App\Administration\Domain\Repository\GradingConfigurationRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository + # GradeExistenceChecker (stub until Notes module exists) App\Administration\Application\Port\GradeExistenceChecker: alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker diff --git a/backend/migrations/Version20260206100000.php b/backend/migrations/Version20260206100000.php new file mode 100644 index 0000000..4337bd4 --- /dev/null +++ b/backend/migrations/Version20260206100000.php @@ -0,0 +1,47 @@ +addSql(<<<'SQL' + CREATE TABLE school_grading_configurations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + school_id UUID NOT NULL, + academic_year_id UUID NOT NULL, + grading_mode VARCHAR(20) NOT NULL DEFAULT 'numeric_20' CHECK (grading_mode IN ('numeric_20', 'numeric_10', 'letters', 'competencies', 'no_grades')), + configured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $this->addSql('CREATE INDEX idx_grading_config_tenant_id ON school_grading_configurations(tenant_id)'); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX idx_grading_config_unique + ON school_grading_configurations (tenant_id, school_id, academic_year_id) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS school_grading_configurations'); + } +} diff --git a/backend/src/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeCommand.php b/backend/src/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeCommand.php new file mode 100644 index 0000000..9f3194a --- /dev/null +++ b/backend/src/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeCommand.php @@ -0,0 +1,16 @@ +tenantId); + $schoolId = SchoolId::fromString($command->schoolId); + $academicYearId = AcademicYearId::fromString($command->academicYearId); + $mode = GradingMode::from($command->gradingMode); + $now = $this->clock->now(); + + $existing = $this->repository->findBySchoolAndYear($tenantId, $schoolId, $academicYearId); + $hasGrades = $this->gradeExistenceChecker->hasGradesForYear($tenantId, $schoolId, $academicYearId); + + if ($existing === null) { + $configuration = SchoolGradingConfiguration::configurer( + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + mode: $mode, + hasExistingGrades: $hasGrades, + configuredAt: $now, + ); + } else { + $existing->changerMode( + nouveauMode: $mode, + hasExistingGrades: $hasGrades, + at: $now, + ); + + $configuration = $existing; + } + + $this->repository->save($configuration); + + return $configuration; + } +} diff --git a/backend/src/Administration/Application/Port/GradeExistenceChecker.php b/backend/src/Administration/Application/Port/GradeExistenceChecker.php index 773eb08..57370d1 100644 --- a/backend/src/Administration/Application/Port/GradeExistenceChecker.php +++ b/backend/src/Administration/Application/Port/GradeExistenceChecker.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Administration\Application\Port; use App\Administration\Domain\Model\SchoolClass\AcademicYearId; +use App\Administration\Domain\Model\SchoolClass\SchoolId; use App\Shared\Domain\Tenant\TenantId; /** @@ -20,4 +21,16 @@ interface GradeExistenceChecker AcademicYearId $academicYearId, int $periodSequence, ): bool; + + /** + * Vérifie si des notes existent pour une année scolaire entière. + * + * Utilisé pour bloquer le changement de mode de notation quand + * des évaluations ont déjà été saisies. + */ + public function hasGradesForYear( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ): bool; } diff --git a/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandler.php b/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandler.php new file mode 100644 index 0000000..ddb9e48 --- /dev/null +++ b/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandler.php @@ -0,0 +1,35 @@ +gradeExistenceChecker->hasGradesForYear( + TenantId::fromString($query->tenantId), + SchoolId::fromString($query->schoolId), + AcademicYearId::fromString($query->academicYearId), + ); + } +} diff --git a/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearQuery.php b/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearQuery.php new file mode 100644 index 0000000..7a4c371 --- /dev/null +++ b/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearQuery.php @@ -0,0 +1,21 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->configurationId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php b/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php new file mode 100644 index 0000000..fac5a35 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php @@ -0,0 +1,17 @@ +mode->scaleMax(); + } + + public function estNumerique(): bool + { + return $this->mode->estNumerique(); + } + + public function calculeMoyenne(): bool + { + return $this->mode->calculeMoyenne(); + } + + public function equals(self $other): bool + { + return $this->mode === $other->mode; + } +} diff --git a/backend/src/Administration/Domain/Model/GradingConfiguration/GradingMode.php b/backend/src/Administration/Domain/Model/GradingConfiguration/GradingMode.php new file mode 100644 index 0000000..d2ddf9f --- /dev/null +++ b/backend/src/Administration/Domain/Model/GradingConfiguration/GradingMode.php @@ -0,0 +1,73 @@ + 20, + self::NUMERIC_10 => 10, + self::LETTERS, self::COMPETENCIES, self::NO_GRADES => null, + }; + } + + /** + * Détermine si le mode utilise une notation numérique. + */ + public function estNumerique(): bool + { + return match ($this) { + self::NUMERIC_20, self::NUMERIC_10 => true, + self::LETTERS, self::COMPETENCIES, self::NO_GRADES => false, + }; + } + + /** + * Détermine si le mode nécessite un calcul de moyenne. + * + * Les compétences et le mode sans notes ne calculent pas de moyenne. + */ + public function calculeMoyenne(): bool + { + return match ($this) { + self::NUMERIC_20, self::NUMERIC_10, self::LETTERS => true, + self::COMPETENCIES, self::NO_GRADES => false, + }; + } + + /** + * Libellé utilisateur en français. + */ + public function label(): string + { + return match ($this) { + self::NUMERIC_20 => 'Notes /20', + self::NUMERIC_10 => 'Notes /10', + self::LETTERS => 'Lettres (A-E)', + self::COMPETENCIES => 'Compétences', + self::NO_GRADES => 'Sans notes', + }; + } +} diff --git a/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfiguration.php b/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfiguration.php new file mode 100644 index 0000000..c1c3f64 --- /dev/null +++ b/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfiguration.php @@ -0,0 +1,145 @@ +updatedAt = $configuredAt; + } + + public static function generateId(): SchoolGradingConfigurationId + { + return SchoolGradingConfigurationId::generate(); + } + + /** + * Configure le mode de notation pour un établissement et une année scolaire. + * + * @param bool $hasExistingGrades Résultat de la vérification par l'Application Layer + */ + public static function configurer( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + GradingMode $mode, + bool $hasExistingGrades, + DateTimeImmutable $configuredAt, + ): self { + // Le mode par défaut (NUMERIC_20) est autorisé même avec des notes existantes : + // c'est le mode implicite avant toute configuration, donc le créer explicitement + // est une opération idempotente. En revanche, changerMode() bloque tout changement + // dès qu'il y a des notes, quel que soit le mode cible. + if ($hasExistingGrades && $mode !== self::DEFAULT_MODE) { + throw new CannotChangeGradingModeWithExistingGradesException(); + } + + $config = new self( + id: SchoolGradingConfigurationId::generate(), + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + gradingConfiguration: new GradingConfiguration($mode), + configuredAt: $configuredAt, + ); + + $config->recordEvent(new ModeNotationConfigure( + configurationId: $config->id, + tenantId: $config->tenantId, + schoolId: $config->schoolId, + academicYearId: $config->academicYearId, + mode: $mode, + occurredOn: $configuredAt, + )); + + return $config; + } + + /** + * Change le mode de notation. + * + * @param bool $hasExistingGrades Résultat de la vérification par l'Application Layer + */ + public function changerMode( + GradingMode $nouveauMode, + bool $hasExistingGrades, + DateTimeImmutable $at, + ): void { + if ($this->gradingConfiguration->mode === $nouveauMode) { + return; + } + + if ($hasExistingGrades) { + throw new CannotChangeGradingModeWithExistingGradesException(); + } + + $this->gradingConfiguration = new GradingConfiguration($nouveauMode); + $this->updatedAt = $at; + + $this->recordEvent(new ModeNotationConfigure( + configurationId: $this->id, + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: $nouveauMode, + occurredOn: $at, + )); + } + + /** + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + SchoolGradingConfigurationId $id, + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + GradingConfiguration $gradingConfiguration, + DateTimeImmutable $configuredAt, + DateTimeImmutable $updatedAt, + ): self { + $config = new self( + id: $id, + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + gradingConfiguration: $gradingConfiguration, + configuredAt: $configuredAt, + ); + + $config->updatedAt = $updatedAt; + + return $config; + } +} diff --git a/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationId.php b/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationId.php new file mode 100644 index 0000000..190e422 --- /dev/null +++ b/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationId.php @@ -0,0 +1,11 @@ + + */ +final readonly class ConfigureGradingModeProcessor implements ProcessorInterface +{ + public function __construct( + private ConfigureGradingModeHandler $handler, + private GradeExistenceChecker $gradeExistenceChecker, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + private SchoolIdResolver $schoolIdResolver, + ) { + } + + /** + * @param GradingModeResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GradingModeResource + { + if (!$this->authorizationChecker->isGranted(GradingModeVoter::CONFIGURE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le mode de notation.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + /** @var string $rawAcademicYearId */ + $rawAcademicYearId = $uriVariables['academicYearId']; + + $academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId); + if ($academicYearId === null) { + throw new NotFoundHttpException('Année scolaire non trouvée.'); + } + + $schoolId = $this->schoolIdResolver->resolveForTenant($tenantId); + + try { + $command = new ConfigureGradingModeCommand( + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + gradingMode: $data->mode ?? '', + ); + + $configuration = ($this->handler)($command); + + foreach ($configuration->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + $resource = GradingModeResource::fromDomain($configuration); + $resource->hasExistingGrades = $this->gradeExistenceChecker->hasGradesForYear( + TenantId::fromString($tenantId), + SchoolId::fromString($schoolId), + AcademicYearId::fromString($academicYearId), + ); + $resource->availableModes = GradingModeResource::allAvailableModes(); + + return $resource; + } catch (CannotChangeGradingModeWithExistingGradesException $e) { + throw new ConflictHttpException($e->getMessage()); + } catch (ValueError $e) { + throw new BadRequestHttpException('Mode de notation invalide : ' . $e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php index f44135f..62f5a58 100644 --- a/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php +++ b/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php @@ -19,6 +19,7 @@ use App\Administration\Infrastructure\Api\Resource\PeriodResource; use App\Administration\Infrastructure\Security\PeriodVoter; use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver; use App\Shared\Infrastructure\Tenant\TenantContext; +use DateMalformedStringException; use Override; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -108,6 +109,8 @@ final readonly class UpdatePeriodProcessor implements ProcessorInterface throw new ConflictHttpException($e->getMessage()); } catch (InvalidPeriodDatesException|PeriodsOverlapException|PeriodsCoverageGapException $e) { throw new BadRequestHttpException($e->getMessage()); + } catch (DateMalformedStringException $e) { + throw new BadRequestHttpException('Format de date invalide : ' . $e->getMessage()); } } } diff --git a/backend/src/Administration/Infrastructure/Api/Provider/GradingModeProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/GradingModeProvider.php new file mode 100644 index 0000000..01cfbba --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/GradingModeProvider.php @@ -0,0 +1,87 @@ + + */ +final readonly class GradingModeProvider implements ProviderInterface +{ + public function __construct( + private GradingConfigurationRepository $repository, + private GradeExistenceChecker $gradeExistenceChecker, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + private SchoolIdResolver $schoolIdResolver, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): GradingModeResource + { + if (!$this->authorizationChecker->isGranted(GradingModeVoter::VIEW)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir la configuration de notation.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantIdStr = (string) $this->tenantContext->getCurrentTenantId(); + $tenantId = TenantId::fromString($tenantIdStr); + /** @var string $rawAcademicYearId */ + $rawAcademicYearId = $uriVariables['academicYearId']; + + $resolvedYearId = $this->academicYearResolver->resolve($rawAcademicYearId); + if ($resolvedYearId === null) { + throw new NotFoundHttpException('Année scolaire non trouvée.'); + } + + $academicYearId = AcademicYearId::fromString($resolvedYearId); + $schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant($tenantIdStr)); + + $config = $this->repository->findBySchoolAndYear( + $tenantId, + $schoolId, + $academicYearId, + ); + + if ($config === null) { + $resource = GradingModeResource::defaultForYear($resolvedYearId); + } else { + $resource = GradingModeResource::fromDomain($config); + } + + $resource->hasExistingGrades = $this->gradeExistenceChecker->hasGradesForYear( + $tenantId, + $schoolId, + $academicYearId, + ); + $resource->availableModes = GradingModeResource::allAvailableModes(); + + return $resource; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/GradingModeResource.php b/backend/src/Administration/Infrastructure/Api/Resource/GradingModeResource.php new file mode 100644 index 0000000..a511f8d --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/GradingModeResource.php @@ -0,0 +1,109 @@ +|null */ + #[ApiProperty(readable: true, writable: false)] + public ?array $availableModes = null; + + public static function fromDomain(SchoolGradingConfiguration $config): self + { + $resource = new self(); + $resource->academicYearId = (string) $config->academicYearId; + $resource->mode = $config->gradingConfiguration->mode->value; + $resource->label = $config->gradingConfiguration->mode->label(); + $resource->scaleMax = $config->gradingConfiguration->scaleMax(); + $resource->isNumeric = $config->gradingConfiguration->estNumerique(); + $resource->calculatesAverage = $config->gradingConfiguration->calculeMoyenne(); + + return $resource; + } + + public static function defaultForYear(string $academicYearId): self + { + $resource = new self(); + $resource->academicYearId = $academicYearId; + $resource->mode = SchoolGradingConfiguration::DEFAULT_MODE->value; + $resource->label = SchoolGradingConfiguration::DEFAULT_MODE->label(); + $resource->scaleMax = SchoolGradingConfiguration::DEFAULT_MODE->scaleMax(); + $resource->isNumeric = SchoolGradingConfiguration::DEFAULT_MODE->estNumerique(); + $resource->calculatesAverage = SchoolGradingConfiguration::DEFAULT_MODE->calculeMoyenne(); + $resource->hasExistingGrades = false; + + return $resource; + } + + /** + * @return array + */ + public static function allAvailableModes(): array + { + return array_map( + static fn (GradingMode $mode) => [ + 'value' => $mode->value, + 'label' => $mode->label(), + ], + GradingMode::cases(), + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php b/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php index 19ecfc4..2e98d57 100644 --- a/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php +++ b/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php @@ -108,6 +108,11 @@ final class CreateTestActivationTokenCommand extends Command $baseUrlOption = $input->getOption('base-url'); $baseUrl = rtrim($baseUrlOption, '/'); + // In interactive mode, replace localhost with tenant subdomain + if ($input->isInteractive() && $usingDefaults) { + $baseUrl = (string) preg_replace('#//localhost([:/])#', "//{$tenantSubdomain}.classeo.local\$1", $baseUrl); + } + // Convert short role name to full Symfony role format $roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput; diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineGradingConfigurationRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineGradingConfigurationRepository.php new file mode 100644 index 0000000..8f073be --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineGradingConfigurationRepository.php @@ -0,0 +1,121 @@ +connection->executeStatement( + 'INSERT INTO school_grading_configurations (id, tenant_id, school_id, academic_year_id, grading_mode, configured_at, updated_at) + VALUES (:id, :tenant_id, :school_id, :academic_year_id, :grading_mode, :configured_at, :updated_at) + ON CONFLICT (tenant_id, school_id, academic_year_id) DO UPDATE SET + grading_mode = EXCLUDED.grading_mode, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $configuration->id, + 'tenant_id' => (string) $configuration->tenantId, + 'school_id' => (string) $configuration->schoolId, + 'academic_year_id' => (string) $configuration->academicYearId, + 'grading_mode' => $configuration->gradingConfiguration->mode->value, + 'configured_at' => $configuration->configuredAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $configuration->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function findBySchoolAndYear( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ): ?SchoolGradingConfiguration { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM school_grading_configurations + WHERE tenant_id = :tenant_id + AND school_id = :school_id + AND academic_year_id = :academic_year_id', + [ + 'tenant_id' => (string) $tenantId, + 'school_id' => (string) $schoolId, + 'academic_year_id' => (string) $academicYearId, + ], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function get(SchoolGradingConfigurationId $id, TenantId $tenantId): SchoolGradingConfiguration + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM school_grading_configurations WHERE id = :id AND tenant_id = :tenant_id', + [ + 'id' => (string) $id, + 'tenant_id' => (string) $tenantId, + ], + ); + + if ($row === false) { + throw GradingConfigurationNotFoundException::withId($id); + } + + return $this->hydrate($row); + } + + /** + * @param array $row + */ + private function hydrate(array $row): SchoolGradingConfiguration + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $schoolId */ + $schoolId = $row['school_id']; + /** @var string $academicYearId */ + $academicYearId = $row['academic_year_id']; + /** @var string $gradingMode */ + $gradingMode = $row['grading_mode']; + /** @var string $configuredAt */ + $configuredAt = $row['configured_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return SchoolGradingConfiguration::reconstitute( + id: SchoolGradingConfigurationId::fromString($id), + tenantId: TenantId::fromString($tenantId), + schoolId: SchoolId::fromString($schoolId), + academicYearId: AcademicYearId::fromString($academicYearId), + gradingConfiguration: new GradingConfiguration(GradingMode::from($gradingMode)), + configuredAt: new DateTimeImmutable($configuredAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryGradingConfigurationRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryGradingConfigurationRepository.php new file mode 100644 index 0000000..21c0fbb --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryGradingConfigurationRepository.php @@ -0,0 +1,59 @@ + Indexed by ID */ + private array $byId = []; + + /** @var array Indexed by tenant:school:year */ + private array $byTenantSchoolYear = []; + + #[Override] + public function save(SchoolGradingConfiguration $configuration): void + { + $idStr = (string) $configuration->id; + $key = $this->compositeKey($configuration->tenantId, $configuration->schoolId, $configuration->academicYearId); + + $this->byId[$idStr] = $configuration; + $this->byTenantSchoolYear[$key] = $configuration; + } + + #[Override] + public function findBySchoolAndYear( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ): ?SchoolGradingConfiguration { + return $this->byTenantSchoolYear[$this->compositeKey($tenantId, $schoolId, $academicYearId)] ?? null; + } + + #[Override] + public function get(SchoolGradingConfigurationId $id, TenantId $tenantId): SchoolGradingConfiguration + { + $config = $this->byId[(string) $id] ?? throw GradingConfigurationNotFoundException::withId($id); + + if (!$config->tenantId->equals($tenantId)) { + throw GradingConfigurationNotFoundException::withId($id); + } + + return $config; + } + + private function compositeKey(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): string + { + return $tenantId . ':' . $schoolId . ':' . $academicYearId; + } +} diff --git a/backend/src/Administration/Infrastructure/Security/GradingModeVoter.php b/backend/src/Administration/Infrastructure/Security/GradingModeVoter.php new file mode 100644 index 0000000..1a781fe --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/GradingModeVoter.php @@ -0,0 +1,100 @@ + + */ +final class GradingModeVoter extends Voter +{ + public const string VIEW = 'GRADING_MODE_VIEW'; + public const string CONFIGURE = 'GRADING_MODE_CONFIGURE'; + + private const array SUPPORTED_ATTRIBUTES = [ + self::VIEW, + self::CONFIGURE, + ]; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true); + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof UserInterface) { + return false; + } + + $roles = $user->getRoles(); + + return match ($attribute) { + self::VIEW => $this->canView($roles), + self::CONFIGURE => $this->canConfigure($roles), + default => false, + }; + } + + /** + * @param string[] $roles + */ + private function canView(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + Role::PROF->value, + Role::VIE_SCOLAIRE->value, + Role::SECRETARIAT->value, + ]); + } + + /** + * @param string[] $roles + */ + private function canConfigure(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + ]); + } + + /** + * @param string[] $userRoles + * @param string[] $allowedRoles + */ + private function hasAnyRole(array $userRoles, array $allowedRoles): bool + { + foreach ($userRoles as $role) { + if (in_array($role, $allowedRoles, true)) { + return true; + } + } + + return false; + } +} diff --git a/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php b/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php index c32563d..997af35 100644 --- a/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php +++ b/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php @@ -6,6 +6,7 @@ namespace App\Administration\Infrastructure\Service; use App\Administration\Application\Port\GradeExistenceChecker; use App\Administration\Domain\Model\SchoolClass\AcademicYearId; +use App\Administration\Domain\Model\SchoolClass\SchoolId; use App\Shared\Domain\Tenant\TenantId; use Override; @@ -24,4 +25,13 @@ final class NoOpGradeExistenceChecker implements GradeExistenceChecker ): bool { return false; } + + #[Override] + public function hasGradesForYear( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ): bool { + return false; + } } diff --git a/backend/src/Scolarite/Domain/Model/GradingMode.php b/backend/src/Scolarite/Domain/Model/GradingMode.php new file mode 100644 index 0000000..e8264b0 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/GradingMode.php @@ -0,0 +1,20 @@ + 100, + 'in_progress' => 50, + 'not_acquired' => 0, + ]; + + private function __construct( + public float $notesWeight, + public float $absencesWeight, + public float $devoirsWeight, + private bool $usesCompetencyMapping, + ) { + } + + public static function forMode(GradingMode $mode): self + { + return match ($mode) { + GradingMode::NO_GRADES => new self( + notesWeight: 0.0, + absencesWeight: 0.5, + devoirsWeight: 0.5, + usesCompetencyMapping: false, + ), + GradingMode::COMPETENCIES => new self( + notesWeight: 0.4, + absencesWeight: 0.3, + devoirsWeight: 0.3, + usesCompetencyMapping: true, + ), + default => new self( + notesWeight: 0.4, + absencesWeight: 0.3, + devoirsWeight: 0.3, + usesCompetencyMapping: false, + ), + }; + } + + /** + * Convertit un niveau de compétence en score numérique (0-100). + * + * @return int|null Score numérique, ou null si le mode n'utilise pas le mapping compétences + */ + public function competencyToScore(string $competencyLevel): ?int + { + if (!$this->usesCompetencyMapping) { + return null; + } + + return self::COMPETENCY_MAPPING[$competencyLevel] + ?? throw new InvalidArgumentException("Niveau de compétence inconnu : '{$competencyLevel}'"); + } +} diff --git a/backend/tests/Functional/Administration/Api/GradingModeEndpointsTest.php b/backend/tests/Functional/Administration/Api/GradingModeEndpointsTest.php new file mode 100644 index 0000000..3101af0 --- /dev/null +++ b/backend/tests/Functional/Administration/Api/GradingModeEndpointsTest.php @@ -0,0 +1,151 @@ +request('GET', '/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function configureGradingModeReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('PUT', '/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['mode' => 'numeric_20'], + ]); + + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // Security - Without authentication (with tenant) + // ========================================================================= + + #[Test] + public function getGradingModeReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function configureGradingModeReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('PUT', 'http://ecole-alpha.classeo.local/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['mode' => 'numeric_20'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + // ========================================================================= + // Validation - Invalid mode + // ========================================================================= + + #[Test] + public function configureGradingModeRejectsInvalidModeWithoutTenant(): void + { + $client = static::createClient(); + + $client->request('PUT', '/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['mode' => 'invalid_mode'], + ]); + + // Without tenant, returns 404 before validation kicks in + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // Special identifiers - 'current', 'next', 'previous' + // ========================================================================= + + #[Test] + public function getGradingModeAcceptsCurrentIdentifier(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/grading-mode', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + // 401 (no auth) not 404 (invalid id) — proves 'current' is accepted + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getGradingModeAcceptsNextIdentifier(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/next/grading-mode', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getGradingModeAcceptsPreviousIdentifier(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/previous/grading-mode', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeHandlerTest.php new file mode 100644 index 0000000..4b96659 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeHandlerTest.php @@ -0,0 +1,226 @@ +repository = new InMemoryGradingConfigurationRepository(); + $this->clock = new class implements Clock { + #[Override] + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-01 10:00:00'); + } + }; + } + + #[Test] + public function itCreatesNewConfigurationWhenNoneExists(): void + { + $handler = $this->createHandler(hasGrades: false); + + $result = $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + self::assertSame(GradingMode::NUMERIC_20, $result->gradingConfiguration->mode); + } + + #[Test] + public function itPersistsConfigurationInRepository(): void + { + $handler = $this->createHandler(hasGrades: false); + + $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'competencies', + )); + + $found = $this->repository->findBySchoolAndYear( + TenantId::fromString(self::TENANT_ID), + SchoolId::fromString(self::SCHOOL_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + self::assertNotNull($found); + self::assertSame(GradingMode::COMPETENCIES, $found->gradingConfiguration->mode); + } + + #[Test] + public function itChangesExistingModeWhenNoGradesExist(): void + { + $handler = $this->createHandler(hasGrades: false); + + $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + $result = $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'letters', + )); + + self::assertSame(GradingMode::LETTERS, $result->gradingConfiguration->mode); + } + + #[Test] + public function itBlocksChangeWhenGradesExist(): void + { + $handlerNoGrades = $this->createHandler(hasGrades: false); + $handlerNoGrades(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + $handlerWithGrades = $this->createHandler(hasGrades: true); + + $this->expectException(CannotChangeGradingModeWithExistingGradesException::class); + + $handlerWithGrades(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'competencies', + )); + } + + #[Test] + public function itAllowsSameModeEvenWithGradesExisting(): void + { + $handlerNoGrades = $this->createHandler(hasGrades: false); + $handlerNoGrades(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + $handlerWithGrades = $this->createHandler(hasGrades: true); + $result = $handlerWithGrades(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + self::assertSame(GradingMode::NUMERIC_20, $result->gradingConfiguration->mode); + } + + #[Test] + public function itBlocksInitialNonDefaultModeWhenGradesExist(): void + { + $handler = $this->createHandler(hasGrades: true); + + $this->expectException(CannotChangeGradingModeWithExistingGradesException::class); + + $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'competencies', + )); + } + + #[Test] + public function itIsolatesConfigurationByTenant(): void + { + $handler = $this->createHandler(hasGrades: false); + + $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + $otherTenantId = '550e8400-e29b-41d4-a716-446655440099'; + $handler(new ConfigureGradingModeCommand( + tenantId: $otherTenantId, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'competencies', + )); + + $config1 = $this->repository->findBySchoolAndYear( + TenantId::fromString(self::TENANT_ID), + SchoolId::fromString(self::SCHOOL_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + $config2 = $this->repository->findBySchoolAndYear( + TenantId::fromString($otherTenantId), + SchoolId::fromString(self::SCHOOL_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + self::assertNotNull($config1); + self::assertNotNull($config2); + self::assertSame(GradingMode::NUMERIC_20, $config1->gradingConfiguration->mode); + self::assertSame(GradingMode::COMPETENCIES, $config2->gradingConfiguration->mode); + } + + private function createHandler(bool $hasGrades): ConfigureGradingModeHandler + { + $gradeChecker = new class($hasGrades) implements GradeExistenceChecker { + public function __construct(private bool $hasGrades) + { + } + + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return $this->hasGrades; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return $this->hasGrades; + } + }; + + return new ConfigureGradingModeHandler( + $this->repository, + $gradeChecker, + $this->clock, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php index 02c9532..a66a17d 100644 --- a/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php @@ -14,6 +14,7 @@ use App\Administration\Domain\Exception\PeriodsOverlapException; use App\Administration\Domain\Model\AcademicYear\DefaultPeriods; use App\Administration\Domain\Model\AcademicYear\PeriodType; use App\Administration\Domain\Model\SchoolClass\AcademicYearId; +use App\Administration\Domain\Model\SchoolClass\SchoolId; use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository; use App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker; use App\Shared\Domain\Clock; @@ -54,7 +55,7 @@ final class UpdatePeriodHandlerTest extends TestCase } #[Test] - public function itUpdatesPeriodDates(): void + public function itRejectsOverlappingPeriodDates(): void { $this->seedTrimesterConfig(); @@ -124,13 +125,7 @@ final class UpdatePeriodHandlerTest extends TestCase { $this->seedTrimesterConfig(); - $gradeChecker = new class implements GradeExistenceChecker { - #[Override] - public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool - { - return true; - } - }; + $gradeChecker = $this->createGradeCheckerWithGrades(); $handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus); @@ -150,13 +145,7 @@ final class UpdatePeriodHandlerTest extends TestCase { $this->seedTrimesterConfig(); - $gradeChecker = new class implements GradeExistenceChecker { - #[Override] - public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool - { - return true; - } - }; + $gradeChecker = $this->createGradeCheckerWithGrades(); $handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus); @@ -172,6 +161,23 @@ final class UpdatePeriodHandlerTest extends TestCase self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d')); } + private function createGradeCheckerWithGrades(): GradeExistenceChecker + { + return new class implements GradeExistenceChecker { + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return true; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return true; + } + }; + } + private function seedTrimesterConfig(): void { $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025); diff --git a/backend/tests/Unit/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandlerTest.php new file mode 100644 index 0000000..614583c --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandlerTest.php @@ -0,0 +1,71 @@ +createChecker(hasGrades: false)); + + $result = $handler(new HasGradesForYearQuery( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + self::assertFalse($result); + } + + #[Test] + public function itReturnsTrueWhenGradesExist(): void + { + $handler = new HasGradesForYearHandler($this->createChecker(hasGrades: true)); + + $result = $handler(new HasGradesForYearQuery( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + self::assertTrue($result); + } + + private function createChecker(bool $hasGrades): GradeExistenceChecker + { + return new class($hasGrades) implements GradeExistenceChecker { + public function __construct(private bool $hasGrades) + { + } + + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return $this->hasGrades; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return $this->hasGrades; + } + }; + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingConfigurationTest.php b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingConfigurationTest.php new file mode 100644 index 0000000..f178024 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingConfigurationTest.php @@ -0,0 +1,79 @@ +mode); + } + + #[Test] + public function itCreatesCompetenciesConfiguration(): void + { + $config = new GradingConfiguration(GradingMode::COMPETENCIES); + + self::assertSame(GradingMode::COMPETENCIES, $config->mode); + } + + #[Test] + public function itCreatesNoGradesConfiguration(): void + { + $config = new GradingConfiguration(GradingMode::NO_GRADES); + + self::assertSame(GradingMode::NO_GRADES, $config->mode); + } + + #[Test] + public function equalConfigurationsAreEqual(): void + { + $config1 = new GradingConfiguration(GradingMode::NUMERIC_20); + $config2 = new GradingConfiguration(GradingMode::NUMERIC_20); + + self::assertTrue($config1->equals($config2)); + } + + #[Test] + public function differentConfigurationsAreNotEqual(): void + { + $config1 = new GradingConfiguration(GradingMode::NUMERIC_20); + $config2 = new GradingConfiguration(GradingMode::COMPETENCIES); + + self::assertFalse($config1->equals($config2)); + } + + #[Test] + public function itDelegatesToModeForScaleMax(): void + { + $config = new GradingConfiguration(GradingMode::NUMERIC_20); + self::assertSame(20, $config->scaleMax()); + + $config = new GradingConfiguration(GradingMode::COMPETENCIES); + self::assertNull($config->scaleMax()); + } + + #[Test] + public function itDelegatesToModeForEstNumerique(): void + { + self::assertTrue((new GradingConfiguration(GradingMode::NUMERIC_20))->estNumerique()); + self::assertFalse((new GradingConfiguration(GradingMode::NO_GRADES))->estNumerique()); + } + + #[Test] + public function itDelegatesToModeForCalculeMoyenne(): void + { + self::assertTrue((new GradingConfiguration(GradingMode::NUMERIC_20))->calculeMoyenne()); + self::assertFalse((new GradingConfiguration(GradingMode::COMPETENCIES))->calculeMoyenne()); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingModeTest.php b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingModeTest.php new file mode 100644 index 0000000..d1a4cd4 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingModeTest.php @@ -0,0 +1,97 @@ +value); + self::assertSame('numeric_10', GradingMode::NUMERIC_10->value); + self::assertSame('letters', GradingMode::LETTERS->value); + self::assertSame('competencies', GradingMode::COMPETENCIES->value); + self::assertSame('no_grades', GradingMode::NO_GRADES->value); + } + + #[Test] + public function numeric20HasCorrectScale(): void + { + self::assertSame(20, GradingMode::NUMERIC_20->scaleMax()); + } + + #[Test] + public function numeric10HasCorrectScale(): void + { + self::assertSame(10, GradingMode::NUMERIC_10->scaleMax()); + } + + #[Test] + public function nonNumericModesHaveNullScale(): void + { + self::assertNull(GradingMode::LETTERS->scaleMax()); + self::assertNull(GradingMode::COMPETENCIES->scaleMax()); + self::assertNull(GradingMode::NO_GRADES->scaleMax()); + } + + #[Test] + public function numericModesUseNumericGrading(): void + { + self::assertTrue(GradingMode::NUMERIC_20->estNumerique()); + self::assertTrue(GradingMode::NUMERIC_10->estNumerique()); + } + + #[Test] + public function nonNumericModesDoNotUseNumericGrading(): void + { + self::assertFalse(GradingMode::LETTERS->estNumerique()); + self::assertFalse(GradingMode::COMPETENCIES->estNumerique()); + self::assertFalse(GradingMode::NO_GRADES->estNumerique()); + } + + #[Test] + public function modesRequiringAverageCalculation(): void + { + self::assertTrue(GradingMode::NUMERIC_20->calculeMoyenne()); + self::assertTrue(GradingMode::NUMERIC_10->calculeMoyenne()); + self::assertTrue(GradingMode::LETTERS->calculeMoyenne()); + self::assertFalse(GradingMode::COMPETENCIES->calculeMoyenne()); + self::assertFalse(GradingMode::NO_GRADES->calculeMoyenne()); + } + + #[Test] + public function labelsAreInFrench(): void + { + self::assertSame('Notes /20', GradingMode::NUMERIC_20->label()); + self::assertSame('Notes /10', GradingMode::NUMERIC_10->label()); + self::assertSame('Lettres (A-E)', GradingMode::LETTERS->label()); + self::assertSame('Compétences', GradingMode::COMPETENCIES->label()); + self::assertSame('Sans notes', GradingMode::NO_GRADES->label()); + } + + #[Test] + public function administrationAndScolariteEnumsAreInSync(): void + { + $adminValues = array_map( + static fn (GradingMode $m) => $m->value, + GradingMode::cases(), + ); + $scolariteValues = array_map( + static fn (ScolariteGradingMode $m) => $m->value, + ScolariteGradingMode::cases(), + ); + + self::assertSame( + $adminValues, + $scolariteValues, + 'Les enums GradingMode des contextes Administration et Scolarité doivent rester synchronisées.', + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationTest.php b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationTest.php new file mode 100644 index 0000000..3ee1c25 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationTest.php @@ -0,0 +1,186 @@ +tenantId = TenantId::generate(); + $this->schoolId = SchoolId::generate(); + $this->academicYearId = AcademicYearId::generate(); + } + + #[Test] + public function itConfiguresGradingMode(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + + self::assertSame(GradingMode::NUMERIC_20, $config->gradingConfiguration->mode); + self::assertSame($this->tenantId, $config->tenantId); + self::assertSame($this->schoolId, $config->schoolId); + self::assertSame($this->academicYearId, $config->academicYearId); + } + + #[Test] + public function itEmitsEventOnCreation(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::COMPETENCIES, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + + $events = $config->pullDomainEvents(); + self::assertCount(1, $events); + } + + #[Test] + public function itChangesGradingModeWhenNoGradesExist(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + $config->pullDomainEvents(); + + $config->changerMode( + nouveauMode: GradingMode::COMPETENCIES, + hasExistingGrades: false, + at: new DateTimeImmutable('2026-02-02'), + ); + + self::assertSame(GradingMode::COMPETENCIES, $config->gradingConfiguration->mode); + $events = $config->pullDomainEvents(); + self::assertCount(1, $events); + } + + #[Test] + public function itBlocksChangeWhenGradesExist(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + + $this->expectException(CannotChangeGradingModeWithExistingGradesException::class); + + $config->changerMode( + nouveauMode: GradingMode::COMPETENCIES, + hasExistingGrades: true, + at: new DateTimeImmutable('2026-02-02'), + ); + } + + #[Test] + public function itDoesNotEmitEventWhenModeIsUnchanged(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + $config->pullDomainEvents(); + + $config->changerMode( + nouveauMode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + at: new DateTimeImmutable('2026-02-02'), + ); + + self::assertEmpty($config->pullDomainEvents()); + } + + #[Test] + public function itReconstitutesFromStorage(): void + { + $id = SchoolGradingConfiguration::generateId(); + + $config = SchoolGradingConfiguration::reconstitute( + id: $id, + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + gradingConfiguration: new GradingConfiguration(GradingMode::LETTERS), + configuredAt: new DateTimeImmutable('2026-02-01'), + updatedAt: new DateTimeImmutable('2026-02-02'), + ); + + self::assertSame(GradingMode::LETTERS, $config->gradingConfiguration->mode); + self::assertEmpty($config->pullDomainEvents()); + } + + #[Test] + public function itBlocksInitialNonDefaultModeWhenGradesExist(): void + { + $this->expectException(CannotChangeGradingModeWithExistingGradesException::class); + + SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::COMPETENCIES, + hasExistingGrades: true, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + } + + #[Test] + public function itAllowsInitialDefaultModeWhenGradesExist(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: true, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + + self::assertSame(GradingMode::NUMERIC_20, $config->gradingConfiguration->mode); + } + + #[Test] + public function defaultModeIsNumeric20(): void + { + self::assertSame(GradingMode::NUMERIC_20, SchoolGradingConfiguration::DEFAULT_MODE); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigureGradingModeProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigureGradingModeProcessorTest.php new file mode 100644 index 0000000..64c108f --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigureGradingModeProcessorTest.php @@ -0,0 +1,254 @@ +repository = new InMemoryGradingConfigurationRepository(); + $this->tenantContext = new TenantContext(); + $this->clock = new class implements Clock { + #[Override] + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2025-10-15 10:00:00'); + } + }; + } + + #[Test] + public function itRejectsUnauthorizedAccess(): void + { + $processor = $this->createProcessor(granted: false); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsRequestWithoutTenant(): void + { + $processor = $this->createProcessor(granted: true); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + + $this->expectException(UnauthorizedHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsInvalidAcademicYearId(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + + $this->expectException(NotFoundHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'invalid']); + } + + #[Test] + public function itConfiguresGradingMode(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'competencies'; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'current']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('competencies', $result->mode); + self::assertSame('Compétences', $result->label); + self::assertFalse($result->hasExistingGrades); + } + + #[Test] + public function itSetsAvailableModesOnResult(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'current']); + + self::assertNotNull($result->availableModes); + self::assertCount(5, $result->availableModes); + } + + #[Test] + public function itThrowsConflictWhenGradesExistAndModeChanges(): void + { + // First configure with numeric_20 (no grades) + $processor = $this->createProcessor(granted: true, hasGrades: false); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + $processor->process($data, new Put(), ['academicYearId' => 'current']); + + // Now try to change mode with grades existing + $processorWithGrades = $this->createProcessor(granted: true, hasGrades: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'competencies'; + + $this->expectException(ConflictHttpException::class); + $processorWithGrades->process($data, new Put(), ['academicYearId' => 'current']); + } + + #[Test] + public function itChangesExistingModeWhenNoGradesExist(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + $processor->process($data, new Put(), ['academicYearId' => 'current']); + + $data = new GradingModeResource(); + $data->mode = 'letters'; + $result = $processor->process($data, new Put(), ['academicYearId' => 'current']); + + self::assertSame('letters', $result->mode); + } + + #[Test] + public function itResolvesNextAcademicYear(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'no_grades'; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'next']); + + self::assertSame('no_grades', $result->mode); + } + + #[Test] + public function itRejectsInvalidGradingModeWithBadRequest(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'invalid_mode'; + + $this->expectException(BadRequestHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'current']); + } + + private function setTenant(): void + { + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + subdomain: 'test', + databaseUrl: 'sqlite:///:memory:', + )); + } + + private function createProcessor(bool $granted, bool $hasGrades = false): ConfigureGradingModeProcessor + { + $authChecker = new class($granted) implements AuthorizationCheckerInterface { + public function __construct(private readonly bool $granted) + { + } + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->granted; + } + }; + + $eventBus = new class implements MessageBusInterface { + public function dispatch(object $message, array $stamps = []): Envelope + { + return new Envelope($message); + } + }; + + $gradeChecker = new class($hasGrades) implements GradeExistenceChecker { + public function __construct(private readonly bool $hasGrades) + { + } + + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return $this->hasGrades; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return $this->hasGrades; + } + }; + + $handler = new ConfigureGradingModeHandler($this->repository, $gradeChecker, $this->clock); + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $schoolIdResolver = new SchoolIdResolver(); + + return new ConfigureGradingModeProcessor( + $handler, + $gradeChecker, + $this->tenantContext, + $eventBus, + $authChecker, + $resolver, + $schoolIdResolver, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Provider/GradingModeProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/GradingModeProviderTest.php new file mode 100644 index 0000000..0be22ef --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/GradingModeProviderTest.php @@ -0,0 +1,226 @@ +repository = new InMemoryGradingConfigurationRepository(); + $this->tenantContext = new TenantContext(); + $this->clock = new class implements Clock { + #[Override] + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2025-10-15 10:00:00'); + } + }; + } + + #[Test] + public function itRejectsUnauthorizedAccess(): void + { + $provider = $this->createProvider(granted: false); + $this->setTenant(); + + $this->expectException(AccessDeniedHttpException::class); + $provider->provide(new Get(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsRequestWithoutTenant(): void + { + $provider = $this->createProvider(granted: true); + + $this->expectException(UnauthorizedHttpException::class); + $provider->provide(new Get(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsInvalidAcademicYearId(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $this->expectException(NotFoundHttpException::class); + $provider->provide(new Get(), ['academicYearId' => 'invalid']); + } + + #[Test] + public function itReturnsDefaultModeWhenNoConfigurationExists(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('numeric_20', $result->mode); + self::assertSame('Notes /20', $result->label); + self::assertFalse($result->hasExistingGrades); + } + + #[Test] + public function itReturnsExistingConfigurationWhenPresent(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $academicYearId = $resolver->resolve('current'); + self::assertNotNull($academicYearId); + + $schoolIdResolver = new SchoolIdResolver(); + $schoolId = $schoolIdResolver->resolveForTenant(self::TENANT_UUID); + + $config = SchoolGradingConfiguration::configurer( + tenantId: TenantId::fromString(self::TENANT_UUID), + schoolId: SchoolId::fromString($schoolId), + academicYearId: AcademicYearId::fromString($academicYearId), + mode: GradingMode::COMPETENCIES, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable(), + ); + $this->repository->save($config); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('competencies', $result->mode); + self::assertSame('Compétences', $result->label); + } + + #[Test] + public function itSetsAvailableModesOnResult(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertNotNull($result->availableModes); + self::assertCount(5, $result->availableModes); + } + + #[Test] + public function itSetsHasExistingGradesFlag(): void + { + $provider = $this->createProvider(granted: true, hasGrades: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertTrue($result->hasExistingGrades); + } + + #[Test] + public function itResolvesNextAcademicYear(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'next']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('numeric_20', $result->mode); + } + + #[Test] + public function itResolvesPreviousAcademicYear(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'previous']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('numeric_20', $result->mode); + } + + private function setTenant(): void + { + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + subdomain: 'test', + databaseUrl: 'sqlite:///:memory:', + )); + } + + private function createProvider(bool $granted, bool $hasGrades = false): GradingModeProvider + { + $authChecker = new class($granted) implements AuthorizationCheckerInterface { + public function __construct(private readonly bool $granted) + { + } + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->granted; + } + }; + + $gradeChecker = new class($hasGrades) implements GradeExistenceChecker { + public function __construct(private readonly bool $hasGrades) + { + } + + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return $this->hasGrades; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return $this->hasGrades; + } + }; + + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $schoolIdResolver = new SchoolIdResolver(); + + return new GradingModeProvider( + $this->repository, + $gradeChecker, + $this->tenantContext, + $authChecker, + $resolver, + $schoolIdResolver, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Resource/GradingModeResourceTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Resource/GradingModeResourceTest.php new file mode 100644 index 0000000..aedd994 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Resource/GradingModeResourceTest.php @@ -0,0 +1,124 @@ +mode); + self::assertSame('Notes /20', $resource->label); + self::assertSame(20, $resource->scaleMax); + self::assertTrue($resource->isNumeric); + self::assertTrue($resource->calculatesAverage); + self::assertSame((string) $config->academicYearId, $resource->academicYearId); + } + + #[Test] + public function fromDomainMapsCompetenciesMode(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: TenantId::generate(), + schoolId: SchoolId::generate(), + academicYearId: AcademicYearId::generate(), + mode: GradingMode::COMPETENCIES, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable(), + ); + + $resource = GradingModeResource::fromDomain($config); + + self::assertSame('competencies', $resource->mode); + self::assertSame('Compétences', $resource->label); + self::assertNull($resource->scaleMax); + self::assertFalse($resource->isNumeric); + self::assertFalse($resource->calculatesAverage); + } + + #[Test] + public function fromDomainMapsNoGradesMode(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: TenantId::generate(), + schoolId: SchoolId::generate(), + academicYearId: AcademicYearId::generate(), + mode: GradingMode::NO_GRADES, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable(), + ); + + $resource = GradingModeResource::fromDomain($config); + + self::assertSame('no_grades', $resource->mode); + self::assertSame('Sans notes', $resource->label); + self::assertNull($resource->scaleMax); + self::assertFalse($resource->isNumeric); + self::assertFalse($resource->calculatesAverage); + } + + #[Test] + public function defaultForYearUsesNumeric20(): void + { + $yearId = 'some-year-id'; + $resource = GradingModeResource::defaultForYear($yearId); + + self::assertSame($yearId, $resource->academicYearId); + self::assertSame('numeric_20', $resource->mode); + self::assertSame('Notes /20', $resource->label); + self::assertSame(20, $resource->scaleMax); + self::assertTrue($resource->isNumeric); + self::assertTrue($resource->calculatesAverage); + self::assertFalse($resource->hasExistingGrades); + } + + #[Test] + public function allAvailableModesReturnsAllFiveModes(): void + { + $modes = GradingModeResource::allAvailableModes(); + + self::assertCount(5, $modes); + + $values = array_column($modes, 'value'); + self::assertContains('numeric_20', $values); + self::assertContains('numeric_10', $values); + self::assertContains('letters', $values); + self::assertContains('competencies', $values); + self::assertContains('no_grades', $values); + } + + #[Test] + public function allAvailableModesContainsLabels(): void + { + $modes = GradingModeResource::allAvailableModes(); + + foreach ($modes as $mode) { + self::assertArrayHasKey('value', $mode); + self::assertArrayHasKey('label', $mode); + self::assertNotEmpty($mode['label']); + } + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/GradingModeVoterTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/GradingModeVoterTest.php new file mode 100644 index 0000000..cb49543 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Security/GradingModeVoterTest.php @@ -0,0 +1,141 @@ +voter = new GradingModeVoter(); + } + + #[Test] + public function itAbstainsForUnrelatedAttributes(): void + { + $user = $this->createMock(UserInterface::class); + $user->method('getRoles')->willReturn(['ROLE_ADMIN']); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + $result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']); + + self::assertSame(GradingModeVoter::ACCESS_ABSTAIN, $result); + } + + #[Test] + public function itDeniesAccessToUnauthenticatedUsers(): void + { + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(null); + + $result = $this->voter->vote($token, null, [GradingModeVoter::VIEW]); + + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsViewToSuperAdmin(): void + { + $result = $this->voteWithRole('ROLE_SUPER_ADMIN', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToProf(): void + { + $result = $this->voteWithRole('ROLE_PROF', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToVieScolaire(): void + { + $result = $this->voteWithRole('ROLE_VIE_SCOLAIRE', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToSecretariat(): void + { + $result = $this->voteWithRole('ROLE_SECRETARIAT', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesViewToParent(): void + { + $result = $this->voteWithRole('ROLE_PARENT', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesViewToEleve(): void + { + $result = $this->voteWithRole('ROLE_ELEVE', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsConfigureToSuperAdmin(): void + { + $result = $this->voteWithRole('ROLE_SUPER_ADMIN', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsConfigureToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesConfigureToProf(): void + { + $result = $this->voteWithRole('ROLE_PROF', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesConfigureToVieScolaire(): void + { + $result = $this->voteWithRole('ROLE_VIE_SCOLAIRE', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesConfigureToSecretariat(): void + { + $result = $this->voteWithRole('ROLE_SECRETARIAT', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + private function voteWithRole(string $role, string $attribute): int + { + $user = $this->createMock(UserInterface::class); + $user->method('getRoles')->willReturn([$role]); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + return $this->voter->vote($token, null, [$attribute]); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Service/SerenityScoreWeightsTest.php b/backend/tests/Unit/Scolarite/Domain/Service/SerenityScoreWeightsTest.php new file mode 100644 index 0000000..544b579 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Service/SerenityScoreWeightsTest.php @@ -0,0 +1,93 @@ +notesWeight); + self::assertSame(0.3, $weights->absencesWeight); + self::assertSame(0.3, $weights->devoirsWeight); + } + + #[Test] + public function competencyModeUsesAdaptedWeights(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::COMPETENCIES); + + self::assertSame(0.4, $weights->notesWeight); + self::assertSame(0.3, $weights->absencesWeight); + self::assertSame(0.3, $weights->devoirsWeight); + } + + #[Test] + public function noGradesModeExcludesNotesComponent(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::NO_GRADES); + + self::assertSame(0.0, $weights->notesWeight); + self::assertSame(0.5, $weights->absencesWeight); + self::assertSame(0.5, $weights->devoirsWeight); + } + + #[Test] + public function lettersModeUsesStandardWeights(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::LETTERS); + + self::assertSame(0.4, $weights->notesWeight); + self::assertSame(0.3, $weights->absencesWeight); + self::assertSame(0.3, $weights->devoirsWeight); + } + + #[Test] + public function weightsAlwaysSumToOne(): void + { + foreach (GradingMode::cases() as $mode) { + $weights = SerenityScoreWeights::forMode($mode); + $sum = $weights->notesWeight + $weights->absencesWeight + $weights->devoirsWeight; + self::assertEqualsWithDelta(1.0, $sum, 0.001, "Weights for {$mode->value} must sum to 1.0"); + } + } + + #[Test] + public function competencyMappingConvertsToPercentage(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::COMPETENCIES); + + self::assertSame(100, $weights->competencyToScore('acquired')); + self::assertSame(50, $weights->competencyToScore('in_progress')); + self::assertSame(0, $weights->competencyToScore('not_acquired')); + } + + #[Test] + public function nonCompetencyModesReturnNullForMapping(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::NUMERIC_20); + + self::assertNull($weights->competencyToScore('acquired')); + } + + #[Test] + public function competencyMappingThrowsOnUnknownLevel(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::COMPETENCIES); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Niveau de compétence inconnu : 'unknown_level'"); + + $weights->competencyToScore('unknown_level'); + } +} diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts index b61b5e0..895e272 100644 --- a/frontend/e2e/classes.spec.ts +++ b/frontend/e2e/classes.spec.ts @@ -25,23 +25,15 @@ test.describe('Classes Management (Story 2.1)', () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); - try { - // Create admin user - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Classes E2E test admin user created'); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); - // Clean up all classes for this tenant to ensure Empty State test works - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Classes cleaned up for E2E tests'); - } catch (error) { - console.error('Setup error:', error); - } + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, + { encoding: 'utf-8' } + ); }); // Helper to login as admin diff --git a/frontend/e2e/pedagogy.spec.ts b/frontend/e2e/pedagogy.spec.ts new file mode 100644 index 0000000..3e8105f --- /dev/null +++ b/frontend/e2e/pedagogy.spec.ts @@ -0,0 +1,284 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-pedagogy-admin@example.com'; +const ADMIN_PASSWORD = 'PedagogyTest123'; + +test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => { + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Reset grading mode to default (numeric_20) to ensure clean state + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php -r " + require 'vendor/autoload.php'; + \\$kernel = new App\\Kernel('dev', true); + \\$kernel->boot(); + \\$conn = \\$kernel->getContainer()->get('doctrine')->getConnection(); + \\$conn->executeStatement('DELETE FROM school_grading_configurations'); + " 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Table might not exist yet, that's OK — default mode applies + } + }); + + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: /se connecter/i }).click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + } + + // ========================================================================= + // Navigation + // ========================================================================= + + test('pedagogy link is visible in admin navigation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + const pedagogyLink = page.getByRole('link', { name: /pédagogie/i }); + await expect(pedagogyLink).toBeVisible(); + }); + + test('pedagogy link navigates to pedagogy page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + await page.getByRole('link', { name: /pédagogie/i }).click(); + await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 }); + }); + + test('pedagogy card is visible on admin dashboard', async ({ page, browserName }) => { + // Svelte 5 delegated onclick is not triggered by Playwright click on webkit + test.skip(browserName === 'webkit', 'Demo role switcher click not supported on webkit'); + + await loginAsAdmin(page); + + // Switch to admin view in demo dashboard + await page.goto(`${ALPHA_URL}/dashboard`); + const adminButton = page.getByRole('button', { name: /admin/i }); + await adminButton.click(); + + await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ + timeout: 10000 + }); + + const pedagogyCard = page.getByRole('link', { name: /pédagogie/i }); + await expect(pedagogyCard).toBeVisible(); + }); + + // ========================================================================= + // AC1: Display grading mode options + // ========================================================================= + + test('shows page title and subtitle', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await expect(page.getByRole('heading', { name: /mode de notation/i })).toBeVisible(); + await expect(page.getByText(/système d'évaluation/i)).toBeVisible(); + }); + + test('shows current mode banner', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + // Wait for loading to finish + await page.waitForLoadState('networkidle'); + + // Should show "Mode actuel" banner + await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 }); + }); + + test('displays all five grading modes', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // All 5 mode cards should be visible (scope to .mode-card to avoid ambiguous matches) + const modeCards = page.locator('.mode-card'); + await expect(modeCards).toHaveCount(5, { timeout: 10000 }); + await expect(modeCards.filter({ hasText: /notes \/20/i })).toBeVisible(); + await expect(modeCards.filter({ hasText: /notes \/10/i })).toBeVisible(); + await expect(modeCards.filter({ hasText: /lettres/i })).toBeVisible(); + await expect(modeCards.filter({ hasText: /compétences/i })).toBeVisible(); + await expect(modeCards.filter({ hasText: /sans notes/i })).toBeVisible(); + }); + + test('default mode is Notes sur 20', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // The "Notes /20" mode card should be selected (has mode-selected class) + const selectedCard = page.locator('.mode-card.mode-selected'); + await expect(selectedCard).toBeVisible({ timeout: 10000 }); + await expect(selectedCard).toContainText(/notes \/20/i); + }); + + // ========================================================================= + // AC1: Year selector + // ========================================================================= + + test('can switch between year tabs', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + const tabs = page.getByRole('tab'); + + // Wait for page to load + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + await page.waitForLoadState('networkidle'); + + // Click next year tab + await tabs.nth(2).click(); + await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + + // Click previous year tab + await tabs.nth(0).click(); + await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + }); + + // ========================================================================= + // AC2-4: Mode selection and preview + // ========================================================================= + + test('selecting a different mode shows save button', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // Save button should not be visible initially + await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible(); + + // Click a different mode + const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i }); + await competenciesCard.click(); + + // Save and cancel buttons should appear + await expect(page.getByRole('button', { name: /enregistrer/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /annuler/i })).toBeVisible(); + }); + + test('cancel button reverts mode selection', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // Select a different mode + const lettersCard = page.locator('.mode-card').filter({ hasText: /lettres/i }); + await lettersCard.click(); + + // Click cancel + await page.getByRole('button', { name: /annuler/i }).click(); + + // Save button should disappear + await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible(); + }); + + test('selecting competencies mode shows competency-specific description', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 }); + + const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i }); + await competenciesCard.click(); + + // Check description inside the card (scoped to avoid matching bulletin impact text) + await expect(competenciesCard.locator('.mode-description')).toContainText( + /acquis.*en cours.*non acquis/i + ); + }); + + test('selecting sans notes mode shows no-grades description', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 }); + + const noGradesCard = page.locator('.mode-card').filter({ hasText: /sans notes/i }); + await noGradesCard.click(); + + // Check description inside the card (scoped to avoid matching bulletin impact text) + await expect(noGradesCard.locator('.mode-description')).toContainText( + /appréciations textuelles/i + ); + }); + + test('shows bulletin impact preview when mode is selected', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // Impact section should be visible + await expect(page.getByText(/impact sur les bulletins/i)).toBeVisible({ timeout: 10000 }); + }); + + // ========================================================================= + // AC2: Can change to a different mode + // ========================================================================= + + test('can save a new grading mode', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 }); + + // Select "Notes sur 10" + const numeric10Card = page.locator('.mode-card').filter({ hasText: /notes \/10/i }); + await numeric10Card.click(); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + + // Success message should appear + await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 }); + + // The selected mode should now be "Notes /10" + const selectedCard = page.locator('.mode-card.mode-selected'); + await expect(selectedCard).toContainText(/notes \/10/i); + + // Reload the page to verify server-side persistence + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.mode-card.mode-selected')).toContainText(/notes \/10/i, { + timeout: 10000 + }); + + // Restore default mode for other tests + const numeric20Card = page.locator('.mode-card').filter({ hasText: /notes \/20/i }); + await numeric20Card.click(); + await page.getByRole('button', { name: /enregistrer/i }).click(); + await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/frontend/e2e/periods.spec.ts b/frontend/e2e/periods.spec.ts index 9fa1469..6c95c41 100644 --- a/frontend/e2e/periods.spec.ts +++ b/frontend/e2e/periods.spec.ts @@ -23,23 +23,15 @@ test.describe('Periods Management (Story 2.3)', () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); - try { - // Create admin user - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Periods E2E test admin user created'); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); - // Clean up all periods for this tenant - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Periods cleaned up for E2E tests'); - } catch (error) { - console.error('Setup error:', error); - } + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`, + { encoding: 'utf-8' } + ); }); async function loginAsAdmin(page: import('@playwright/test').Page) { diff --git a/frontend/e2e/subjects.spec.ts b/frontend/e2e/subjects.spec.ts index 2e43376..5a7f037 100644 --- a/frontend/e2e/subjects.spec.ts +++ b/frontend/e2e/subjects.spec.ts @@ -25,23 +25,15 @@ test.describe('Subjects Management (Story 2.2)', () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); - try { - // Create admin user - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Subjects E2E test admin user created'); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); - // Clean up all subjects for this tenant to ensure Empty State test works - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Subjects cleaned up for E2E tests'); - } catch (error) { - console.error('Setup error:', error); - } + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, + { encoding: 'utf-8' } + ); }); // Helper to login as admin diff --git a/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte b/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte index 8cfca2c..58bd440 100644 --- a/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte +++ b/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte @@ -2,6 +2,11 @@ import type { SerenityScore } from '$types'; import { getSerenityEmoji, getSerenityLabel } from '$lib/features/dashboard/serenity-score'; + // TODO: Adapter la formule et les poids affichés selon le mode de notation + // (no_grades: 0/50/50, competencies: renommer Notes→Compétences + note mapping). + // À traiter quand le Score Sérénité sera connecté aux vraies données. + // Voir backend SerenityScoreWeights::forMode() pour la logique de pondération. + let { score, isEnabled = false, @@ -16,10 +21,9 @@ onToggleOptIn?: ((enabled: boolean) => void) | undefined; } = $props(); - let localEnabled = $state(isEnabled); + let localEnabled = $state(false); - // Sync local state with parent prop changes - $effect(() => { + $effect.pre(() => { localEnabled = isEnabled; }); @@ -43,8 +47,8 @@ - -