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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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