Files
breakpilot-lehrer/studio-v2/e2e/stundenplan.spec.ts
T
Benjamin Admin 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
Stundenplan Phase 3b: 3 more Stammdaten managers, first constraint editor, full test coverage
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>
2026-05-21 22:44:39 +02:00

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()
})
})