/**
* Component: Interactive Torrent Search Modal Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
const searchByRequestMock = vi.hoisted(() => vi.fn());
const selectTorrentMock = vi.hoisted(() => vi.fn());
const searchByAudiobookMock = vi.hoisted(() => vi.fn());
const requestWithTorrentMock = vi.hoisted(() => vi.fn());
const searchEbooksMock = vi.hoisted(() => vi.fn());
const selectEbookMock = vi.hoisted(() => vi.fn());
const searchEbooksByAsinMock = vi.hoisted(() => vi.fn());
const selectEbookByAsinMock = vi.hoisted(() => vi.fn());
const replaceWithTorrentMock = vi.hoisted(() => vi.fn());
const useIsTruncatedMock = vi.hoisted(() => vi.fn(() => false));
vi.mock('@/lib/hooks/useReportedIssues', () => ({
useReplaceWithTorrent: () => ({
replaceWithTorrent: replaceWithTorrentMock,
isLoading: false,
error: null,
}),
}));
vi.mock('@/lib/hooks/useIsTruncated', () => ({
useIsTruncated: useIsTruncatedMock,
}));
vi.mock('@/lib/hooks/useRequests', () => ({
useInteractiveSearch: () => ({
searchTorrents: searchByRequestMock,
isLoading: false,
error: null,
}),
useSelectTorrent: () => ({
selectTorrent: selectTorrentMock,
isLoading: false,
error: null,
}),
useSearchTorrents: () => ({
searchTorrents: searchByAudiobookMock,
isLoading: false,
error: null,
}),
useRequestWithTorrent: () => ({
requestWithTorrent: requestWithTorrentMock,
isLoading: false,
error: null,
}),
useInteractiveSearchEbook: () => ({
searchEbooks: searchEbooksMock,
isLoading: false,
error: null,
}),
useSelectEbook: () => ({
selectEbook: selectEbookMock,
isLoading: false,
error: null,
}),
useInteractiveSearchEbookByAsin: () => ({
searchEbooks: searchEbooksByAsinMock,
isLoading: false,
error: null,
}),
useSelectEbookByAsin: () => ({
selectEbook: selectEbookByAsinMock,
isLoading: false,
error: null,
}),
}));
const baseResult = {
guid: 'torrent-1',
rank: 1,
title: 'Test Torrent',
size: 2.4 * 1024 ** 3,
score: 88,
bonusPoints: 5,
seeders: 42,
indexer: 'ProIndexer',
format: 'M4B',
infoUrl: 'https://example.com/torrent',
};
describe('InteractiveTorrentSearchModal', () => {
it('searches by request id on open and confirms download', async () => {
searchByRequestMock.mockResolvedValueOnce([baseResult]);
selectTorrentMock.mockResolvedValueOnce(undefined);
const onClose = vi.fn();
const onSuccess = vi.fn();
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
render(
);
await waitFor(() => {
expect(searchByRequestMock).toHaveBeenCalledWith('req-123', undefined);
});
expect(await screen.findByText('Test Torrent')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Get' }));
fireEvent.click(await screen.findByRole('button', { name: 'Download' }));
await waitFor(() => {
expect(selectTorrentMock).toHaveBeenCalledWith('req-123', baseResult);
});
expect(onSuccess).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('searches by audiobook data and requests with torrent', async () => {
searchByAudiobookMock.mockResolvedValueOnce([baseResult]);
requestWithTorrentMock.mockResolvedValueOnce(undefined);
const onClose = vi.fn();
const fullAudiobook = { asin: 'ASIN-1', title: 'Test Book', author: 'Test Author' };
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
render(
);
await waitFor(() => {
expect(searchByAudiobookMock).toHaveBeenCalledWith('Test Book', 'Test Author', 'ASIN-1');
});
fireEvent.click(screen.getByRole('button', { name: 'Get' }));
fireEvent.click(await screen.findByRole('button', { name: 'Download' }));
await waitFor(() => {
expect(requestWithTorrentMock).toHaveBeenCalledWith(fullAudiobook, baseResult);
});
expect(onClose).toHaveBeenCalled();
});
it('uses a custom title when pressing Enter', async () => {
searchByRequestMock.mockResolvedValueOnce([]);
searchByRequestMock.mockResolvedValueOnce([]);
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
render(
);
await waitFor(() => {
expect(searchByRequestMock).toHaveBeenCalledWith('req-456', undefined);
});
const input = screen.getByPlaceholderText('Search title...');
fireEvent.change(input, { target: { value: 'Custom Title' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
await waitFor(() => {
expect(searchByRequestMock).toHaveBeenNthCalledWith(2, 'req-456', 'Custom Title');
});
});
describe('title chips and chevron expand', () => {
const renderWithResults = async (results: any[]) => {
searchByRequestMock.mockResolvedValueOnce(results);
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
const utils = render(
,
);
await waitFor(() => {
expect(searchByRequestMock).toHaveBeenCalled();
});
return utils;
};
it('renders the title verbatim regardless of bracketed metadata', async () => {
await renderWithResults([
{ ...baseResult, guid: 'verbatim', title: 'Foundation [German] [Unabridged]' },
]);
const link = await screen.findByRole('link', { name: 'Foundation [German] [Unabridged]' });
expect(link.textContent).toBe('Foundation [German] [Unabridged]');
expect(link).toHaveAttribute('aria-label', 'Foundation [German] [Unabridged]');
expect(link).toHaveAttribute('title', 'Foundation [German] [Unabridged]');
});
it('renders no chips when the title has no brackets', async () => {
await renderWithResults([
{ ...baseResult, guid: 'no-brackets', title: 'Plain Title', format: undefined },
]);
await screen.findByRole('link', { name: 'Plain Title' });
// Slate-toned chip class is unique to title-tag chips
expect(document.querySelectorAll('span.bg-slate-100').length).toBe(0);
});
it('renders a slate chip for each bracketed tag', async () => {
await renderWithResults([
{ ...baseResult, guid: 'multi', title: 'Foundation [German] [Unabridged]', format: 'M4B' },
]);
await screen.findByRole('link', { name: 'Foundation [German] [Unabridged]' });
const german = screen.getByText('German');
const unabridged = screen.getByText('Unabridged');
expect(german.className).toMatch(/bg-slate-100/);
expect(unabridged.className).toMatch(/bg-slate-100/);
});
it('filters a tag that matches displayFormat case-insensitively', async () => {
await renderWithResults([
{ ...baseResult, guid: 'dedupe', title: 'Foundation [MP3]', format: 'mp3' },
]);
await screen.findByRole('link', { name: 'Foundation [MP3]' });
// The purple format pill renders the format (uppercased by CSS, raw text retained)
expect(screen.getByText('mp3')).toBeInTheDocument();
// No duplicate slate chip for MP3
expect(document.querySelectorAll('span.bg-slate-100').length).toBe(0);
});
it('does not render the chevron when the title fits', async () => {
useIsTruncatedMock.mockReturnValue(false);
await renderWithResults([
{ ...baseResult, guid: 'fits', title: 'Foundation [German]' },
]);
await screen.findByRole('link', { name: 'Foundation [German]' });
expect(screen.queryByRole('button', { name: /show full title|hide full title/i })).not.toBeInTheDocument();
});
it('renders the chevron when the title is truncated', async () => {
useIsTruncatedMock.mockReturnValue(true);
await renderWithResults([
{ ...baseResult, guid: 'truncated', title: 'A Very Long Title That Overflows [German]' },
]);
await screen.findByRole('link', { name: 'A Very Long Title That Overflows [German]' });
const chevron = screen.getByRole('button', { name: 'Show full title' });
expect(chevron).toHaveAttribute('aria-expanded', 'false');
});
it('toggles expansion when the chevron is clicked and keeps it visible while expanded', async () => {
useIsTruncatedMock.mockReturnValue(true);
await renderWithResults([
{ ...baseResult, guid: 'toggle', title: 'A Very Long Title That Overflows [German]' },
]);
const link = await screen.findByRole('link', { name: 'A Very Long Title That Overflows [German]' });
expect(link.className).toMatch(/truncate/);
expect(link.className).not.toMatch(/break-words/);
const chevron = screen.getByRole('button', { name: 'Show full title' });
fireEvent.click(chevron);
// After expand, the hook may report not-truncated; chevron must stay visible.
useIsTruncatedMock.mockReturnValue(false);
const collapse = screen.getByRole('button', { name: 'Hide full title' });
expect(collapse).toHaveAttribute('aria-expanded', 'true');
expect(link.className).toMatch(/break-words/);
expect(link.className).not.toMatch(/truncate/);
fireEvent.click(collapse);
expect(screen.queryByRole('button', { name: 'Hide full title' })).not.toBeInTheDocument();
});
it('expands rows independently', async () => {
useIsTruncatedMock.mockReturnValue(true);
await renderWithResults([
{ ...baseResult, guid: 'row-a', title: 'A Title That Overflows [German]' },
{ ...baseResult, guid: 'row-b', title: 'B Title That Overflows [Spanish]' },
]);
await screen.findByRole('link', { name: 'A Title That Overflows [German]' });
const chevrons = screen.getAllByRole('button', { name: 'Show full title' });
expect(chevrons.length).toBe(2);
fireEvent.click(chevrons[0]);
expect(screen.getAllByRole('button', { name: 'Hide full title' }).length).toBe(1);
expect(screen.getAllByRole('button', { name: 'Show full title' }).length).toBe(1);
});
it('clicking the title link does not toggle expansion', async () => {
useIsTruncatedMock.mockReturnValue(true);
await renderWithResults([
{ ...baseResult, guid: 'link-click', title: 'A Very Long Title [German]' },
]);
const link = await screen.findByRole('link', { name: 'A Very Long Title [German]' });
expect(link).toHaveAttribute('href', 'https://example.com/torrent');
fireEvent.click(link);
expect(screen.getByRole('button', { name: 'Show full title' })).toHaveAttribute('aria-expanded', 'false');
});
it('falls back gracefully on malformed brackets without crashing', async () => {
useIsTruncatedMock.mockReturnValue(false);
await renderWithResults([
{ ...baseResult, guid: 'malformed', title: 'Foundation [unclosed' },
]);
const link = await screen.findByRole('link', { name: 'Foundation [unclosed' });
expect(link.textContent).toBe('Foundation [unclosed');
});
it('resets expansion state when the modal closes and reopens', async () => {
useIsTruncatedMock.mockReturnValue(true);
searchByRequestMock.mockResolvedValueOnce([
{ ...baseResult, guid: 'reset', title: 'A Long Title That Overflows [German]' },
]);
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
const { rerender } = render(
,
);
await screen.findByRole('link', { name: 'A Long Title That Overflows [German]' });
fireEvent.click(screen.getByRole('button', { name: 'Show full title' }));
expect(screen.getByRole('button', { name: 'Hide full title' })).toHaveAttribute('aria-expanded', 'true');
// Close
rerender(
,
);
// Reopen — search runs again
searchByRequestMock.mockResolvedValueOnce([
{ ...baseResult, guid: 'reset', title: 'A Long Title That Overflows [German]' },
]);
rerender(
,
);
await screen.findByRole('link', { name: 'A Long Title That Overflows [German]' });
expect(screen.getByRole('button', { name: 'Show full title' })).toHaveAttribute('aria-expanded', 'false');
});
});
});