Files
breakpilot-lehrer/studio-v2/e2e/stundenplan.spec.ts
T
Benjamin Admin bf5ea860cc
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 37s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m56s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 23s
Phase 7: pinning, plan versions, solver budget + UX polish
Backend (school-service):
  - tt_solution gains parent_solution_id (self-FK, ON DELETE SET NULL)
    and seconds_limit columns via ALTER TABLE IF NOT EXISTS.
  - CreateTimetableSolutionRequest accepts optional parent_solution_id
    and seconds_limit (5-600s) with binding validation.
  - CreateSolution checks parent ownership before INSERT so users can't
    fork another tenant's plan.
  - New PUT /timetable/lessons/:id/pin endpoint; ownership enforced via
    the lesson's solution.created_by_user_id JOIN.

Solver:
  - Lesson.pinned now carries @PlanningPin so Timefold leaves locked
    cells untouched during the search.
  - build_problem() takes optional parent_solution_id; if set, copies
    pinned (class_id, subject_id, day, period, room) tuples onto fresh
    Lesson objects via greedy first-fit matching. Surplus pinned rows
    from curriculum changes are silently dropped.
  - _build_factory(seconds) replaces the module-level factory so each
    job honours its tt_solution.seconds_limit override.
  - persist_solution writes lesson.pinned back so subsequent re-solves
    inherit it.

Frontend (studio-v2):
  - SolutionList grows three knobs in the create-form: Basieren auf
    (parent dropdown, only completed solutions, disabled when none),
    Sekunden-Limit (5-600), and the existing Name.
  - PlanView cells get a pin/unpin button with optimistic update and
    rollback on error. Pinned cells gain an amber ring.
  - types.ts + api.ts mirror the new fields; lessonsApi.pin(id, bool).
  - HelpPanel: collapsible 6-step Bedienungsanleitung explaining the
    setup-to-plan workflow. Anchored at the top of /stundenplan above
    the dev token banner.
  - page.tsx switches to the same gradient + animated-blob background
    used on /korrektur so /stundenplan stops looking like a slate-900
    test page.
  - JWT dev banner gets a step-by-step explanation of how to grab the
    token from DevTools and a non-blocking success indicator (no more
    alert()).

Tests:
  - school-service: 6 new validator cases for parent_solution_id +
    seconds_limit boundaries. 73 subtests total, all green.
  - studio-v2: mockSchoolApi adds PUT /lessons/:id/pin route. 5 new
    Playwright tests across two suites (parent-selector visibility +
    options, seconds-limit input, pin button render, pin-icon flip).
    Existing tests adjusted to the new help panel + JWT banner wording.

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

481 lines
23 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, 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('JWT dev field exists and persists into localStorage', async ({ page }) => {
await page.getByText('Anmeldung noch nicht integriert').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('🔒')
})
})