Add a tri-state like/dislike toggle beside the bookmark action (signed-in only): click a reaction to set it, the other to switch, or the active one to clear — optimistic with rollback, mirroring the bookmark toggle. Seeds from the new GET /me/reactions/:id in the detail loader (non-critical: any failure degrades to an unset toggle). Unit tests cover the state machine; e2e drives set → switch → clear against the real endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
63 lines
2.5 KiB
TypeScript
63 lines
2.5 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
|
|
|
|
const setReaction = vi.fn();
|
|
const clearReaction = vi.fn();
|
|
vi.mock('$lib/api/reactions', () => ({
|
|
setReaction: (...a: unknown[]) => setReaction(...a),
|
|
clearReaction: (...a: unknown[]) => clearReaction(...a)
|
|
}));
|
|
|
|
import ReactionButtons from './ReactionButtons.svelte';
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
setReaction.mockReset();
|
|
clearReaction.mockReset();
|
|
});
|
|
|
|
const pressed = (testid: string) =>
|
|
screen.getByTestId(testid).getAttribute('aria-pressed');
|
|
|
|
describe('ReactionButtons', () => {
|
|
it('reflects the initial reaction', () => {
|
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: 'like' } });
|
|
expect(pressed('reaction-like')).toBe('true');
|
|
expect(pressed('reaction-dislike')).toBe('false');
|
|
});
|
|
|
|
it('likes when nothing is set', async () => {
|
|
setReaction.mockResolvedValue({ manga_id: 'm1', reaction: 'like' });
|
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: null } });
|
|
await fireEvent.click(screen.getByTestId('reaction-like'));
|
|
expect(setReaction).toHaveBeenCalledWith('m1', 'like');
|
|
expect(pressed('reaction-like')).toBe('true');
|
|
});
|
|
|
|
it('clears when clicking the active reaction again', async () => {
|
|
clearReaction.mockResolvedValue(undefined);
|
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: 'like' } });
|
|
await fireEvent.click(screen.getByTestId('reaction-like'));
|
|
expect(clearReaction).toHaveBeenCalledWith('m1');
|
|
expect(pressed('reaction-like')).toBe('false');
|
|
});
|
|
|
|
it('switches from like to dislike', async () => {
|
|
setReaction.mockResolvedValue({ manga_id: 'm1', reaction: 'dislike' });
|
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: 'like' } });
|
|
await fireEvent.click(screen.getByTestId('reaction-dislike'));
|
|
expect(setReaction).toHaveBeenCalledWith('m1', 'dislike');
|
|
expect(pressed('reaction-dislike')).toBe('true');
|
|
expect(pressed('reaction-like')).toBe('false');
|
|
});
|
|
|
|
it('rolls back on failure', async () => {
|
|
setReaction.mockRejectedValue(new Error('boom'));
|
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: null } });
|
|
await fireEvent.click(screen.getByTestId('reaction-like'));
|
|
// Optimistic update reverted after the rejection.
|
|
await Promise.resolve();
|
|
expect(pressed('reaction-like')).toBe('false');
|
|
});
|
|
});
|