Files
breakpilot-lehrer/studio-v2/e2e/stundenplan.spec.ts
T
Benjamin Admin 612ecec6d9
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 27s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m18s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 23s
Phase 6: Plan-Ansicht — solution list + weekly grid view
Frontend additions in studio-v2:
  - types.ts adds TimetableSolution, TimetableLesson, SolutionStatus,
    CreateTimetableSolution mirroring the Go models.
  - lib/stundenplan/api.ts adds solutionsApi with list/get/create/remove/
    lessons. Solve trigger is POST /timetable/solutions — school-service
    forwards to the solver-service over the Docker network.
  - _components/plan/SolutionList: table of past solves with status
    badges, hard/soft score, Anzeigen + Loeschen buttons, and a
    'Neuen Plan generieren' trigger. Auto-polls every 4 s while any
    solution is pending/running, clears the interval otherwise.
  - _components/plan/PlanView: Mo–Fr × period weekly grid. Three
    perspectives (Klasse / Lehrer / Raum) toggleable via test-id'd
    buttons; selector below lists every unique resource with at least
    one lesson. Cells colour-coded by tt_subject.color.
  - _components/plan/PlanHub orchestrates list + view; default tab in
    page.tsx switches from 'klassen' to 'plan'.

Tests:
  - mockSchoolApi helper extracted to e2e/_helpers.ts so the spec file
    stays under 500 LOC. Helper now also mocks /solutions GET/POST/DELETE
    and /solutions/:id/lessons; solutions kept in a closure so POST
    appears in the next GET.
  - 8 new tests across two suites: SolutionList empty state, list
    render, completed-vs-failed Anzeigen visibility, solve trigger;
    PlanView placeholder when no selection, grid render, perspective
    switching.
  - Existing Klassen CRUD tests now click the Klassen tab first
    (Plan is the new default landing tab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:03:55 +02:00

404 lines
20 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'
import { mockSchoolApi, MOCK_TEACHER_ID, MOCK_SUBJECT_ID, MOCK_CLASS_ID } from './_helpers'
/**
* E2E tests for /stundenplan
*
* Backend calls go through /api/school/* (Next.js proxy → school-service).
* Tests intercept those routes via mockSchoolApi() from _helpers.ts so the
* suite stays hermetic.
*/
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 9 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 ['Plan', 'Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) {
await expect(tabs.getByRole('button', { name: label, exact: true })).toBeVisible()
}
})
test('Plan tab is active by default', async ({ page }) => {
await expect(page.getByTestId('plan-hub')).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 9 tabs render their manager', async ({ page }) => {
const tabs = page.locator('main nav')
const cases: { label: string; testId: string }[] = [
{ label: 'Klassen', testId: 'klassen-manager' },
{ 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' },
{ label: 'Plan', testId: 'plan-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', () => {
const gotoKlassen = async (page: Page) => {
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
// Plan is now the default tab; switch to Klassen first.
await page.locator('main nav').getByRole('button', { name: 'Klassen', exact: true }).click()
}
test('empty state shows when no classes', async ({ page }) => {
await mockSchoolApi(page, { classes: [] })
await gotoKlassen(page)
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 gotoKlassen(page)
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 gotoKlassen(page)
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 gotoKlassen(page)
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()
})
})
// ==========================================================================
// Phase 6 — Plan-Ansicht
// ==========================================================================
test.describe('Stundenplan — Plan tab + SolutionList', () => {
test('empty state when no solutions exist', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Noch keine Plaene generiert.')).toBeVisible()
await expect(page.getByTestId('solve-trigger')).toBeEnabled()
})
test('renders solutions returned by the backend', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: -42, created_at: '2026-05-22T10:00:00Z' },
{ id: 's2', created_by_user_id: 'u', name: 'Plan B', status: 'failed', error_message: 'no lessons', created_at: '2026-05-22T11:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await expect(page.getByRole('cell', { name: 'Plan A' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'Plan B' })).toBeVisible()
await expect(page.getByText('Fertig').first()).toBeVisible()
await expect(page.getByText('Fehler').first()).toBeVisible()
await expect(page.getByText('0H / -42S')).toBeVisible()
})
test('completed solutions expose an Anzeigen button; failed ones do not', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
{ id: 's2', created_by_user_id: 'u', name: 'Plan B', status: 'failed', created_at: '2026-05-22T11:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
const planA = page.getByRole('row', { name: /Plan A/ })
const planB = page.getByRole('row', { name: /Plan B/ })
await expect(planA.getByRole('button', { name: 'Anzeigen' })).toBeVisible()
await expect(planB.getByRole('button', { name: 'Anzeigen' })).toHaveCount(0)
})
test('triggering Solve calls POST /solutions and reloads the list', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByTestId('solve-trigger').click()
await expect(page.getByText('Plan').first()).toBeVisible()
})
})
test.describe('Stundenplan — PlanView grid', () => {
test('shows placeholder hint until a solution is selected', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Waehle einen abgeschlossenen Plan oben')).toBeVisible()
await expect(page.getByTestId('plan-view')).toHaveCount(0)
})
test('clicking Anzeigen mounts the PlanView with mocked lessons', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
],
lessons: [
{ id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' },
{ id: 'l2', solution_id: 's1', class_id: 'c1', subject_id: 'sub2', teacher_id: 't1', room_id: 'r1', day_of_week: 2, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Deutsch', teacher_name: 'Schmidt, Anna', room_name: 'A101' },
],
subjects: [
{ id: 'sub1', name: 'Mathematik', short_code: 'M', color: '#3b82f6', is_main_subject: true, created_by_user_id: 'u', created_at: '' },
{ id: 'sub2', name: 'Deutsch', short_code: 'D', color: '#ef4444', is_main_subject: true, created_by_user_id: 'u', created_at: '' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
await expect(page.getByTestId('plan-view')).toBeVisible()
await expect(page.getByTestId('cell-1-1')).toBeVisible()
await expect(page.getByTestId('cell-2-1')).toBeVisible()
})
test('switching perspective updates the resource selector', async ({ page }) => {
await mockSchoolApi(page, {
solutions: [
{ id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' },
],
lessons: [
{ id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
await expect(page.getByTestId('plan-view')).toBeVisible()
// Default perspective is 'class' → selector shows '5a'.
await expect(page.locator('select').last()).toHaveValue('c1')
await page.getByTestId('perspective-teacher').click()
await expect(page.locator('select').last()).toHaveValue('t1')
await page.getByTestId('perspective-room').click()
await expect(page.locator('select').last()).toHaveValue('r1')
})
})