import { test, expect, Page } from '@playwright/test' /** * E2E tests for /stundenplan * * Backend calls go through /api/school/* (Next.js proxy → school-service). * For most tests we intercept those routes so the suite is hermetic and does * not depend on a populated database or a valid JWT. */ const MOCK_TEACHER_ID = '11111111-1111-1111-1111-111111111111' interface MockClass { id: string name: string grade_level: number student_count: number notes?: string created_by_user_id: string created_at: string } async function mockSchoolApi(page: Page, opts: { classes?: MockClass[]; teachers?: unknown[] } = {}) { const classes = opts.classes ?? [] const teachers = opts.teachers ?? [] await page.route('**/api/school/timetable/classes', async (route) => { if (route.request().method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(classes) }) } if (route.request().method() === 'POST') { const body = JSON.parse(route.request().postData() || '{}') const created: MockClass = { id: 'new-class-id', name: body.name, grade_level: body.grade_level, student_count: body.student_count ?? 0, notes: body.notes, created_by_user_id: 'test-user', created_at: new Date().toISOString(), } classes.push(created) return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) }) } return route.fulfill({ status: 405 }) }) await page.route('**/api/school/timetable/teachers', async (route) => { if (route.request().method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(teachers) }) } return route.fulfill({ status: 405 }) }) await page.route('**/api/school/timetable/subjects', async (route) => { return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) }) await page.route('**/api/school/timetable/rooms', async (route) => { return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) }) await page.route('**/api/school/timetable/constraints/teacher/unavailable-day', async (route) => { return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) }) } test.describe('Stundenplan — Page Shell', () => { test.beforeEach(async ({ page }) => { await mockSchoolApi(page) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') }) test('page loads with title and subtitle', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Stundenplan' })).toBeVisible() await expect(page.getByText('Stammdaten und Regeln fuer den Solver')).toBeVisible() }) test('shows all 8 tabs', async ({ page }) => { for (const label of ['Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) { await expect(page.getByRole('button', { name: label })).toBeVisible() } }) test('Klassen tab is active by default', async ({ page }) => { await expect(page.getByTestId('klassen-manager')).toBeVisible() }) test('JWT dev field exists and persists into localStorage', async ({ page }) => { await page.getByText('Dev: JWT-Token setzen').click() page.on('dialog', d => d.accept()) await page.getByPlaceholder('Bearer-Token').fill('test-jwt-abc') await page.getByRole('button', { name: 'Speichern' }).click() const stored = await page.evaluate(() => localStorage.getItem('bp_stundenplan_jwt')) expect(stored).toBe('test-jwt-abc') }) }) test.describe('Stundenplan — Tab navigation', () => { test.beforeEach(async ({ page }) => { await mockSchoolApi(page) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') }) test('switching to Lehrer shows the Lehrer manager', async ({ page }) => { await page.getByRole('button', { name: 'Lehrer', exact: true }).click() await expect(page.getByTestId('lehrer-manager')).toBeVisible() await expect(page.getByRole('heading', { name: /^Lehrer/ })).toBeVisible() }) test('switching to Faecher shows the Faecher manager', async ({ page }) => { await page.getByRole('button', { name: 'Faecher', exact: true }).click() await expect(page.getByTestId('faecher-manager')).toBeVisible() }) test('switching to Raeume shows the Raeume manager', async ({ page }) => { await page.getByRole('button', { name: 'Raeume', exact: true }).click() await expect(page.getByTestId('raeume-manager')).toBeVisible() }) test('switching to Regeln shows the constraint editor', async ({ page }) => { await page.getByRole('button', { name: 'Regeln (Constraints)' }).click() await expect(page.getByTestId('teacher-unavailable-day-editor')).toBeVisible() await expect(page.getByText('Lehrer: Tag nicht verfuegbar')).toBeVisible() }) test('unimplemented tabs show placeholder', async ({ page }) => { await page.getByRole('button', { name: 'Zeitraster' }).click() await expect(page.getByTestId('not-implemented')).toBeVisible() await expect(page.getByText('Noch nicht implementiert')).toBeVisible() }) }) test.describe('Stundenplan — Klassen CRUD', () => { test('empty state shows when no classes', async ({ page }) => { await mockSchoolApi(page, { classes: [] }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await expect(page.getByText('Noch keine Klassen angelegt.')).toBeVisible() }) test('renders classes returned by the backend', async ({ page }) => { await mockSchoolApi(page, { classes: [ { id: 'c1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '2026-05-21T10:00:00Z' }, { id: 'c2', name: '5b', grade_level: 5, student_count: 23, created_by_user_id: 'u', created_at: '2026-05-21T10:00:00Z' }, ], }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await expect(page.getByText('Klassen (2)')).toBeVisible() await expect(page.getByRole('cell', { name: '5a' })).toBeVisible() await expect(page.getByRole('cell', { name: '5b' })).toBeVisible() }) test('+ Neue Klasse toggles the form', async ({ page }) => { await mockSchoolApi(page) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await expect(page.getByPlaceholder('z.B. 5a')).toHaveCount(0) await page.getByRole('button', { name: '+ Neue Klasse' }).click() await expect(page.getByPlaceholder('z.B. 5a')).toBeVisible() await page.getByRole('button', { name: 'Abbrechen' }).click() await expect(page.getByPlaceholder('z.B. 5a')).toHaveCount(0) }) test('form submission appends a new class to the list', async ({ page }) => { await mockSchoolApi(page, { classes: [] }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.getByRole('button', { name: '+ Neue Klasse' }).click() await page.getByPlaceholder('z.B. 5a').fill('7c') await page.locator('input[type=number]').first().fill('7') await page.getByRole('button', { name: 'Anlegen' }).click() await expect(page.getByText('Klassen (1)')).toBeVisible() await expect(page.getByRole('cell', { name: '7c' })).toBeVisible() }) }) test.describe('Stundenplan — Constraint editor empty teacher state', () => { test('shows warning when no teachers exist', async ({ page }) => { await mockSchoolApi(page, { teachers: [] }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.getByRole('button', { name: 'Regeln (Constraints)' }).click() await expect(page.getByText('Zuerst Lehrer anlegen')).toBeVisible() }) test('enables form button when teachers exist', async ({ page }) => { await mockSchoolApi(page, { teachers: [ { id: MOCK_TEACHER_ID, first_name: 'Anna', last_name: 'Schmidt', short_code: 'SCH', employment_percentage: 100, max_hours_week: 28, created_by_user_id: 'u', created_at: '2026-05-21T10:00:00Z' }, ], }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.getByRole('button', { name: 'Regeln (Constraints)' }).click() const btn = page.getByRole('button', { name: '+ Neue Regel' }) await expect(btn).toBeEnabled() await btn.click() await expect(page.getByRole('combobox').first()).toBeVisible() await expect(page.getByText('Schmidt, Anna')).toBeVisible() }) }) test.describe('Stundenplan — Sidebar entry', () => { test('sidebar contains Stundenplan link', async ({ page }) => { await mockSchoolApi(page) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') const sidebar = page.locator('aside').first() await expect(sidebar.getByText(/Stundenplan|Timetable/).first()).toBeVisible() }) })