feat: D1 — Shopping list (Issue #30) #43

Merged
marcel merged 24 commits from feat/issue-30-shopping-list into master 2026-04-08 22:22:02 +02:00
3 changed files with 217 additions and 0 deletions
Showing only changes of commit f6265efa92 - Show all commits

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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();
});
});