feat(detail): like/dislike buttons on the manga page
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>
This commit is contained in:
62
frontend/src/lib/components/ReactionButtons.svelte.test.ts
Normal file
62
frontend/src/lib/components/ReactionButtons.svelte.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user