Files
breakpilot-lehrer/studio-v2/e2e/stundenplan.spec.ts
T
Benjamin Admin 082a5bb68c
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 30s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m35s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 21s
Strip orphan straight-quote pairings in JSX descriptions
The German „X" markers in the description prop combined a curly „
(U+201E) with a straight " (U+0022). The straight quote prematurely
terminated the JavaScript string inside the JSX expression. Removing
both markers around the example text keeps the description readable
and unambiguously valid JSX.

Test selector for the UnavailableWindow description updated to match
the new wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:52:09 +02:00

371 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/teacher/max-hours-day',
'constraints/teacher/max-hours-week',
'constraints/teacher/excluded-subject',
'constraints/teacher/excluded-room',
'constraints/subject/max-consecutive',
'constraints/subject/preferred-period',
'constraints/subject/min-day-gap',
'constraints/subject/contiguous-when-repeated',
'constraints/subject/double-lesson',
'constraints/class/max-hours-day',
'constraints/class/no-gaps',
'constraints/room/requires-type',
'constraints/room/unavailable',
]) {
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 <main nav>.
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 MoSo.
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:0008:45').first()).toBeVisible()
await expect(page.getByText('08:5009: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:0017: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('all unique-labelled constraint editors mount when selected', async ({ page }) => {
// Skip 'Max. Stunden / Tag' (teacher + class both use it) — covered separately below.
const cases: { label: string; testId: string }[] = [
{ label: 'Tag nicht verfuegbar', testId: 'teacher-unavailable-day-editor' },
{ label: 'Zeitfenster nicht verfuegbar', testId: 'teacher-unavailable-window-editor' },
{ label: 'Max. Stunden / Woche', testId: 'teacher-max-hours-week-editor' },
{ label: 'Fach ausgeschlossen', testId: 'teacher-excluded-subject-editor' },
{ label: 'Raum ausgeschlossen', testId: 'teacher-excluded-room-editor' },
{ label: 'Max. Stunden am Stueck', testId: 'subject-max-consecutive-editor' },
{ label: 'Bevorzugter Stunden-Bereich', testId: 'subject-preferred-period-editor' },
{ label: 'Min. Tagesabstand', testId: 'subject-min-day-gap-editor' },
{ label: 'Bei Mehrfach: zusammenhaengend', testId: 'subject-contiguous-when-repeated-editor' },
{ label: 'Doppelstunde bevorzugt', testId: 'subject-double-lesson-editor' },
{ label: 'Keine Freistunden', testId: 'class-no-gaps-editor' },
{ label: 'Fach benoetigt Raumtyp', testId: 'room-requires-type-editor' },
{ label: 'Raum nicht verfuegbar', testId: 'room-unavailable-editor' },
]
for (const c of cases) {
await page.getByRole('button', { name: c.label, exact: true }).click()
await expect(page.getByTestId(c.testId)).toBeVisible()
}
})
test('both Max. Stunden / Tag entries mount the right editor', async ({ page }) => {
// The label appears twice (Lehrer + Klasse). .first() targets the
// teacher entry (earlier in the DOM), .nth(1) the class entry.
const buttons = page.getByRole('button', { name: 'Max. Stunden / Tag', exact: true })
await buttons.first().click()
await expect(page.getByTestId('teacher-max-hours-day-editor')).toBeVisible()
await buttons.nth(1).click()
await expect(page.getByTestId('class-max-hours-day-editor')).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()
})
})