diff --git a/frontend/src/lib/recipes/FilterChipRow.svelte b/frontend/src/lib/recipes/FilterChipRow.svelte new file mode 100644 index 0000000..bac6cae --- /dev/null +++ b/frontend/src/lib/recipes/FilterChipRow.svelte @@ -0,0 +1,20 @@ + + +
+ {#each chips as label (label)} + + {/each} +
diff --git a/frontend/src/lib/recipes/FilterChipRow.test.ts b/frontend/src/lib/recipes/FilterChipRow.test.ts new file mode 100644 index 0000000..ae28811 --- /dev/null +++ b/frontend/src/lib/recipes/FilterChipRow.test.ts @@ -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)]'); + }); +}); diff --git a/frontend/src/lib/recipes/RecipeCard.svelte b/frontend/src/lib/recipes/RecipeCard.svelte new file mode 100644 index 0000000..8140373 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeCard.svelte @@ -0,0 +1,60 @@ + + + +
+ {#if recipe.heroImageUrl} + {recipe.name} + {:else} +
+ +
+ {/if} +
+ +
+

{recipe.name}

+ {#if metadata} +

{metadata}

+ {/if} +
+
diff --git a/frontend/src/lib/recipes/RecipeCard.test.ts b/frontend/src/lib/recipes/RecipeCard.test.ts new file mode 100644 index 0000000..f9792a4 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeCard.test.ts @@ -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]'); + }); +}); diff --git a/frontend/src/lib/recipes/RecipeGrid.svelte b/frontend/src/lib/recipes/RecipeGrid.svelte new file mode 100644 index 0000000..1c35c17 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeGrid.svelte @@ -0,0 +1,21 @@ + + +{#if recipes.length > 0} +
+ {#each recipes as recipe (recipe.id)} + + {/each} +
+{:else} +
+

Noch keine Rezepte vorhanden.

+ + Rezept hinzufügen + +
+{/if} diff --git a/frontend/src/lib/recipes/RecipeGrid.test.ts b/frontend/src/lib/recipes/RecipeGrid.test.ts new file mode 100644 index 0000000..fccdbe0 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeGrid.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/recipes/types.ts b/frontend/src/lib/recipes/types.ts new file mode 100644 index 0000000..9d8284b --- /dev/null +++ b/frontend/src/lib/recipes/types.ts @@ -0,0 +1,7 @@ +export type RecipeSummary = { + id: string; + name: string; + cookTimeMin?: number; + effort?: string; + heroImageUrl?: string; +}; diff --git a/frontend/src/routes/(app)/recipes/+page.server.ts b/frontend/src/routes/(app)/recipes/+page.server.ts new file mode 100644 index 0000000..bff4ccb --- /dev/null +++ b/frontend/src/routes/(app)/recipes/+page.server.ts @@ -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 + })) + }; +}; diff --git a/frontend/src/routes/(app)/recipes/+page.svelte b/frontend/src/routes/(app)/recipes/+page.svelte index 1faa0a3..3b7a089 100644 --- a/frontend/src/routes/(app)/recipes/+page.svelte +++ b/frontend/src/routes/(app)/recipes/+page.svelte @@ -1 +1,47 @@ -

Rezepte

+ + + + Rezepte — Mealplan + + +
+
+

Rezepte

+ Rezept hinzufügen +
+ + + + (activeFilter = f)} /> + + +
diff --git a/frontend/src/routes/(app)/recipes/page.server.test.ts b/frontend/src/routes/(app)/recipes/page.server.test.ts new file mode 100644 index 0000000..50c1358 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/page.server.test.ts @@ -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([]); + }); +}); diff --git a/frontend/src/routes/(app)/recipes/page.test.ts b/frontend/src/routes/(app)/recipes/page.test.ts new file mode 100644 index 0000000..8fe49fc --- /dev/null +++ b/frontend/src/routes/(app)/recipes/page.test.ts @@ -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(); + }); +});