Compare commits
22 Commits
feat/issue
...
0511a735a5
| Author | SHA1 | Date | |
|---|---|---|---|
| 0511a735a5 | |||
| 33f3b30cb4 | |||
| e4d3008139 | |||
| 6505cb4251 | |||
| 3d49e6b7bf | |||
| 4e2b0b5727 | |||
| 2cef8a1169 | |||
| fcf0f297bb | |||
| 0256b4360b | |||
| 00c48a7c96 | |||
| ce860d68e4 | |||
| b39d04acce | |||
| c7e56a173d | |||
| 86a25eb038 | |||
| a34c6f30f2 | |||
| 9bb6293d9f | |||
| 47c748145d | |||
| a25286e385 | |||
| a733e8dd66 | |||
| 35ed6ca878 | |||
| dc99459a2e | |||
| 021d308a71 |
20
frontend/src/lib/recipes/FilterChipRow.svelte
Normal file
20
frontend/src/lib/recipes/FilterChipRow.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
let { activeFilter, onFilter }: { activeFilter: string; onFilter: (filter: string) => void } = $props();
|
||||
|
||||
const chips = ['Alle', 'Leicht', 'Mittel', 'Schwer'];
|
||||
</script>
|
||||
|
||||
<div class="flex gap-[8px] overflow-x-auto px-[16px] py-[12px] scrollbar-none">
|
||||
{#each chips as label (label)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeFilter === label}
|
||||
onclick={() => onFilter(label)}
|
||||
class="font-sans text-[13px] font-medium tracking-[0.04em] px-[14px] py-[5px] rounded-[12px] border cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--green-light)] {activeFilter === label
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)] border-[var(--green-light)]'
|
||||
: 'bg-[var(--color-surface)] text-[var(--color-text-muted)] border-[var(--color-border)]'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
51
frontend/src/lib/recipes/FilterChipRow.test.ts
Normal file
51
frontend/src/lib/recipes/FilterChipRow.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import FilterChipRow from './FilterChipRow.svelte';
|
||||
|
||||
describe('FilterChipRow', () => {
|
||||
it('renders all effort filter chips', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Mittel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Schwer' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks active chip with aria-pressed=true', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Alle' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('marks inactive chips with aria-pressed=false', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Mittel' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Schwer' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('calls onFilter with the chip label when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilter = vi.fn();
|
||||
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leicht' }));
|
||||
expect(onFilter).toHaveBeenCalledWith('Leicht');
|
||||
});
|
||||
|
||||
it('calls onFilter with Alle when reset chip clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilter = vi.fn();
|
||||
render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Alle' }));
|
||||
expect(onFilter).toHaveBeenCalledWith('Alle');
|
||||
});
|
||||
|
||||
it('active chip has green-tint styling', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Mittel', onFilter: vi.fn() } });
|
||||
const btn = screen.getByRole('button', { name: 'Mittel' });
|
||||
expect(btn.className).toContain('bg-[var(--green-tint)]');
|
||||
});
|
||||
});
|
||||
30
frontend/src/lib/recipes/IngredientList.svelte
Normal file
30
frontend/src/lib/recipes/IngredientList.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Ingredient } from './types';
|
||||
|
||||
let { ingredients }: { ingredients: Ingredient[] } = $props();
|
||||
|
||||
const sortedIngredients = $derived(
|
||||
[...ingredients].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
|
||||
>
|
||||
Zutaten
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
{#each sortedIngredients as ingredient (ingredient.ingredientId ?? ingredient.name)}
|
||||
<li class="flex items-baseline gap-[12px] py-[8px] border-b border-[var(--color-border)] last:border-b-0">
|
||||
{#if ingredient.quantity != null}
|
||||
<span class="text-[13px] font-medium text-[var(--color-text)] w-[80px] shrink-0">
|
||||
{ingredient.quantity}{ingredient.unit != null ? ` ${ingredient.unit}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-[14px] text-[var(--color-text)]">{ingredient.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
57
frontend/src/lib/recipes/IngredientList.test.ts
Normal file
57
frontend/src/lib/recipes/IngredientList.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import IngredientList from './IngredientList.svelte';
|
||||
|
||||
const mockIngredients = [
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
|
||||
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' },
|
||||
{ ingredientId: 'i3', name: 'Salz', quantity: undefined, unit: undefined }
|
||||
];
|
||||
|
||||
describe('IngredientList', () => {
|
||||
it('renders the section heading', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a row for each ingredient', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
|
||||
expect(screen.getByText('Salz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quantity and unit when present', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.getByText('200 g')).toBeInTheDocument();
|
||||
expect(screen.getByText('400 g')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no quantity when not present', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.queryByText('undefined')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has no remove buttons (read-only)', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when ingredients array is empty', () => {
|
||||
render(IngredientList, { props: { ingredients: [] } });
|
||||
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ingredients sorted by sortOrder', () => {
|
||||
const unsorted = [
|
||||
{ ingredientId: 'i3', name: 'Oregano', quantity: 1, unit: 'TL', sortOrder: 3 },
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g', sortOrder: 1 },
|
||||
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g', sortOrder: 2 }
|
||||
];
|
||||
render(IngredientList, { props: { ingredients: unsorted } });
|
||||
const spans = document.querySelectorAll('li span:last-child');
|
||||
expect(spans[0].textContent).toBe('Spaghetti');
|
||||
expect(spans[1].textContent).toBe('Hackfleisch');
|
||||
expect(spans[2].textContent).toBe('Oregano');
|
||||
});
|
||||
});
|
||||
60
frontend/src/lib/recipes/RecipeCard.svelte
Normal file
60
frontend/src/lib/recipes/RecipeCard.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { RecipeSummary } from './types';
|
||||
|
||||
let { recipe, compact = false }: { recipe: RecipeSummary; compact?: boolean } = $props();
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
|
||||
recipe.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/recipes/{recipe.id}"
|
||||
class="block rounded-[var(--radius-md)] overflow-hidden border border-[var(--color-border)] bg-[var(--color-surface)] hover:shadow-[var(--shadow-card)]"
|
||||
>
|
||||
<div
|
||||
data-testid="image-area"
|
||||
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
|
||||
>
|
||||
{#if recipe.heroImageUrl}
|
||||
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
data-testid="image-placeholder"
|
||||
class="w-full h-full bg-[var(--color-border)] flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
class="text-[var(--color-text-muted)] opacity-50"
|
||||
>
|
||||
<!-- plate -->
|
||||
<circle cx="12" cy="13" r="6" />
|
||||
<path d="M12 7V5" />
|
||||
<!-- fork tines -->
|
||||
<path d="M8 3v3c0 1.1.9 2 2 2h4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="px-2 py-1.5">
|
||||
<p class="font-medium text-[13px] text-[var(--color-text)] truncate">{recipe.name}</p>
|
||||
{#if metadata}
|
||||
<p class="text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
62
frontend/src/lib/recipes/RecipeCard.test.ts
Normal file
62
frontend/src/lib/recipes/RecipeCard.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import RecipeCard from './RecipeCard.svelte';
|
||||
|
||||
const mockRecipe = {
|
||||
id: 'recipe-1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined
|
||||
};
|
||||
|
||||
describe('RecipeCard', () => {
|
||||
it('renders the recipe name', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cook time when present', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.getByText(/30/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders effort when present', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.getByText(/easy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows placeholder when no heroImageUrl', () => {
|
||||
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } });
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows image when heroImageUrl is provided', () => {
|
||||
render(RecipeCard, {
|
||||
props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } }
|
||||
});
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', '/uploads/test.jpg');
|
||||
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
|
||||
});
|
||||
|
||||
it('wraps in a link to the recipe detail page', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/recipes/recipe-1');
|
||||
});
|
||||
|
||||
it('applies compact image height when compact prop is true', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe, compact: true } });
|
||||
const imageArea = document.querySelector('[data-testid="image-area"]');
|
||||
expect(imageArea?.className).toContain('h-[64px]');
|
||||
});
|
||||
|
||||
it('applies full image height when compact prop is false', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe, compact: false } });
|
||||
const imageArea = document.querySelector('[data-testid="image-area"]');
|
||||
expect(imageArea?.className).toContain('h-[100px]');
|
||||
});
|
||||
});
|
||||
282
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
282
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
type Category = { id: string; name: string; tagType?: string };
|
||||
|
||||
type EditRecipe = {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
ingredients: { name: string; quantity: number; unit: string }[];
|
||||
steps: { instruction: string }[];
|
||||
tagIds: string[];
|
||||
} | null;
|
||||
|
||||
const { recipe, categories, action }: {
|
||||
recipe: EditRecipe;
|
||||
categories: Category[];
|
||||
action: string;
|
||||
} = $props();
|
||||
|
||||
const effortOptions = [
|
||||
{ label: 'Leicht', value: 'Easy' },
|
||||
{ label: 'Mittel', value: 'Medium' },
|
||||
{ label: 'Schwer', value: 'Hard' }
|
||||
];
|
||||
|
||||
const initial = (() => $state.snapshot(recipe))();
|
||||
|
||||
let name = $state(initial?.name ?? '');
|
||||
let serves = $state<number | ''>(initial?.serves ?? '');
|
||||
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
|
||||
let effort = $state(initial?.effort ?? '');
|
||||
let selectedTagIds = $state<string[]>(initial?.tagIds ? [...initial.tagIds] : []);
|
||||
let ingredients = $state(
|
||||
initial?.ingredients.map((ing) => ({
|
||||
name: ing.name,
|
||||
quantity: ing.quantity as number | '',
|
||||
unit: ing.unit
|
||||
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
|
||||
);
|
||||
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
||||
</script>
|
||||
|
||||
<form method="POST" {action} use:enhance>
|
||||
<!-- Error banner -->
|
||||
{#if $page.form?.error}
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-[20px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]"
|
||||
>
|
||||
{$page.form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Two-column layout -->
|
||||
<div class="md:flex md:gap-[32px]">
|
||||
<!-- Left column: main form fields -->
|
||||
<div class="md:flex-1">
|
||||
<!-- Basic info -->
|
||||
<div class="mb-[24px]">
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="name"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="serves"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Portionen
|
||||
</label>
|
||||
<input
|
||||
id="serves"
|
||||
name="serves"
|
||||
type="number"
|
||||
bind:value={serves}
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="cookTimeMin"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Kochzeit
|
||||
</label>
|
||||
<input
|
||||
id="cookTimeMin"
|
||||
name="cookTimeMin"
|
||||
type="number"
|
||||
bind:value={cookTimeMin}
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Effort chips -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">
|
||||
Schwierigkeitsgrad
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
{#each effortOptions as opt (opt.value)}
|
||||
<label
|
||||
class={[
|
||||
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
|
||||
effort === opt.value
|
||||
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
|
||||
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
|
||||
].join(' ')}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="effort"
|
||||
value={opt.value}
|
||||
bind:group={effort}
|
||||
class="sr-only"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredients -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
|
||||
<div class="flex flex-col gap-[8px]">
|
||||
{#each ingredients as ing, i (i)}
|
||||
<div class="flex items-center gap-[8px]">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={ing.quantity}
|
||||
placeholder="Menge"
|
||||
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={ing.unit}
|
||||
placeholder="Einheit"
|
||||
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={ing.name}
|
||||
placeholder="Zutat"
|
||||
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}
|
||||
class="shrink-0 text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (ingredients = [...ingredients, { name: '', quantity: '' as number | '', unit: '' }])}
|
||||
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
|
||||
>
|
||||
Zutat hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Schritte</p>
|
||||
<div class="flex flex-col gap-[12px]">
|
||||
{#each steps as _, i (i)}
|
||||
<div class="flex items-start gap-[12px]">
|
||||
<span
|
||||
class="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[var(--green-tint)] text-[12px] font-medium text-[var(--green-dark)]"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div class="flex flex-1 flex-col gap-[6px]">
|
||||
<textarea
|
||||
bind:value={steps[i]}
|
||||
placeholder="Schritt beschreiben…"
|
||||
rows="3"
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none resize-none"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (steps = steps.filter((_, j) => j !== i))}
|
||||
class="self-start text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (steps = [...steps, ''])}
|
||||
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
|
||||
>
|
||||
Schritt hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: categories -->
|
||||
<div class="md:w-[280px] md:flex-shrink-0 mt-[24px] md:mt-0">
|
||||
<div
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[20px]"
|
||||
>
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
{#each categories as cat (cat.id)}
|
||||
<label
|
||||
class={[
|
||||
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
|
||||
selectedTagIds.includes(cat.id)
|
||||
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
|
||||
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
|
||||
].join(' ')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="tagIds"
|
||||
value={cat.id}
|
||||
checked={selectedTagIds.includes(cat.id)}
|
||||
onchange={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
selectedTagIds = [...selectedTagIds, cat.id];
|
||||
} else {
|
||||
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
|
||||
}
|
||||
}}
|
||||
class="sr-only"
|
||||
/>
|
||||
{cat.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden inputs for form submission -->
|
||||
<input type="hidden" name="ingredientsJson" value={JSON.stringify(ingredients)} />
|
||||
<input type="hidden" name="stepsJson" value={JSON.stringify(steps)} />
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-[32px] flex items-center justify-between">
|
||||
<a
|
||||
href="/recipes"
|
||||
class="text-[13px] font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
Abbrechen
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
165
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
165
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { writable } from 'svelte/store';
|
||||
import RecipeForm from './RecipeForm.svelte';
|
||||
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: writable({ form: null, url: new URL('http://localhost/recipes/new') })
|
||||
}));
|
||||
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: () => ({ destroy: () => {} })
|
||||
}));
|
||||
|
||||
const mockCategories = [
|
||||
{ id: 'c1', name: 'Pasta', tagType: 'category' },
|
||||
{ id: 'c2', name: 'Fleisch', tagType: 'category' }
|
||||
];
|
||||
|
||||
const emptyProps = {
|
||||
recipe: null,
|
||||
categories: mockCategories,
|
||||
action: '?/create'
|
||||
};
|
||||
|
||||
const editProps = {
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Medium',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
ingredients: [
|
||||
{ name: 'Spaghetti', quantity: 200, unit: 'g' }
|
||||
],
|
||||
steps: [
|
||||
{ instruction: 'Wasser aufsetzen' }
|
||||
],
|
||||
tagIds: ['c1']
|
||||
},
|
||||
categories: mockCategories,
|
||||
action: '?/update'
|
||||
};
|
||||
|
||||
describe('RecipeForm', () => {
|
||||
it('renders recipe name input', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders serves input', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByLabelText(/portionen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cook time input', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByLabelText(/kochzeit/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills name when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('Spaghetti Bolognese');
|
||||
});
|
||||
|
||||
it('prefills serves when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByLabelText(/portionen/i)).toHaveValue(4);
|
||||
});
|
||||
|
||||
it('renders effort chips', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('radio', { name: /leicht/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /mittel/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /schwer/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills effort when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByRole('radio', { name: /mittel/i })).toBeChecked();
|
||||
});
|
||||
|
||||
it('renders category chips', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: 'Fleisch' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills selected categories when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: 'Fleisch' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders at least one ingredient row initially for empty form', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByPlaceholderText(/zutat/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills ingredient rows when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByDisplayValue('Spaghetti')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds ingredient row when "Zutat hinzufügen" is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
const before = screen.getAllByPlaceholderText(/zutat/i).length;
|
||||
await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i }));
|
||||
expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it('removes ingredient row when remove button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: editProps });
|
||||
await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i }));
|
||||
const before = screen.getAllByPlaceholderText(/zutat/i).length;
|
||||
const removeButtons = screen.getAllByRole('button', { name: /entfernen/i });
|
||||
await user.click(removeButtons[0]);
|
||||
expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before - 1);
|
||||
});
|
||||
|
||||
it('renders at least one step row initially for empty form', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByPlaceholderText(/schritt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills step rows when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByDisplayValue('Wasser aufsetzen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds step row when "Schritt hinzufügen" is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
const before = screen.getAllByPlaceholderText(/schritt/i).length;
|
||||
await user.click(screen.getByRole('button', { name: /schritt hinzufügen/i }));
|
||||
expect(screen.getAllByPlaceholderText(/schritt/i)).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it('renders save button', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('button', { name: /speichern/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cancel link back to /recipes', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
const cancelLink = screen.getByRole('link', { name: /abbrechen/i });
|
||||
expect(cancelLink).toHaveAttribute('href', '/recipes');
|
||||
});
|
||||
|
||||
it('displays form error message when $page.form.error is set', async () => {
|
||||
const { page } = await import('$app/stores');
|
||||
(page as ReturnType<typeof writable>).set({ form: { error: 'Name ist erforderlich' }, url: new URL('http://localhost/recipes/new') });
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Name ist erforderlich');
|
||||
(page as ReturnType<typeof writable>).set({ form: null, url: new URL('http://localhost/recipes/new') });
|
||||
});
|
||||
|
||||
it('does not display error banner when form has no error', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
21
frontend/src/lib/recipes/RecipeGrid.svelte
Normal file
21
frontend/src/lib/recipes/RecipeGrid.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import RecipeCard from './RecipeCard.svelte';
|
||||
import type { RecipeSummary } from './types';
|
||||
|
||||
let { recipes }: { recipes: RecipeSummary[] } = $props();
|
||||
</script>
|
||||
|
||||
{#if recipes.length > 0}
|
||||
<div data-testid="recipe-grid" class="grid grid-cols-2 lg:grid-cols-4 gap-[8px] lg:gap-[12px] p-[16px]">
|
||||
{#each recipes as recipe (recipe.id)}
|
||||
<RecipeCard {recipe} compact={true} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-[48px] px-[24px] text-center">
|
||||
<p class="text-[var(--color-text-muted)] text-[14px] mb-[16px]">Noch keine Rezepte vorhanden.</p>
|
||||
<a href="/recipes/new" class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white">
|
||||
Rezept hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
41
frontend/src/lib/recipes/RecipeGrid.test.ts
Normal file
41
frontend/src/lib/recipes/RecipeGrid.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import RecipeGrid from './RecipeGrid.svelte';
|
||||
|
||||
const mockRecipes = [
|
||||
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
|
||||
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
|
||||
{ id: 'r3', name: 'Caesar Salad', cookTimeMin: 15, effort: 'Easy' }
|
||||
];
|
||||
|
||||
describe('RecipeGrid', () => {
|
||||
it('renders a card for each recipe', () => {
|
||||
render(RecipeGrid, { props: { recipes: mockRecipes } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Caesar Salad')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders 3 links for 3 recipes', () => {
|
||||
render(RecipeGrid, { props: { recipes: mockRecipes } });
|
||||
expect(screen.getAllByRole('link')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('shows empty state when recipes array is empty', () => {
|
||||
render(RecipeGrid, { props: { recipes: [] } });
|
||||
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('empty state links to recipe creation', () => {
|
||||
render(RecipeGrid, { props: { recipes: [] } });
|
||||
const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i });
|
||||
expect(addLink).toHaveAttribute('href', '/recipes/new');
|
||||
});
|
||||
|
||||
it('grid has 2-col mobile and 4-col desktop classes', () => {
|
||||
render(RecipeGrid, { props: { recipes: mockRecipes } });
|
||||
const grid = document.querySelector('[data-testid="recipe-grid"]');
|
||||
expect(grid?.className).toContain('grid-cols-2');
|
||||
expect(grid?.className).toContain('lg:grid-cols-4');
|
||||
});
|
||||
});
|
||||
70
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
70
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { Tag } from './types';
|
||||
|
||||
type RecipeHeroData = {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
tags: Tag[];
|
||||
};
|
||||
|
||||
let { recipe }: { recipe: RecipeHeroData } = $props();
|
||||
|
||||
let hasImage = $derived(!!recipe.heroImageUrl);
|
||||
|
||||
let pillBase = $derived(
|
||||
hasImage
|
||||
? 'bg-white/20 text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||
: 'bg-[var(--color-border)] text-[var(--color-text-muted)] text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||
);
|
||||
|
||||
let cookBtnClass = $derived(
|
||||
hasImage
|
||||
? 'font-sans text-[13px] font-medium tracking-[0.04em] bg-white text-[var(--green-dark)] rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||
: 'font-sans text-[13px] font-medium tracking-[0.04em] bg-[var(--green-dark)] text-white rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="recipe-hero"
|
||||
class="min-h-[200px] md:min-h-[240px] {hasImage
|
||||
? 'relative text-white'
|
||||
: 'bg-[var(--green-tint)] text-[var(--color-text)]'} p-[24px] md:p-[32px]"
|
||||
>
|
||||
{#if hasImage}
|
||||
<img
|
||||
src={recipe.heroImageUrl}
|
||||
alt={recipe.name}
|
||||
class="object-cover w-full h-full absolute inset-0"
|
||||
/>
|
||||
<div class="absolute inset-0" style="background: rgba(0,0,0,0.5);"></div>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<a href="/recipes" class="text-[13px] font-sans font-medium text-[var(--color-text-muted)]">← Zurück</a>
|
||||
|
||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] mt-[8px]">
|
||||
{recipe.name}
|
||||
</h1>
|
||||
|
||||
<div class="flex gap-[8px] flex-wrap mt-[12px]">
|
||||
{#if recipe.cookTimeMin != null}
|
||||
<span class={pillBase}>{recipe.cookTimeMin} Min</span>
|
||||
{/if}
|
||||
{#if recipe.effort}
|
||||
<span class={pillBase}>{recipe.effort}</span>
|
||||
{/if}
|
||||
{#if recipe.serves != null}
|
||||
<span class={pillBase}>{recipe.serves} Port.</span>
|
||||
{/if}
|
||||
{#each recipe.tags as tag (tag.id)}
|
||||
<span class={pillBase}>{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<a href="/cook/{recipe.id}" class={cookBtnClass}>Jetzt kochen</a>
|
||||
</div>
|
||||
</div>
|
||||
87
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
87
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import RecipeHero from './RecipeHero.svelte';
|
||||
|
||||
const baseRecipe = {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
tags: [] as { id: string; name: string; tagType?: string }[]
|
||||
};
|
||||
|
||||
describe('RecipeHero', () => {
|
||||
it('renders the recipe name', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders green-tint hero when no image', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const hero = document.querySelector('[data-testid="recipe-hero"]');
|
||||
expect(hero?.className).toContain('bg-[var(--green-tint)]');
|
||||
});
|
||||
|
||||
it('renders image when heroImageUrl is provided', () => {
|
||||
render(RecipeHero, {
|
||||
props: { recipe: { ...baseRecipe, heroImageUrl: '/uploads/pasta.jpg' } }
|
||||
});
|
||||
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
|
||||
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
|
||||
});
|
||||
|
||||
it('renders cook time pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/30 Min/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders effort pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/Easy/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders serves pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/4/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders back link to /recipes', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const backLink = screen.getByRole('link', { name: /zurück/i });
|
||||
expect(backLink).toHaveAttribute('href', '/recipes');
|
||||
});
|
||||
|
||||
it('renders cook now link to /cook/[id]', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
|
||||
expect(cookLink).toHaveAttribute('href', '/cook/r1');
|
||||
});
|
||||
|
||||
it('does not render img when no heroImageUrl', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tag pills', () => {
|
||||
render(RecipeHero, {
|
||||
props: {
|
||||
recipe: {
|
||||
...baseRecipe,
|
||||
tags: [
|
||||
{ id: 't1', name: 'Pasta' },
|
||||
{ id: 't2', name: 'Italienisch' }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(screen.getByText('Pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Italienisch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no tag pills when tags array is empty', () => {
|
||||
render(RecipeHero, { props: { recipe: { ...baseRecipe, tags: [] } } });
|
||||
expect(screen.queryByText('Pasta')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
33
frontend/src/lib/recipes/StepList.svelte
Normal file
33
frontend/src/lib/recipes/StepList.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { Step } from './types';
|
||||
|
||||
let { steps }: { steps: Step[] } = $props();
|
||||
|
||||
const sortedSteps = $derived(
|
||||
[...steps].sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0))
|
||||
);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
|
||||
>
|
||||
Zubereitung
|
||||
</h2>
|
||||
<ol>
|
||||
{#each sortedSteps as step (step.stepNumber)}
|
||||
<li class="flex gap-[16px] items-start mb-[20px]">
|
||||
<div
|
||||
data-testid="step-circle"
|
||||
aria-hidden="true"
|
||||
class="w-[28px] h-[28px] rounded-full bg-[var(--green-dark)] text-white flex items-center justify-center shrink-0 font-sans text-[13px] font-medium"
|
||||
>
|
||||
{step.stepNumber}
|
||||
</div>
|
||||
<p class="text-[14px] text-[var(--color-text)] leading-[1.6] pt-[4px]">
|
||||
{step.instruction}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
50
frontend/src/lib/recipes/StepList.test.ts
Normal file
50
frontend/src/lib/recipes/StepList.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import StepList from './StepList.svelte';
|
||||
|
||||
const mockSteps = [
|
||||
{ stepNumber: 1, instruction: 'Wasser zum Kochen bringen' },
|
||||
{ stepNumber: 2, instruction: 'Spaghetti al dente kochen' },
|
||||
{ stepNumber: 3, instruction: 'Sauce bereiten' }
|
||||
];
|
||||
|
||||
describe('StepList', () => {
|
||||
it('renders the section heading', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders each step instruction', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
expect(screen.getByText('Wasser zum Kochen bringen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Spaghetti al dente kochen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sauce bereiten')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders step numbers', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders numbered circles with step numbers', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
const circles = document.querySelectorAll('[data-testid="step-circle"]');
|
||||
expect(circles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders steps in stepNumber order', () => {
|
||||
const shuffled = [mockSteps[2], mockSteps[0], mockSteps[1]];
|
||||
render(StepList, { props: { steps: shuffled } });
|
||||
const circles = document.querySelectorAll('[data-testid="step-circle"]');
|
||||
expect(circles[0].textContent).toBe('1');
|
||||
expect(circles[1].textContent).toBe('2');
|
||||
expect(circles[2].textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('renders empty state when no steps', () => {
|
||||
render(StepList, { props: { steps: [] } });
|
||||
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/recipes/types.ts
Normal file
38
frontend/src/lib/recipes/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type RecipeSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
name: string;
|
||||
tagType?: string;
|
||||
};
|
||||
|
||||
export type Ingredient = {
|
||||
ingredientId?: string;
|
||||
name?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type Step = {
|
||||
stepNumber?: number;
|
||||
instruction?: string;
|
||||
};
|
||||
|
||||
export type RecipeDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
ingredients: Ingredient[];
|
||||
steps: Step[];
|
||||
tags: Tag[];
|
||||
};
|
||||
21
frontend/src/routes/(app)/recipes/+page.server.ts
Normal file
21
frontend/src/routes/(app)/recipes/+page.server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.GET('/v1/recipes', {});
|
||||
|
||||
if (error || !data?.data) {
|
||||
return { recipes: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
recipes: data.data.map((r) => ({
|
||||
id: r.id!,
|
||||
name: r.name!,
|
||||
cookTimeMin: r.cookTimeMin,
|
||||
effort: r.effort,
|
||||
heroImageUrl: r.heroImageUrl
|
||||
}))
|
||||
};
|
||||
};
|
||||
@@ -1 +1,47 @@
|
||||
<h1 class="text-2xl font-medium p-6">Rezepte</h1>
|
||||
<script lang="ts">
|
||||
import FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
|
||||
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
|
||||
import type { RecipeSummary } from '$lib/recipes/types';
|
||||
|
||||
let { data }: { data: { recipes: RecipeSummary[] } } = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let activeFilter = $state('Alle');
|
||||
|
||||
const effortMap: Record<string, string> = {
|
||||
Leicht: 'Easy',
|
||||
Mittel: 'Medium',
|
||||
Schwer: 'Hard'
|
||||
};
|
||||
|
||||
let filteredRecipes = $derived(
|
||||
data.recipes
|
||||
.filter((r) => {
|
||||
if (activeFilter === 'Alle') return true;
|
||||
return r.effort === effortMap[activeFilter];
|
||||
})
|
||||
.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rezepte — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">Rezepte</h1>
|
||||
<a href="/recipes/new" class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white">Rezept hinzufügen</a>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Suchen…"
|
||||
class="input"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
|
||||
<FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} />
|
||||
|
||||
<RecipeGrid recipes={filteredRecipes} />
|
||||
</div>
|
||||
|
||||
41
frontend/src/routes/(app)/recipes/[id]/+page.server.ts
Normal file
41
frontend/src/routes/(app)/recipes/[id]/+page.server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||
const api = apiClient(fetch);
|
||||
const { data, error: apiError } = await api.GET('/v1/recipes/{id}', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
if (apiError || !data) {
|
||||
error(404, 'Recipe not found');
|
||||
}
|
||||
|
||||
return {
|
||||
recipe: {
|
||||
id: data.id!,
|
||||
name: data.name!,
|
||||
serves: data.serves,
|
||||
cookTimeMin: data.cookTimeMin,
|
||||
effort: data.effort,
|
||||
heroImageUrl: data.heroImageUrl,
|
||||
ingredients: (data.ingredients ?? []).map((ing) => ({
|
||||
ingredientId: ing.ingredientId,
|
||||
name: ing.name,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit,
|
||||
sortOrder: ing.sortOrder
|
||||
})),
|
||||
steps: (data.steps ?? []).map((s) => ({
|
||||
stepNumber: s.stepNumber,
|
||||
instruction: s.instruction
|
||||
})),
|
||||
tags: (data.tags ?? []).map((t) => ({
|
||||
id: t.id!,
|
||||
name: t.name!,
|
||||
tagType: t.tagType
|
||||
}))
|
||||
}
|
||||
};
|
||||
};
|
||||
34
frontend/src/routes/(app)/recipes/[id]/+page.svelte
Normal file
34
frontend/src/routes/(app)/recipes/[id]/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import RecipeHero from '$lib/recipes/RecipeHero.svelte';
|
||||
import IngredientList from '$lib/recipes/IngredientList.svelte';
|
||||
import StepList from '$lib/recipes/StepList.svelte';
|
||||
import type { RecipeDetail } from '$lib/recipes/types';
|
||||
|
||||
let { data }: { data: { recipe: RecipeDetail } } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.recipe.name} — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="hidden md:flex items-center justify-end px-[24px] py-[12px] border-b border-[var(--color-border)]">
|
||||
<a
|
||||
href="/recipes/{data.recipe.id}/edit"
|
||||
class="border border-[var(--color-border)] text-[var(--color-text)] text-[13px] font-medium font-sans tracking-[0.04em] rounded-[var(--radius-md)] px-[16px] py-[8px]"
|
||||
>
|
||||
Bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<RecipeHero recipe={data.recipe} />
|
||||
|
||||
<div class="md:flex">
|
||||
<div class="md:flex-1 md:border-r md:border-[var(--color-border)] p-[24px]">
|
||||
<IngredientList ingredients={data.recipe.ingredients} />
|
||||
</div>
|
||||
<div class="md:flex-1 p-[24px]">
|
||||
<StepList steps={data.recipe.steps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
98
frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts
Normal file
98
frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { error, redirect, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||
const api = apiClient(fetch);
|
||||
const [recipeResult, tagsResult] = await Promise.all([
|
||||
api.GET('/v1/recipes/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/v1/tags', {})
|
||||
]);
|
||||
|
||||
if (recipeResult.error || !recipeResult.data) {
|
||||
error(404, 'Recipe not found');
|
||||
}
|
||||
|
||||
const recipe = recipeResult.data;
|
||||
const allTags = tagsResult.data ?? [];
|
||||
const categories = allTags
|
||||
.filter((t) => t.tagType === 'category')
|
||||
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
|
||||
|
||||
return {
|
||||
recipe: {
|
||||
id: recipe.id!,
|
||||
name: recipe.name!,
|
||||
serves: recipe.serves,
|
||||
cookTimeMin: recipe.cookTimeMin,
|
||||
effort: recipe.effort,
|
||||
heroImageUrl: recipe.heroImageUrl,
|
||||
ingredients: (recipe.ingredients ?? []).map((ing) => ({
|
||||
name: ing.name ?? '',
|
||||
quantity: ing.quantity ?? 0,
|
||||
unit: ing.unit ?? ''
|
||||
})),
|
||||
steps: (recipe.steps ?? [])
|
||||
.sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0))
|
||||
.map((s) => ({ instruction: s.instruction ?? '' })),
|
||||
tagIds: (recipe.tags ?? []).map((t) => t.id!)
|
||||
},
|
||||
categories
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, fetch, params }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const serves = formData.get('serves');
|
||||
const cookTimeMin = formData.get('cookTimeMin');
|
||||
const effort = formData.get('effort') as string;
|
||||
const ingredientsJson = formData.get('ingredientsJson') as string;
|
||||
const stepsJson = formData.get('stepsJson') as string;
|
||||
const tagIds = formData.getAll('tagIds') as string[];
|
||||
|
||||
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
|
||||
if (!effort || !VALID_EFFORTS.includes(effort))
|
||||
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
|
||||
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
|
||||
|
||||
let parsedIngredients: unknown[];
|
||||
let parsedSteps: unknown[];
|
||||
try {
|
||||
parsedIngredients = JSON.parse(ingredientsJson || '[]');
|
||||
parsedSteps = JSON.parse(stepsJson || '[]');
|
||||
} catch {
|
||||
return fail(400, { error: 'Ungültige Formulardaten' });
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { error: apiError } = await api.PUT('/v1/recipes/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
body: {
|
||||
name: name.trim(),
|
||||
serves: serves ? Number(serves) || undefined : undefined,
|
||||
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
|
||||
effort,
|
||||
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
|
||||
.filter((ing) => ing.name?.trim())
|
||||
.map((ing, i) => ({
|
||||
newIngredientName: ing.name.trim(),
|
||||
quantity: Number(ing.quantity) || 0,
|
||||
unit: ing.unit || '',
|
||||
sortOrder: i
|
||||
})),
|
||||
steps: (parsedSteps as string[])
|
||||
.filter((s) => s?.trim())
|
||||
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
|
||||
tagIds
|
||||
}
|
||||
});
|
||||
|
||||
if (apiError) return fail(500, { error: 'Fehler beim Speichern' });
|
||||
|
||||
redirect(303, '/recipes');
|
||||
}
|
||||
};
|
||||
17
frontend/src/routes/(app)/recipes/[id]/edit/+page.svelte
Normal file
17
frontend/src/routes/(app)/recipes/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import RecipeForm from '$lib/recipes/RecipeForm.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.recipe?.name ?? 'Rezept bearbeiten'} — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-[24px]">
|
||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-medium text-[var(--color-text)] mb-[24px]">
|
||||
Rezept bearbeiten
|
||||
</h1>
|
||||
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/update" />
|
||||
</div>
|
||||
190
frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts
Normal file
190
frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPut = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, PUT: mockPut })
|
||||
}));
|
||||
|
||||
describe('edit recipe page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPut.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockRecipe = {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
ingredients: [{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }],
|
||||
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
|
||||
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
|
||||
};
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'Pasta', tagType: 'category' },
|
||||
{ id: 't2', name: 'Fleisch', tagType: 'category' }
|
||||
];
|
||||
|
||||
it('fetches recipe and tags in parallel', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
|
||||
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||
});
|
||||
await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns recipe data mapped for form', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
|
||||
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||
});
|
||||
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(result.recipe.name).toBe('Spaghetti Bolognese');
|
||||
expect(result.recipe.effort).toBe('Easy');
|
||||
});
|
||||
|
||||
it('returns categories from tags', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
|
||||
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||
});
|
||||
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(result.categories).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('throws 404 when recipe not found', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: undefined, error: { status: 404 } });
|
||||
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||
});
|
||||
await expect(
|
||||
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit recipe page — update action', () => {
|
||||
let actions: any;
|
||||
|
||||
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
|
||||
const base: Record<string, string | string[]> = {
|
||||
name: 'Test Rezept',
|
||||
effort: 'Easy',
|
||||
tagIds: ['t1'],
|
||||
ingredientsJson: '[]',
|
||||
stepsJson: '[]',
|
||||
...overrides
|
||||
};
|
||||
const fd = new FormData();
|
||||
for (const [key, val] of Object.entries(base)) {
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) fd.append(key, v);
|
||||
} else {
|
||||
fd.append(key, val);
|
||||
}
|
||||
}
|
||||
return fd;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPut.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('returns fail(422) when name is missing', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ name: '' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when effort is missing', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ effort: '' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when effort is not a valid value', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ effort: 'VeryHard' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when no tagIds', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ tagIds: [] }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(400) when ingredientsJson is invalid JSON', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns fail(400) when stepsJson is invalid JSON', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ stepsJson: '{broken' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('calls PUT /v1/recipes/{id} with correct body on success', async () => {
|
||||
mockPut.mockResolvedValue({ error: undefined });
|
||||
const fd = makeFormData({
|
||||
ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
|
||||
stepsJson: JSON.stringify(['Kochen'])
|
||||
});
|
||||
await actions.update({
|
||||
request: { formData: async () => fd },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any).catch(() => {});
|
||||
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
|
||||
params: { path: { id: 'r1' } },
|
||||
body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' })
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns fail(500) when API returns error', async () => {
|
||||
mockPut.mockResolvedValue({ error: { status: 500 } });
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData() },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(500);
|
||||
});
|
||||
});
|
||||
66
frontend/src/routes/(app)/recipes/[id]/page.server.test.ts
Normal file
66
frontend/src/routes/(app)/recipes/[id]/page.server.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet })
|
||||
}));
|
||||
|
||||
describe('recipe detail page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockRecipe = {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined,
|
||||
ingredients: [
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }
|
||||
],
|
||||
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
|
||||
tags: []
|
||||
};
|
||||
|
||||
it('fetches recipe from GET /v1/recipes/{id}', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
|
||||
await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
|
||||
params: { path: { id: 'r1' } }
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns recipe data on success', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
|
||||
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(result.recipe.name).toBe('Spaghetti Bolognese');
|
||||
expect(result.recipe.serves).toBe(4);
|
||||
});
|
||||
|
||||
it('throws 404 error when API returns error', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 404 } });
|
||||
await expect(
|
||||
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('throws 404 error when API returns 403 (different household — intentional)', async () => {
|
||||
// Security design: we return 404 for both "not found" and "forbidden"
|
||||
// to avoid revealing resource existence to unauthorized users
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 403 } });
|
||||
await expect(
|
||||
load({ fetch: vi.fn(), params: { id: 'r-other-household' } } as any)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
92
frontend/src/routes/(app)/recipes/[id]/page.test.ts
Normal file
92
frontend/src/routes/(app)/recipes/[id]/page.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
const mockData = {
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
ingredients: [
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
|
||||
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' }
|
||||
],
|
||||
steps: [
|
||||
{ stepNumber: 1, instruction: 'Wasser aufsetzen' },
|
||||
{ stepNumber: 2, instruction: 'Sauce zubereiten' }
|
||||
],
|
||||
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
|
||||
}
|
||||
};
|
||||
|
||||
describe('recipe detail page', () => {
|
||||
it('renders the recipe name', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders page title', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(document.title).toBe('Spaghetti Bolognese — Mealplan');
|
||||
});
|
||||
|
||||
it('renders back link to /recipes', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
const backLink = screen.getByRole('link', { name: /zurück/i });
|
||||
expect(backLink).toHaveAttribute('href', '/recipes');
|
||||
});
|
||||
|
||||
it('renders cook now link to /cook/[id]', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
|
||||
expect(cookLink).toHaveAttribute('href', '/cook/r1');
|
||||
});
|
||||
|
||||
it('renders ingredients section heading', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders steps section heading', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ingredient names', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders step instructions', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Wasser aufsetzen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sauce zubereiten')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders edit link to /recipes/[id]/edit', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
const editLink = screen.getByRole('link', { name: /bearbeiten/i });
|
||||
expect(editLink).toHaveAttribute('href', '/recipes/r1/edit');
|
||||
});
|
||||
|
||||
it('renders tag pills in hero', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Pasta')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hero image when heroImageUrl is provided', () => {
|
||||
render(Page, {
|
||||
props: {
|
||||
data: {
|
||||
recipe: { ...mockData.recipe, heroImageUrl: '/uploads/pasta.jpg' }
|
||||
}
|
||||
}
|
||||
});
|
||||
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
|
||||
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
|
||||
});
|
||||
});
|
||||
70
frontend/src/routes/(app)/recipes/new/+page.server.ts
Normal file
70
frontend/src/routes/(app)/recipes/new/+page.server.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.GET('/v1/tags', {});
|
||||
|
||||
const allTags = error || !data ? [] : data;
|
||||
const categories = allTags
|
||||
.filter((t) => t.tagType === 'category')
|
||||
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
|
||||
|
||||
return { recipe: null, categories };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const serves = formData.get('serves');
|
||||
const cookTimeMin = formData.get('cookTimeMin');
|
||||
const effort = formData.get('effort') as string;
|
||||
const ingredientsJson = formData.get('ingredientsJson') as string;
|
||||
const stepsJson = formData.get('stepsJson') as string;
|
||||
const tagIds = formData.getAll('tagIds') as string[];
|
||||
|
||||
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
|
||||
if (!effort || !VALID_EFFORTS.includes(effort))
|
||||
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
|
||||
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
|
||||
|
||||
let parsedIngredients: unknown[];
|
||||
let parsedSteps: unknown[];
|
||||
try {
|
||||
parsedIngredients = JSON.parse(ingredientsJson || '[]');
|
||||
parsedSteps = JSON.parse(stepsJson || '[]');
|
||||
} catch {
|
||||
return fail(400, { error: 'Ungültige Formulardaten' });
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { error: apiError } = await api.POST('/v1/recipes', {
|
||||
body: {
|
||||
name: name.trim(),
|
||||
serves: serves ? Number(serves) || undefined : undefined,
|
||||
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
|
||||
effort,
|
||||
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
|
||||
.filter((ing) => ing.name?.trim())
|
||||
.map((ing, i) => ({
|
||||
newIngredientName: ing.name.trim(),
|
||||
quantity: Number(ing.quantity) || 0,
|
||||
unit: ing.unit || '',
|
||||
sortOrder: i
|
||||
})),
|
||||
steps: (parsedSteps as string[])
|
||||
.filter((s) => s?.trim())
|
||||
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
|
||||
tagIds
|
||||
}
|
||||
});
|
||||
|
||||
if (apiError) return fail(500, { error: 'Fehler beim Speichern' });
|
||||
|
||||
redirect(303, '/recipes');
|
||||
}
|
||||
};
|
||||
17
frontend/src/routes/(app)/recipes/new/+page.svelte
Normal file
17
frontend/src/routes/(app)/recipes/new/+page.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import RecipeForm from '$lib/recipes/RecipeForm.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neues Rezept — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-[24px]">
|
||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-medium text-[var(--color-text)] mb-[24px]">
|
||||
Neues Rezept
|
||||
</h1>
|
||||
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/create" />
|
||||
</div>
|
||||
154
frontend/src/routes/(app)/recipes/new/page.server.test.ts
Normal file
154
frontend/src/routes/(app)/recipes/new/page.server.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
||||
}));
|
||||
|
||||
describe('new recipe page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'Pasta', tagType: 'category' },
|
||||
{ id: 't2', name: 'Fleisch', tagType: 'category' }
|
||||
];
|
||||
|
||||
it('fetches tags from GET /v1/tags', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
|
||||
await load({ fetch: vi.fn() } as any);
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/tags', expect.anything());
|
||||
});
|
||||
|
||||
it('returns categories filtered from tags', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.categories).toHaveLength(2);
|
||||
expect(result.categories[0].name).toBe('Pasta');
|
||||
});
|
||||
|
||||
it('returns empty categories when API fails', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.categories).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null recipe for new form', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.recipe).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('new recipe page — create action', () => {
|
||||
let actions: any;
|
||||
|
||||
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
|
||||
const base: Record<string, string | string[]> = {
|
||||
name: 'Test Rezept',
|
||||
effort: 'Easy',
|
||||
tagIds: ['t1'],
|
||||
ingredientsJson: '[]',
|
||||
stepsJson: '[]',
|
||||
...overrides
|
||||
};
|
||||
const fd = new FormData();
|
||||
for (const [key, val] of Object.entries(base)) {
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) fd.append(key, v);
|
||||
} else {
|
||||
fd.append(key, val);
|
||||
}
|
||||
}
|
||||
return fd;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('returns fail(422) when name is missing', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ name: '' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when effort is missing', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ effort: '' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when effort is not a valid value', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ effort: 'InvalidEffort' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when no tagIds', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ tagIds: [] }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(400) when ingredientsJson is invalid JSON', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns fail(400) when stepsJson is invalid JSON', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ stepsJson: '{broken' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('calls POST /v1/recipes with correct body on success', async () => {
|
||||
mockPost.mockResolvedValue({ error: undefined });
|
||||
const fd = makeFormData({
|
||||
ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
|
||||
stepsJson: JSON.stringify(['Kochen'])
|
||||
});
|
||||
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(
|
||||
() => {}
|
||||
);
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' }) }));
|
||||
});
|
||||
|
||||
it('returns fail(500) when API returns error', async () => {
|
||||
mockPost.mockResolvedValue({ error: { status: 500 } });
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData() },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(500);
|
||||
});
|
||||
});
|
||||
45
frontend/src/routes/(app)/recipes/page.server.test.ts
Normal file
45
frontend/src/routes/(app)/recipes/page.server.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet })
|
||||
}));
|
||||
|
||||
describe('recipe library page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockRecipes = [
|
||||
{ id: 'r1', name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' },
|
||||
{ id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' }
|
||||
];
|
||||
|
||||
it('fetches recipes from GET /v1/recipes', async () => {
|
||||
mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined });
|
||||
await load({ fetch: vi.fn() } as any);
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/recipes', expect.any(Object));
|
||||
});
|
||||
|
||||
it('returns recipes in data', async () => {
|
||||
mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.recipes).toHaveLength(2);
|
||||
expect(result.recipes[0].name).toBe('Spaghetti');
|
||||
});
|
||||
|
||||
it('returns empty array when API fails', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.recipes).toEqual([]);
|
||||
});
|
||||
});
|
||||
99
frontend/src/routes/(app)/recipes/page.test.ts
Normal file
99
frontend/src/routes/(app)/recipes/page.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
const mockData = {
|
||||
recipes: [
|
||||
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
|
||||
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
|
||||
{ id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' }
|
||||
]
|
||||
};
|
||||
|
||||
describe('recipe library page', () => {
|
||||
it('renders all recipe cards initially', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gemüsesuppe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the page title', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(document.title).toBe('Rezepte — Mealplan');
|
||||
});
|
||||
|
||||
it('renders a link to add a new recipe', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i });
|
||||
expect(addLink).toHaveAttribute('href', '/recipes/new');
|
||||
});
|
||||
|
||||
it('filters recipes by search term', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/suchen/i);
|
||||
await user.type(searchInput, 'Curry');
|
||||
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters recipes by effort chip', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mittel' }));
|
||||
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no recipes match search', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/suchen/i);
|
||||
await user.type(searchInput, 'xyznotexist');
|
||||
|
||||
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders filter chips', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state page when no recipes at all', () => {
|
||||
render(Page, { props: { data: { recipes: [] } } });
|
||||
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies search and effort filter together', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leicht' }));
|
||||
const searchInput = screen.getByPlaceholderText(/suchen/i);
|
||||
await user.type(searchInput, 'Gemüse');
|
||||
|
||||
expect(screen.getByText('Gemüsesuppe')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Chicken Curry')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets to all recipes when Alle chip is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mittel' }));
|
||||
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Alle' }));
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user