diff --git a/frontend/src/components/__tests__/Field.test.jsx b/frontend/src/components/__tests__/Field.test.jsx
index 3f57356d..092871f1 100644
--- a/frontend/src/components/__tests__/Field.test.jsx
+++ b/frontend/src/components/__tests__/Field.test.jsx
@@ -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 }) => (
{description &&
{description}
}
),
- NumberInput: ({ label, description, value, onChange }) => (
+ NumberInput: ({ label, description, value, onChange, placeholder }) => (
{
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
+ placeholder={placeholder}
+ aria-describedby={description}
+ />
+ {description &&
{description}
}
+
+ ),
+ Textarea: ({ label, description, value, onChange, placeholder }) => (
+
+
+
{description &&
{description}
}
@@ -44,13 +59,14 @@ vi.mock('@mantine/core', async () => {
{description &&
{description}
}
),
- Select: ({ label, description, value, data, onChange }) => (
+ Select: ({ label, description, value, data, onChange, placeholder }) => (
),
+ Text: ({ children, fw, size, c }) => (
+
+ {children}
+
+ ),
};
});
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByLabelText('Test')).toHaveValue('');
+ });
+
+ it('should handle 0 as valid number value', () => {
+ const field = {
+ id: 'count',
+ type: 'number',
+ label: 'Count',
+ default: 10,
+ };
+
+ render();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByLabelText('Status')).toHaveValue('');
+ });
+ });
+
describe('Default fallback', () => {
it('should render TextInput for unknown type', () => {
const field = {
diff --git a/frontend/src/components/__tests__/GuideRow.test.jsx b/frontend/src/components/__tests__/GuideRow.test.jsx
index 3f103305..8dc18eaf 100644
--- a/frontend/src/components/__tests__/GuideRow.test.jsx
+++ b/frontend/src/components/__tests__/GuideRow.test.jsx
@@ -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(
-
- );
+ // 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();
+
+ 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();
- 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();
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();
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();
- render();
+ 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(
-
- );
-
- 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(
-
- );
-
- 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();
+
+ // 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();
+
+ // 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();
+
+ 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();
+
+ // 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(
+
+ );
+
+ const initialCalls = mockData.renderProgram.mock.calls.length;
+
+ // Scroll to different position
+ scrollRef.current = HOUR_WIDTH * 12;
+ const newData = { ...data, guideScrollLeftRef: scrollRef };
+
+ rerender();
+
+ // 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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();
+
+ 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();
+
+ 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(
+
+ );
+ const { container: container2 } = render(
+
+ );
+
+ // 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();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ 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();
+
+ // 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();
+
+ expect(screen.getByTestId('guide-row')).toBeInTheDocument();
+ });
+
+ it('should handle row index out of bounds', () => {
+ const data = {
+ ...mockData,
+ filteredChannels: [mockChannel],
+ };
+
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should memoize component to prevent unnecessary re-renders', () => {
+ const { rerender } = render(
+
+ );
+
+ const renderCount = mockData.renderProgram.mock.calls.length;
+
+ // Re-render with same props
+ rerender();
+
+ // Should not cause additional renders due to React.memo
+ expect(mockData.renderProgram.mock.calls.length).toBe(renderCount);
+ });
+ });
});
diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx
index 341d0c32..d3163b1d 100644
--- a/frontend/src/components/__tests__/Sidebar.test.jsx
+++ b/frontend/src/components/__tests__/Sidebar.test.jsx
@@ -16,6 +16,10 @@ vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
+vi.mock('../NotificationCenter', () => ({
+ default: () => Notification Center
,
+}));
+
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: ({ onClick }) => ,
@@ -30,6 +34,12 @@ vi.mock('lucide-react', () => ({
LogOut: ({ onClick }) => ,
User: ({ onClick }) => ,
FileImage: ({ onClick }) => ,
+ Webhook: () => ,
+ Logs: () => ,
+ ChevronDown: () => ,
+ ChevronRight: () => ,
+ MonitorCog: () => ,
+ Blocks: () => ,
}));
// Mock UserForm component
@@ -85,14 +95,11 @@ vi.mock('@mantine/core', async () => {
{children}
),
+ ScrollArea: ({ children }) => {children}
,
};
});
-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();
+ });
+ });
});