Updated tests after sync

This commit is contained in:
Nick Sandstrom 2026-03-02 23:22:03 -08:00
parent 08d79fb4db
commit f3304bd976
3 changed files with 1024 additions and 57 deletions

View file

@ -5,20 +5,21 @@ import { Field } from '../Field';
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
TextInput: ({ label, description, value, onChange }) => (
TextInput: ({ label, description, value, onChange, type, placeholder }) => (
<div>
<label htmlFor="text-input">{label}</label>
<input
id="text-input"
type="text"
type={type || 'text'}
value={value}
onChange={onChange}
placeholder={placeholder}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
NumberInput: ({ label, description, value, onChange }) => (
NumberInput: ({ label, description, value, onChange, placeholder }) => (
<div>
<label htmlFor="number-input">{label}</label>
<input
@ -26,6 +27,20 @@ vi.mock('@mantine/core', async () => {
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
placeholder={placeholder}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
Textarea: ({ label, description, value, onChange, placeholder }) => (
<div>
<label htmlFor="textarea-input">{label}</label>
<textarea
id="textarea-input"
value={value}
onChange={onChange}
placeholder={placeholder}
aria-describedby={description}
/>
{description && <div>{description}</div>}
@ -44,13 +59,14 @@ vi.mock('@mantine/core', async () => {
{description && <div>{description}</div>}
</div>
),
Select: ({ label, description, value, data, onChange }) => (
Select: ({ label, description, value, data, onChange, placeholder }) => (
<div>
<label htmlFor="select-input">{label}</label>
<select
id="select-input"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-describedby={description}
>
{data.map((option) => (
@ -62,6 +78,11 @@ vi.mock('@mantine/core', async () => {
{description && <div>{description}</div>}
</div>
),
Text: ({ children, fw, size, c }) => (
<div data-fw={fw} data-size={size} data-color={c}>
{children}
</div>
),
};
});
@ -371,6 +392,399 @@ describe('Field', () => {
});
});
describe('Textarea (text type)', () => {
it('should render Textarea for text type', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
help_text: 'Enter your biography',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toBeInTheDocument();
expect(screen.getByText('Enter your biography')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
default: '',
};
render(<Field field={field} value="My bio" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveValue('My bio');
});
it('should use default value when value is null', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
default: 'Default bio',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveValue('Default bio');
});
it('should call onChange with field id and value', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Biography'), {
target: { value: 'New bio text' },
});
expect(mockOnChange).toHaveBeenCalledWith('bio', 'New bio text');
});
it('should render with placeholder', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
placeholder: 'Enter your bio here...',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveAttribute(
'placeholder',
'Enter your bio here...'
);
});
});
describe('Info type', () => {
it('should render info with label and description', () => {
const field = {
id: 'info1',
type: 'info',
label: 'Important Information',
description: 'This is important info',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Important Information')).toBeInTheDocument();
expect(screen.getByText('This is important info')).toBeInTheDocument();
});
it('should render info with only description', () => {
const field = {
id: 'info2',
type: 'info',
description: 'Just a description',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Just a description')).toBeInTheDocument();
});
it('should render info with only label', () => {
const field = {
id: 'info3',
type: 'info',
label: 'Just a label',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Just a label')).toBeInTheDocument();
});
it('should prioritize help_text over description', () => {
const field = {
id: 'info4',
type: 'info',
label: 'Title',
help_text: 'Help text takes priority',
description: 'This should not appear',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Help text takes priority')).toBeInTheDocument();
expect(screen.queryByText('This should not appear')).not.toBeInTheDocument();
});
it('should use field.value if no help_text or description', () => {
const field = {
id: 'info5',
type: 'info',
label: 'Title',
value: 'Value text',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Value text')).toBeInTheDocument();
});
it('should not call onChange for info type', () => {
const field = {
id: 'info6',
type: 'info',
label: 'Read-only Info',
description: 'Cannot be changed',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(mockOnChange).not.toHaveBeenCalled();
});
});
describe('Password input type', () => {
it('should render password input when input_type is password', () => {
const field = {
id: 'password',
type: 'string',
label: 'Password',
input_type: 'password',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
});
it('should render text input when input_type is not password', () => {
const field = {
id: 'username',
type: 'string',
label: 'Username',
input_type: 'text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Username')).toHaveAttribute('type', 'text');
});
it('should default to text input when input_type is undefined', () => {
const field = {
id: 'email',
type: 'string',
label: 'Email',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Email')).toHaveAttribute('type', 'text');
});
});
describe('Description priority', () => {
it('should prioritize help_text over description', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
help_text: 'Help text',
description: 'Description text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByText('Help text')).toBeInTheDocument();
expect(screen.queryByText('Description text')).not.toBeInTheDocument();
});
it('should use description when help_text is not provided', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
description: 'Description text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByText('Description text')).toBeInTheDocument();
});
it('should use field.value when neither help_text nor description provided', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
value: 'Value text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByText('Value text')).toBeInTheDocument();
});
it('should not show description when all are undefined', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Test')).toBeInTheDocument();
// No description should be present
});
});
describe('Placeholder handling', () => {
it('should render placeholder for TextInput', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
placeholder: 'Enter your name',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toHaveAttribute(
'placeholder',
'Enter your name'
);
});
it('should render placeholder for NumberInput', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
placeholder: 'Enter your age',
default: 0,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toHaveAttribute(
'placeholder',
'Enter your age'
);
});
it('should render placeholder for Select', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
placeholder: 'Select a country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toHaveAttribute(
'placeholder',
'Select a country'
);
});
it('should render placeholder for Textarea', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
placeholder: 'Tell us about yourself',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveAttribute(
'placeholder',
'Tell us about yourself'
);
});
});
describe('Edge cases', () => {
it('should handle empty string default value', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
default: '',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Test')).toHaveValue('');
});
it('should handle 0 as valid number value', () => {
const field = {
id: 'count',
type: 'number',
label: 'Count',
default: 10,
};
render(<Field field={field} value={0} onChange={mockOnChange} />);
expect(screen.getByLabelText('Count')).toHaveValue(0);
});
it('should handle false as valid boolean value', () => {
const field = {
id: 'enabled',
type: 'boolean',
label: 'Enabled',
default: true,
};
render(<Field field={field} value={false} onChange={mockOnChange} />);
expect(screen.getByLabelText('Enabled')).not.toBeChecked();
});
it('should handle empty string as valid select value', () => {
const field = {
id: 'status',
type: 'select',
label: 'Status',
default: 'active',
options: [
{ value: '', label: 'None' },
{ value: 'active', label: 'Active' },
],
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Status')).toHaveValue('');
});
});
describe('Default fallback', () => {
it('should render TextInput for unknown type', () => {
const field = {

View file

@ -28,6 +28,18 @@ vi.mock('@mantine/core', async () => {
};
});
// Helper function to create programs at specific times
const createProgramAtTime = (id, startHour, durationMinutes) => {
const timelineStart = new Date('2024-01-01T00:00:00Z');
const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
return {
id,
title: `Program ${id}`,
startMs,
endMs: startMs + durationMinutes * 60 * 1000,
};
};
describe('GuideRow', () => {
const mockChannel = {
id: 'channel-1',
@ -64,6 +76,9 @@ describe('GuideRow', () => {
)),
handleLogoClick: vi.fn(),
contentWidth: 1920,
guideScrollLeftRef: { current: 0 },
viewportWidth: 1920,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const mockStyle = {
@ -160,24 +175,26 @@ describe('GuideRow', () => {
describe('Programs Rendering', () => {
it('should render programs when channel has programs', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
// Create program at hour 0 (definitely within viewport at scrollLeft 0)
const visibleProgram = createProgramAtTime('program-1', 0, 60);
expect(screen.getByTestId(`program-${mockProgram.id}`)).toBeInTheDocument();
expect(screen.getByText('Test Program')).toBeInTheDocument();
expect(mockData.renderProgram).toHaveBeenCalledWith(
mockProgram,
undefined,
mockChannel
);
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, [visibleProgram]]]),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('program-program-1')).toBeInTheDocument();
expect(mockData.renderProgram).toHaveBeenCalledWith(visibleProgram, undefined, mockChannel);
});
it('should render multiple programs', () => {
const programs = [
mockProgram,
{ ...mockProgram, id: 'program-2', title: 'Another Program' },
createProgramAtTime('prog-1', 0, 60),
createProgramAtTime('prog-2', 1, 30),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
@ -185,9 +202,8 @@ describe('GuideRow', () => {
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByText('Test Program')).toBeInTheDocument();
expect(screen.getByText('Another Program')).toBeInTheDocument();
expect(mockData.renderProgram).toHaveBeenCalledTimes(2);
expect(screen.getByTestId('program-prog-1')).toBeInTheDocument();
expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
});
it('should render placeholder when channel has no programs', () => {
@ -199,7 +215,7 @@ describe('GuideRow', () => {
render(<GuideRow index={0} style={mockStyle} data={data} />);
const placeholders = screen.getAllByText('No program data');
expect(placeholders.length).toBe(Math.ceil(24 / 2));
expect(placeholders.length).toBeGreaterThan(0);
});
it('should render placeholder when programsByChannelId does not contain channel', () => {
@ -211,7 +227,7 @@ describe('GuideRow', () => {
render(<GuideRow index={0} style={mockStyle} data={data} />);
const placeholders = screen.getAllByText('No program data');
expect(placeholders.length).toBe(Math.ceil(24 / 2));
expect(placeholders.length).toBeGreaterThan(0);
});
it('should position placeholder programs correctly', () => {
@ -252,12 +268,10 @@ describe('GuideRow', () => {
});
it('should show play icon on hover', () => {
const data = {
...mockData,
hoveredChannelId: mockChannel.id,
};
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
render(<GuideRow index={0} style={mockStyle} data={data} />);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.mouseEnter(logo);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
});
@ -269,28 +283,6 @@ describe('GuideRow', () => {
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
});
it('should call setHoveredChannelId on mouse enter', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.mouseEnter(logo);
expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(mockChannel.id);
});
it('should call setHoveredChannelId with null on mouse leave', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.mouseLeave(logo);
expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(null);
});
});
describe('Layout and Styling', () => {
@ -332,4 +324,392 @@ describe('GuideRow', () => {
expect(imageContainer).toHaveAttribute('h', `${customHeight - 32}px`);
});
});
describe('Horizontal Viewport Culling', () => {
it('should only render programs visible in viewport', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60), // Hour 0
createProgramAtTime('prog-2', 6, 60), // Hour 6
createProgramAtTime('prog-3', 12, 60), // Hour 12
createProgramAtTime('prog-4', 18, 60), // Hour 18
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 6 }, // Scroll to hour 6
viewportWidth: HOUR_WIDTH * 4, // Show 4 hours
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Program at hour 6 should be visible
expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
// Programs outside viewport + buffer should not be rendered
expect(mockData.renderProgram).toHaveBeenCalledTimes(1);
});
it('should render programs within buffer zone', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60),
createProgramAtTime('prog-2', 1, 60),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 2 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Programs within H_BUFFER (600px) should be rendered
const renderedPrograms = mockData.renderProgram.mock.calls.map(
call => call[0]
);
expect(renderedPrograms.length).toBeGreaterThan(0);
});
it('should not render programs completely outside viewport and buffer', () => {
const programs = [
createProgramAtTime('prog-far-left', 0, 60),
createProgramAtTime('prog-visible', 10, 60),
createProgramAtTime('prog-far-right', 22, 60),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const renderedPrograms = mockData.renderProgram.mock.calls.map(
call => call[0].id
);
// Only visible program should be rendered
expect(renderedPrograms).toContain('prog-visible');
expect(renderedPrograms).not.toContain('prog-far-left');
expect(renderedPrograms).not.toContain('prog-far-right');
});
it('should handle edge case where program spans viewport boundary', () => {
const programs = [
createProgramAtTime('prog-spanning', 5, 180), // 3-hour program
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 6 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Program spanning viewport should be visible
expect(screen.getByTestId('program-prog-spanning')).toBeInTheDocument();
});
it('should update visible programs when scroll position changes', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60),
createProgramAtTime('prog-2', 12, 60),
];
const scrollRef = { current: 0 };
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: scrollRef,
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const { rerender } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const initialCalls = mockData.renderProgram.mock.calls.length;
// Scroll to different position
scrollRef.current = HOUR_WIDTH * 12;
const newData = { ...data, guideScrollLeftRef: scrollRef };
rerender(<GuideRow index={0} style={mockStyle} data={newData} />);
// Different programs should be rendered
expect(mockData.renderProgram.mock.calls.length).toBeGreaterThan(initialCalls);
});
});
describe('Placeholder Culling', () => {
it('should only render placeholders visible in viewport', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const visiblePlaceholders = screen.getAllByText('No program data');
// Should render fewer than total placeholders due to culling
expect(visiblePlaceholders.length).toBeLessThan(Math.ceil(24 / 2));
expect(visiblePlaceholders.length).toBeGreaterThan(0);
});
it('should not render placeholders outside viewport', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 20 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const visiblePlaceholders = screen.getAllByText('No program data');
// Near the end of timeline, should show fewer placeholders
expect(visiblePlaceholders.length).toBeGreaterThan(0);
expect(visiblePlaceholders.length).toBeLessThanOrEqual(3);
});
});
describe('Hover State Management', () => {
it('should show play icon on mouse enter', () => {
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo');
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
fireEvent.mouseEnter(logoContainer);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
});
it('should hide play icon on mouse leave', () => {
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.mouseEnter(logoContainer);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
fireEvent.mouseLeave(logoContainer);
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
});
it('should maintain hover state independently per row', () => {
const data = {
...mockData,
filteredChannels: [mockChannel, { ...mockChannel, id: 'channel-2', name: 'Channel 2' }],
};
// Render both rows separately
const { container: container1 } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const { container: container2 } = render(
<GuideRow index={1} style={{ ...mockStyle, top: PROGRAM_HEIGHT }} data={data} />
);
// Hover over first row
const logo1 = container1.querySelector('.channel-logo');
fireEvent.mouseEnter(logo1);
// First row should show play icon
expect(container1.querySelector('[data-testid="play-icon"]')).toBeInTheDocument();
// Second row should not show play icon
expect(container2.querySelector('[data-testid="play-icon"]')).not.toBeInTheDocument();
});
});
describe('Program Time Positioning', () => {
const createTimedProgram = (startHour, durationMinutes) => {
const timelineStart = new Date('2024-01-01T00:00:00Z');
const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
return {
id: `program-${startHour}`,
title: `Program at ${startHour}h`,
startMs,
endMs: startMs + durationMinutes * 60 * 1000,
};
};
it('should calculate correct viewport boundaries', () => {
const programs = [
createTimedProgram(6, 60),
createTimedProgram(12, 60),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Both programs should be rendered since they're within viewport range
expect(mockData.renderProgram).toHaveBeenCalled();
});
it('should handle programs at timeline boundaries', () => {
const programs = [
createTimedProgram(0, 60), // Start of timeline
createTimedProgram(23, 60), // End of timeline
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: 0 },
viewportWidth: HOUR_WIDTH * 24,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByText('Program at 0h')).toBeInTheDocument();
expect(screen.getByText('Program at 23h')).toBeInTheDocument();
});
it('should handle very short programs', () => {
const programs = [
createTimedProgram(12, 5), // 5-minute program
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 12 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('program-program-12')).toBeInTheDocument();
});
it('should handle very long programs', () => {
const programs = [
createTimedProgram(6, 360), // 6-hour program
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Long program spanning viewport should be visible
expect(screen.getByTestId('program-program-6')).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty programsByChannelId gracefully', () => {
const data = {
...mockData,
programsByChannelId: new Map(),
guideScrollLeftRef: { current: 0 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getAllByText('No program data').length).toBeGreaterThan(0);
});
it('should handle zero viewport width', () => {
const programs = [mockProgram];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: 0 },
viewportWidth: 0,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Should still render due to buffer
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
});
it('should handle negative scroll position', () => {
const programs = [createProgramAtTime('prog-1', 0, 60)];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: -100 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
});
it('should handle row index out of bounds', () => {
const data = {
...mockData,
filteredChannels: [mockChannel],
};
const { container } = render(
<GuideRow index={999} style={mockStyle} data={data} />
);
expect(container.firstChild).toBeNull();
});
it('should memoize component to prevent unnecessary re-renders', () => {
const { rerender } = render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const renderCount = mockData.renderProgram.mock.calls.length;
// Re-render with same props
rerender(<GuideRow index={0} style={mockStyle} data={mockData} />);
// Should not cause additional renders due to React.memo
expect(mockData.renderProgram.mock.calls.length).toBe(renderCount);
});
});
});

View file

@ -16,6 +16,10 @@ vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
vi.mock('../NotificationCenter', () => ({
default: () => <div data-testid="notification-center">Notification Center</div>,
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: ({ onClick }) => <div data-testid="list-ordered-icon" onClick={onClick} />,
@ -30,6 +34,12 @@ vi.mock('lucide-react', () => ({
LogOut: ({ onClick }) => <div data-testid="logout-icon" onClick={onClick} />,
User: ({ onClick }) => <div data-testid="user-icon" onClick={onClick} />,
FileImage: ({ onClick }) => <div data-testid="file-image-icon" onClick={onClick} />,
Webhook: () => <div data-testid="webhook-icon" />,
Logs: () => <div data-testid="logs-icon" />,
ChevronDown: () => <div data-testid="chevron-down-icon" />,
ChevronRight: () => <div data-testid="chevron-right-icon" />,
MonitorCog: () => <div data-testid="monitor-cog-icon" />,
Blocks: () => <div data-testid="blocks-icon" />,
}));
// Mock UserForm component
@ -85,14 +95,11 @@ vi.mock('@mantine/core', async () => {
{children}
</nav>
),
ScrollArea: ({ children }) => <div>{children}</div>,
};
});
const mockChannels = {
'channel-1': { id: 'channel-1', name: 'Channel 1' },
'channel-2': { id: 'channel-2', name: 'Channel 2' },
'channel-3': { id: 'channel-3', name: 'Channel 3' },
};
const mockChannels = [ 'channel-1', 'channel-2', 'channel-3' ];
const mockEnvironment = {
public_ip: '192.168.1.1',
@ -182,7 +189,7 @@ describe('Sidebar', () => {
});
describe('Navigation Links - Admin User', () => {
it('should render all admin navigation items', () => {
it('should render all admin navigation items', async () => {
renderSidebar();
expect(screen.getByText('Channels')).toBeInTheDocument();
@ -192,9 +199,16 @@ describe('Sidebar', () => {
expect(screen.getByText('DVR')).toBeInTheDocument();
expect(screen.getByText('Stats')).toBeInTheDocument();
expect(screen.getByText('Plugins')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Logo Manager')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
// Expand System group to access Users
const systemButton = screen.getByText('System');
fireEvent.click(systemButton);
await waitFor(() => {
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Logo Manager')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
});
it('should display channel count badge', () => {
@ -452,4 +466,163 @@ describe('Sidebar', () => {
expect(flag).toBeInTheDocument();
});
});
describe('NavGroup Component', () => {
it('should render Integrations group with children collapsed by default', () => {
renderSidebar();
expect(screen.getByText('Integrations')).toBeInTheDocument();
expect(screen.queryByText('Connections')).not.toBeInTheDocument();
expect(screen.queryByText('Logs')).not.toBeInTheDocument();
});
it('should expand Integrations group when clicked', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.getByText('Connections')).toBeInTheDocument();
expect(screen.getByText('Logs')).toBeInTheDocument();
});
});
it('should collapse Integrations group when clicked again', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
// Expand
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.getByText('Connections')).toBeInTheDocument();
});
// Collapse
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.queryByText('Connections')).not.toBeInTheDocument();
expect(screen.queryByText('Logs')).not.toBeInTheDocument();
});
});
it('should render System group with children collapsed by default', () => {
renderSidebar();
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.queryByText('Users')).not.toBeInTheDocument();
expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument();
});
it('should expand System group when clicked', async () => {
renderSidebar();
const systemGroup = screen.getByText('System').closest('button');
fireEvent.click(systemGroup);
await waitFor(() => {
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Logo Manager')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
});
it('should hide group label when collapsed sidebar', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Integrations')).not.toBeInTheDocument();
expect(screen.queryByText('System')).not.toBeInTheDocument();
});
it('should not show multiple groups collapsed when both expanded', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
const systemGroup = screen.getByText('System').closest('button');
// Expand Integrations
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.getByText('Connections')).toBeInTheDocument();
});
// Expand System (Integrations should remain expanded)
fireEvent.click(systemGroup);
await waitFor(() => {
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Connections')).toBeInTheDocument();
});
});
});
describe('NotificationCenter Integration', () => {
it('should render NotificationCenter when authenticated and expanded', () => {
renderSidebar();
expect(screen.getByTestId('notification-center')).toBeInTheDocument();
});
it('should render NotificationCenter when authenticated and collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.getByTestId('notification-center')).toBeInTheDocument();
});
it('should not render NotificationCenter when not authenticated', () => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: false,
user: null,
logout: vi.fn(),
};
return selector(state);
});
renderSidebar();
expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
});
it('should not render NotificationCenter when not authenticated and collapsed', () => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: false,
user: null,
logout: vi.fn(),
};
return selector(state);
});
renderSidebar({ collapsed: true });
expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
});
});
describe('Channel Count Badge', () => {
it('should display 0 when no channels exist', () => {
useChannelsStore.mockReturnValue({});
renderSidebar();
expect(screen.getByText('(0)')).toBeInTheDocument();
});
it('should handle null channelIds gracefully', () => {
useChannelsStore.mockReturnValue(null);
renderSidebar();
expect(screen.getByText('(0)')).toBeInTheDocument();
});
it('should handle array of channel IDs', () => {
useChannelsStore.mockReturnValue(['channel-1', 'channel-2', 'channel-3']);
renderSidebar();
expect(screen.getByText('(3)')).toBeInTheDocument();
});
});
});