feat: Configuration du mode de notation par établissement

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).
This commit is contained in:
2026-02-07 01:06:55 +01:00
parent f19d0ae3ef
commit ff18850a43
51 changed files with 3963 additions and 79 deletions

View File

@@ -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

View File

@@ -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 });
});
});

View File

@@ -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) {

View File

@@ -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