mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -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)
|
- **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%
|
- **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
|
- **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%
|
- Active indicator: "Setting up..." with spinner when progress = 0%, "Active" with pulsing dot when progress > 0%
|
||||||
|
|
||||||
**Forms**
|
**Forms**
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
|
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
|
||||||
|
import { extractTitleTags } from '@/lib/utils/title-tags';
|
||||||
|
import { useIsTruncated } from '@/lib/hooks/useIsTruncated';
|
||||||
import {
|
import {
|
||||||
useInteractiveSearch,
|
useInteractiveSearch,
|
||||||
useSelectTorrent,
|
useSelectTorrent,
|
||||||
@@ -119,6 +121,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
|
const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
|
||||||
const [isCustomConfirming, setIsCustomConfirming] = useState(false);
|
const [isCustomConfirming, setIsCustomConfirming] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [expandedGuids, setExpandedGuids] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
// Stable close handler via ref
|
// Stable close handler via ref
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
@@ -157,6 +160,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchTitle(customSearchTerms || audiobook.title);
|
setSearchTitle(customSearchTerms || audiobook.title);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setExpandedGuids(new Set());
|
||||||
}, [isOpen, audiobook.title, customSearchTerms]);
|
}, [isOpen, audiobook.title, customSearchTerms]);
|
||||||
|
|
||||||
// Perform search when modal opens
|
// Perform search when modal opens
|
||||||
@@ -189,6 +193,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
|
|
||||||
const performSearch = async () => {
|
const performSearch = async () => {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setExpandedGuids(new Set());
|
||||||
try {
|
try {
|
||||||
let data;
|
let data;
|
||||||
if (isEbookMode) {
|
if (isEbookMode) {
|
||||||
@@ -380,125 +385,24 @@ export function InteractiveTorrentSearchModal({
|
|||||||
{/* Results List */}
|
{/* Results List */}
|
||||||
{!isSearching && results.length > 0 && (
|
{!isSearching && results.length > 0 && (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{results.map((result) => {
|
{results.map((result) => (
|
||||||
const score = Math.round(result.score);
|
<ResultRow
|
||||||
const style = getScoreStyle(score);
|
key={result.guid}
|
||||||
const isUsenet = result.protocol === 'usenet';
|
result={result}
|
||||||
const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
|
isEbookMode={isEbookMode}
|
||||||
const displayFormat = result.format || result.ebookFormat;
|
isExpanded={expandedGuids.has(result.guid)}
|
||||||
|
isDownloading={isDownloading}
|
||||||
return (
|
onToggleExpand={() => {
|
||||||
<div
|
setExpandedGuids((prev) => {
|
||||||
key={result.guid}
|
const next = new Set(prev);
|
||||||
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"
|
if (next.has(result.guid)) next.delete(result.guid);
|
||||||
>
|
else next.add(result.guid);
|
||||||
{/* Score Badge */}
|
return next;
|
||||||
<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)})`}
|
onDownload={() => handleDownloadClick(result)}
|
||||||
>
|
/>
|
||||||
<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">·</span>
|
|
||||||
|
|
||||||
{/* Indexer / Source */}
|
|
||||||
{isAnnasArchive ? (
|
|
||||||
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
|
||||||
) : (
|
|
||||||
<span>{result.indexer}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Size */}
|
|
||||||
{result.size > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
|
||||||
<span>{formatSize(result.size)}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Format */}
|
|
||||||
{displayFormat && (
|
|
||||||
<>
|
|
||||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</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">·</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">·</span>
|
|
||||||
<span>{formatAge(result.publishDate)}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bonus Points */}
|
|
||||||
{result.bonusPoints > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -615,3 +519,175 @@ export function InteractiveTorrentSearchModal({
|
|||||||
|
|
||||||
return createPortal(modalContent, document.body);
|
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">·</span>
|
||||||
|
|
||||||
|
{/* Indexer / Source */}
|
||||||
|
{isAnnasArchive ? (
|
||||||
|
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
||||||
|
) : (
|
||||||
|
<span>{result.indexer}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Size */}
|
||||||
|
{result.size > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||||
|
<span>{formatSize(result.size)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Format */}
|
||||||
|
{displayFormat && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600 select-none">·</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">·</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">·</span>
|
||||||
|
<span>{formatAge(result.publishDate)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bonus Points */}
|
||||||
|
{result.bonusPoints > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600 select-none">·</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 searchEbooksByAsinMock = vi.hoisted(() => vi.fn());
|
||||||
const selectEbookByAsinMock = vi.hoisted(() => vi.fn());
|
const selectEbookByAsinMock = vi.hoisted(() => vi.fn());
|
||||||
const replaceWithTorrentMock = vi.hoisted(() => vi.fn());
|
const replaceWithTorrentMock = vi.hoisted(() => vi.fn());
|
||||||
|
const useIsTruncatedMock = vi.hoisted(() => vi.fn(() => false));
|
||||||
|
|
||||||
vi.mock('@/lib/hooks/useReportedIssues', () => ({
|
vi.mock('@/lib/hooks/useReportedIssues', () => ({
|
||||||
useReplaceWithTorrent: () => ({
|
useReplaceWithTorrent: () => ({
|
||||||
@@ -27,6 +28,10 @@ vi.mock('@/lib/hooks/useReportedIssues', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/useIsTruncated', () => ({
|
||||||
|
useIsTruncated: useIsTruncatedMock,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||||
useInteractiveSearch: () => ({
|
useInteractiveSearch: () => ({
|
||||||
searchTorrents: searchByRequestMock,
|
searchTorrents: searchByRequestMock,
|
||||||
@@ -172,4 +177,190 @@ describe('InteractiveTorrentSearchModal', () => {
|
|||||||
expect(searchByRequestMock).toHaveBeenNthCalledWith(2, 'req-456', 'Custom Title');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user