4657589b89
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 29s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 22s
- 'Lehrer' label collided with a Sidebar entry; scope to <main nav>. - 'Schmidt, Anna' lived in a closed <option>; assert on count via select.locator() instead of toBeVisible(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
224 lines
9.1 KiB
TypeScript
224 lines
9.1 KiB
TypeScript
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 }) => {
|
|
// Sidebar entries collide with tab labels for 'Lehrer' — scope to <nav> and use exact match.
|
|
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('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()
|
|
const select = page.getByRole('combobox').first()
|
|
await expect(select).toBeVisible()
|
|
// <option> elements live inside <select>; assert on the option's text, not visibility.
|
|
await expect(select.locator('option', { hasText: 'Schmidt, Anna' })).toHaveCount(1)
|
|
})
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|