Files
Benjamin Admin 306886a42b Phase 8: CSV + ICS export, print view, MkDocs docs, SBOM + dev-mode auth
Auth (Test-Mode):
  - middleware.AuthMiddleware now takes a devMode flag. In dev,
    requests without Authorization fall back to a deterministic dev
    UUID (00000000-...-001) and role=teacher. ENVIRONMENT=production
    re-enables the strict 401 path.
  - main.go wires devMode = cfg.Environment != "production".
  - page.tsx replaces the red 'Anmeldung noch nicht integriert' banner
    with a softer Testumgebung notice; the manual-token form moves
    behind a nested details block.

Export endpoints (school-service):
  - LoadExportLessons joins tt_lesson with tt_period for wall-clock
    times; one query feeds both CSV and ICS.
  - WriteCSV streams 10 columns including pinned flag.
  - WriteICS emits one VEVENT per lesson anchored to a Monday — caller
    overridable via ?start=YYYY-MM-DD. RFC 5545 escapes for ',', ';',
    '\n' in icsEscape().
  - NextMonday helper for the default anchor.
  - GET /timetable/solutions/:id/export.{csv,ics} handlers attach
    Content-Disposition: attachment so browsers download instead of
    rendering.

Frontend:
  - lib/stundenplan/api.ts downloadSolutionExport() fetches as blob,
    triggers a synthetic <a download> click, and forwards the JWT when
    present.
  - PlanView gains CSV / ICS / Drucken buttons next to the perspective
    selector. The toolbar carries class 'no-print' so window.print()
    yields only the grid.
  - globals.css @media print rule hides chrome, forces white
    background, gives the table proper borders for A4.

Docs:
  - docs-src/services/stundenplan/{index,architecture,constraints,
    solver-tuning,export}.md with nav entry in mkdocs.yml under
    Services → Stundenplaner.
  - sbom/stundenplan/README.md lists manually-verified key dependencies
    and the policy reference. scripts/stundenplan-sbom.sh generates
    full machine-readable inventories via go-licenses + pip-licenses
    + license-checker when those tools are available.

Tests:
  - internal/services/timetable_exports_test.go: 4 unit tests covering
    CSV column layout + quoting, ICS structure + DTSTART formatting,
    icsEscape special chars, NextMonday weekday math.
  - studio-v2/e2e/stundenplan-export.spec.ts split out of the main spec
    file (LOC budget) — 3 tests for button render, CSV download,
    ICS download.
  - mockSchoolApi extended with export.csv + export.ics routes.

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

482 lines
23 KiB
TypeScript
Raw Permalink 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, Regeln und Plan-Generierung fuer den Schul-Stundenplan')).toBeVisible()
})
test('help panel toggles open', async ({ page }) => {
const panel = page.getByTestId('help-panel')
await expect(panel).toBeVisible()
await expect(panel.getByText('1. Klassen, Lehrer, Faecher, Raeume anlegen')).toHaveCount(0)
await panel.getByRole('button', { name: /Bedienungsanleitung/ }).click()
await expect(panel.getByText('1. Klassen, Lehrer, Faecher, Raeume anlegen')).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('Dev mode banner is collapsed by default; manual token still available', async ({ page }) => {
await page.getByText('Testumgebung — Anmeldung deaktiviert').click()
await page.getByText('Manueller Token').click()
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')
})
})
// ==========================================================================
// Phase 7 — Pinning + Plan-Versionen
// ==========================================================================
test.describe('Stundenplan — Solve options (parent + seconds limit)', () => {
test('parent-selector disabled when no completed solutions exist', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('parent-selector')).toBeDisabled()
})
test('parent-selector lists only completed solutions', 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' },
{ id: 's3', created_by_user_id: 'u', name: 'Plan C', status: 'completed', hard_score: 0, soft_score: -5, created_at: '2026-05-22T12:00:00Z' },
],
})
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
const sel = page.getByTestId('parent-selector')
await expect(sel).toBeEnabled()
// Only Plan A and Plan C are options (Plan B failed).
await expect(sel.locator('option', { hasText: 'Plan A' })).toHaveCount(1)
await expect(sel.locator('option', { hasText: 'Plan C' })).toHaveCount(1)
await expect(sel.locator('option', { hasText: 'Plan B' })).toHaveCount(0)
})
test('seconds-limit field accepts numeric input', async ({ page }) => {
await mockSchoolApi(page, { solutions: [] })
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
const field = page.getByTestId('seconds-limit')
await field.fill('120')
await expect(field).toHaveValue('120')
})
})
test.describe('Stundenplan — Pin lesson', () => {
const baseOpts = () => ({
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' },
],
})
test('pin button is rendered on each lesson cell', async ({ page }) => {
await mockSchoolApi(page, baseOpts())
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
await expect(page.getByTestId('pin-l1')).toBeVisible()
})
test('clicking pin flips the icon via optimistic update', async ({ page }) => {
await mockSchoolApi(page, baseOpts())
await page.goto('/stundenplan')
await page.waitForLoadState('networkidle')
await page.getByRole('button', { name: 'Anzeigen' }).click()
const pinBtn = page.getByTestId('pin-l1')
await expect(pinBtn).toContainText('📌')
await pinBtn.click()
await expect(pinBtn).toContainText('🔒')
})
})