Files
breakpilot-lehrer/studio-v2/e2e/stundenplan.spec.ts
T
Benjamin Admin c2c09e1cd9
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 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 3m31s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 22s
Stundenplan Phase 3c: complete Stammdaten + RegelnHub with 4 editors
Frontend additions in studio-v2:
  - PeriodsManager renders the weekly grid as a Mo–So table with one
    row per period_index. New entries auto-increment period_index so
    the user can hit Anlegen repeatedly for a full day's slots.
  - CurriculumManager joins classes + subjects; new entries refuse to
    open when either prerequisite list is empty (banner instead).
  - AssignmentsManager joins teacher × class × subject with the same
    prerequisite-banner pattern.
  - regeln/RegelnHub: vertical sidebar grouping all 15 constraint
    types by parent entity (Lehrer/Fach/Klasse/Raum). Implemented
    editors are clickable, the other 11 are visibly disabled with
    a 'soon' tag.
  - Three new editors:
      TeacherUnavailableWindowEditor (time-window pattern),
      SubjectMaxConsecutiveEditor (number-input pattern),
      SubjectPreferredPeriodEditor (number range pattern).
  - page.tsx wires every tab to its manager; the not-implemented
    placeholder is gone (no more empty tabs).

Test coverage:
  - e2e/stundenplan.spec.ts rewritten: 23 tests across 7 suites,
    covering all 8 tabs, the new managers' prerequisite banners,
    sub-tab switching in the RegelnHub, and the disabled state of
    not-yet-implemented constraint rules. Each test mocks the
    backend via page.route() so the suite stays hermetic.

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

333 lines
14 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/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 <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('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()
})
})