Dispatcharr/frontend/src/components/__tests__/ProgramPreview.test.jsx
SergeantPanda 223dff33ed feat(timeshift): add catch-up playback, stats, and admin APIs
Add end-to-end catch-up support for XC clients and native apps: provider
proxy with failover and per-viewer session pooling, REST session minting
for tokenless playback URLs, catch-up admin stats, combined connection
stats, and Stats UI with dedicated cards plus websocket updates.

Includes Redis namespace consolidation under timeshift:* (dropping legacy
timeshift_ id prefixes), dedicated catch-up stop by session_id, and
cleaner channel/client metadata split for stats keys.

Closes #133
2026-07-11 17:14:31 +00:00

260 lines
7.9 KiB
JavaScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ProgramPreview from '../ProgramPreview';
// Mock Mantine components as lightweight stubs
vi.mock('@mantine/core', () => {
return {
ActionIcon: ({ children, onClick, ...props }) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Progress: ({ value }) => (
<div data-testid="progress" data-value={value} />
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Tooltip: ({ children }) => <>{children}</>,
};
});
vi.mock('lucide-react', () => ({
ChevronDown: () => <span data-testid="chevron-down" />,
ChevronRight: () => <span data-testid="chevron-right" />,
Radio: () => <span data-testid="radio-icon" />,
}));
afterEach(() => {
vi.useRealTimers();
});
describe('ProgramPreview', () => {
it('renders nothing when loading=false, fetched=false, program=null', () => {
const { container } = render(
<ProgramPreview loading={false} fetched={false} program={null} />
);
expect(container.innerHTML).toBe('');
});
it('shows loading text when loading=true', () => {
render(<ProgramPreview loading={true} fetched={false} program={null} />);
expect(screen.getByText('Loading EPG data...')).toBeTruthy();
});
it('shows no current program message when fetched=true and program=null', () => {
render(<ProgramPreview loading={false} fetched={true} program={null} />);
expect(
screen.getByText('No current program (EPG may need refresh)')
).toBeTruthy();
});
it('shows program title when program is provided', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'Test Show',
start_time: new Date(now - 1800000).toISOString(),
end_time: new Date(now + 1800000).toISOString(),
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
expect(screen.getByText('Test Show')).toBeTruthy();
});
it('shows custom label when label prop is overridden', () => {
const program = { title: 'Show', start_time: null, end_time: null };
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
label="Currently Airing:"
/>
);
expect(screen.getByText('Currently Airing:')).toBeTruthy();
});
it('shows default label "Now Playing:"', () => {
const program = { title: 'Show', start_time: null, end_time: null };
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
expect(screen.getByText('Now Playing:')).toBeTruthy();
});
it('expand/collapse reveals description and time details', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'Expandable Show',
description: 'A detailed description',
start_time: new Date(now - 3600000).toISOString(),
end_time: new Date(now + 3600000).toISOString(),
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
// Description not visible initially
expect(screen.queryByText('A detailed description')).toBeNull();
// Click chevron to expand
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);
// Description should now be visible
expect(screen.getByText('A detailed description')).toBeTruthy();
// Time info visible
expect(screen.getByText(/elapsed/)).toBeTruthy();
expect(screen.getByText(/remaining/)).toBeTruthy();
// Click again to collapse
const collapseButton = screen.getByTestId('chevron-down').closest('button');
fireEvent.click(collapseButton);
// Description and time should be hidden again
expect(screen.queryByText('A detailed description')).toBeNull();
expect(screen.queryByText(/elapsed/)).toBeNull();
});
it('does not render description block when program has no description', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'No Desc Show',
description: null,
start_time: new Date(now - 3600000).toISOString(),
end_time: new Date(now + 3600000).toISOString(),
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
// Expand
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);
// Time info should be visible, but no italic description block
expect(screen.getByText(/elapsed/)).toBeTruthy();
expect(screen.queryByText('null')).toBeNull();
});
it('uses session-based progress for catch-up programmes', () => {
const program = {
title: 'Archived Show',
description: 'Catch-up description',
start_time: '2026-06-08T17:00:00+00:00',
end_time: '2026-06-08T18:00:00+00:00',
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
timelineMode="catchup"
label="Watching:"
playbackElapsedSeconds={900}
/>
);
expect(screen.getByText('Watching:')).toBeTruthy();
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);
expect(screen.getByText(/Aired /)).toBeTruthy();
expect(screen.getByText('15:00 watched')).toBeTruthy();
expect(screen.getByText('45:00 remaining')).toBeTruthy();
expect(screen.getByTestId('progress').getAttribute('data-value')).toBe('25');
});
});
describe('formatProgramTime', () => {
// We need to test the exported helper; import it via the module
// Since it's not exported, we test it indirectly through rendered output
it('formats time with hours correctly', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'Long Show',
description: 'desc',
start_time: new Date(now - 2 * 3600000 - 30 * 60000).toISOString(), // 2h30m ago
end_time: new Date(now + 30 * 60000).toISOString(), // 30m from now
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
// Expand to see time
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);
// Elapsed should show ~2:30:xx format (with hours)
const elapsedEl = screen.getByText(/elapsed/);
expect(elapsedEl.textContent).toMatch(/2:30:\d{2} elapsed/);
});
it('formats time without hours correctly', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
const now = Date.now();
const program = {
title: 'Short Show',
description: 'desc',
start_time: new Date(now - 5 * 60000).toISOString(), // 5m ago
end_time: new Date(now + 25 * 60000).toISOString(), // 25m from now
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);
// Elapsed should show ~5:xx format (no hours)
const elapsedEl = screen.getByText(/elapsed/);
expect(elapsedEl.textContent).toMatch(/5:\d{2} elapsed/);
// With pinned time, remaining should be exactly 25:00
const remainingEl = screen.getByText(/remaining/);
expect(remainingEl.textContent).toBe('25:00 remaining');
});
});