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
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>
481 lines
23 KiB
TypeScript
481 lines
23 KiB
TypeScript
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 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()
|
||
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('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('🔒')
|
||
})
|
||
})
|