Extract title tags & per-row chevron expand

Add parsing and UX for bracketed title metadata and per-row title expansion. Introduces extractTitleTags (src/lib/utils/title-tags.ts) to pull bracketed tags from result titles (de-duplicated, slash-split) and useIsTruncated (src/lib/hooks/useIsTruncated.ts) to detect horizontal overflow. Refactors InteractiveTorrentSearchModal to a ResultRow component that renders title chips (slate chips) for parsed tags (filtered vs displayFormat), shows a chevron disclosure only when the title is truncated (or while expanded), toggles expansion per GUID, and resets expansion state when the modal closes. Tests added/updated for the component, hook, and parser; documentation updated to reflect behavior.
This commit is contained in:
kikootwo
2026-05-16 10:41:44 -04:00
parent 31d30bdfa0
commit 1065577a04
7 changed files with 619 additions and 120 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ src/components/
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search). When status=`awaiting_release` and `releaseDate` is set, shows "Releases <Mon DD, YYYY>" next to the status badge (UTC-formatted)
- **StatusBadge** - Color-coded status (pending=yellow, awaiting_search=yellow, searching=blue, downloading=purple, downloaded=green, processing=orange, awaiting_import=orange, available=green, completed=green, failed=red, warn=orange, cancelled=gray, awaiting_approval=yellow, awaiting_release=teal "Awaiting Release", denied=red). Shows "Initializing..." when downloading with 0% progress (fetching torrent info), "Downloading" when progress > 0%
- **ProgressBar** - Animated fill with percentage
- **InteractiveTorrentSearchModal** ✅ - Responsive table of ranked torrent results, uses ConfirmModal for downloads, hides columns on smaller screens (size on mobile, seeds on tablet, indexer on desktop)
- **InteractiveTorrentSearchModal** ✅ - Responsive table of ranked torrent results, uses ConfirmModal for downloads, hides columns on smaller screens (size on mobile, seeds on tablet, indexer on desktop). Titles render verbatim; bracketed tags (e.g. `[German]`, `[Unabridged]`) parsed via `extractTitleTags` render as slate chips in the metadata row (de-duped vs `displayFormat`); an explicit chevron-disclosure button toggles per-`guid` expand only when the title is truncated (via `useIsTruncated`), state resets on close
- Active indicator: "Setting up..." with spinner when progress = 0%, "Active" with pulsing dot when progress > 0%
**Forms**
@@ -12,6 +12,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
import { extractTitleTags } from '@/lib/utils/title-tags';
import { useIsTruncated } from '@/lib/hooks/useIsTruncated';
import {
useInteractiveSearch,
useSelectTorrent,
@@ -119,6 +121,7 @@ export function InteractiveTorrentSearchModal({
const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
const [isCustomConfirming, setIsCustomConfirming] = useState(false);
const [mounted, setMounted] = useState(false);
const [expandedGuids, setExpandedGuids] = useState<Set<string>>(() => new Set());
// Stable close handler via ref
const onCloseRef = useRef(onClose);
@@ -157,6 +160,7 @@ export function InteractiveTorrentSearchModal({
useEffect(() => {
setSearchTitle(customSearchTerms || audiobook.title);
setResults([]);
setExpandedGuids(new Set());
}, [isOpen, audiobook.title, customSearchTerms]);
// Perform search when modal opens
@@ -189,6 +193,7 @@ export function InteractiveTorrentSearchModal({
const performSearch = async () => {
setResults([]);
setExpandedGuids(new Set());
try {
let data;
if (isEbookMode) {
@@ -380,125 +385,24 @@ export function InteractiveTorrentSearchModal({
{/* Results List */}
{!isSearching && results.length > 0 && (
<div className="space-y-0.5">
{results.map((result) => {
const score = Math.round(result.score);
const style = getScoreStyle(score);
const isUsenet = result.protocol === 'usenet';
const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
const displayFormat = result.format || result.ebookFormat;
return (
<div
key={result.guid}
className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-gray-50/80 dark:hover:bg-white/[0.03] transition-colors group"
>
{/* Score Badge */}
<div
className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`}
title={`Score: ${score} (Match: ${Math.round(result.breakdown?.matchScore ?? 0)}, Format: ${Math.round(result.breakdown?.formatScore ?? 0)}, Size: ${Math.round(result.breakdown?.sizeScore ?? 0)}, Seeds: ${Math.round(result.breakdown?.seederScore ?? 0)})`}
>
<span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}>
{score}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title Row */}
<div className="flex items-center gap-1.5">
<a
href={result.infoUrl || result.guid}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
title={result.title}
>
{result.title}
</a>
</div>
{/* Metadata Row */}
<div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
{/* Rank */}
<span className="text-gray-400 dark:text-gray-500 font-medium">#{result.rank}</span>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{/* Indexer / Source */}
{isAnnasArchive ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna&apos;s Archive</span>
) : (
<span>{result.indexer}</span>
)}
{/* Size */}
{result.size > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatSize(result.size)}</span>
</>
)}
{/* Format */}
{displayFormat && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
{displayFormat}
</span>
</>
)}
{/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */}
{!isAnnasArchive && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{isUsenet ? (
<span className="flex items-center gap-0.5 text-sky-600 dark:text-sky-400">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
NZB
</span>
) : (
<span className="flex items-center gap-0.5">
<svg className="w-3 h-3 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<span className="text-emerald-600 dark:text-emerald-400">{result.seeders ?? 0}</span>
</span>
)}
</>
)}
{/* Age */}
{result.publishDate && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatAge(result.publishDate)}</span>
</>
)}
{/* Bonus Points */}
{result.bonusPoints > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">+{Math.round(result.bonusPoints)}</span>
</>
)}
</div>
</div>
{/* Action Button */}
<button
onClick={() => handleDownloadClick(result)}
disabled={isDownloading}
className="flex-shrink-0 px-4 py-1.5 text-[13px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:hover:bg-blue-400/20 rounded-full transition-all active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
>
Get
</button>
</div>
);
})}
{results.map((result) => (
<ResultRow
key={result.guid}
result={result}
isEbookMode={isEbookMode}
isExpanded={expandedGuids.has(result.guid)}
isDownloading={isDownloading}
onToggleExpand={() => {
setExpandedGuids((prev) => {
const next = new Set(prev);
if (next.has(result.guid)) next.delete(result.guid);
else next.add(result.guid);
return next;
});
}}
onDownload={() => handleDownloadClick(result)}
/>
))}
</div>
)}
</div>
@@ -615,3 +519,175 @@ export function InteractiveTorrentSearchModal({
return createPortal(modalContent, document.body);
}
interface ResultRowProps {
result: RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string };
isEbookMode: boolean;
isExpanded: boolean;
isDownloading: boolean;
onToggleExpand: () => void;
onDownload: () => void;
}
function ResultRow({
result,
isEbookMode,
isExpanded,
isDownloading,
onToggleExpand,
onDownload,
}: ResultRowProps) {
const score = Math.round(result.score);
const style = getScoreStyle(score);
const isUsenet = result.protocol === 'usenet';
const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
const displayFormat = result.format || result.ebookFormat;
const { tags } = extractTitleTags(result.title);
const displayFormatLower = (displayFormat ?? '').toLowerCase();
const chipTags = tags.filter((t) => t.toLowerCase() !== displayFormatLower);
const titleRef = useRef<HTMLAnchorElement | null>(null);
const isTruncated = useIsTruncated(titleRef);
// Why: keep chevron rendered while expanded so users can collapse — once
// expanded, scrollWidth no longer exceeds clientWidth and isTruncated flips false.
const showChevron = isTruncated || isExpanded;
return (
<div className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-gray-50/80 dark:hover:bg-white/[0.03] transition-colors group">
{/* Score Badge */}
<div
className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`}
title={`Score: ${score} (Match: ${Math.round(result.breakdown?.matchScore ?? 0)}, Format: ${Math.round(result.breakdown?.formatScore ?? 0)}, Size: ${Math.round(result.breakdown?.sizeScore ?? 0)}, Seeds: ${Math.round(result.breakdown?.seederScore ?? 0)})`}
>
<span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}>
{score}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title Row */}
<div className="flex items-center gap-1.5 min-w-0">
<a
ref={titleRef}
href={result.infoUrl || result.guid}
target="_blank"
rel="noopener noreferrer"
className={`text-sm font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-colors flex-1 min-w-0 ${isExpanded ? 'break-words whitespace-normal' : 'truncate'}`}
title={result.title}
aria-label={result.title}
>
{result.title}
</a>
{showChevron && (
<button
type="button"
onClick={onToggleExpand}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Hide full title' : 'Show full title'}
className="flex-shrink-0 p-2 -my-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100/70 dark:text-gray-500 dark:hover:text-gray-300 dark:hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 transition-colors"
>
<svg
className={`w-3 h-3 transition-transform duration-200 ease-out ${isExpanded ? 'rotate-90' : ''}`}
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
{/* Metadata Row */}
<div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
{/* Rank */}
<span className="text-gray-400 dark:text-gray-500 font-medium">#{result.rank}</span>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{/* Indexer / Source */}
{isAnnasArchive ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna&apos;s Archive</span>
) : (
<span>{result.indexer}</span>
)}
{/* Size */}
{result.size > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatSize(result.size)}</span>
</>
)}
{/* Format */}
{displayFormat && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
{displayFormat}
</span>
</>
)}
{/* Title tag chips (language/edition/etc.) */}
{chipTags.map((tag) => (
<span
key={tag}
className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-slate-100 dark:bg-slate-500/15 text-slate-700 dark:text-slate-300"
>
{tag}
</span>
))}
{/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */}
{!isAnnasArchive && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{isUsenet ? (
<span className="flex items-center gap-0.5 text-sky-600 dark:text-sky-400">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
NZB
</span>
) : (
<span className="flex items-center gap-0.5">
<svg className="w-3 h-3 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<span className="text-emerald-600 dark:text-emerald-400">{result.seeders ?? 0}</span>
</span>
)}
</>
)}
{/* Age */}
{result.publishDate && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatAge(result.publishDate)}</span>
</>
)}
{/* Bonus Points */}
{result.bonusPoints > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">+{Math.round(result.bonusPoints)}</span>
</>
)}
</div>
</div>
{/* Action Button */}
<button
onClick={onDownload}
disabled={isDownloading}
className="flex-shrink-0 px-4 py-1.5 text-[13px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:hover:bg-blue-400/20 rounded-full transition-all active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
>
Get
</button>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Component: useIsTruncated Hook
* Documentation: documentation/frontend/components.md
*
* Returns whether the referenced element's content overflows horizontally
* (i.e. is being clipped by `truncate` / `overflow: hidden`). Used by the
* Interactive Search modal to render an expand-disclosure chevron only when
* the title is actually being cut off — keeping the row clean when there's
* nothing to disclose.
*/
import { useLayoutEffect, useState, type RefObject } from 'react';
export function useIsTruncated(ref: RefObject<HTMLElement | null>): boolean {
const [isTruncated, setIsTruncated] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
setIsTruncated(el.scrollWidth > el.clientWidth);
};
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [ref]);
return isTruncated;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Component: Title Tag Extraction Utility
* Documentation: documentation/frontend/components.md
*
* Pure parser used by the Interactive Search modal to split a result title
* into a "residual" string and a list of bracketed metadata tags. Brackets
* are ASCII `[`/`]` only (full-width `【】` is intentionally unsupported —
* Audible/indexer titles use ASCII in practice). Inner content is split on
* `/`, trimmed, empty segments dropped, then de-duplicated case-insensitively
* while preserving first-seen casing. The regex is non-nested by design:
* `Foundation [Edition [Deluxe]]` extracts `Deluxe` and leaves the outer
* `[Edition ]` in the residual. Accepted trade-off for v1 (rare).
*/
export interface TitleTags {
residual: string;
tags: string[];
}
// Why: character class excludes brackets so the regex never spans a nested
// pair — this is what makes the inner `[Deluxe]` win over the outer group.
const BRACKET_GROUP = /\[([^\[\]]*)\]/g;
export function extractTitleTags(title: string): TitleTags {
if (!title) return { residual: '', tags: [] };
const tags: string[] = [];
const seen = new Set<string>();
for (const match of title.matchAll(BRACKET_GROUP)) {
for (const segment of match[1].split('/')) {
const trimmed = segment.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
tags.push(trimmed);
}
}
const residual = title.replace(BRACKET_GROUP, ' ').replace(/\s+/g, ' ').trim();
return { residual, tags };
}
@@ -18,6 +18,7 @@ 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: () => ({
@@ -27,6 +28,10 @@ vi.mock('@/lib/hooks/useReportedIssues', () => ({
}),
}));
vi.mock('@/lib/hooks/useIsTruncated', () => ({
useIsTruncated: useIsTruncatedMock,
}));
vi.mock('@/lib/hooks/useRequests', () => ({
useInteractiveSearch: () => ({
searchTorrents: searchByRequestMock,
@@ -172,4 +177,190 @@ describe('InteractiveTorrentSearchModal', () => {
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(
<InteractiveTorrentSearchModal
isOpen={true}
onClose={vi.fn()}
requestId="req-chip"
audiobook={{ title: 'Test Book', author: 'Author' }}
/>,
);
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(
<InteractiveTorrentSearchModal
isOpen={true}
onClose={vi.fn()}
requestId="req-reset"
audiobook={{ title: 'Test Book', author: 'Author' }}
/>,
);
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(
<InteractiveTorrentSearchModal
isOpen={false}
onClose={vi.fn()}
requestId="req-reset"
audiobook={{ title: 'Test Book', author: 'Author' }}
/>,
);
// Reopen — search runs again
searchByRequestMock.mockResolvedValueOnce([
{ ...baseResult, guid: 'reset', title: 'A Long Title That Overflows [German]' },
]);
rerender(
<InteractiveTorrentSearchModal
isOpen={true}
onClose={vi.fn()}
requestId="req-reset"
audiobook={{ title: 'Test Book', author: 'Author' }}
/>,
);
await screen.findByRole('link', { name: 'A Long Title That Overflows [German]' });
expect(screen.getByRole('button', { name: 'Show full title' })).toHaveAttribute('aria-expanded', 'false');
});
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Component: useIsTruncated Hook Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import React, { useRef } from 'react';
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useIsTruncated } from '@/lib/hooks/useIsTruncated';
function Probe({ scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) {
const ref = useRef<HTMLSpanElement | null>(null);
const attach = (el: HTMLSpanElement | null) => {
ref.current = el;
if (el) {
Object.defineProperty(el, 'scrollWidth', { configurable: true, value: scrollWidth });
Object.defineProperty(el, 'clientWidth', { configurable: true, value: clientWidth });
}
};
const truncated = useIsTruncated(ref);
return (
<span ref={attach} data-testid="probe" data-truncated={truncated ? 'yes' : 'no'}>
probe
</span>
);
}
describe('useIsTruncated', () => {
it('returns false when scrollWidth fits inside clientWidth', () => {
const { getByTestId } = render(<Probe scrollWidth={80} clientWidth={120} />);
expect(getByTestId('probe').getAttribute('data-truncated')).toBe('no');
});
it('returns false when scrollWidth equals clientWidth', () => {
const { getByTestId } = render(<Probe scrollWidth={100} clientWidth={100} />);
expect(getByTestId('probe').getAttribute('data-truncated')).toBe('no');
});
it('returns true when scrollWidth exceeds clientWidth', () => {
const { getByTestId } = render(<Probe scrollWidth={400} clientWidth={120} />);
expect(getByTestId('probe').getAttribute('data-truncated')).toBe('yes');
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Tests for extractTitleTags — one case per row of the Edge Cases table
* in .zach-flow/engineering-brief.md.
*/
import { describe, it, expect } from 'vitest';
import { extractTitleTags } from '@/lib/utils/title-tags';
describe('extractTitleTags', () => {
it('returns the original title and no tags when there are no brackets', () => {
expect(extractTitleTags('Foundation')).toEqual({ residual: 'Foundation', tags: [] });
});
it('extracts a single bracketed tag', () => {
expect(extractTitleTags('Foundation [German]')).toEqual({
residual: 'Foundation',
tags: ['German'],
});
});
it('splits a single bracket group on slash', () => {
expect(extractTitleTags('Foundation [German / Unabridged]')).toEqual({
residual: 'Foundation',
tags: ['German', 'Unabridged'],
});
});
it('extracts multiple bracket groups in order', () => {
expect(extractTitleTags('Foundation [German] [Unabridged]')).toEqual({
residual: 'Foundation',
tags: ['German', 'Unabridged'],
});
});
it('collapses inner whitespace and trailing text after stripped brackets', () => {
expect(extractTitleTags('Foundation [German] [Unabridged] v2')).toEqual({
residual: 'Foundation v2',
tags: ['German', 'Unabridged'],
});
});
it('handles a leading bracket group', () => {
expect(extractTitleTags('[Audible] Foundation')).toEqual({
residual: 'Foundation',
tags: ['Audible'],
});
});
it('leaves a malformed unclosed bracket in the residual and returns no tags', () => {
expect(extractTitleTags('Foundation [unclosed')).toEqual({
residual: 'Foundation [unclosed',
tags: [],
});
});
it('treats an empty bracket group as no tags', () => {
expect(extractTitleTags('Foundation []')).toEqual({
residual: 'Foundation',
tags: [],
});
});
it('treats a bracket group of only separators as no tags', () => {
expect(extractTitleTags('Foundation [ / / ]')).toEqual({
residual: 'Foundation',
tags: [],
});
});
it('splits a bracket group with multiple slash-separated values', () => {
expect(extractTitleTags('Foundation [a/b/c]')).toEqual({
residual: 'Foundation',
tags: ['a', 'b', 'c'],
});
});
it('extracts the inner tag from a nested bracket group and accepts the partial residual', () => {
expect(extractTitleTags('Foundation [Edition [Deluxe]]')).toEqual({
residual: 'Foundation [Edition ]',
tags: ['Deluxe'],
});
});
it('de-duplicates tags case-insensitively, preserving first-seen casing', () => {
expect(extractTitleTags('Foundation [German] [german]')).toEqual({
residual: 'Foundation',
tags: ['German'],
});
});
it('trims surrounding whitespace from the title', () => {
expect(extractTitleTags(' Foundation [German] ')).toEqual({
residual: 'Foundation',
tags: ['German'],
});
});
it('handles 200+ char titles', () => {
const longBody = 'A'.repeat(220);
const result = extractTitleTags(`${longBody} [German]`);
expect(result.tags).toEqual(['German']);
expect(result.residual).toBe(longBody);
});
it('returns empty values for an empty string', () => {
expect(extractTitleTags('')).toEqual({ residual: '', tags: [] });
});
it('returns empty values for a whitespace-only string', () => {
expect(extractTitleTags(' ')).toEqual({ residual: '', tags: [] });
});
});