diff --git a/frontend/src/lib/planner/DesktopDayTile.svelte b/frontend/src/lib/planner/DesktopDayTile.svelte new file mode 100644 index 0000000..2476f96 --- /dev/null +++ b/frontend/src/lib/planner/DesktopDayTile.svelte @@ -0,0 +1,201 @@ + + +{#if slot.recipe} +
+
+
+

+ {slot.recipe.name} +

+
+ +
+ + + {#if slot.recipe.cookTimeMin} + {slot.recipe.cookTimeMin} min + {/if} + + {#if slot.recipe.effort} + {slot.recipe.effort} + {/if} + +
+ e.stopPropagation()}>Koch-Modus + e.stopPropagation()}>Rezept ansehen +
+ + {#if isPlanner} + + {/if} + + {#if isPlanner && slot.id} + + {/if} +
+
+
+{:else} + +{/if} + + diff --git a/frontend/src/lib/planner/DesktopDayTile.test.ts b/frontend/src/lib/planner/DesktopDayTile.test.ts new file mode 100644 index 0000000..89603a2 --- /dev/null +++ b/frontend/src/lib/planner/DesktopDayTile.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import DesktopDayTile from './DesktopDayTile.svelte'; + +const filledSlot = { + id: 's1', + slotDate: '2026-04-14', + recipe: { + id: 'r1', + name: 'Pasta Bolognese', + cookTimeMin: 45, + effort: 'mittel', + heroImageUrl: null, + tags: [{ id: 't1', name: 'Rind', tagType: 'protein' }] + } +}; + +const emptySlot = { id: null, slotDate: '2026-04-14', recipe: null }; + +describe('DesktopDayTile — filled slot', () => { + describe('front face', () => { + it('renders recipe name on front face', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByText('Pasta Bolognese')).toBeTruthy(); + }); + + it('has data-testid="day-meal-card" on the scene element', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByTestId('day-meal-card')).toBeTruthy(); + }); + + it('applies today ring when isToday', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: true, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + const scene = screen.getByTestId('day-meal-card'); + expect(scene.getAttribute('data-today')).toBe('true'); + }); + + it('applies selected ring when activeSlotId matches slot id', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + const scene = screen.getByTestId('day-meal-card'); + expect(scene.getAttribute('data-flipped')).toBe('true'); + }); + + it('dims tile when another slot is active', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 'other-slot', isPlanner: true, slotMap: {}, suggestions: [] } }); + const scene = screen.getByTestId('day-meal-card'); + expect(scene.getAttribute('data-dimmed')).toBe('true'); + }); + + it('is not dimmed when no slot is active', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + const scene = screen.getByTestId('day-meal-card'); + expect(scene.getAttribute('data-dimmed')).toBe('false'); + }); + }); + + describe('flip interaction', () => { + it('calls onflip with slot id when scene is clicked', async () => { + const onflip = vi.fn(); + const user = userEvent.setup(); + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } }); + await user.click(screen.getByTestId('day-meal-card')); + expect(onflip).toHaveBeenCalledWith('s1'); + }); + + it('calls onflip when Enter key is pressed on scene', async () => { + const onflip = vi.fn(); + const user = userEvent.setup(); + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } }); + screen.getByTestId('day-meal-card').focus(); + await user.keyboard('{Enter}'); + expect(onflip).toHaveBeenCalledWith('s1'); + }); + + it('calls onflip when Space key is pressed on scene', async () => { + const onflip = vi.fn(); + const user = userEvent.setup(); + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [], onflip } }); + screen.getByTestId('day-meal-card').focus(); + await user.keyboard(' '); + expect(onflip).toHaveBeenCalledWith('s1'); + }); + }); + + describe('back face (flipped state)', () => { + it('shows recipe name on back face when flipped', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + // Back face should also show recipe name + const names = screen.getAllByText('Pasta Bolognese'); + expect(names.length).toBeGreaterThanOrEqual(1); + }); + + it('shows Koch-Modus link on back face when flipped', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('link', { name: /Koch-Modus/i })).toBeTruthy(); + }); + + it('shows Rezept ansehen link on back face when flipped', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy(); + }); + + it('shows close button on back face', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('button', { name: /Schließen/i })).toBeTruthy(); + }); + + it('calls onclose when close button clicked', async () => { + const onclose = vi.fn(); + const user = userEvent.setup(); + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onclose } }); + await user.click(screen.getByRole('button', { name: /Schließen/i })); + expect(onclose).toHaveBeenCalledOnce(); + }); + + it('shows Gericht tauschen button for planner on back face', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy(); + }); + + it('hides Gericht tauschen for non-planner', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: false, slotMap: {}, suggestions: [] } }); + expect(screen.queryByRole('button', { name: /Gericht tauschen/i })).toBeNull(); + }); + + it('calls onswap when Gericht tauschen clicked', async () => { + const onswap = vi.fn(); + const user = userEvent.setup(); + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onswap } }); + await user.click(screen.getByRole('button', { name: /Gericht tauschen/i })); + expect(onswap).toHaveBeenCalledOnce(); + }); + + it('shows Entfernen button for planner when slot has id', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy(); + }); + + it('calls onremove when Entfernen clicked', async () => { + const onremove = vi.fn(); + const user = userEvent.setup(); + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [], onremove } }); + await user.click(screen.getByRole('button', { name: /Entfernen/i })); + expect(onremove).toHaveBeenCalledOnce(); + }); + + it('aria-expanded is true when flipped', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: 's1', isPlanner: true, slotMap: {}, suggestions: [] } }); + const scene = screen.getByTestId('day-meal-card'); + expect(scene.getAttribute('aria-expanded')).toBe('true'); + }); + + it('aria-expanded is false when not flipped', () => { + render(DesktopDayTile, { props: { slot: filledSlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + const scene = screen.getByTestId('day-meal-card'); + expect(scene.getAttribute('aria-expanded')).toBe('false'); + }); + }); +}); + +describe('DesktopDayTile — empty slot', () => { + it('renders EmptyDayTile (shows Gericht wählen) for empty slot', () => { + render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.getByRole('button', { name: /Gericht wählen/i })).toBeTruthy(); + }); + + it('does not render Koch-Modus for empty slot', () => { + render(DesktopDayTile, { props: { slot: emptySlot, isToday: false, activeSlotId: null, isPlanner: true, slotMap: {}, suggestions: [] } }); + expect(screen.queryByRole('link', { name: /Koch-Modus/i })).toBeNull(); + }); +});