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' const MOCK_SUBJECT_ID = '22222222-2222-2222-2222-222222222222' const MOCK_CLASS_ID = '33333333-3333-3333-3333-333333333333' interface MockClass { id: string name: string grade_level: number student_count: number notes?: string created_by_user_id: string created_at: string } interface MockOpts { classes?: MockClass[] teachers?: unknown[] subjects?: unknown[] rooms?: unknown[] periods?: unknown[] curriculum?: unknown[] assignments?: unknown[] } async function mockSchoolApi(page: Page, opts: MockOpts = {}) { const classes = opts.classes ?? [] const teachers = opts.teachers ?? [] const subjects = opts.subjects ?? [] const rooms = opts.rooms ?? [] const periods = opts.periods ?? [] const curriculum = opts.curriculum ?? [] const assignments = opts.assignments ?? [] 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 }) }) // Helper to mount a read-only endpoint with a static list. const staticList = (path: string, data: unknown) => page.route(`**/api/school/timetable/${path}`, async (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) })) await staticList('teachers', teachers) await staticList('subjects', subjects) await staticList('rooms', rooms) await staticList('periods', periods) await staticList('curriculum', curriculum) await staticList('assignments', assignments) // Constraint endpoints — all empty by default. for (const path of [ 'constraints/teacher/unavailable-day', 'constraints/teacher/unavailable-window', 'constraints/subject/max-consecutive', 'constraints/subject/preferred-period', ]) { await staticList(path, []) } } 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 }) => { // Sidebar entries collide with tab labels for 'Lehrer' — scope to
. const tabs = page.locator('main nav') for (const label of ['Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) { await expect(tabs.getByRole('button', { name: label, exact: true })).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('all 8 tabs render their manager', async ({ page }) => { const tabs = page.locator('main nav') const cases: { label: string; testId: string }[] = [ { label: 'Lehrer', testId: 'lehrer-manager' }, { label: 'Faecher', testId: 'faecher-manager' }, { label: 'Raeume', testId: 'raeume-manager' }, { label: 'Zeitraster', testId: 'periods-manager' }, { label: 'Stundentafel', testId: 'curriculum-manager' }, { label: 'Lehrauftraege', testId: 'assignments-manager' }, { label: 'Regeln (Constraints)', testId: 'regeln-hub' }, ] for (const c of cases) { await tabs.getByRole('button', { name: c.label, exact: true }).click() await expect(page.getByTestId(c.testId)).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 — Periods grid', () => { test('empty state shows when no periods', async ({ page }) => { await mockSchoolApi(page, { periods: [] }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.locator('main nav').getByRole('button', { name: 'Zeitraster', exact: true }).click() await expect(page.getByText('Noch kein Zeitraster definiert.')).toBeVisible() }) test('renders weekday grid with period entries', async ({ page }) => { await mockSchoolApi(page, { periods: [ { id: 'p1', day_of_week: 1, period_index: 1, start_time: '08:00', end_time: '08:45', is_break: false, created_by_user_id: 'u', created_at: '' }, { id: 'p2', day_of_week: 2, period_index: 1, start_time: '08:00', end_time: '08:45', is_break: false, created_by_user_id: 'u', created_at: '' }, { id: 'p3', day_of_week: 1, period_index: 2, start_time: '08:50', end_time: '09:35', is_break: false, created_by_user_id: 'u', created_at: '' }, ], }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.locator('main nav').getByRole('button', { name: 'Zeitraster', exact: true }).click() await expect(page.getByText('Zeitraster (3)')).toBeVisible() // Header row contains weekday abbreviations Mo–So. for (const day of ['Mo', 'Di', 'Mi']) { await expect(page.getByRole('columnheader', { name: day, exact: true })).toBeVisible() } // Two distinct time labels should appear in the grid. await expect(page.getByText('08:00–08:45').first()).toBeVisible() await expect(page.getByText('08:50–09:35')).toBeVisible() }) }) test.describe('Stundenplan — Curriculum prerequisites', () => { test('shows warning when classes or subjects missing', async ({ page }) => { await mockSchoolApi(page, { classes: [], subjects: [] }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.locator('main nav').getByRole('button', { name: 'Stundentafel', exact: true }).click() await expect(page.getByText('Zuerst Klassen und Faecher anlegen.')).toBeVisible() }) test('enables form when both classes and subjects exist', async ({ page }) => { await mockSchoolApi(page, { classes: [{ id: MOCK_CLASS_ID, name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }], subjects: [{ id: MOCK_SUBJECT_ID, name: 'Mathematik', short_code: 'M', is_main_subject: true, created_by_user_id: 'u', created_at: '' }], }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.locator('main nav').getByRole('button', { name: 'Stundentafel', exact: true }).click() const btn = page.getByRole('button', { name: '+ Neuer Eintrag' }) await expect(btn).toBeEnabled() await btn.click() await expect(page.getByRole('combobox').first().locator('option', { hasText: '5a' })).toHaveCount(1) await expect(page.locator('select').nth(1).locator('option', { hasText: 'Mathematik' })).toHaveCount(1) }) }) test.describe('Stundenplan — Assignments prerequisites', () => { test('warns when teachers/classes/subjects missing', async ({ page }) => { await mockSchoolApi(page) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.locator('main nav').getByRole('button', { name: 'Lehrauftraege', exact: true }).click() await expect(page.getByText('Zuerst Klassen, Faecher und Lehrer anlegen.')).toBeVisible() }) test('renders existing assignments with joined names', 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: '' }], classes: [{ id: MOCK_CLASS_ID, name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }], subjects: [{ id: MOCK_SUBJECT_ID, name: 'Mathe', short_code: 'M', is_main_subject: true, created_by_user_id: 'u', created_at: '' }], assignments: [{ id: 'a1', teacher_id: MOCK_TEACHER_ID, class_id: MOCK_CLASS_ID, subject_id: MOCK_SUBJECT_ID, teacher_name: 'Schmidt, Anna', class_name: '5a', subject_name: 'Mathe', created_at: '', }], }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.locator('main nav').getByRole('button', { name: 'Lehrauftraege', exact: true }).click() await expect(page.getByText('Lehrauftraege (1)')).toBeVisible() await expect(page.getByRole('cell', { name: 'Schmidt, Anna' })).toBeVisible() await expect(page.getByRole('cell', { name: 'Mathe' })).toBeVisible() }) }) test.describe('Stundenplan — Regeln Hub', () => { test.beforeEach(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: '' }], subjects: [{ id: MOCK_SUBJECT_ID, name: 'Mathe', short_code: 'M', is_main_subject: true, created_by_user_id: 'u', created_at: '' }], }) await page.goto('/stundenplan') await page.waitForLoadState('networkidle') await page.locator('main nav').getByRole('button', { name: 'Regeln (Constraints)' }).click() }) test('shows all 4 rule groups in the sidebar', async ({ page }) => { for (const group of ['Lehrer', 'Fach', 'Klasse', 'Raum']) { await expect(page.getByTestId('regeln-hub').getByText(group, { exact: true })).toBeVisible() } }) test('TeacherUnavailableDay editor is active by default', async ({ page }) => { await expect(page.getByTestId('teacher-unavailable-day-editor')).toBeVisible() }) test('switching to UnavailableWindow editor swaps the right pane', async ({ page }) => { await page.getByRole('button', { name: 'Zeitfenster nicht verfuegbar' }).click() await expect(page.getByTestId('teacher-unavailable-window-editor')).toBeVisible() await expect(page.getByText('„Lehrer Z Dienstags 13:00–17:00 nicht".')).toBeVisible() }) test('switching to SubjectMaxConsecutive editor works', async ({ page }) => { await page.getByRole('button', { name: 'Max. Stunden am Stueck' }).click() await expect(page.getByTestId('subject-max-consecutive-editor')).toBeVisible() }) test('switching to SubjectPreferredPeriod editor works', async ({ page }) => { await page.getByRole('button', { name: 'Bevorzugter Stunden-Bereich' }).click() await expect(page.getByTestId('subject-preferred-period-editor')).toBeVisible() }) test('unimplemented rules are disabled in the sidebar', async ({ page }) => { const soonBtn = page.getByRole('button', { name: /Min\. Tagesabstand/ }) await expect(soonBtn).toBeDisabled() await expect(page.getByText('soon').first()).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() }) })