diff --git a/frontend/src/lib/shopping/AddCustomItem.test.ts b/frontend/src/lib/shopping/AddCustomItem.test.ts new file mode 100644 index 0000000..f19cce7 --- /dev/null +++ b/frontend/src/lib/shopping/AddCustomItem.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import AddCustomItem from './AddCustomItem.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('AddCustomItem', () => { + it('shows collapsed trigger button initially', () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + expect(screen.getByText(/Artikel hinzufügen/)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument(); + }); + + it('expands form when trigger button is clicked', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument(); + }); + + it('collapses form when Abbrechen is clicked', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Abbrechen')); + expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument(); + }); + + it('submit button is disabled when name is empty', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + expect(screen.getByRole('button', { name: /Hinzufügen/i })).toBeDisabled(); + }); + + it('submit button is enabled when name is entered', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + await userEvent.type(screen.getByPlaceholderText('Artikelname'), 'Papier'); + expect(screen.getByRole('button', { name: /Hinzufügen/i })).not.toBeDisabled(); + }); + + it('form submits to ?/addItem action', async () => { + render(AddCustomItem, { props: { listId: 'list-1' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + const form = screen.getByPlaceholderText('Artikelname').closest('form')!; + expect(form).toHaveAttribute('action', '?/addItem'); + }); + + it('form includes listId as hidden input', async () => { + render(AddCustomItem, { props: { listId: 'list-42' } }); + await userEvent.click(screen.getByText(/Artikel hinzufügen/)); + const form = screen.getByPlaceholderText('Artikelname').closest('form')!; + const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement; + expect(listIdInput.value).toBe('list-42'); + }); +}); diff --git a/frontend/src/lib/shopping/ChecklistItem.test.ts b/frontend/src/lib/shopping/ChecklistItem.test.ts new file mode 100644 index 0000000..0629c78 --- /dev/null +++ b/frontend/src/lib/shopping/ChecklistItem.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ChecklistItem from './ChecklistItem.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('ChecklistItem', () => { + const baseProps = { + listId: 'list-1', + itemId: 'item-1', + name: 'Tomaten', + quantity: null, + unit: null, + isChecked: false, + sourceRecipes: [] + }; + + it('renders the item name', () => { + render(ChecklistItem, { props: baseProps }); + expect(screen.getByText('Tomaten')).toBeInTheDocument(); + }); + + it('renders quantity and unit when provided', () => { + render(ChecklistItem, { props: { ...baseProps, quantity: 3, unit: 'Stück' } }); + expect(screen.getByText('3 Stück')).toBeInTheDocument(); + }); + + it('renders quantity without unit when unit is null', () => { + render(ChecklistItem, { props: { ...baseProps, quantity: 2, unit: null } }); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('applies line-through style when checked', () => { + render(ChecklistItem, { props: { ...baseProps, isChecked: true } }); + const nameEl = screen.getByText('Tomaten'); + expect(nameEl.className).toContain('line-through'); + }); + + it('does not apply line-through when unchecked', () => { + render(ChecklistItem, { props: { ...baseProps, isChecked: false } }); + const nameEl = screen.getByText('Tomaten'); + expect(nameEl.className).not.toContain('line-through'); + }); + + it('shows recipe label for source recipes when unchecked', () => { + render(ChecklistItem, { + props: { + ...baseProps, + sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }] + } + }); + expect(screen.getByText(/Für: Spaghetti/)).toBeInTheDocument(); + }); + + it('hides recipe label when item is checked', () => { + render(ChecklistItem, { + props: { + ...baseProps, + isChecked: true, + sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }] + } + }); + expect(screen.queryByText(/Für:/)).not.toBeInTheDocument(); + }); + + it('sets aria-checked to false when unchecked', () => { + render(ChecklistItem, { props: { ...baseProps, isChecked: false } }); + const button = screen.getByRole('checkbox'); + expect(button).toHaveAttribute('aria-checked', 'false'); + }); + + it('sets aria-checked to true when checked', () => { + render(ChecklistItem, { props: { ...baseProps, isChecked: true } }); + const button = screen.getByRole('checkbox'); + expect(button).toHaveAttribute('aria-checked', 'true'); + }); + + it('passes listId and itemId as hidden inputs', () => { + render(ChecklistItem, { props: baseProps }); + const form = screen.getByRole('checkbox').closest('form')!; + const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement; + const itemIdInput = form.querySelector('input[name="itemId"]') as HTMLInputElement; + expect(listIdInput.value).toBe('list-1'); + expect(itemIdInput.value).toBe('item-1'); + }); +}); diff --git a/frontend/src/lib/shopping/ShoppingHeader.test.ts b/frontend/src/lib/shopping/ShoppingHeader.test.ts new file mode 100644 index 0000000..f7d7fc6 --- /dev/null +++ b/frontend/src/lib/shopping/ShoppingHeader.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ShoppingHeader from './ShoppingHeader.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('ShoppingHeader', () => { + const baseProps = { + totalItems: 0, + checkedCount: 0, + generatedAt: null, + weekPlanId: null, + isPlanner: false, + hasShoppingList: false + }; + + it('renders the heading', () => { + render(ShoppingHeader, { props: baseProps }); + expect(screen.getByText('Einkaufsliste')).toBeInTheDocument(); + }); + + it('shows counts when hasShoppingList is true', () => { + render(ShoppingHeader, { + props: { ...baseProps, totalItems: 5, checkedCount: 2, hasShoppingList: true } + }); + expect(screen.getByText(/3 Artikel übrig/)).toBeInTheDocument(); + expect(screen.getByText(/2 abgehakt/)).toBeInTheDocument(); + }); + + it('does not show counts when hasShoppingList is false', () => { + render(ShoppingHeader, { props: { ...baseProps, hasShoppingList: false } }); + expect(screen.queryByText(/Artikel übrig/)).not.toBeInTheDocument(); + }); + + it('shows generate button for planner with weekPlanId', () => { + render(ShoppingHeader, { + props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1' } + }); + expect(screen.getByRole('button', { name: /Liste generieren/i })).toBeInTheDocument(); + }); + + it('shows regenerate button when planner already has a list', () => { + render(ShoppingHeader, { + props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1', hasShoppingList: true, totalItems: 3, checkedCount: 0 } + }); + expect(screen.getByRole('button', { name: /Neu generieren/i })).toBeInTheDocument(); + }); + + it('hides generate button for non-planner', () => { + render(ShoppingHeader, { + props: { ...baseProps, isPlanner: false, weekPlanId: 'plan-1' } + }); + expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument(); + }); + + it('hides generate button when weekPlanId is null', () => { + render(ShoppingHeader, { + props: { ...baseProps, isPlanner: true, weekPlanId: null } + }); + expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument(); + }); + + it('shows formatted timestamp when generatedAt is provided', () => { + render(ShoppingHeader, { + props: { ...baseProps, hasShoppingList: true, generatedAt: '2026-04-06T10:30:00Z', totalItems: 1, checkedCount: 0 } + }); + expect(screen.getByText(/erstellt/)).toBeInTheDocument(); + }); +});