Phase 7: pinning, plan versions, solver budget + UX polish
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>
This commit is contained in:
Benjamin Admin
2026-05-22 08:19:39 +02:00
parent 612ecec6d9
commit bf5ea860cc
17 changed files with 591 additions and 124 deletions
+10
View File
@@ -118,6 +118,16 @@ export async function mockSchoolApi(page: Page, opts: MockOpts = {}) {
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/lessons$/, async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(lessons) })
})
// Phase 7: lesson-level pin toggle.
await page.route(/\/api\/school\/timetable\/lessons\/[^/]+\/pin$/, async (route) => {
if (route.request().method() !== 'PUT') return route.fulfill({ status: 405 })
const body = JSON.parse(route.request().postData() || '{}')
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ message: 'ok', pinned: body.pinned ?? false }),
})
})
await page.route(/\/api\/school\/timetable\/solutions\/[^/]+$/, async (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"deleted"}' })
+81 -4
View File
@@ -18,7 +18,15 @@ test.describe('Stundenplan — Page Shell', () => {
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()
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 }) => {
@@ -34,9 +42,8 @@ test.describe('Stundenplan — Page Shell', () => {
})
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.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')
@@ -401,3 +408,73 @@ test.describe('Stundenplan — PlanView grid', () => {
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('🔒')
})
})