From 72816d6b35782a3caf15ddbbbcbdb17957493d65 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:49:42 +0200 Subject: [PATCH 1/9] feat(schema): ingredient.section_heading (Migration 012 + Type) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuegt das nullable Feld section_heading zur ingredient-Tabelle hinzu (Migration 012), erweitert den Ingredient-Typ und aktualisiert alle drei Return-Stellen in parseIngredient. Downstream-Sites (repository, Editor, Tests) bleiben rot – werden in Task 2+ behoben. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/db/migrations/012_ingredient_section.sql | 4 ++++ src/lib/server/parsers/ingredient.ts | 6 +++--- src/lib/types.ts | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/db/migrations/012_ingredient_section.sql diff --git a/src/lib/server/db/migrations/012_ingredient_section.sql b/src/lib/server/db/migrations/012_ingredient_section.sql new file mode 100644 index 0000000..deb1568 --- /dev/null +++ b/src/lib/server/db/migrations/012_ingredient_section.sql @@ -0,0 +1,4 @@ +-- Nullable -- old rows keep NULL, new rows may have a section heading. +-- Rendering rule: if section_heading is set (not NULL and not empty), +-- a new section with this title starts at this ingredient row. +ALTER TABLE ingredient ADD COLUMN section_heading TEXT; diff --git a/src/lib/server/parsers/ingredient.ts b/src/lib/server/parsers/ingredient.ts index 51230fc..8d28b83 100644 --- a/src/lib/server/parsers/ingredient.ts +++ b/src/lib/server/parsers/ingredient.ts @@ -105,16 +105,16 @@ export function parseIngredient(raw: string, position = 0): Ingredient { if (tail.length > 0) { const quantity = clampQuantity(UNICODE_FRACTION_MAP[firstChar]); const { unit, name } = splitUnitAndName(tail); - return { position, quantity, unit, name, note, raw_text: rawText }; + return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null }; } } const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/; const qtyMatch = qtyPattern.exec(working); if (!qtyMatch) { - return { position, quantity: null, unit: null, name: working, note, raw_text: rawText }; + return { position, quantity: null, unit: null, name: working, note, raw_text: rawText, section_heading: null }; } const quantity = clampQuantity(parseQuantity(qtyMatch[1])); const { unit, name } = splitUnitAndName(qtyMatch[2]); - return { position, quantity, unit, name, note, raw_text: rawText }; + return { position, quantity, unit, name, note, raw_text: rawText, section_heading: null }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index c2d63d2..4be0532 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -5,6 +5,7 @@ export type Ingredient = { name: string; note: string | null; raw_text: string; + section_heading: string | null; }; export type Step = { From b0d5f921e224b7497d94e7641ad62c6eab7812b4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:52:13 +0200 Subject: [PATCH 2/9] docs(migration): 012 Kommentar an 010/011-Stil angleichen (DE, Begruendung) --- src/lib/server/db/migrations/012_ingredient_section.sql | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/lib/server/db/migrations/012_ingredient_section.sql b/src/lib/server/db/migrations/012_ingredient_section.sql index deb1568..c4ce7ff 100644 --- a/src/lib/server/db/migrations/012_ingredient_section.sql +++ b/src/lib/server/db/migrations/012_ingredient_section.sql @@ -1,4 +1,7 @@ --- Nullable -- old rows keep NULL, new rows may have a section heading. --- Rendering rule: if section_heading is set (not NULL and not empty), --- a new section with this title starts at this ingredient row. +-- Nullable-Spalte fuer optionale Sektionsueberschriften bei Zutaten. User +-- soll im Editor gruppieren koennen ("Fuer den Teig", "Fuer die Fuellung"). +-- Rendering-Regel: Ist section_heading gesetzt (nicht NULL, nicht leer), +-- startet an dieser Zeile eine neue Sektion mit diesem Titel; alle folgenden +-- Zutaten gehoeren dazu, bis die naechste Zeile wieder eine Ueberschrift hat. +-- Ordnung bleibt die bestehende position-Spalte. ALTER TABLE ingredient ADD COLUMN section_heading TEXT; From a1baf7f30acb5a0cbc9ef56c252a9fb2e5bbd142 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:55:46 +0200 Subject: [PATCH 3/9] feat(db): section_heading roundtrip in recipe-repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INSERT/SELECT in insertRecipe, replaceIngredients und getRecipeById um section_heading ergänzt. IngredientSchema im PATCH-Endpoint sowie Ingredient-Fixtures in search-local-, scaler- und repository-Tests auf das neue Pflichtfeld aktualisiert. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/recipes/repository.ts | 14 ++++---- src/routes/api/recipes/[id]/+server.ts | 3 +- tests/integration/recipe-repository.test.ts | 36 ++++++++++++++++++--- tests/integration/search-local.test.ts | 2 +- tests/unit/scaler.test.ts | 3 +- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/lib/server/recipes/repository.ts b/src/lib/server/recipes/repository.ts index 24d54ee..6a97c17 100644 --- a/src/lib/server/recipes/repository.ts +++ b/src/lib/server/recipes/repository.ts @@ -64,11 +64,11 @@ export function insertRecipe(db: Database.Database, recipe: Recipe): number { const id = Number(info.lastInsertRowid); const insIng = db.prepare( - `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text) - VALUES (?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ); for (const ing of recipe.ingredients) { - insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text); + insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading); } const insStep = db.prepare( @@ -104,7 +104,7 @@ export function getRecipeById(db: Database.Database, id: number): Recipe | null const ingredients = db .prepare( - `SELECT position, quantity, unit, name, note, raw_text + `SELECT position, quantity, unit, name, note, raw_text, section_heading FROM ingredient WHERE recipe_id = ? ORDER BY position` ) .all(id) as Ingredient[]; @@ -215,11 +215,11 @@ export function replaceIngredients( const tx = db.transaction(() => { db.prepare('DELETE FROM ingredient WHERE recipe_id = ?').run(recipeId); const ins = db.prepare( - `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text) - VALUES (?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text, section_heading) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ); for (const ing of ingredients) { - ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text); + ins.run(recipeId, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text, ing.section_heading); } refreshFts(db, recipeId); }); diff --git a/src/routes/api/recipes/[id]/+server.ts b/src/routes/api/recipes/[id]/+server.ts index 5448d9d..37f73b0 100644 --- a/src/routes/api/recipes/[id]/+server.ts +++ b/src/routes/api/recipes/[id]/+server.ts @@ -24,7 +24,8 @@ const IngredientSchema = z.object({ unit: z.string().max(30).nullable(), name: z.string().min(1).max(200), note: z.string().max(300).nullable(), - raw_text: z.string().max(500) + raw_text: z.string().max(500), + section_heading: z.string().max(200).nullable() }); const StepSchema = z.object({ diff --git a/tests/integration/recipe-repository.test.ts b/tests/integration/recipe-repository.test.ts index 6cd0ebd..2821469 100644 --- a/tests/integration/recipe-repository.test.ts +++ b/tests/integration/recipe-repository.test.ts @@ -70,7 +70,8 @@ describe('recipe repository', () => { unit: 'g', name: 'Pancetta', note: null, - raw_text: '200 g Pancetta' + raw_text: '200 g Pancetta', + section_heading: null } ], tags: ['Italienisch'] @@ -118,13 +119,13 @@ describe('recipe repository', () => { baseRecipe({ title: 'Pasta', ingredients: [ - { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta' } + { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '200 g Pancetta', section_heading: null } ] }) ); replaceIngredients(db, id, [ - { position: 1, quantity: 500, unit: 'g', name: 'Nudeln', note: null, raw_text: '500 g Nudeln' }, - { position: 2, quantity: 2, unit: null, name: 'Eier', note: null, raw_text: '2 Eier' } + { position: 1, quantity: 500, unit: 'g', name: 'Nudeln', note: null, raw_text: '500 g Nudeln', section_heading: null }, + { position: 2, quantity: 2, unit: null, name: 'Eier', note: null, raw_text: '2 Eier', section_heading: null } ]); const loaded = getRecipeById(db, id); expect(loaded?.ingredients.length).toBe(2); @@ -154,4 +155,31 @@ describe('recipe repository', () => { const loaded = getRecipeById(db, id); expect(loaded?.steps.map((s) => s.text)).toEqual(['Erst', 'Dann']); }); + + it('persistiert section_heading und gibt es beim Laden zurueck', () => { + const db = openInMemoryForTest(); + const recipe = baseRecipe({ + title: 'Torte', + ingredients: [ + { position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Für den Teig' }, + { position: 2, quantity: 100, unit: 'g', name: 'Zucker', note: null, raw_text: '100 g Zucker', section_heading: null }, + { position: 3, quantity: 300, unit: 'g', name: 'Beeren', note: null, raw_text: '300 g Beeren', section_heading: 'Für die Füllung' } + ] + }); + const id = insertRecipe(db, recipe); + const loaded = getRecipeById(db, id); + expect(loaded!.ingredients[0].section_heading).toBe('Für den Teig'); + expect(loaded!.ingredients[1].section_heading).toBeNull(); + expect(loaded!.ingredients[2].section_heading).toBe('Für die Füllung'); + }); + + it('replaceIngredients persistiert section_heading', () => { + const db = openInMemoryForTest(); + const id = insertRecipe(db, baseRecipe({ title: 'X' })); + replaceIngredients(db, id, [ + { position: 1, quantity: null, unit: null, name: 'A', note: null, raw_text: 'A', section_heading: 'Kopf' } + ]); + const loaded = getRecipeById(db, id); + expect(loaded!.ingredients[0].section_heading).toBe('Kopf'); + }); }); diff --git a/tests/integration/search-local.test.ts b/tests/integration/search-local.test.ts index 8b53639..81b0299 100644 --- a/tests/integration/search-local.test.ts +++ b/tests/integration/search-local.test.ts @@ -48,7 +48,7 @@ describe('searchLocal', () => { recipe({ title: 'Pasta', ingredients: [ - { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '' } + { position: 1, quantity: 200, unit: 'g', name: 'Pancetta', note: null, raw_text: '', section_heading: null } ] }) ); diff --git a/tests/unit/scaler.test.ts b/tests/unit/scaler.test.ts index 5edcef2..472ac31 100644 --- a/tests/unit/scaler.test.ts +++ b/tests/unit/scaler.test.ts @@ -8,7 +8,8 @@ const mk = (q: number | null, unit: string | null, name: string): Ingredient => unit, name, note: null, - raw_text: '' + raw_text: '', + section_heading: null }); describe('roundQuantity', () => { From 96cb55495e36e2e01b9528a3a5647b1def0e02dd Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:00:21 +0200 Subject: [PATCH 4/9] test(scaler): section_heading ueberlebt Skalierung Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/scaler.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/scaler.test.ts b/tests/unit/scaler.test.ts index 472ac31..7aca3b5 100644 --- a/tests/unit/scaler.test.ts +++ b/tests/unit/scaler.test.ts @@ -41,4 +41,15 @@ describe('scaleIngredients', () => { const scaled = scaleIngredients([mk(100, 'g', 'Butter')], 1 / 3); expect(scaled[0].quantity).toBe(33); }); + + it('preserves section_heading through scaling', () => { + const input: Ingredient[] = [ + { position: 1, quantity: 200, unit: 'g', name: 'Mehl', note: null, raw_text: '200 g Mehl', section_heading: 'Teig' }, + { position: 2, quantity: null, unit: null, name: 'Ei', note: null, raw_text: 'Ei', section_heading: null } + ]; + const scaled = scaleIngredients(input, 2); + expect(scaled[0].section_heading).toBe('Teig'); + expect(scaled[1].section_heading).toBeNull(); + expect(scaled[0].quantity).toBe(400); + }); }); From 526c7433f4bb96473f42f6055e1ba85e69aac97d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:03:29 +0200 Subject: [PATCH 5/9] feat(editor): Sektionsueberschriften in IngredientRow + Insert-Button DraftIng bekommt section_heading: string | null. IngredientRow rendert davor einen Fade-in-Insert-Button (null) oder ein Heading- Input mit Entfernen-Button (string). Props onaddSection/onremoveSection ergaenzt; Styles an bestehendem Block angehaengt. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/components/IngredientRow.svelte | 94 ++++++++++++++++++++++- src/lib/components/recipe-editor-types.ts | 1 + 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/lib/components/IngredientRow.svelte b/src/lib/components/IngredientRow.svelte index cb775fe..03a806f 100644 --- a/src/lib/components/IngredientRow.svelte +++ b/src/lib/components/IngredientRow.svelte @@ -1,5 +1,5 @@ +{#if ing.section_heading === null} +
  • + +
  • +{:else} +
  • + + +
  • +{/if}
    • {#each scaled as ing, i (i)} + {#if ing.section_heading && ing.section_heading.trim()} +
    • {ing.section_heading}
    • + {/if}
    • {#if ing.quantity !== null || ing.unit} @@ -281,6 +284,19 @@ padding: 0; margin: 0; } + .ing-list .section-heading { + list-style: none; + font-weight: 600; + color: #2b6a3d; + font-size: 1rem; + margin-top: 0.9rem; + margin-bottom: 0.2rem; + padding: 0.15rem 0; + border-bottom: 1px solid #e4eae7; + } + .ing-list .section-heading:first-child { + margin-top: 0; + } .ing-list li { display: flex; gap: 0.75rem; From c07d2f99ad8b6ce3af268de9d4bc9c363e0ab5c1 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:19:13 +0200 Subject: [PATCH 9/9] test(e2e): Zutaten-Sektionen CRUD + UI-Flow auf kochwas-dev 4 new remote specs: API roundtrip, editor add-section + view render, section remove, empty heading -> null on save. All 46 pass. Co-Authored-By: Claude Sonnet 4.6 --- tests/e2e/remote/ingredient-sections.spec.ts | 219 +++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 tests/e2e/remote/ingredient-sections.spec.ts diff --git a/tests/e2e/remote/ingredient-sections.spec.ts b/tests/e2e/remote/ingredient-sections.spec.ts new file mode 100644 index 0000000..7bad458 --- /dev/null +++ b/tests/e2e/remote/ingredient-sections.spec.ts @@ -0,0 +1,219 @@ +import { test, expect } from '@playwright/test'; +import { setActiveProfile, HENDRIK_ID } from './fixtures/profile'; + +// Helper: idempotent recipe delete. +async function deleteRecipe( + request: Parameters[1]>[0]['request'], + id: number +): Promise { + await request.delete(`/api/recipes/${id}`); +} + +// Shared ingredient payload builder — fills all required Zod fields. +function makeIngredient( + position: number, + name: string, + section_heading: string | null, + overrides: Partial<{ + quantity: number | null; + unit: string | null; + note: string | null; + raw_text: string; + }> = {} +) { + return { + position, + quantity: overrides.quantity ?? null, + unit: overrides.unit ?? null, + name, + note: overrides.note ?? null, + raw_text: overrides.raw_text ?? name, + section_heading + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-test cleanup scaffolding — single variable, reset in beforeEach. +// ───────────────────────────────────────────────────────────────────────────── + +let createdId: number | null = null; + +test.beforeEach(() => { + createdId = null; +}); + +test.afterEach(async ({ request }) => { + if (createdId !== null) { + await deleteRecipe(request, createdId); + createdId = null; + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test 1 — pure API roundtrip (no browser needed) +// ───────────────────────────────────────────────────────────────────────────── + +test('API: section_heading persistiert ueber PATCH + GET', async ({ request }) => { + // 1. Create blank recipe. + const createRes = await request.post('/api/recipes/blank'); + expect(createRes.status()).toBe(200); + const { id } = (await createRes.json()) as { id: number }; + createdId = id; + + // 2. PATCH with 3 ingredients carrying section_heading values. + const patchRes = await request.patch(`/api/recipes/${id}`, { + data: { + ingredients: [ + makeIngredient(1, 'Mehl', 'Fuer den Teig', { quantity: 200, unit: 'g', raw_text: '200 g Mehl' }), + makeIngredient(2, 'Zucker', null, { quantity: 100, unit: 'g', raw_text: '100 g Zucker' }), + makeIngredient(3, 'Beeren', 'Fuer die Fuellung', { quantity: 150, unit: 'g', raw_text: '150 g Beeren' }) + ] + } + }); + expect(patchRes.status()).toBe(200); + + // 3. GET and assert persisted values. + const getRes = await request.get(`/api/recipes/${id}`); + expect(getRes.status()).toBe(200); + const body = (await getRes.json()) as { + recipe: { ingredients: Array<{ name: string; section_heading: string | null }> }; + }; + const ings = body.recipe.ingredients; + + const mehl = ings.find((i) => i.name === 'Mehl'); + const zucker = ings.find((i) => i.name === 'Zucker'); + const beeren = ings.find((i) => i.name === 'Beeren'); + + expect(mehl?.section_heading).toBe('Fuer den Teig'); + expect(zucker?.section_heading).toBeNull(); + expect(beeren?.section_heading).toBe('Fuer die Fuellung'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test 2 — UI edit flow: add section, save, assert view renders heading +// ───────────────────────────────────────────────────────────────────────────── + +test('Editor: Abschnitt via Inline-Button anlegen, View rendert Ueberschrift', async ({ + page, + request +}) => { + // 1. Create blank recipe via API. + const createRes = await request.post('/api/recipes/blank'); + expect(createRes.status()).toBe(200); + const { id } = (await createRes.json()) as { id: number }; + createdId = id; + + // 2. Open recipe in edit mode. + await setActiveProfile(page, HENDRIK_ID); + await page.goto(`/recipes/${id}?edit=1`); + + // 3. Add two ingredient rows. + const addIngBtn = page.getByRole('button', { name: /Zutat hinzufügen/i }); + await addIngBtn.click(); + await addIngBtn.click(); + + // Fill the two ingredient rows by aria-label "Zutat" inputs. + const nameInputs = page.locator('.ing-list .ing-row input[aria-label="Zutat"]'); + await nameInputs.nth(0).fill('Mehl'); + await nameInputs.nth(1).fill('Zucker'); + + // 4. Click "Abschnitt hinzufügen" above the first row. + // The button is inside .section-insert which is opacity:0 until hover/focus. + // Hover the ing-list to trigger visibility, then click. + await page.hover('.ing-list'); + await page.locator('.ing-list .add-section').first().click(); + + // 5. Type heading text into the section-heading input that appeared. + const headingInput = page.locator('.ing-list input[aria-label="Sektionsüberschrift"]').first(); + await headingInput.fill('Fuer den Teig'); + + // 6. Save — exact match to avoid colliding with "Kommentar speichern". + await page.getByRole('button', { name: 'Speichern', exact: true }).click(); + + // After save, editMode becomes false — page switches to view mode. + // Wait for the section-heading element to confirm view mode is active. + await expect(page.locator('.ing-list .section-heading').first()).toBeVisible({ timeout: 8000 }); + + // 7. Assert heading text is rendered. + await expect(page.locator('.ing-list .section-heading').first()).toHaveText('Fuer den Teig'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test 3 — UI: remove an existing section heading, save, confirm it's gone +// ───────────────────────────────────────────────────────────────────────────── + +test('Editor: Sektion entfernen speichert ohne Ueberschrift', async ({ page, request }) => { + // 1. Create blank recipe and pre-populate via API. + const createRes = await request.post('/api/recipes/blank'); + expect(createRes.status()).toBe(200); + const { id } = (await createRes.json()) as { id: number }; + createdId = id; + + await request.patch(`/api/recipes/${id}`, { + data: { + ingredients: [makeIngredient(1, 'Butter', 'Teig', { raw_text: 'Butter' })] + } + }); + + // 2. Open editor. + await setActiveProfile(page, HENDRIK_ID); + await page.goto(`/recipes/${id}?edit=1`); + + // The section-heading-row should be visible since heading = 'Teig'. + const removeBtn = page + .locator('.ing-list') + .getByRole('button', { name: 'Sektion entfernen' }); + await expect(removeBtn).toBeVisible({ timeout: 6000 }); + + // 3. Click the section-remove X button. + await removeBtn.click(); + + // 4. Save — exact match to avoid colliding with "Kommentar speichern". + await page.getByRole('button', { name: 'Speichern', exact: true }).click(); + + // Wait for view mode (editMode = false makes RecipeEditor unmount). + // The .section-heading-row is part of the editor; in view mode we check + // the view's .ing-list for absence of .section-heading items. + await expect(page.locator('.ing-list .section-heading')).toHaveCount(0, { timeout: 8000 }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test 4 — empty heading trims to null on save +// ───────────────────────────────────────────────────────────────────────────── + +test('Editor: leeres Heading wird beim Speichern zu null', async ({ page, request }) => { + // 1. Create blank recipe. + const createRes = await request.post('/api/recipes/blank'); + expect(createRes.status()).toBe(200); + const { id } = (await createRes.json()) as { id: number }; + createdId = id; + + // 2. Open editor, add one ingredient, open section input and leave it empty. + await setActiveProfile(page, HENDRIK_ID); + await page.goto(`/recipes/${id}?edit=1`); + + await page.getByRole('button', { name: /Zutat hinzufügen/i }).click(); + await page.locator('.ing-list .ing-row input[aria-label="Zutat"]').first().fill('Eier'); + + // Trigger add-section visibility and click. + await page.hover('.ing-list'); + await page.locator('.ing-list .add-section').first().click(); + + // Leave the heading input empty (do not type anything). + // The save() function trims '' → null. + + // 3. Save — exact match to avoid colliding with "Kommentar speichern". + await page.getByRole('button', { name: 'Speichern', exact: true }).click(); + + // Wait until view mode is active (editor gone). + await expect(page.locator('.ing-list .section-heading')).toHaveCount(0, { timeout: 8000 }); + + // 4. Confirm via API that section_heading is null. + const getRes = await request.get(`/api/recipes/${id}`); + expect(getRes.status()).toBe(200); + const body = (await getRes.json()) as { + recipe: { ingredients: Array<{ name: string; section_heading: string | null }> }; + }; + const eier = body.recipe.ingredients.find((i) => i.name === 'Eier'); + expect(eier?.section_heading).toBeNull(); +});