feat: Permettre aux élèves de consulter leur emploi du temps

Les élèves n'avaient aucun moyen de voir leur emploi du temps
depuis l'application. Cette fonctionnalité ajoute une page dédiée
avec deux modes de visualisation (jour et semaine), la navigation
temporelle, et le détail des cours au tap.

Le backend résout l'EDT de l'élève en chaînant : affectation classe →
créneaux récurrents + exceptions + calendrier scolaire → enrichissement
des noms (matières/enseignants). Le frontend utilise un cache offline
(Workbox NetworkFirst) pour rester consultable hors connexion.
This commit is contained in:
2026-03-05 16:21:37 +01:00
parent ae640e91ac
commit 36ceefb625
30 changed files with 3526 additions and 30 deletions

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetStudentSchedule;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleHandler;
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleQuery;
use App\Scolarite\Application\Query\GetStudentSchedule\StudentScheduleSlotDto;
use App\Scolarite\Application\Service\ScheduleResolver;
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleExceptionRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetStudentScheduleHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryScheduleSlotRepository $slotRepository;
private InMemoryScheduleExceptionRepository $exceptionRepository;
protected function setUp(): void
{
$this->slotRepository = new InMemoryScheduleSlotRepository();
$this->exceptionRepository = new InMemoryScheduleExceptionRepository();
}
#[Test]
public function returnsEmptyWhenStudentHasNoClass(): void
{
$handler = $this->createHandler(classId: null);
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertSame([], $result);
}
#[Test]
public function returnsScheduleForStudentClass(): void
{
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
$this->saveRecurringSlot(DayOfWeek::TUESDAY, '10:00', '11:00');
$handler = $this->createHandler(classId: self::CLASS_ID);
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02', // Monday
));
self::assertCount(2, $result);
self::assertContainsOnlyInstancesOf(StudentScheduleSlotDto::class, $result);
}
#[Test]
public function enrichesSlotsWithSubjectAndTeacherNames(): void
{
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
$handler = $this->createHandler(
classId: self::CLASS_ID,
subjectNames: [self::SUBJECT_ID => 'Mathématiques'],
teacherNames: [self::TEACHER_ID => 'Jean Dupont'],
);
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertCount(1, $result);
self::assertSame('Mathématiques', $result[0]->subjectName);
self::assertSame('Jean Dupont', $result[0]->teacherName);
}
#[Test]
public function computesMondayFromAnyDayOfWeek(): void
{
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
$handler = $this->createHandler(classId: self::CLASS_ID);
// Wednesday of the same week → should still return Monday's slot
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-04', // Wednesday
));
self::assertCount(1, $result);
self::assertSame('2026-03-02', $result[0]->date);
}
#[Test]
public function returnsCorrectDtoFields(): void
{
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00', 'Salle 101');
$handler = $this->createHandler(
classId: self::CLASS_ID,
subjectNames: [self::SUBJECT_ID => 'Français'],
teacherNames: [self::TEACHER_ID => 'Marie Martin'],
);
$result = $handler(new GetStudentScheduleQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
date: '2026-03-02',
));
self::assertCount(1, $result);
$dto = $result[0];
self::assertSame('2026-03-02', $dto->date);
self::assertSame(1, $dto->dayOfWeek);
self::assertSame('08:00', $dto->startTime);
self::assertSame('09:00', $dto->endTime);
self::assertSame(self::SUBJECT_ID, $dto->subjectId);
self::assertSame('Français', $dto->subjectName);
self::assertSame(self::TEACHER_ID, $dto->teacherId);
self::assertSame('Marie Martin', $dto->teacherName);
self::assertSame('Salle 101', $dto->room);
self::assertFalse($dto->isModified);
self::assertNull($dto->exceptionId);
}
private function saveRecurringSlot(
DayOfWeek $day,
string $start,
string $end,
?string $room = null,
): void {
$slot = ScheduleSlot::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString(self::CLASS_ID),
subjectId: SubjectId::fromString(self::SUBJECT_ID),
teacherId: UserId::fromString(self::TEACHER_ID),
dayOfWeek: $day,
timeSlot: new TimeSlot($start, $end),
room: $room,
isRecurring: true,
now: new DateTimeImmutable('2026-01-01'),
recurrenceStart: new DateTimeImmutable('2026-01-01'),
);
$this->slotRepository->save($slot);
}
/**
* @param array<string, string> $subjectNames
* @param array<string, string> $teacherNames
*/
private function createHandler(
?string $classId = null,
array $subjectNames = [],
array $teacherNames = [],
): GetStudentScheduleHandler {
$tenantId = TenantId::fromString(self::TENANT_ID);
$studentClassReader = new class($classId) implements StudentClassReader {
public function __construct(private ?string $classId)
{
}
public function currentClassId(string $studentId, TenantId $tenantId): ?string
{
return $this->classId;
}
};
$calendarProvider = new class($tenantId) implements CurrentCalendarProvider {
public function __construct(private TenantId $tenantId)
{
}
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
{
return SchoolCalendar::initialiser($this->tenantId, AcademicYearId::generate());
}
};
$displayReader = new class($subjectNames, $teacherNames) implements ScheduleDisplayReader {
/** @param array<string, string> $subjects @param array<string, string> $teachers */
public function __construct(
private array $subjects,
private array $teachers,
) {
}
public function subjectNames(string $tenantId, string ...$subjectIds): array
{
return $this->subjects;
}
public function teacherNames(string $tenantId, string ...$teacherIds): array
{
return $this->teachers;
}
};
return new GetStudentScheduleHandler(
$studentClassReader,
new ScheduleResolver($this->slotRepository, $this->exceptionRepository),
$calendarProvider,
$displayReader,
);
}
}