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:
70
frontend/tests/unit/features/schedule/scheduleCache.test.ts
Normal file
70
frontend/tests/unit/features/schedule/scheduleCache.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock $app/environment
|
||||
vi.mock('$app/environment', () => ({
|
||||
browser: true
|
||||
}));
|
||||
|
||||
import { isOffline, recordSync, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
|
||||
|
||||
describe('scheduleCache', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('isOffline', () => {
|
||||
it('returns false when navigator is online', () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
|
||||
expect(isOffline()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when navigator is offline', () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
|
||||
expect(isOffline()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync tracking', () => {
|
||||
it('returns null when no sync has been recorded', () => {
|
||||
expect(getLastSyncDate()).toBeNull();
|
||||
});
|
||||
|
||||
it('records and retrieves the last sync date', () => {
|
||||
recordSync();
|
||||
const date = getLastSyncDate();
|
||||
expect(date).not.toBeNull();
|
||||
expect(new Date(date!).getTime()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefetchScheduleDays', () => {
|
||||
it('prefetches 7 past days + today + 23 future days (31 total)', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue({});
|
||||
const today = new Date('2026-03-10');
|
||||
|
||||
await prefetchScheduleDays(fetchFn, today);
|
||||
|
||||
// 7 past + 1 today + 23 future = 31 calls
|
||||
expect(fetchFn).toHaveBeenCalledTimes(31);
|
||||
// First call: 7 days ago
|
||||
expect(fetchFn).toHaveBeenCalledWith('2026-03-03');
|
||||
// Today
|
||||
expect(fetchFn).toHaveBeenCalledWith('2026-03-10');
|
||||
// Last call: 23 days ahead
|
||||
expect(fetchFn).toHaveBeenCalledWith('2026-04-02');
|
||||
});
|
||||
|
||||
it('silently handles fetch failures', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValue({});
|
||||
|
||||
await expect(
|
||||
prefetchScheduleDays(fetchFn, new Date('2026-03-02'))
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(31);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
|
||||
vi.mock('$lib/features/schedule/stores/scheduleCache', () => ({
|
||||
isOffline: vi.fn(() => false),
|
||||
getLastSyncDate: vi.fn(() => null)
|
||||
}));
|
||||
|
||||
function makeSlot(overrides: Partial<ScheduleSlot> = {}): ScheduleSlot {
|
||||
return {
|
||||
slotId: 'slot-1',
|
||||
date: '2026-03-05',
|
||||
dayOfWeek: 4,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
subjectId: 'sub-1',
|
||||
subjectName: 'Mathématiques',
|
||||
teacherId: 'teacher-1',
|
||||
teacherName: 'M. Dupont',
|
||||
room: 'Salle 101',
|
||||
isModified: false,
|
||||
exceptionId: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('ScheduleWidget', () => {
|
||||
it('renders slots with subject, teacher, time and room', () => {
|
||||
const slot = makeSlot();
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [slot], nextSlotId: null }
|
||||
});
|
||||
|
||||
expect(screen.getByText('Mathématiques')).toBeTruthy();
|
||||
expect(screen.getByText('M. Dupont')).toBeTruthy();
|
||||
expect(screen.getByText('08:00')).toBeTruthy();
|
||||
expect(screen.getByText('09:00')).toBeTruthy();
|
||||
expect(screen.getByText('Salle 101')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows empty message when no slots', () => {
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [], nextSlotId: null }
|
||||
});
|
||||
|
||||
expect(screen.getByText("Aucun cours aujourd'hui")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [], nextSlotId: null, isLoading: true }
|
||||
});
|
||||
|
||||
expect(screen.getByText('Chargement...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [], nextSlotId: null, error: 'Erreur réseau' }
|
||||
});
|
||||
|
||||
expect(screen.getByText('Erreur réseau')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('highlights next slot with "Prochain" badge', () => {
|
||||
const slots = [
|
||||
makeSlot({ slotId: 'slot-1', startTime: '08:00', endTime: '09:00' }),
|
||||
makeSlot({
|
||||
slotId: 'slot-2',
|
||||
startTime: '10:00',
|
||||
endTime: '11:00',
|
||||
subjectName: 'Français',
|
||||
teacherName: 'Mme Martin'
|
||||
})
|
||||
];
|
||||
|
||||
render(ScheduleWidget, {
|
||||
props: { slots, nextSlotId: 'slot-2' }
|
||||
});
|
||||
|
||||
expect(screen.getByText('Prochain')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show "Prochain" badge when nextSlotId is null', () => {
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [makeSlot()], nextSlotId: null }
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Prochain')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render room when room is null', () => {
|
||||
const slot = makeSlot({ room: null });
|
||||
const { container } = render(ScheduleWidget, {
|
||||
props: { slots: [slot], nextSlotId: null }
|
||||
});
|
||||
|
||||
expect(container.querySelector('.slot-room')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders multiple slots with data-testid', () => {
|
||||
const slots = [
|
||||
makeSlot({ slotId: 'slot-1' }),
|
||||
makeSlot({ slotId: 'slot-2', subjectName: 'Français' }),
|
||||
makeSlot({ slotId: 'slot-3', subjectName: 'Histoire' })
|
||||
];
|
||||
|
||||
const { container } = render(ScheduleWidget, {
|
||||
props: { slots, nextSlotId: null }
|
||||
});
|
||||
|
||||
const items = container.querySelectorAll('[data-testid="schedule-slot"]');
|
||||
expect(items.length).toBe(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user