From 90c9ea18944c89a787bd3980f3b41312a2512a34 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Wed, 8 Apr 2026 22:57:05 +0200 Subject: [PATCH] feat(planner): add UndoBar component with 4s auto-dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows undo notification after slot add/replace. Rückgängig button calls onundo, auto-dismisses after 4s via ondismiss callback. Also patches test-setup for userEvent + fake timers compatibility. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/UndoBar.svelte | 67 ++++++++++++++++++++++++ frontend/src/lib/planner/UndoBar.test.ts | 56 ++++++++++++++++++++ frontend/src/test-setup.ts | 34 ++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 frontend/src/lib/planner/UndoBar.svelte create mode 100644 frontend/src/lib/planner/UndoBar.test.ts diff --git a/frontend/src/lib/planner/UndoBar.svelte b/frontend/src/lib/planner/UndoBar.svelte new file mode 100644 index 0000000..2c6f1c6 --- /dev/null +++ b/frontend/src/lib/planner/UndoBar.svelte @@ -0,0 +1,67 @@ + + +{#if visible} +
+ + {message} + + +
+{/if} diff --git a/frontend/src/lib/planner/UndoBar.test.ts b/frontend/src/lib/planner/UndoBar.test.ts new file mode 100644 index 0000000..90a8f04 --- /dev/null +++ b/frontend/src/lib/planner/UndoBar.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import UndoBar from './UndoBar.svelte'; + +describe('UndoBar', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('is not mounted when visible is false', () => { + render(UndoBar, { props: { visible: false, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } }); + expect(screen.queryByTestId('undo-bar')).toBeNull(); + }); + + it('is mounted and shows message when visible is true', () => { + render(UndoBar, { props: { visible: true, message: 'Gericht hinzugefügt', onundo: vi.fn(), ondismiss: vi.fn() } }); + expect(screen.getByTestId('undo-bar')).toBeTruthy(); + expect(screen.getByText('Gericht hinzugefügt')).toBeTruthy(); + }); + + it('shows Rückgängig button', () => { + render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } }); + expect(screen.getByRole('button', { name: /Rückgängig/i })).toBeTruthy(); + }); + + it('calls onundo when Rückgängig is clicked', async () => { + const onundo = vi.fn(); + render(UndoBar, { props: { visible: true, message: 'Test', onundo, ondismiss: vi.fn() } }); + await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i })); + expect(onundo).toHaveBeenCalledOnce(); + }); + + it('calls ondismiss after 4 seconds', async () => { + const ondismiss = vi.fn(); + render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } }); + await act(() => { vi.advanceTimersByTime(4000); }); + expect(ondismiss).toHaveBeenCalledOnce(); + }); + + it('does not call ondismiss before 4 seconds', async () => { + const ondismiss = vi.fn(); + render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } }); + await act(() => { vi.advanceTimersByTime(3999); }); + expect(ondismiss).not.toHaveBeenCalled(); + }); + + it('has role="status" for accessibility', () => { + render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } }); + expect(screen.getByRole('status')).toBeTruthy(); + }); +}); diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index bb02c60..147fa4e 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1 +1,35 @@ import '@testing-library/jest-dom/vitest'; +// Import @testing-library/svelte here so its beforeEach (setup/asyncWrapper=act) +// is registered before our own beforeEach below. +import '@testing-library/svelte'; +import { configure } from '@testing-library/dom'; +import { vi } from 'vitest'; +import { tick } from 'svelte'; +import userEvent from '@testing-library/user-event'; + +// Patch userEvent direct-API methods to use delay:null when fake timers are +// active. With delay:null, user-event's internal wait() short-circuits +// (typeof null !== 'number') and no setTimeout is scheduled — so clicks and +// other interactions work correctly under vi.useFakeTimers(). +const originalClick = userEvent.click.bind(userEvent); +// @ts-expect-error patching direct API +userEvent.click = (element: Element, options = {}) => { + if (vi.isFakeTimers()) { + // @ts-expect-error delay:null is a valid user-event option + return originalClick(element, { delay: null, ...options }); + } + return originalClick(element, options); +}; + +// Also update asyncWrapper to call tick() after async operations so Svelte +// DOM updates are flushed. @testing-library/svelte's act() already does this, +// but we re-configure after it to preserve our fake-timer behaviour. +beforeEach(() => { + configure({ + asyncWrapper: async (fn: () => Promise) => { + const result = await fn(); + await tick(); + return result; + } + }); +});