diff --git a/frontend/src/lib/onboarding/StaplesManager.svelte b/frontend/src/lib/onboarding/StaplesManager.svelte new file mode 100644 index 0000000..3e133ad --- /dev/null +++ b/frontend/src/lib/onboarding/StaplesManager.svelte @@ -0,0 +1,80 @@ + + +
+ {#if errorMessage} +

{errorMessage}

+ {/if} + +
+ {#each categories as category (category.id)} + ({ + ...ing, + isStaple: stapleState[ing.id] ?? ing.isStaple + }))} + onToggle={handleToggle} + /> + {/each} +
+
diff --git a/frontend/src/lib/onboarding/StaplesManager.test.ts b/frontend/src/lib/onboarding/StaplesManager.test.ts new file mode 100644 index 0000000..2dfee3a --- /dev/null +++ b/frontend/src/lib/onboarding/StaplesManager.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import StaplesManager from './StaplesManager.svelte'; + +const mockCategories = [ + { + id: 'cat-1', + name: 'Öle & Fette', + ingredients: [ + { id: 'ing-1', name: 'Olivenöl', isStaple: true }, + { id: 'ing-2', name: 'Butter', isStaple: false } + ] + }, + { + id: 'cat-2', + name: 'Gewürze', + ingredients: [ + { id: 'ing-3', name: 'Salz', isStaple: true }, + { id: 'ing-4', name: 'Pfeffer', isStaple: true } + ] + } +]; + +describe('StaplesManager', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('renders all categories', () => { + render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } }); + expect(screen.getByText('Öle & Fette')).toBeInTheDocument(); + expect(screen.getByText('Gewürze')).toBeInTheDocument(); + }); + + it('renders all chips with correct initial aria-pressed state', () => { + render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } }); + expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Butter' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Salz' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('clicking a chip immediately updates aria-pressed (optimistic)', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } }); + + const butter = screen.getByRole('button', { name: 'Butter' }); + expect(butter).toHaveAttribute('aria-pressed', 'false'); + await user.click(butter); + expect(butter).toHaveAttribute('aria-pressed', 'true'); + }); + + it('rapid clicks on same chip result in exactly one fetch call after debounce', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } }); + + const butter = screen.getByRole('button', { name: 'Butter' }); + await user.click(butter); + await user.click(butter); + await user.click(butter); + + expect(fetch).not.toHaveBeenCalled(); + vi.advanceTimersByTime(300); + await vi.runAllTimersAsync(); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('reverts chip and shows error when PATCH fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } }); + + const butter = screen.getByRole('button', { name: 'Butter' }); + await user.click(butter); + expect(butter).toHaveAttribute('aria-pressed', 'true'); + + vi.advanceTimersByTime(300); + await vi.runAllTimersAsync(); + + expect(butter).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText(/konnte nicht gespeichert werden/i)).toBeInTheDocument(); + }); + + it('uses 2-column grid class in onboarding context', () => { + render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } }); + const grid = screen.getByTestId('category-grid'); + expect(grid.className).toContain('md:grid-cols-2'); + }); + + it('uses 3-column grid class in settings context', () => { + render(StaplesManager, { props: { categories: mockCategories, context: 'settings' } }); + const grid = screen.getByTestId('category-grid'); + expect(grid.className).toContain('md:grid-cols-3'); + }); +});