feat: Calculer automatiquement les moyennes après chaque saisie de notes
Les enseignants ont besoin de moyennes à jour immédiatement après la publication ou modification des notes, sans attendre un batch nocturne. Le système recalcule via Domain Events synchrones : statistiques d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées (normalisation /20), et moyenne générale par élève. Les résultats sont stockés dans des tables dénormalisées avec cache Redis (TTL 5 min). Trois endpoints API exposent les données avec contrôle d'accès par rôle. Une commande console permet le backfill des données historiques au déploiement.
This commit is contained in:
@@ -37,16 +37,26 @@ test.describe('Activation with Parent-Child Auto-Link', () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
const run = (cmd: string) => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8' });
|
||||
} catch (e) {
|
||||
if (attempt === 2) throw e;
|
||||
execSync('sleep 2');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// 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' }
|
||||
run(
|
||||
`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`
|
||||
);
|
||||
|
||||
// Create student user and capture userId
|
||||
const studentOutput = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
const studentOutput = run(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`
|
||||
);
|
||||
studentUserId = extractUserId(studentOutput);
|
||||
|
||||
@@ -96,7 +106,7 @@ test.describe('Activation with Parent-Child Auto-Link', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe('Admin Responsive Navigation', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe('Admin Search & Pagination (Story 2.8b)', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
361
frontend/e2e/appreciations.spec.ts
Normal file
361
frontend/e2e/appreciations.spec.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
|
||||
|
||||
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 TEACHER_EMAIL = 'e2e-appr-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'ApprTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
/** Navigate to grades page and verify grade is loaded (pre-seeded via SQL). */
|
||||
async function waitForGradeLoaded(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
|
||||
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
||||
// Grade was pre-inserted in beforeEach, should show as 15/20
|
||||
await expect(page.locator('.status-graded').first()).toContainText('15/20', { timeout: 10000 });
|
||||
}
|
||||
|
||||
let evaluationId: string;
|
||||
let classId: string;
|
||||
let student1Id: string;
|
||||
|
||||
test.describe('Appreciations (Story 6.4)', () => {
|
||||
test.beforeAll(async () => {
|
||||
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
|
||||
const classOutput = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","appr-class-${TENANT_ID}")->toString();` +
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
classId = classOutput;
|
||||
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
`VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-APPR-4A', '4ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
const subjectOutput = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","appr-subject-${TENANT_ID}")->toString();` +
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
const subjectId = subjectOutput;
|
||||
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-APPR-Français', 'E2APRFR', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
runSql(
|
||||
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
createTestUser('ecole-alpha', 'e2e-appr-student1@example.com', 'Student123', 'ROLE_ELEVE --firstName=Claire --lastName=Petit');
|
||||
|
||||
const studentIds = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email = 'e2e-appr-student1@example.com' AND tenant_id='${TENANT_ID}'" 2>&1`
|
||||
);
|
||||
const idMatches = studentIds.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g);
|
||||
if (idMatches && idMatches.length >= 1) {
|
||||
student1Id = idMatches[0]!;
|
||||
}
|
||||
|
||||
runSql(
|
||||
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${student1Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
|
||||
);
|
||||
|
||||
const evalOutput = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","appr-eval-${TENANT_ID}")->toString();` +
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
evaluationId = evalOutput;
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Clean appreciation templates for the teacher
|
||||
runSql(`DELETE FROM appreciation_templates WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
||||
|
||||
// Clean grades and recreate evaluation
|
||||
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE evaluation_id = '${evaluationId}')`);
|
||||
runSql(`DELETE FROM grades WHERE evaluation_id = '${evaluationId}'`);
|
||||
runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`);
|
||||
|
||||
runSql(
|
||||
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
|
||||
`SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', ` +
|
||||
`(SELECT id FROM subjects WHERE code='E2APRFR' AND tenant_id='${TENANT_ID}' LIMIT 1), ` +
|
||||
`u.id, 'E2E Contrôle Français', '2026-04-15', 20, 1.0, 'published', NULL, NOW(), NOW() ` +
|
||||
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
||||
`ON CONFLICT (id) DO UPDATE SET grades_published_at = NULL, updated_at = NOW()`
|
||||
);
|
||||
|
||||
// Pre-insert a grade for the student so appreciation tests don't depend on auto-save
|
||||
runSql(
|
||||
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', '${evaluationId}', '${student1Id}', 15, 'graded', ` +
|
||||
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'), NOW(), NOW() ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.describe('Appreciation Input', () => {
|
||||
test('clicking appreciation icon opens text area', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await waitForGradeLoaded(page);
|
||||
|
||||
// Click appreciation button
|
||||
const apprBtn = page.locator('.btn-appreciation').first();
|
||||
await apprBtn.click();
|
||||
|
||||
// Appreciation panel should open with textarea
|
||||
await expect(page.locator('.appreciation-panel')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.appreciation-textarea')).toBeVisible();
|
||||
});
|
||||
|
||||
test('typing appreciation shows character counter', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await waitForGradeLoaded(page);
|
||||
|
||||
// Open appreciation panel
|
||||
await page.locator('.btn-appreciation').first().click();
|
||||
await expect(page.locator('.appreciation-textarea')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Type appreciation
|
||||
await page.locator('.appreciation-textarea').fill('Bon travail');
|
||||
|
||||
// Should show character count
|
||||
await expect(page.locator('.char-counter')).toContainText('11/500');
|
||||
});
|
||||
|
||||
// Firefox: auto-save debounce (setTimeout) doesn't trigger reliably with Playwright fill()
|
||||
test('appreciation auto-saves after typing', async ({ page, browserName }) => {
|
||||
test.skip(browserName === 'firefox', 'Firefox auto-save timing unreliable with Playwright');
|
||||
await loginAsTeacher(page);
|
||||
await waitForGradeLoaded(page);
|
||||
|
||||
// Open appreciation panel
|
||||
await page.locator('.btn-appreciation').first().click();
|
||||
await expect(page.locator('.appreciation-textarea')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Type appreciation text
|
||||
await page.locator('.appreciation-textarea').fill('Très bon travail ce trimestre');
|
||||
|
||||
// Wait for auto-save by checking the UI status indicator
|
||||
await expect(page.getByText('Sauvegardé')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('appreciation icon changes when appreciation exists', async ({ page }) => {
|
||||
// Pre-insert appreciation via SQL
|
||||
runSql(
|
||||
`UPDATE grades SET appreciation = 'Excellent' WHERE evaluation_id = '${evaluationId}' AND student_id = '${student1Id}' AND tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await waitForGradeLoaded(page);
|
||||
|
||||
// Button should have "has-appreciation" class since appreciation was pre-inserted
|
||||
await expect(page.locator('.btn-appreciation.has-appreciation').first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Appreciation Templates', () => {
|
||||
test('can open template manager and create a template', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await waitForGradeLoaded(page);
|
||||
|
||||
// Open appreciation panel
|
||||
await page.locator('.btn-appreciation').first().click();
|
||||
await expect(page.locator('.appreciation-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click "Gérer" to open template manager
|
||||
await page.locator('.btn-template-manage').click();
|
||||
|
||||
// Template manager modal should be visible
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
await expect(modal.getByText('Gérer les modèles')).toBeVisible();
|
||||
|
||||
// Fill the new template form
|
||||
await modal.locator('.template-input').fill('Très bon travail');
|
||||
await modal.locator('.template-textarea').fill('Très bon travail, continuez ainsi !');
|
||||
await modal.getByLabel('Positive').check();
|
||||
|
||||
// Listen for POST
|
||||
const createPromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// Create template
|
||||
await modal.getByRole('button', { name: 'Créer' }).click();
|
||||
await createPromise;
|
||||
|
||||
// Template should appear in list
|
||||
await expect(modal.getByText('Très bon travail, continuez')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can apply template to appreciation', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await waitForGradeLoaded(page);
|
||||
|
||||
// Open appreciation panel
|
||||
await page.locator('.btn-appreciation').first().click();
|
||||
await expect(page.locator('.appreciation-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Create a template first via the manager
|
||||
await page.locator('.btn-template-manage').click();
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await modal.locator('.template-input').fill('Progrès encourageants');
|
||||
await modal.locator('.template-textarea').fill('Progrès encourageants ce trimestre, poursuivez vos efforts.');
|
||||
|
||||
const createPromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await modal.getByRole('button', { name: 'Créer' }).click();
|
||||
await createPromise;
|
||||
|
||||
// Close manager
|
||||
await modal.getByRole('button', { name: 'Fermer' }).click();
|
||||
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The appreciation panel may still be open from before the modal,
|
||||
// or it may have closed. Toggle if needed.
|
||||
const panel = page.locator('.appreciation-panel');
|
||||
if (!(await panel.isVisible())) {
|
||||
await page.locator('.btn-appreciation').first().click();
|
||||
}
|
||||
await expect(panel).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click "Modèles" to show template dropdown
|
||||
await page.locator('.btn-template-select').click();
|
||||
await expect(page.locator('.template-dropdown')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Listen for appreciation auto-save
|
||||
const apprSavePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/appreciation') && resp.request().method() === 'PUT',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// Click the template
|
||||
await page.locator('.template-item').first().click();
|
||||
|
||||
// Textarea should contain the template content
|
||||
await expect(page.locator('.appreciation-textarea')).toHaveValue('Progrès encourageants ce trimestre, poursuivez vos efforts.', { timeout: 5000 });
|
||||
|
||||
// Wait for auto-save
|
||||
await apprSavePromise;
|
||||
});
|
||||
|
||||
test('can edit an existing template', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await waitForGradeLoaded(page);
|
||||
|
||||
// Open appreciation panel then template manager
|
||||
await page.locator('.btn-appreciation').first().click();
|
||||
await page.locator('.btn-template-manage').click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Create a template to edit
|
||||
await modal.locator('.template-input').fill('Avant modification');
|
||||
await modal.locator('.template-textarea').fill('Contenu avant modification');
|
||||
const createPromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await modal.getByRole('button', { name: 'Créer' }).click();
|
||||
await createPromise;
|
||||
|
||||
// Click "Modifier" on the template
|
||||
await modal.getByRole('button', { name: 'Modifier' }).first().click();
|
||||
|
||||
// Form should show "Modifier le modèle" and be pre-filled
|
||||
await expect(modal.getByText('Modifier le modèle')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Clear and fill with new values
|
||||
await modal.locator('.template-input').fill('Après modification');
|
||||
await modal.locator('.template-textarea').fill('Contenu après modification');
|
||||
|
||||
// Submit the edit
|
||||
const updatePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/appreciation-templates/') && resp.request().method() === 'PUT',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await modal.getByRole('button', { name: 'Modifier' }).first().click();
|
||||
await updatePromise;
|
||||
|
||||
// Verify updated template is displayed
|
||||
await expect(modal.getByText('Après modification', { exact: true })).toBeVisible({ timeout: 5000 });
|
||||
await expect(modal.getByText('Avant modification', { exact: true })).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can delete a template', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await waitForGradeLoaded(page);
|
||||
|
||||
// Open appreciation panel then template manager
|
||||
await page.locator('.btn-appreciation').first().click();
|
||||
await page.locator('.btn-template-manage').click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Create a template
|
||||
await modal.locator('.template-input').fill('À supprimer');
|
||||
await modal.locator('.template-textarea').fill('Contenu test');
|
||||
const createPromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/appreciation-templates') && resp.request().method() === 'POST',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await modal.getByRole('button', { name: 'Créer' }).click();
|
||||
await createPromise;
|
||||
|
||||
// Template should be visible
|
||||
await expect(modal.getByText('À supprimer')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Delete it
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/appreciation-templates/') && resp.request().method() === 'DELETE',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await modal.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await deletePromise;
|
||||
|
||||
// Template should disappear
|
||||
await expect(modal.getByText('À supprimer')).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,7 @@ test.describe('Branding Visual Customization', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ test.describe('Calendar Management (Story 2.11)', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ test.describe('Calendar Management (Story 2.11)', () => {
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ async function loginAsAdmin(page: Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ test.describe('Child Selector', () => {
|
||||
await page.locator('#email').fill(PARENT_EMAIL);
|
||||
await page.locator('#password').fill(PARENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Admin Class Detail Page [P1]', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -151,8 +151,16 @@ test.describe('Admin Class Detail Page [P1]', () => {
|
||||
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Search for the newly created class to handle pagination
|
||||
const searchModify = page.locator('input[type="search"]');
|
||||
if (await searchModify.isVisible()) {
|
||||
await searchModify.fill(originalName);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Navigate to edit page
|
||||
const classCard = page.locator('.class-card', { hasText: originalName });
|
||||
await expect(classCard).toBeVisible({ timeout: 15000 });
|
||||
await classCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/);
|
||||
|
||||
@@ -168,13 +176,13 @@ test.describe('Admin Class Detail Page [P1]', () => {
|
||||
|
||||
// Go back to list and verify the new name appears (use search for pagination)
|
||||
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
if (await searchInput.isVisible()) {
|
||||
await searchInput.fill(newName);
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
await expect(page.getByText(newName)).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(newName)).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
@@ -233,8 +241,16 @@ test.describe('Admin Class Detail Page [P1]', () => {
|
||||
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Search for the newly created class to handle pagination
|
||||
const searchCancel = page.locator('input[type="search"]');
|
||||
if (await searchCancel.isVisible()) {
|
||||
await searchCancel.fill(originalName);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Navigate to edit page
|
||||
const classCard = page.locator('.class-card', { hasText: originalName });
|
||||
await expect(classCard).toBeVisible({ timeout: 15000 });
|
||||
await classCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/);
|
||||
|
||||
@@ -246,10 +262,17 @@ test.describe('Admin Class Detail Page [P1]', () => {
|
||||
await page.getByRole('button', { name: /annuler/i }).click();
|
||||
|
||||
// Should go back to the classes list
|
||||
await expect(page).toHaveURL(/\/admin\/classes$/);
|
||||
await expect(page).toHaveURL(/\/admin\/classes$/, { timeout: 10000 });
|
||||
|
||||
// Search for the class to handle pagination
|
||||
const searchAfterCancel = page.locator('input[type="search"]');
|
||||
if (await searchAfterCancel.isVisible()) {
|
||||
await searchAfterCancel.fill(originalName);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// The original name should still be visible, modified name should not
|
||||
await expect(page.getByText(originalName)).toBeVisible();
|
||||
await expect(page.getByText(originalName)).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Should-Not-Persist')).not.toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ test.describe('Classes Management (Story 2.1)', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
371
frontend/e2e/competencies.spec.ts
Normal file
371
frontend/e2e/competencies.spec.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
|
||||
|
||||
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 TEACHER_EMAIL = 'e2e-comp-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'CompTest123';
|
||||
const STUDENT1_EMAIL = 'e2e-comp-student1@example.com';
|
||||
const STUDENT2_EMAIL = 'e2e-comp-student2@example.com';
|
||||
const STUDENT_PASSWORD = 'Student123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const UUID_NS = '6ba7b814-9dad-11d1-80b4-00c04fd430c8';
|
||||
|
||||
function uuid5(name: string): string {
|
||||
return execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("${UUID_NS}","${name}")->toString();` +
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
}
|
||||
|
||||
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function loginAsStudent(page: import('@playwright/test').Page, email: string) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(STUDENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
// Deterministic IDs for test data
|
||||
let classId: string;
|
||||
let subjectId: string;
|
||||
let evaluationId: string;
|
||||
let frameworkId: string;
|
||||
let competency1Id: string;
|
||||
let competency2Id: string;
|
||||
let ce1Id: string;
|
||||
let ce2Id: string;
|
||||
let student1Id: string;
|
||||
let student2Id: string;
|
||||
|
||||
test.describe('Competencies Mode (Story 6.5)', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Create test users
|
||||
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
||||
createTestUser('ecole-alpha', STUDENT1_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Clara --lastName=Dupont');
|
||||
createTestUser('ecole-alpha', STUDENT2_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Hugo --lastName=Leroy');
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
|
||||
// Create deterministic IDs
|
||||
classId = uuid5(`comp-class-${TENANT_ID}`);
|
||||
subjectId = uuid5(`comp-subject-${TENANT_ID}`);
|
||||
evaluationId = uuid5(`comp-eval-${TENANT_ID}`);
|
||||
frameworkId = uuid5(`comp-framework-${TENANT_ID}`);
|
||||
competency1Id = uuid5(`comp-c1-${TENANT_ID}`);
|
||||
competency2Id = uuid5(`comp-c2-${TENANT_ID}`);
|
||||
ce1Id = uuid5(`comp-ce1-${TENANT_ID}`);
|
||||
ce2Id = uuid5(`comp-ce2-${TENANT_ID}`);
|
||||
|
||||
// Create test class
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
`VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-COMP-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create test subject
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-COMP-Maths', 'E2CMPMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create teacher assignment
|
||||
runSql(
|
||||
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Resolve student IDs
|
||||
const studentIds = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('${STUDENT1_EMAIL}','${STUDENT2_EMAIL}') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`
|
||||
);
|
||||
const idMatches = studentIds.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g);
|
||||
if (idMatches && idMatches.length >= 2) {
|
||||
student1Id = idMatches[0]!;
|
||||
student2Id = idMatches[1]!;
|
||||
}
|
||||
|
||||
// Assign students to class
|
||||
runSql(
|
||||
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${student1Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${student2Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
|
||||
);
|
||||
|
||||
// Create competency framework
|
||||
runSql(
|
||||
`INSERT INTO competency_frameworks (id, tenant_id, name, is_default, created_at) ` +
|
||||
`VALUES ('${frameworkId}', '${TENANT_ID}', 'Socle commun E2E', true, NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create 2 competencies
|
||||
runSql(
|
||||
`INSERT INTO competencies (id, framework_id, code, name, description, sort_order) ` +
|
||||
`VALUES ('${competency1Id}', '${frameworkId}', 'D1.1', 'Comprendre et s''exprimer', 'Langue française', 0) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO competencies (id, framework_id, code, name, description, sort_order) ` +
|
||||
`VALUES ('${competency2Id}', '${frameworkId}', 'D2.1', 'Méthodes et outils', 'Organisation du travail', 1) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Clean competency results and competency evaluations, then recreate evaluation + links
|
||||
runSql(`DELETE FROM student_competency_results WHERE competency_evaluation_id IN ('${ce1Id}', '${ce2Id}')`);
|
||||
runSql(`DELETE FROM competency_evaluations WHERE evaluation_id = '${evaluationId}'`);
|
||||
runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`);
|
||||
|
||||
// Create evaluation
|
||||
runSql(
|
||||
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, created_at, updated_at) ` +
|
||||
`SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', '${subjectId}', ` +
|
||||
`u.id, 'E2E Contrôle Compétences', '2026-04-15', 20, 1.0, 'published', NOW(), NOW() ` +
|
||||
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
||||
`ON CONFLICT (id) DO UPDATE SET updated_at = NOW()`
|
||||
);
|
||||
|
||||
// Link competencies to evaluation
|
||||
runSql(
|
||||
`INSERT INTO competency_evaluations (id, evaluation_id, competency_id) ` +
|
||||
`VALUES ('${ce1Id}', '${evaluationId}', '${competency1Id}') ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO competency_evaluations (id, evaluation_id, competency_id) ` +
|
||||
`VALUES ('${ce2Id}', '${evaluationId}', '${competency2Id}') ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.describe('Competency Grid Display', () => {
|
||||
test('shows competency grid with students and competencies', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
|
||||
// Should display evaluation title
|
||||
await expect(page.getByRole('heading', { name: /E2E Contrôle Compétences/i })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should show competency column headers
|
||||
await expect(page.getByText('D1.1')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('D2.1')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show student names
|
||||
await expect(page.getByText('Dupont Clara')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Leroy Hugo')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show level-cell elements
|
||||
const levelCells = page.locator('.level-cell');
|
||||
await expect(levelCells.first()).toBeVisible({ timeout: 10000 });
|
||||
// 2 students x 2 competencies = 4 cells
|
||||
await expect(levelCells).toHaveCount(4);
|
||||
});
|
||||
|
||||
test('shows level legend with standard levels', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
|
||||
// Should show level legend
|
||||
await expect(page.getByText('Niveaux :')).toBeVisible({ timeout: 15000 });
|
||||
const legendItems = page.locator('.legend-item');
|
||||
await expect(legendItems).toHaveCount(4, { timeout: 10000 });
|
||||
await expect(legendItems.nth(0)).toContainText('Non acquis');
|
||||
await expect(legendItems.nth(1)).toContainText("En cours d'acquisition");
|
||||
await expect(legendItems.nth(2)).toContainText('Acquis');
|
||||
await expect(legendItems.nth(3)).toContainText('Dépassé');
|
||||
});
|
||||
|
||||
test('"Compétences" link navigates from evaluations page', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`);
|
||||
await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Wait for evaluation cards to load
|
||||
await expect(page.getByText('E2E Contrôle Compétences')).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole('link', { name: /compétences/i }).first().click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /E2E Contrôle Compétences/i })).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Competency Level Input', () => {
|
||||
test('can set a competency level by clicking button', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the first level button (1 = "Non acquis") in the first cell
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
const firstLevelBtn = firstCell.locator('.level-btn').first();
|
||||
await firstLevelBtn.click();
|
||||
|
||||
// The button should become active
|
||||
await expect(firstLevelBtn).toHaveClass(/active/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('toggle same level clears selection', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
const levelBtn = firstCell.locator('.level-btn').nth(1);
|
||||
|
||||
// Click to set level
|
||||
await levelBtn.click();
|
||||
await expect(levelBtn).toHaveClass(/active/, { timeout: 5000 });
|
||||
|
||||
// Click same button immediately to toggle off (no wait for save)
|
||||
await levelBtn.click();
|
||||
await expect(levelBtn).not.toHaveClass(/active/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Keyboard Navigation', () => {
|
||||
test('keyboard shortcut 1-4 sets level', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Focus the first cell
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
await firstCell.focus();
|
||||
|
||||
// Press '3' to set level 3 (Acquis)
|
||||
await firstCell.press('3');
|
||||
|
||||
// The third level button should become active
|
||||
await expect(firstCell.locator('.level-btn').nth(2)).toHaveClass(/active/, { timeout: 5000 });
|
||||
|
||||
// Press '1' to set level 1 (Non acquis)
|
||||
await firstCell.press('1');
|
||||
|
||||
// The first level button should become active, third should not
|
||||
await expect(firstCell.locator('.level-btn').first()).toHaveClass(/active/, { timeout: 5000 });
|
||||
await expect(firstCell.locator('.level-btn').nth(2)).not.toHaveClass(/active/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Tab moves to next cell', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
await firstCell.focus();
|
||||
await firstCell.press('Tab');
|
||||
|
||||
// Second cell should be focused (next competency for the same student)
|
||||
const secondCell = page.locator('.level-cell').nth(1);
|
||||
await expect(secondCell).toBeFocused({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Arrow keys navigate the grid', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/competencies`);
|
||||
await expect(page.locator('.level-cell').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const firstCell = page.locator('.level-cell').first();
|
||||
await firstCell.focus();
|
||||
|
||||
// ArrowRight moves to the next competency (same row)
|
||||
await firstCell.press('ArrowRight');
|
||||
const secondCell = page.locator('.level-cell').nth(1);
|
||||
await expect(secondCell).toBeFocused({ timeout: 3000 });
|
||||
|
||||
// ArrowDown moves to the same competency for the next student
|
||||
// Cell index 1 => student 0, comp 1. ArrowDown => student 1, comp 1 => cell index 3
|
||||
await secondCell.press('ArrowDown');
|
||||
const cellBelow = page.locator('.level-cell').nth(3);
|
||||
await expect(cellBelow).toBeFocused({ timeout: 3000 });
|
||||
|
||||
// ArrowLeft moves back
|
||||
await cellBelow.press('ArrowLeft');
|
||||
const cellLeft = page.locator('.level-cell').nth(2);
|
||||
await expect(cellLeft).toBeFocused({ timeout: 3000 });
|
||||
|
||||
// ArrowUp moves back up
|
||||
await cellLeft.press('ArrowUp');
|
||||
await expect(firstCell).toBeFocused({ timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Student Competency View', () => {
|
||||
test.beforeEach(async () => {
|
||||
// Seed competency results so the student has data to view
|
||||
runSql(
|
||||
`INSERT INTO student_competency_results (id, tenant_id, competency_evaluation_id, student_id, level_code, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${ce1Id}', '${student1Id}', 'acquired', NOW(), NOW()) ` +
|
||||
`ON CONFLICT (competency_evaluation_id, student_id) DO UPDATE SET level_code = 'acquired', updated_at = NOW()`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO student_competency_results (id, tenant_id, competency_evaluation_id, student_id, level_code, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${ce2Id}', '${student1Id}', 'in_progress', NOW(), NOW()) ` +
|
||||
`ON CONFLICT (competency_evaluation_id, student_id) DO UPDATE SET level_code = 'in_progress', updated_at = NOW()`
|
||||
);
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test('student sees competency progress page', async ({ page }) => {
|
||||
await loginAsStudent(page, STUDENT1_EMAIL);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/student-competencies`);
|
||||
|
||||
// Should display heading
|
||||
await expect(page.getByRole('heading', { name: /mes compétences/i })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should show competency cards
|
||||
const cards = page.locator('.competency-cards');
|
||||
await expect(page.locator('.competency-card').first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(cards.getByText('D1.1')).toBeVisible({ timeout: 10000 });
|
||||
await expect(cards.getByText('D2.1')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show current level badges
|
||||
await expect(page.locator('.card-badge').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show the hint text
|
||||
await expect(page.getByText(/cliquez sur une compétence pour voir l'historique/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('student can view history by clicking a competency card', async ({ page }) => {
|
||||
await loginAsStudent(page, STUDENT1_EMAIL);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/student-competencies`);
|
||||
|
||||
await expect(page.locator('.competency-card').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the first competency card
|
||||
await page.locator('.competency-card').first().click();
|
||||
|
||||
// The card should be selected
|
||||
await expect(page.locator('.competency-card.selected')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The history panel should appear
|
||||
await expect(page.locator('.history-panel')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should show evaluation title in history
|
||||
await expect(page.locator('.history-panel').getByText('E2E Contrôle Compétences')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(STUDENT_EMAIL);
|
||||
await page.locator('#password').fill(STUDENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ test.describe('Dashboard', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
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);
|
||||
import { runSql, clearCache, resolveDeterministicIds, createTestUser } from './helpers';
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
@@ -15,48 +10,12 @@ const TEACHER_EMAIL = 'e2e-eval-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'EvalTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||
const output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
).trim();
|
||||
const [schoolId, academicYearId] = output.split('\n');
|
||||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -79,7 +38,7 @@ async function selectClassAndSubject(page: import('@playwright/test').Page) {
|
||||
}
|
||||
|
||||
function seedTeacherAssignments() {
|
||||
const { academicYearId } = resolveDeterministicIds();
|
||||
const { academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||
@@ -98,13 +57,10 @@ function seedTeacherAssignments() {
|
||||
test.describe('Evaluation Management (Story 6.1)', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Create teacher user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
||||
|
||||
// Ensure classes and subject exist
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
@@ -133,15 +89,15 @@ test.describe('Evaluation Management (Story 6.1)', () => {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
const { schoolId: sId, academicYearId: ayId } = resolveDeterministicIds(TENANT_ID);
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${sId}', '${ayId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${sId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
@@ -361,7 +317,7 @@ test.describe('Evaluation Management (Story 6.1)', () => {
|
||||
test.describe('Filter by class', () => {
|
||||
test('class filter dropdown filters the evaluation list', async ({ page }) => {
|
||||
// Seed a second class and assignment for this test
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
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);
|
||||
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
@@ -15,48 +10,12 @@ const TEACHER_EMAIL = 'e2e-grade-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'GradeTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||
const output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
).trim();
|
||||
const [schoolId, academicYearId] = output.split('\n');
|
||||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -70,20 +29,16 @@ let student2Id: string;
|
||||
test.describe('Grade Input Grid (Story 6.2)', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Create teacher user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
|
||||
// Create test class
|
||||
const classOutput = execSync(
|
||||
const classOutput = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-class-${TENANT_ID}")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
classId = classOutput;
|
||||
|
||||
@@ -93,12 +48,11 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
||||
);
|
||||
|
||||
// Create test subject
|
||||
const subjectOutput = execSync(
|
||||
const subjectOutput = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-subject-${TENANT_ID}")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
const subjectId = subjectOutput;
|
||||
|
||||
@@ -116,19 +70,12 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
||||
);
|
||||
|
||||
// Create 2 test students
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student1@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Alice --lastName=Durand 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student2@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Bob --lastName=Martin 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
createTestUser('ecole-alpha', 'e2e-grade-student1@example.com', 'Student123', 'ROLE_ELEVE --firstName=Alice --lastName=Durand');
|
||||
createTestUser('ecole-alpha', 'e2e-grade-student2@example.com', 'Student123', 'ROLE_ELEVE --firstName=Bob --lastName=Martin');
|
||||
|
||||
// Assign students to class
|
||||
const studentIds = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
const studentIds = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`
|
||||
);
|
||||
const idMatches = studentIds.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g);
|
||||
if (idMatches && idMatches.length >= 2) {
|
||||
@@ -147,12 +94,11 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
||||
);
|
||||
|
||||
// Create test evaluation
|
||||
const evalOutput = execSync(
|
||||
const evalOutput = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-eval-${TENANT_ID}")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
evaluationId = evalOutput;
|
||||
|
||||
@@ -236,9 +182,10 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
||||
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const firstInput = page.locator('.grade-input').first();
|
||||
await firstInput.fill('/abs');
|
||||
await firstInput.clear();
|
||||
await firstInput.pressSequentially('/abs');
|
||||
|
||||
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('/disp marks student as dispensed', async ({ page }) => {
|
||||
@@ -247,9 +194,10 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
||||
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const firstInput = page.locator('.grade-input').first();
|
||||
await firstInput.fill('/disp');
|
||||
await firstInput.clear();
|
||||
await firstInput.pressSequentially('/disp');
|
||||
|
||||
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ test.describe('Guardian Management', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
58
frontend/e2e/helpers.ts
Normal file
58
frontend/e2e/helpers.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export const projectRoot = join(__dirname, '../..');
|
||||
export const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
export function execWithRetry(command: string, maxRetries = 3): string {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return execSync(command, { encoding: 'utf-8' });
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries) throw error;
|
||||
// Wait before retry: 1s, 2s, 3s
|
||||
execSync(`sleep ${attempt}`);
|
||||
}
|
||||
}
|
||||
throw new Error('Unreachable');
|
||||
}
|
||||
|
||||
export function runSql(sql: string) {
|
||||
execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`
|
||||
);
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
try {
|
||||
execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDeterministicIds(tenantId: string): { schoolId: string; academicYearId: string } {
|
||||
const output = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`$t="${tenantId}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`
|
||||
).trim();
|
||||
const [schoolId, academicYearId] = output.split('\n');
|
||||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
export function createTestUser(tenant: string, email: string, password: string, role: string) {
|
||||
execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=${tenant} --email=${email} --password=${password} --role=${role} 2>&1`
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -323,6 +323,7 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
|
||||
// T4.3 : Delete attachment
|
||||
test('can delete an uploaded attachment', async ({ page }) => {
|
||||
test.slow(); // upload + delete needs more than 30s
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ test.describe('Homework Rules Configuration', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
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);
|
||||
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
@@ -17,42 +12,6 @@ const TEACHER_EMAIL = 'e2e-sub-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'SubTeacher123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||
const output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
).trim();
|
||||
const [schoolId, academicYearId] = output.split('\n');
|
||||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
function getNextWeekday(daysFromNow: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + daysFromNow);
|
||||
@@ -79,7 +38,7 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(STUDENT_EMAIL);
|
||||
await page.locator('#password').fill(STUDENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -89,7 +48,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -102,27 +61,20 @@ test.describe('Homework Submission (Story 5.10)', () => {
|
||||
|
||||
test.beforeAll(async () => {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`
|
||||
);
|
||||
} catch {
|
||||
// Cache pools may not exist
|
||||
}
|
||||
|
||||
// Create student user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
createTestUser('ecole-alpha', STUDENT_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE');
|
||||
|
||||
// Create teacher user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
|
||||
// Ensure class exists
|
||||
try {
|
||||
@@ -358,9 +310,8 @@ test.describe('Homework Submission (Story 5.10)', () => {
|
||||
await loginAsTeacher(page);
|
||||
|
||||
// Get the homework ID from the database
|
||||
const homeworkIdOutput = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM homework WHERE title = 'E2E Devoir à rendre' AND tenant_id = '${TENANT_ID}' LIMIT 1" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
const homeworkIdOutput = execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM homework WHERE title = 'E2E Devoir à rendre' AND tenant_id = '${TENANT_ID}' LIMIT 1" 2>&1`
|
||||
);
|
||||
|
||||
const idMatch = homeworkIdOutput.match(
|
||||
|
||||
@@ -74,7 +74,7 @@ async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -435,12 +435,14 @@ test.describe('Homework Management (Story 5.1)', () => {
|
||||
await editorVal.click();
|
||||
await editorVal.pressSequentially('Test validation');
|
||||
|
||||
// Set a past date — fill() works with Svelte 5 bind:value
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const y = yesterday.getFullYear();
|
||||
const m = String(yesterday.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(yesterday.getDate()).padStart(2, '0');
|
||||
// Set a past weekday — must be Mon-Fri to avoid frontend weekend validation
|
||||
const pastDay = new Date();
|
||||
do {
|
||||
pastDay.setDate(pastDay.getDate() - 1);
|
||||
} while (pastDay.getDay() === 0 || pastDay.getDay() === 6);
|
||||
const y = pastDay.getFullYear();
|
||||
const m = String(pastDay.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(pastDay.getDate()).padStart(2, '0');
|
||||
const pastDate = `${y}-${m}-${d}`;
|
||||
await page.locator('#hw-due-date').fill(pastDate);
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ test.describe('Image Rights Management', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ test.describe('Image Rights Management', () => {
|
||||
await page.locator('#email').fill(STUDENT_EMAIL);
|
||||
await page.locator('#password').fill(STUDENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -315,7 +315,7 @@ test.describe('Image Rights Management', () => {
|
||||
await page.goto(`${ALPHA_URL}/admin/image-rights`);
|
||||
|
||||
// Admin guard in +layout.svelte redirects non-admin users to /dashboard
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
|
||||
expect(page.url()).toContain('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ async function loginAsAdmin(page: Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ async function loginAsParent(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(PARENT_EMAIL);
|
||||
await page.locator('#password').fill(PARENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ test.describe('Periods Management (Story 2.3)', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
|
||||
// Admin guard redirects non-admin users to /dashboard
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
|
||||
expect(page.url()).toContain('/dashboard');
|
||||
});
|
||||
|
||||
@@ -137,7 +137,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||
|
||||
// Admin guard redirects non-admin users to /dashboard
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
|
||||
expect(page.url()).toContain('/dashboard');
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
await page.goto(`${ALPHA_URL}/admin`);
|
||||
|
||||
// Admin guard redirects non-admin users to /dashboard
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 30000 });
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 60000 });
|
||||
expect(page.url()).toContain('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ async function login(page: import('@playwright/test').Page, email: string) {
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(TEST_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ async function loginAsStudent(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(STUDENT_EMAIL);
|
||||
await page.locator('#password').fill(STUDENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -357,7 +357,7 @@ test.describe('Student Homework Consultation (Story 5.7)', () => {
|
||||
await page.locator('.filter-chip', { hasText: /maths/i }).click();
|
||||
|
||||
const cards = page.locator('.homework-card');
|
||||
await expect(cards).toHaveCount(1, { timeout: 5000 });
|
||||
await expect(cards).toHaveCount(1);
|
||||
await expect(cards.first().locator('.card-title')).toContainText('Exercices chapitre 3');
|
||||
});
|
||||
|
||||
@@ -369,10 +369,10 @@ test.describe('Student Homework Consultation (Story 5.7)', () => {
|
||||
|
||||
// Filter then unfilter
|
||||
await page.locator('.filter-chip', { hasText: /maths/i }).click();
|
||||
await expect(page.locator('.homework-card')).toHaveCount(1, { timeout: 5000 });
|
||||
await expect(page.locator('.homework-card')).toHaveCount(1);
|
||||
|
||||
await page.locator('.filter-chip', { hasText: /tous/i }).click();
|
||||
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 5000 });
|
||||
await expect(page.locator('.homework-card')).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -413,7 +413,7 @@ test.describe('Student Homework Consultation (Story 5.7)', () => {
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Done state should persist (localStorage)
|
||||
await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
@@ -46,7 +46,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ test.describe('Student Management', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ test.describe('Subjects Management (Story 2.2)', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
|
||||
await page.locator('#email').fill(TARGET_EMAIL);
|
||||
await page.locator('#password').fill(TARGET_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ test.describe('User Blocking', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ test.describe('User Creation', () => {
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -21,11 +21,15 @@ const config: PlaywrightTestConfig = {
|
||||
fullyParallel: !process.env.CI,
|
||||
// Use 1 worker in CI to ensure no parallel execution across different browser projects
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
// Long sequential CI runs (~3h) cause sporadic slowdowns across all browsers
|
||||
expect: process.env.CI ? { timeout: 15000 } : undefined,
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure'
|
||||
video: 'retain-on-failure',
|
||||
navigationTimeout: process.env.CI ? 30000 : undefined,
|
||||
actionTimeout: process.env.CI ? 15000 : undefined
|
||||
},
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
@@ -40,7 +44,8 @@ const config: PlaywrightTestConfig = {
|
||||
name: 'firefox',
|
||||
use: {
|
||||
browserName: 'firefox'
|
||||
}
|
||||
},
|
||||
timeout: process.env.CI ? 60000 : undefined
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
interface CompetencyProgress {
|
||||
competencyId: string;
|
||||
competencyCode: string;
|
||||
competencyName: string;
|
||||
currentLevelCode: string | null;
|
||||
currentLevelName: string | null;
|
||||
history: Array<{ date: string; levelCode: string; evaluationTitle: string }>;
|
||||
}
|
||||
|
||||
interface CompetencyLevel {
|
||||
code: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
progress: CompetencyProgress[];
|
||||
levels: CompetencyLevel[];
|
||||
}
|
||||
|
||||
let { progress, levels }: Props = $props();
|
||||
|
||||
function getBarWidth(levelCode: string | null): number {
|
||||
if (!levelCode || levels.length === 0) return 0;
|
||||
const idx = levels.findIndex((l) => l.code === levelCode);
|
||||
if (idx === -1) return 0;
|
||||
return ((idx + 1) / levels.length) * 100;
|
||||
}
|
||||
|
||||
function getBarColor(levelCode: string | null): string {
|
||||
if (!levelCode) return '#e2e8f0';
|
||||
const level = levels.find((l) => l.code === levelCode);
|
||||
return level?.color ?? '#94a3b8';
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="competency-progress-chart">
|
||||
{#if progress.length === 0}
|
||||
<p class="empty-message">Aucune compétence évaluée pour le moment.</p>
|
||||
{:else}
|
||||
<div class="bars-container">
|
||||
{#each progress as item (item.competencyId)}
|
||||
<div class="bar-row">
|
||||
<div class="bar-label" title={item.competencyName}>
|
||||
<span class="bar-code">{item.competencyCode}</span>
|
||||
<span class="bar-name">{item.competencyName}</span>
|
||||
</div>
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar-fill"
|
||||
style="width: {getBarWidth(item.currentLevelCode)}%; background-color: {getBarColor(item.currentLevelCode)};"
|
||||
title={item.currentLevelName ?? 'Non évalué'}
|
||||
></div>
|
||||
<!-- Level markers -->
|
||||
{#each levels as level, i (level.code)}
|
||||
<div
|
||||
class="bar-marker"
|
||||
style="left: {((i + 1) / levels.length) * 100}%"
|
||||
title={level.name}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="bar-value" style="color: {getBarColor(item.currentLevelCode)}">
|
||||
{item.currentLevelName ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Level scale -->
|
||||
<div class="level-scale">
|
||||
{#each levels as level (level.code)}
|
||||
<span class="scale-item" style="--level-color: {level.color}">
|
||||
<span class="scale-dot" style="background-color: {level.color}"></span>
|
||||
{level.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.competency-progress-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.bars-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 120px;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.bar-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-code {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text, #1e293b);
|
||||
}
|
||||
|
||||
.bar-name {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
position: relative;
|
||||
height: 1.25rem;
|
||||
background: var(--color-surface-alt, #f1f5f9);
|
||||
border-radius: 0.625rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 0.625rem;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.bar-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.level-scale {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border, #e2e8f0);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scale-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.scale-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
64
frontend/src/lib/stores/appreciationOfflineStore.ts
Normal file
64
frontend/src/lib/stores/appreciationOfflineStore.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
const DB_NAME = 'classeo-appreciations';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'pending-appreciations';
|
||||
|
||||
interface PendingAppreciation {
|
||||
evaluationId: string;
|
||||
studentId: string;
|
||||
appreciation: string | null;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, {
|
||||
keyPath: ['evaluationId', 'studentId'],
|
||||
});
|
||||
store.createIndex('byEvaluation', 'evaluationId', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function savePendingAppreciation(appreciation: PendingAppreciation): Promise<void> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).put(appreciation);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPendingAppreciations(evaluationId: string): Promise<PendingAppreciation[]> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const index = tx.objectStore(STORE_NAME).index('byEvaluation');
|
||||
const request = index.getAll(evaluationId);
|
||||
request.onsuccess = () => resolve(request.result as PendingAppreciation[]);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearPendingAppreciations(evaluationId: string): Promise<void> {
|
||||
const db = await openDb();
|
||||
const pending = await getPendingAppreciations(evaluationId);
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
for (const item of pending) {
|
||||
store.delete([item.evaluationId, item.studentId]);
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
@@ -109,6 +109,7 @@
|
||||
{/if}
|
||||
{#if isEleve}
|
||||
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
|
||||
<a href="/dashboard/student-competencies" class="nav-link" class:active={pathname === '/dashboard/student-competencies'}>Compétences</a>
|
||||
{/if}
|
||||
{#if isParent}
|
||||
<a href="/dashboard/parent-schedule" class="nav-link" class:active={pathname === '/dashboard/parent-schedule'}>EDT enfants</a>
|
||||
@@ -163,6 +164,9 @@
|
||||
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
|
||||
Mon emploi du temps
|
||||
</a>
|
||||
<a href="/dashboard/student-competencies" class="mobile-nav-link" class:active={pathname === '/dashboard/student-competencies'}>
|
||||
Compétences
|
||||
</a>
|
||||
{/if}
|
||||
{#if isParent}
|
||||
<a href="/dashboard/parent-schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-schedule'}>
|
||||
|
||||
375
frontend/src/routes/dashboard/student-competencies/+page.svelte
Normal file
375
frontend/src/routes/dashboard/student-competencies/+page.svelte
Normal file
@@ -0,0 +1,375 @@
|
||||
<script lang="ts">
|
||||
import { getApiBaseUrl } from '$lib/api';
|
||||
import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth';
|
||||
import CompetencyProgressBar from '$lib/components/organisms/CompetencyProgress/CompetencyProgressBar.svelte';
|
||||
|
||||
interface CompetencyLevel {
|
||||
code: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface CompetencyProgress {
|
||||
competencyId: string;
|
||||
competencyCode: string;
|
||||
competencyName: string;
|
||||
currentLevelCode: string | null;
|
||||
currentLevelName: string | null;
|
||||
history: Array<{ date: string; levelCode: string; evaluationTitle: string }>;
|
||||
}
|
||||
|
||||
let levels: CompetencyLevel[] = $state([]);
|
||||
let progress: CompetencyProgress[] = $state([]);
|
||||
let isLoading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let selectedCompetencyId: string | null = $state(null);
|
||||
|
||||
let selectedHistory = $derived.by(() => {
|
||||
if (!selectedCompetencyId) return null;
|
||||
return progress.find((p) => p.competencyId === selectedCompetencyId) ?? null;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
loadAll();
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const userId = await getAuthenticatedUserId();
|
||||
if (!userId) throw new Error('Non authentifié');
|
||||
|
||||
const [levelsRes, progressRes] = await Promise.all([
|
||||
authenticatedFetch(`${apiUrl}/competency-levels`),
|
||||
authenticatedFetch(`${apiUrl}/students/${userId}/competency-progress`),
|
||||
]);
|
||||
|
||||
if (!levelsRes.ok) throw new Error('Erreur lors du chargement des niveaux');
|
||||
if (!progressRes.ok) throw new Error('Erreur lors du chargement de la progression');
|
||||
|
||||
const levelsData = await levelsRes.json();
|
||||
levels = (levelsData['hydra:member'] ?? levelsData['member'] ?? (Array.isArray(levelsData) ? levelsData : [])) as CompetencyLevel[];
|
||||
|
||||
const progressData = await progressRes.json();
|
||||
progress = (progressData['hydra:member'] ?? progressData['member'] ?? (Array.isArray(progressData) ? progressData : [])) as CompetencyProgress[];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelColor(levelCode: string): string {
|
||||
const level = levels.find((l) => l.code === levelCode);
|
||||
return level?.color ?? '#94a3b8';
|
||||
}
|
||||
|
||||
function getLevelName(levelCode: string): string {
|
||||
const level = levels.find((l) => l.code === levelCode);
|
||||
return level?.name ?? levelCode;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(dateStr));
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mes compétences - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="competencies-page">
|
||||
<h1>Mes compétences</h1>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner" role="alert">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => (error = null)}>Fermer</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement de vos compétences...</p>
|
||||
</div>
|
||||
{:else if progress.length === 0}
|
||||
<div class="empty-state">
|
||||
<h2>Aucune compétence évaluée</h2>
|
||||
<p>Vos résultats de compétences apparaîtront ici dès qu'une évaluation par compétences sera complétée.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<section class="synthesis-section">
|
||||
<h2>Synthèse</h2>
|
||||
<CompetencyProgressBar {progress} {levels} />
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h2>Détail par compétence</h2>
|
||||
<p class="detail-hint">Cliquez sur une compétence pour voir l'historique de progression.</p>
|
||||
|
||||
<div class="competency-cards">
|
||||
{#each progress as item (item.competencyId)}
|
||||
<button
|
||||
class="competency-card"
|
||||
class:selected={selectedCompetencyId === item.competencyId}
|
||||
onclick={() => {
|
||||
selectedCompetencyId = selectedCompetencyId === item.competencyId ? null : item.competencyId;
|
||||
}}
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="card-code">{item.competencyCode}</span>
|
||||
{#if item.currentLevelCode}
|
||||
<span
|
||||
class="card-badge"
|
||||
style="background-color: {getLevelColor(item.currentLevelCode)}"
|
||||
>
|
||||
{item.currentLevelName}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="card-badge card-badge-empty">Non évalué</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-name">{item.competencyName}</div>
|
||||
{#if item.history.length > 1}
|
||||
<div class="card-evals">{item.history.length} évaluations</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedHistory}
|
||||
<div class="history-panel">
|
||||
<h3>Historique : {selectedHistory.competencyCode} - {selectedHistory.competencyName}</h3>
|
||||
<div class="history-timeline">
|
||||
{#each selectedHistory.history as entry, i (i)}
|
||||
<div class="timeline-entry">
|
||||
<div
|
||||
class="timeline-dot"
|
||||
style="background-color: {getLevelColor(entry.levelCode)}"
|
||||
></div>
|
||||
<div class="timeline-content">
|
||||
<span class="timeline-level" style="color: {getLevelColor(entry.levelCode)}">
|
||||
{getLevelName(entry.levelCode)}
|
||||
</span>
|
||||
<span class="timeline-eval">{entry.evaluationTitle}</span>
|
||||
<span class="timeline-date">{formatDate(entry.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.competencies-page {
|
||||
padding: 1.5rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
color: var(--color-error, #dc2626);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-banner p { margin: 0; }
|
||||
.error-banner button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-error, #dc2626);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border, #e2e8f0);
|
||||
border-top-color: var(--color-primary, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.synthesis-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-hint {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.competency-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.competency-card {
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.competency-card:hover {
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.competency-card.selected {
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
box-shadow: 0 0 0 1px var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.card-code {
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-badge-empty {
|
||||
background: var(--color-text-secondary, #94a3b8);
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.card-evals {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-tertiary, #a1a1aa);
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.history-panel h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.history-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.timeline-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.25rem;
|
||||
margin-left: -1.375rem;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.timeline-level {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.timeline-eval {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text, #1e293b);
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
</style>
|
||||
@@ -57,6 +57,14 @@
|
||||
const itemsPerPage = 30;
|
||||
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
|
||||
|
||||
// Competency data
|
||||
interface CompetencyItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
let availableCompetencies = $state<CompetencyItem[]>([]);
|
||||
|
||||
// Create modal
|
||||
let showCreateModal = $state(false);
|
||||
let newClassId = $state('');
|
||||
@@ -66,6 +74,8 @@
|
||||
let newEvaluationDate = $state('');
|
||||
let newGradeScale = $state(20);
|
||||
let newCoefficient = $state(1.0);
|
||||
let newIsCompetency = $state(false);
|
||||
let newSelectedCompetencyIds = $state<string[]>([]);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// Edit modal
|
||||
@@ -135,9 +145,10 @@
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
|
||||
const [classesRes, subjectsRes] = await Promise.all([
|
||||
const [classesRes, subjectsRes, competenciesRes] = await Promise.all([
|
||||
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
|
||||
authenticatedFetch(`${apiUrl}/subjects?itemsPerPage=100`),
|
||||
authenticatedFetch(`${apiUrl}/competencies`).catch(() => null),
|
||||
loadAssignments().catch((e) => {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors du chargement des affectations';
|
||||
}),
|
||||
@@ -158,6 +169,11 @@
|
||||
|
||||
classes = extractCollection<SchoolClass>(classesData);
|
||||
subjects = extractCollection<Subject>(subjectsData);
|
||||
|
||||
if (competenciesRes && competenciesRes.ok) {
|
||||
const compData = await competenciesRes.json();
|
||||
availableCompetencies = extractCollection<CompetencyItem>(compData);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
@@ -264,14 +280,19 @@
|
||||
newEvaluationDate = '';
|
||||
newGradeScale = 20;
|
||||
newCoefficient = 1.0;
|
||||
newIsCompetency = false;
|
||||
newSelectedCompetencyIds = [];
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
showCreateModal = false;
|
||||
newIsCompetency = false;
|
||||
newSelectedCompetencyIds = [];
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newClassId || !newSubjectId || !newTitle.trim() || !newEvaluationDate) return;
|
||||
if (newIsCompetency && newSelectedCompetencyIds.length === 0) return;
|
||||
|
||||
try {
|
||||
isSubmitting = true;
|
||||
@@ -286,8 +307,8 @@
|
||||
title: newTitle.trim(),
|
||||
description: newDescription.trim() || null,
|
||||
evaluationDate: newEvaluationDate,
|
||||
gradeScale: newGradeScale,
|
||||
coefficient: newCoefficient,
|
||||
gradeScale: newIsCompetency ? 1 : newGradeScale,
|
||||
coefficient: newIsCompetency ? 1.0 : newCoefficient,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -296,6 +317,21 @@
|
||||
throw new Error((data as Record<string, string> | null)?.['message'] ?? 'Erreur lors de la création');
|
||||
}
|
||||
|
||||
// Link competencies if competency evaluation
|
||||
if (newIsCompetency && newSelectedCompetencyIds.length > 0) {
|
||||
const evalData = await response.json();
|
||||
const evalId = (evalData as Record<string, string>)['id'];
|
||||
const linkRes = await authenticatedFetch(`${apiUrl}/evaluations/${evalId}/competencies`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ competencyIds: newSelectedCompetencyIds }),
|
||||
});
|
||||
if (!linkRes.ok) {
|
||||
const linkData = await linkRes.json().catch(() => null);
|
||||
throw new Error((linkData as Record<string, string> | null)?.['hydra:description'] ?? 'Erreur lors de l\'association des compétences');
|
||||
}
|
||||
}
|
||||
|
||||
closeCreateModal();
|
||||
await loadEvaluations();
|
||||
} catch (e) {
|
||||
@@ -486,6 +522,9 @@
|
||||
<a class="btn-primary btn-sm" href="/dashboard/teacher/evaluations/{ev.id}/grades">
|
||||
Saisir les notes
|
||||
</a>
|
||||
<a class="btn-secondary btn-sm" href="/dashboard/teacher/evaluations/{ev.id}/competencies">
|
||||
Compétences
|
||||
</a>
|
||||
<button class="btn-secondary btn-sm" onclick={() => openEditModal(ev)}>
|
||||
Modifier
|
||||
</button>
|
||||
@@ -587,35 +626,73 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group form-group-half">
|
||||
<label for="ev-scale">Barème *</label>
|
||||
<input
|
||||
type="number"
|
||||
id="ev-scale"
|
||||
bind:value={newGradeScale}
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{#if gradeScalePreview}
|
||||
<small class="form-hint">{gradeScalePreview}</small>
|
||||
{#if availableCompetencies.length > 0}
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={newIsCompetency} />
|
||||
Évaluation par compétences
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if newIsCompetency}
|
||||
<div class="form-group">
|
||||
<label>Compétences évaluées *</label>
|
||||
<div class="competency-checklist">
|
||||
{#each availableCompetencies as comp (comp.id)}
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={comp.id}
|
||||
checked={newSelectedCompetencyIds.includes(comp.id)}
|
||||
onchange={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (target.checked) {
|
||||
newSelectedCompetencyIds = [...newSelectedCompetencyIds, comp.id];
|
||||
} else {
|
||||
newSelectedCompetencyIds = newSelectedCompetencyIds.filter((id) => id !== comp.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span class="comp-code">{comp.code}</span> {comp.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{#if newIsCompetency && newSelectedCompetencyIds.length === 0}
|
||||
<small class="form-hint form-hint-warning">Sélectionnez au moins une compétence</small>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="form-row">
|
||||
<div class="form-group form-group-half">
|
||||
<label for="ev-scale">Barème *</label>
|
||||
<input
|
||||
type="number"
|
||||
id="ev-scale"
|
||||
bind:value={newGradeScale}
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{#if gradeScalePreview}
|
||||
<small class="form-hint">{gradeScalePreview}</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group form-group-half">
|
||||
<label for="ev-coeff">Coefficient</label>
|
||||
<input
|
||||
type="number"
|
||||
id="ev-coeff"
|
||||
bind:value={newCoefficient}
|
||||
min="0.1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
/>
|
||||
<div class="form-group form-group-half">
|
||||
<label for="ev-coeff">Coefficient</label>
|
||||
<input
|
||||
type="number"
|
||||
id="ev-coeff"
|
||||
bind:value={newCoefficient}
|
||||
min="0.1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick={closeCreateModal}>Annuler</button>
|
||||
@@ -1176,4 +1253,28 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.competency-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.competency-checklist .comp-code {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { getApiBaseUrl } from '$lib/api';
|
||||
import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth';
|
||||
|
||||
interface Evaluation {
|
||||
id: string;
|
||||
title: string;
|
||||
classId: string;
|
||||
className: string | null;
|
||||
subjectName: string | null;
|
||||
evaluationDate: string;
|
||||
gradesPublishedAt: string | null;
|
||||
}
|
||||
|
||||
interface CompetencyLevel {
|
||||
code: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface CompetencyEvaluation {
|
||||
id: string;
|
||||
competencyId: string;
|
||||
competencyCode: string;
|
||||
competencyName: string;
|
||||
}
|
||||
|
||||
interface ResultCell {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
competencyEvaluationId: string;
|
||||
competencyCode: string;
|
||||
competencyName: string;
|
||||
levelCode: string | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
let evaluationId: string = $derived($page.params.id ?? '');
|
||||
let evaluation: Evaluation | null = $state(null);
|
||||
let levels: CompetencyLevel[] = $state([]);
|
||||
let competencyEvaluations: CompetencyEvaluation[] = $state([]);
|
||||
let results: ResultCell[] = $state([]);
|
||||
let isLoading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let saveStatus: string | null = $state(null);
|
||||
let saveTimer: number | null = $state(null);
|
||||
|
||||
// Derived: unique students
|
||||
let students = $derived.by(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const r of results) {
|
||||
if (!seen.has(r.studentId)) {
|
||||
seen.set(r.studentId, r.studentName);
|
||||
}
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
||||
});
|
||||
|
||||
// Derived: unique competencies (in order)
|
||||
let competencies = $derived.by(() => {
|
||||
const seen = new Map<string, { code: string; name: string; ceId: string }>();
|
||||
for (const ce of competencyEvaluations) {
|
||||
if (!seen.has(ce.competencyId)) {
|
||||
seen.set(ce.competencyId, {
|
||||
code: ce.competencyCode,
|
||||
name: ce.competencyName,
|
||||
ceId: ce.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...seen.entries()].map(([id, v]) => ({ id, ...v }));
|
||||
});
|
||||
|
||||
// Derived: result lookup by studentId-ceId for reactive template bindings
|
||||
let resultMap = $derived.by(() => {
|
||||
const map = new Map<string, ResultCell>();
|
||||
for (const r of results) {
|
||||
map.set(`${r.studentId}-${r.competencyEvaluationId}`, r);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// Keyboard shortcut map: 1=first level, 2=second, etc. (based on array index)
|
||||
let levelByShortcut = $derived.by(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
map.set(String(i + 1), levels[i]!.code);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (evaluationId) {
|
||||
loadAll();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (saveTimer !== null) {
|
||||
window.clearTimeout(saveTimer);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
await getAuthenticatedUserId();
|
||||
|
||||
// Load evaluation, levels, competency evaluations, and results in parallel
|
||||
const [evalRes, levelsRes, ceRes, resultsRes] = await Promise.all([
|
||||
authenticatedFetch(`${apiUrl}/evaluations/${evaluationId}`),
|
||||
authenticatedFetch(`${apiUrl}/competency-levels`),
|
||||
authenticatedFetch(`${apiUrl}/evaluations/${evaluationId}/competencies`),
|
||||
authenticatedFetch(`${apiUrl}/evaluations/${evaluationId}/competency-results`),
|
||||
]);
|
||||
|
||||
if (!evalRes.ok) throw new Error('Évaluation non trouvée');
|
||||
if (!levelsRes.ok) throw new Error('Erreur lors du chargement des niveaux');
|
||||
if (!ceRes.ok) throw new Error('Erreur lors du chargement des compétences');
|
||||
if (!resultsRes.ok) throw new Error('Erreur lors du chargement des résultats');
|
||||
|
||||
evaluation = await evalRes.json();
|
||||
|
||||
const levelsData = await levelsRes.json();
|
||||
levels = (levelsData['hydra:member'] ?? levelsData['member'] ?? (Array.isArray(levelsData) ? levelsData : [])) as CompetencyLevel[];
|
||||
|
||||
const ceData = await ceRes.json();
|
||||
competencyEvaluations = (ceData['hydra:member'] ?? ceData['member'] ?? (Array.isArray(ceData) ? ceData : [])) as CompetencyEvaluation[];
|
||||
|
||||
const resultsData = await resultsRes.json();
|
||||
const rawResults: Array<{
|
||||
id: string | null;
|
||||
competencyEvaluationId: string;
|
||||
competencyId: string;
|
||||
competencyCode: string;
|
||||
competencyName: string;
|
||||
studentId: string;
|
||||
studentName: string | null;
|
||||
levelCode: string | null;
|
||||
}> = resultsData['hydra:member'] ?? resultsData['member'] ?? (Array.isArray(resultsData) ? resultsData : []);
|
||||
|
||||
results = rawResults.map((r) => ({
|
||||
studentId: r.studentId,
|
||||
studentName: r.studentName ?? '',
|
||||
competencyEvaluationId: r.competencyEvaluationId,
|
||||
competencyCode: r.competencyCode ?? '',
|
||||
competencyName: r.competencyName ?? '',
|
||||
levelCode: r.levelCode,
|
||||
dirty: false,
|
||||
}));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setLevel(studentId: string, ceId: string, levelCode: string) {
|
||||
const idx = results.findIndex(
|
||||
(r) => r.studentId === studentId && r.competencyEvaluationId === ceId,
|
||||
);
|
||||
if (idx === -1) return;
|
||||
|
||||
const current = results[idx]!;
|
||||
// Toggle: if already same level, clear it
|
||||
const newLevel = current.levelCode === levelCode ? null : levelCode;
|
||||
results[idx] = { ...current, levelCode: newLevel, dirty: true };
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
function handleCellKeydown(event: KeyboardEvent, studentId: string, ceId: string) {
|
||||
const levelCode = levelByShortcut.get(event.key);
|
||||
if (levelCode) {
|
||||
event.preventDefault();
|
||||
setLevel(studentId, ceId, levelCode);
|
||||
return;
|
||||
}
|
||||
|
||||
const cellIndex = getCellIndex(studentId, ceId);
|
||||
|
||||
if (event.key === 'Tab' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
focusNextCell(cellIndex);
|
||||
} else if (event.key === 'Tab' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
focusPrevCell(cellIndex);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
focusNextCell(cellIndex);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
focusPrevCell(cellIndex);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
focusCellBelow(studentId, ceId);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
focusCellAbove(studentId, ceId);
|
||||
}
|
||||
}
|
||||
|
||||
function getCellIndex(studentId: string, ceId: string): number {
|
||||
const studentIdx = students.findIndex((s) => s.id === studentId);
|
||||
const compIdx = competencies.findIndex((c) => c.ceId === ceId);
|
||||
return studentIdx * competencies.length + compIdx;
|
||||
}
|
||||
|
||||
function focusNextCell(currentIndex: number) {
|
||||
const nextIndex = currentIndex + 1;
|
||||
const totalCells = students.length * competencies.length;
|
||||
if (nextIndex < totalCells) {
|
||||
focusCellByIndex(nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function focusPrevCell(currentIndex: number) {
|
||||
const prevIndex = currentIndex - 1;
|
||||
if (prevIndex >= 0) {
|
||||
focusCellByIndex(prevIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function focusCellBelow(studentId: string, ceId: string) {
|
||||
const studentIdx = students.findIndex((s) => s.id === studentId);
|
||||
if (studentIdx < students.length - 1) {
|
||||
const nextStudent = students[studentIdx + 1]!;
|
||||
const el = document.getElementById(`cell-${nextStudent.id}-${ceId}`);
|
||||
el?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function focusCellAbove(studentId: string, ceId: string) {
|
||||
const studentIdx = students.findIndex((s) => s.id === studentId);
|
||||
if (studentIdx > 0) {
|
||||
const prevStudent = students[studentIdx - 1]!;
|
||||
const el = document.getElementById(`cell-${prevStudent.id}-${ceId}`);
|
||||
el?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function focusCellByIndex(index: number) {
|
||||
const studentIdx = Math.floor(index / competencies.length);
|
||||
const compIdx = index % competencies.length;
|
||||
const student = students[studentIdx];
|
||||
const comp = competencies[compIdx];
|
||||
if (student && comp) {
|
||||
const el = document.getElementById(`cell-${student.id}-${comp.ceId}`);
|
||||
el?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoSave() {
|
||||
if (saveTimer !== null) {
|
||||
window.clearTimeout(saveTimer);
|
||||
}
|
||||
saveTimer = window.setTimeout(() => {
|
||||
saveResults();
|
||||
}, 500) as unknown as number;
|
||||
}
|
||||
|
||||
async function saveResults() {
|
||||
const dirtyResults = results.filter((r) => r.dirty);
|
||||
if (dirtyResults.length === 0) return;
|
||||
|
||||
// Snapshot dirty IDs before async operation to avoid race condition
|
||||
const dirtyKeys = new Set(dirtyResults.map((r) => `${r.studentId}-${r.competencyEvaluationId}`));
|
||||
|
||||
try {
|
||||
saveStatus = 'Sauvegarde...';
|
||||
const apiUrl = getApiBaseUrl();
|
||||
|
||||
const payload = dirtyResults.map((r) => ({
|
||||
studentId: r.studentId,
|
||||
competencyEvaluationId: r.competencyEvaluationId,
|
||||
levelCode: r.levelCode,
|
||||
}));
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`${apiUrl}/evaluations/${evaluationId}/competency-results`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ results: payload }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
(data as Record<string, string> | null)?.['hydra:description'] ??
|
||||
(data as Record<string, string> | null)?.['message'] ??
|
||||
'Erreur lors de la sauvegarde',
|
||||
);
|
||||
}
|
||||
|
||||
// Only mark saved the results that were actually sent
|
||||
results = results.map((r) => {
|
||||
const key = `${r.studentId}-${r.competencyEvaluationId}`;
|
||||
return r.dirty && dirtyKeys.has(key) ? { ...r, dirty: false } : r;
|
||||
});
|
||||
saveStatus = 'Sauvegardé';
|
||||
window.setTimeout(() => {
|
||||
if (saveStatus === 'Sauvegardé') saveStatus = null;
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
saveStatus = null;
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelName(levelCode: string | null): string {
|
||||
if (!levelCode) return '';
|
||||
const level = levels.find((l) => l.code === levelCode);
|
||||
return level?.name ?? levelCode;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(dateStr));
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{evaluation?.title ?? 'Compétences'} - Évaluation par compétences</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="competency-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<a href="/dashboard/teacher/evaluations" class="back-link">← Retour aux évaluations</a>
|
||||
{#if evaluation}
|
||||
<h1>{evaluation.title}</h1>
|
||||
<div class="header-meta">
|
||||
{#if evaluation.className}
|
||||
<span class="meta-tag">{evaluation.className}</span>
|
||||
{/if}
|
||||
{#if evaluation.subjectName}
|
||||
<span class="meta-tag">{evaluation.subjectName}</span>
|
||||
{/if}
|
||||
<span class="meta-tag">{formatDate(evaluation.evaluationDate)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if saveStatus}
|
||||
<div class="save-indicator" class:saving={saveStatus === 'Sauvegarde...'}>
|
||||
{saveStatus}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner" role="alert">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => (error = null)}>Fermer</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement de la grille de compétences...</p>
|
||||
</div>
|
||||
{:else if competencies.length === 0}
|
||||
<div class="empty-state">
|
||||
<h2>Aucune compétence associée</h2>
|
||||
<p>Cette évaluation n'a pas encore de compétences associées.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Level legend -->
|
||||
<div class="level-legend">
|
||||
<span class="legend-label">Niveaux :</span>
|
||||
{#each levels as level, i (level.code)}
|
||||
<span class="legend-item" style="--level-color: {level.color}">
|
||||
<span class="legend-key">{i + 1}</span>
|
||||
<span class="legend-dot" style="background-color: {level.color}"></span>
|
||||
{level.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Competency grid -->
|
||||
<div class="grid-container" role="grid" aria-label="Grille de compétences">
|
||||
<table class="competency-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="student-col" scope="col">Élève</th>
|
||||
{#each competencies as comp (comp.ceId)}
|
||||
<th class="competency-col" scope="col" title={comp.name}>
|
||||
<span class="comp-code">{comp.code}</span>
|
||||
<span class="comp-name">{comp.name}</span>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each students as student (student.id)}
|
||||
<tr>
|
||||
<td class="student-name">{student.name}</td>
|
||||
{#each competencies as comp (comp.ceId)}
|
||||
{@const cellKey = `${student.id}-${comp.ceId}`}
|
||||
<td
|
||||
class="level-cell"
|
||||
class:dirty={resultMap.get(cellKey)?.dirty}
|
||||
id="cell-{student.id}-{comp.ceId}"
|
||||
tabindex="0"
|
||||
role="gridcell"
|
||||
aria-label="{student.name} - {comp.name}: {getLevelName(resultMap.get(cellKey)?.levelCode ?? null)}"
|
||||
onkeydown={(e) => handleCellKeydown(e, student.id, comp.ceId)}
|
||||
>
|
||||
<div class="level-buttons">
|
||||
{#each levels as level, levelIdx (level.code)}
|
||||
<button
|
||||
class="level-btn"
|
||||
class:active={resultMap.get(cellKey)?.levelCode === level.code}
|
||||
style="--btn-color: {level.color}"
|
||||
title="{level.name} (Raccourci: {levelIdx + 1})"
|
||||
onclick={() => setLevel(student.id, comp.ceId, level.code)}
|
||||
>
|
||||
{levelIdx + 1}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.competency-page {
|
||||
padding: 1.5rem;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--color-primary, #3b82f6);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
background: var(--color-surface-alt, #f1f5f9);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.save-indicator {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.save-indicator.saving {
|
||||
background: var(--color-warning-bg, #fef9c3);
|
||||
color: var(--color-warning, #ca8a04);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
color: var(--color-error, #dc2626);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-banner button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-error, #dc2626);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border, #e2e8f0);
|
||||
border-top-color: var(--color-primary, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.level-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface-alt, #f8fafc);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #1e293b);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.legend-key {
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.25rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.competency-grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.competency-grid th {
|
||||
background: var(--color-surface-alt, #f8fafc);
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid var(--color-border, #e2e8f0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.student-col {
|
||||
text-align: left !important;
|
||||
min-width: 180px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.competency-col {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.comp-code {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.comp-name {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.competency-grid td {
|
||||
padding: 0.375rem;
|
||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.student-name {
|
||||
font-weight: 500;
|
||||
padding-left: 0.75rem !important;
|
||||
background: var(--color-surface, #fff);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.level-cell {
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.level-cell:focus-within {
|
||||
box-shadow: inset 0 0 0 2px var(--color-primary, #3b82f6);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.level-cell.dirty {
|
||||
background: var(--color-warning-bg, #fefce8);
|
||||
}
|
||||
|
||||
.level-buttons {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.level-btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 2px solid var(--color-border, #e2e8f0);
|
||||
background: var(--color-surface, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #94a3b8);
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.level-btn:hover {
|
||||
border-color: var(--btn-color, #3b82f6);
|
||||
color: var(--btn-color, #3b82f6);
|
||||
}
|
||||
|
||||
.level-btn.active {
|
||||
background: var(--btn-color, #3b82f6);
|
||||
border-color: var(--btn-color, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user