73636f76a2
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 30s
CI / test-python-klausur (push) Failing after 3m6s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 21s
Frontend additions in studio-v2:
- LehrerManager / FaecherManager / RaeumeManager — same CRUD pattern as
Klassen, with entity-specific form fields and table columns.
- regeln/TeacherUnavailableDayEditor — first constraint editor, joins
against teachersApi to render a readable name in the dropdown and
list. Falls back to a guidance banner when no teachers exist yet.
- page.tsx wires up the new tabs; data-testid attributes added across
managers so the Playwright suite can target them deterministically.
Tests:
- school-service: timetable_constraints_more_test.go fills the
remaining 9 constraint DTOs (TeacherMaxHoursDay/Week,
TeacherExcludedSubject/Room, SubjectMinDayGap,
SubjectContiguousWhenRepeated, SubjectDoubleLesson, ClassNoGaps,
RoomRequiresType). 66 subtests total, all green.
- studio-v2: e2e/stundenplan.spec.ts covers the page shell, tab
navigation, Klassen CRUD with mocked backend, constraint editor's
empty-teacher fallback, sidebar entry. All school-service calls
intercepted via page.route() so the suite is hermetic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
8.8 KiB
TypeScript
220 lines
8.8 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 }) => {
|
|
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()
|
|
})
|
|
})
|