From 1b2a02881dd666fb0f578649e578b383ba9e2605 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 10:07:46 +0200 Subject: [PATCH] feat(planner): add MealActionSheet component for mobile swap trigger Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/MealActionSheet.svelte | 111 ++++++++++++++++++ .../src/lib/planner/MealActionSheet.test.ts | 79 +++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 frontend/src/lib/planner/MealActionSheet.svelte create mode 100644 frontend/src/lib/planner/MealActionSheet.test.ts diff --git a/frontend/src/lib/planner/MealActionSheet.svelte b/frontend/src/lib/planner/MealActionSheet.svelte new file mode 100644 index 0000000..9199d35 --- /dev/null +++ b/frontend/src/lib/planner/MealActionSheet.svelte @@ -0,0 +1,111 @@ + + +{#if open} + +
+
e.stopPropagation()} + > + +
+
+
+ + +

+ {slot.recipe?.name ?? ''} +

+ + + {#if meta} +

+ {meta} +

+ {/if} + + +
+ + + {#if slot.recipe} + + ๐Ÿณ Cook now + + + + ๐Ÿ‘ View recipe + + {/if} + + +
+
+
+{/if} diff --git a/frontend/src/lib/planner/MealActionSheet.test.ts b/frontend/src/lib/planner/MealActionSheet.test.ts new file mode 100644 index 0000000..399952c --- /dev/null +++ b/frontend/src/lib/planner/MealActionSheet.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import MealActionSheet from './MealActionSheet.svelte'; + +const slot = { + id: 's1', + slotDate: '2026-04-08', + recipe: { id: 'r1', name: 'Tomato pasta', effort: 'easy', cookTimeMin: 45 } +}; + +const baseProps = { + open: true, + slot, + onswap: vi.fn(), + oncancel: vi.fn() +}; + +describe('MealActionSheet', () => { + it('renders meal title', () => { + render(MealActionSheet, { props: baseProps }); + expect(screen.getByText('Tomato pasta')).toBeTruthy(); + }); + + it('renders meal metadata', () => { + render(MealActionSheet, { props: baseProps }); + expect(screen.getByText(/45 min/i)).toBeTruthy(); + expect(screen.getByText(/easy/i)).toBeTruthy(); + }); + + it('renders all 4 action buttons', () => { + render(MealActionSheet, { props: baseProps }); + expect(screen.getByRole('button', { name: /Swap this meal/i })).toBeTruthy(); + expect(screen.getByRole('link', { name: /Cook now/i })).toBeTruthy(); + expect(screen.getByRole('link', { name: /View recipe/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeTruthy(); + }); + + it('Cook now links to the cook route', () => { + render(MealActionSheet, { props: baseProps }); + const link = screen.getByRole('link', { name: /Cook now/i }); + expect(link.getAttribute('href')).toBe('/recipes/r1/cook'); + }); + + it('View recipe links to the recipe detail route', () => { + render(MealActionSheet, { props: baseProps }); + const link = screen.getByRole('link', { name: /View recipe/i }); + expect(link.getAttribute('href')).toBe('/recipes/r1'); + }); + + it('clicking Swap this meal calls onswap', async () => { + const onswap = vi.fn(); + const user = userEvent.setup(); + render(MealActionSheet, { props: { ...baseProps, onswap } }); + await user.click(screen.getByRole('button', { name: /Swap this meal/i })); + expect(onswap).toHaveBeenCalledOnce(); + }); + + it('clicking Cancel calls oncancel', async () => { + const oncancel = vi.fn(); + const user = userEvent.setup(); + render(MealActionSheet, { props: { ...baseProps, oncancel } }); + await user.click(screen.getByRole('button', { name: /Cancel/i })); + expect(oncancel).toHaveBeenCalledOnce(); + }); + + it('clicking backdrop calls oncancel', async () => { + const oncancel = vi.fn(); + const user = userEvent.setup(); + render(MealActionSheet, { props: { ...baseProps, oncancel } }); + await user.click(screen.getByTestId('sheet-backdrop')); + expect(oncancel).toHaveBeenCalledOnce(); + }); + + it('does not render when open is false', () => { + render(MealActionSheet, { props: { ...baseProps, open: false } }); + expect(screen.queryByText('Tomato pasta')).toBeNull(); + }); +});