From 1065577a04dbe918f96e747fa80176811b169a37 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Sat, 16 May 2026 10:41:44 -0400 Subject: [PATCH] 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. --- documentation/frontend/components.md | 2 +- .../InteractiveTorrentSearchModal.tsx | 314 +++++++++++------- src/lib/hooks/useIsTruncated.ts | 34 ++ src/lib/utils/title-tags.ts | 41 +++ .../InteractiveTorrentSearchModal.test.tsx | 191 +++++++++++ tests/lib/hooks/useIsTruncated.test.tsx | 45 +++ tests/lib/utils/title-tags.test.ts | 112 +++++++ 7 files changed, 619 insertions(+), 120 deletions(-) create mode 100644 src/lib/hooks/useIsTruncated.ts create mode 100644 src/lib/utils/title-tags.ts create mode 100644 tests/lib/hooks/useIsTruncated.test.tsx create mode 100644 tests/lib/utils/title-tags.test.ts diff --git a/documentation/frontend/components.md b/documentation/frontend/components.md index efc1a9a..ccc8fa3 100644 --- a/documentation/frontend/components.md +++ b/documentation/frontend/components.md @@ -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** diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index cd8ce50..d306ba4 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -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>(() => 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 && (
- {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 ( -
- {/* Score Badge */} -
- - {score} - -
- - {/* Content */} -
- {/* Title Row */} - - - {/* Metadata Row */} -
- {/* Rank */} - #{result.rank} - · - - {/* Indexer / Source */} - {isAnnasArchive ? ( - Anna's Archive - ) : ( - {result.indexer} - )} - - {/* Size */} - {result.size > 0 && ( - <> - · - {formatSize(result.size)} - - )} - - {/* Format */} - {displayFormat && ( - <> - · - - {displayFormat} - - - )} - - {/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */} - {!isAnnasArchive && ( - <> - · - {isUsenet ? ( - - - - - NZB - - ) : ( - - - - - {result.seeders ?? 0} - - )} - - )} - - {/* Age */} - {result.publishDate && ( - <> - · - {formatAge(result.publishDate)} - - )} - - {/* Bonus Points */} - {result.bonusPoints > 0 && ( - <> - · - +{Math.round(result.bonusPoints)} - - )} -
-
- - {/* Action Button */} - -
- ); - })} + {results.map((result) => ( + { + 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)} + /> + ))}
)} @@ -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(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 ( +
+ {/* Score Badge */} +
+ + {score} + +
+ + {/* Content */} +
+ {/* Title Row */} +
+ + {result.title} + + {showChevron && ( + + )} +
+ + {/* Metadata Row */} +
+ {/* Rank */} + #{result.rank} + · + + {/* Indexer / Source */} + {isAnnasArchive ? ( + Anna's Archive + ) : ( + {result.indexer} + )} + + {/* Size */} + {result.size > 0 && ( + <> + · + {formatSize(result.size)} + + )} + + {/* Format */} + {displayFormat && ( + <> + · + + {displayFormat} + + + )} + + {/* Title tag chips (language/edition/etc.) */} + {chipTags.map((tag) => ( + + {tag} + + ))} + + {/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */} + {!isAnnasArchive && ( + <> + · + {isUsenet ? ( + + + + + NZB + + ) : ( + + + + + {result.seeders ?? 0} + + )} + + )} + + {/* Age */} + {result.publishDate && ( + <> + · + {formatAge(result.publishDate)} + + )} + + {/* Bonus Points */} + {result.bonusPoints > 0 && ( + <> + · + +{Math.round(result.bonusPoints)} + + )} +
+
+ + {/* Action Button */} + +
+ ); +} diff --git a/src/lib/hooks/useIsTruncated.ts b/src/lib/hooks/useIsTruncated.ts new file mode 100644 index 0000000..11598ad --- /dev/null +++ b/src/lib/hooks/useIsTruncated.ts @@ -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): 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; +} diff --git a/src/lib/utils/title-tags.ts b/src/lib/utils/title-tags.ts new file mode 100644 index 0000000..35eb218 --- /dev/null +++ b/src/lib/utils/title-tags.ts @@ -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(); + 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 }; +} diff --git a/tests/components/requests/InteractiveTorrentSearchModal.test.tsx b/tests/components/requests/InteractiveTorrentSearchModal.test.tsx index 3ac6fd1..dc0d3ba 100644 --- a/tests/components/requests/InteractiveTorrentSearchModal.test.tsx +++ b/tests/components/requests/InteractiveTorrentSearchModal.test.tsx @@ -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( + , + ); + 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'); + }); + }); }); diff --git a/tests/lib/hooks/useIsTruncated.test.tsx b/tests/lib/hooks/useIsTruncated.test.tsx new file mode 100644 index 0000000..7368b45 --- /dev/null +++ b/tests/lib/hooks/useIsTruncated.test.tsx @@ -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(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 ( + + probe + + ); +} + +describe('useIsTruncated', () => { + it('returns false when scrollWidth fits inside clientWidth', () => { + const { getByTestId } = render(); + expect(getByTestId('probe').getAttribute('data-truncated')).toBe('no'); + }); + + it('returns false when scrollWidth equals clientWidth', () => { + const { getByTestId } = render(); + expect(getByTestId('probe').getAttribute('data-truncated')).toBe('no'); + }); + + it('returns true when scrollWidth exceeds clientWidth', () => { + const { getByTestId } = render(); + expect(getByTestId('probe').getAttribute('data-truncated')).toBe('yes'); + }); +}); diff --git a/tests/lib/utils/title-tags.test.ts b/tests/lib/utils/title-tags.test.ts new file mode 100644 index 0000000..41505fc --- /dev/null +++ b/tests/lib/utils/title-tags.test.ts @@ -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: [] }); + }); +});