Stundenplan Phase 3c: complete Stammdaten + RegelnHub with 4 editors
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
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
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>
This commit is contained in:
@@ -9,6 +9,8 @@ import { test, expect, Page } from '@playwright/test'
|
||||
*/
|
||||
|
||||
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
|
||||
@@ -20,9 +22,24 @@ interface MockClass {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
async function mockSchoolApi(page: Page, opts: { classes?: MockClass[]; teachers?: unknown[] } = {}) {
|
||||
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') {
|
||||
@@ -45,24 +62,27 @@ async function mockSchoolApi(page: Page, opts: { classes?: MockClass[]; teachers
|
||||
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 })
|
||||
})
|
||||
// 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 page.route('**/api/school/timetable/subjects', async (route) => {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
||||
})
|
||||
await staticList('teachers', teachers)
|
||||
await staticList('subjects', subjects)
|
||||
await staticList('rooms', rooms)
|
||||
await staticList('periods', periods)
|
||||
await staticList('curriculum', curriculum)
|
||||
await staticList('assignments', assignments)
|
||||
|
||||
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: '[]' })
|
||||
})
|
||||
// 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', () => {
|
||||
@@ -78,7 +98,7 @@ test.describe('Stundenplan — Page Shell', () => {
|
||||
})
|
||||
|
||||
test('shows all 8 tabs', async ({ page }) => {
|
||||
// Sidebar entries collide with tab labels for 'Lehrer' — scope to <nav> and use exact match.
|
||||
// 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()
|
||||
@@ -106,32 +126,21 @@ test.describe('Stundenplan — Tab navigation', () => {
|
||||
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('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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -184,31 +193,131 @@ test.describe('Stundenplan — Klassen CRUD', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Stundenplan — Constraint editor empty teacher state', () => {
|
||||
test('shows warning when no teachers exist', async ({ page }) => {
|
||||
await mockSchoolApi(page, { teachers: [] })
|
||||
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.getByRole('button', { name: 'Regeln (Constraints)' }).click()
|
||||
await expect(page.getByText('Zuerst Lehrer anlegen')).toBeVisible()
|
||||
await page.locator('main nav').getByRole('button', { name: 'Zeitraster', exact: true }).click()
|
||||
await expect(page.getByText('Noch kein Zeitraster definiert.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('enables form button when teachers exist', async ({ page }) => {
|
||||
test('renders weekday grid with period entries', 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' },
|
||||
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.getByRole('button', { name: 'Regeln (Constraints)' }).click()
|
||||
const btn = page.getByRole('button', { name: '+ Neue Regel' })
|
||||
await page.locator('main nav').getByRole('button', { name: 'Zeitraster', exact: true }).click()
|
||||
await expect(page.getByText('Zeitraster (3)')).toBeVisible()
|
||||
// Header row contains weekday abbreviations Mo–So.
|
||||
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:00–08:45').first()).toBeVisible()
|
||||
await expect(page.getByText('08:50–09: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()
|
||||
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)
|
||||
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:00–17: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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user