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
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:
@@ -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"}' })
|
||||
|
||||
@@ -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('🔒')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user