A try of overhauling the frontend tests, bailed after 24h+

This commit is contained in:
2026-06-11 09:09:02 +02:00
parent a9e1e96622
commit bb58389675
33 changed files with 3257 additions and 275 deletions
@@ -0,0 +1,135 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import BedtimePlot from '../../../../src/components/stats/BedtimePlot';
import { useStats } from '../../../../src/hooks/useStats';
import { XAxis, YAxis, Tooltip } from 'recharts';
vi.mock('../../../../src/hooks/useStats', () => ({
useStats: vi.fn(),
}));
vi.mock('recharts', async () => {
const actual = await vi.importActual<typeof import('recharts')>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) => <div data-testid="responsive-container">{children}</div>,
LineChart: ({ children }: any) => <div data-testid="line-chart">{children}</div>,
XAxis: vi.fn(({ tickFormatter }) => <div data-testid="x-axis" data-tick-formatter={String(tickFormatter)} />),
YAxis: vi.fn(({ tickFormatter }) => <div data-testid="y-axis" data-tick-formatter={String(tickFormatter)} />),
CartesianGrid: vi.fn(),
Tooltip: vi.fn(({ formatter, labelFormatter }) => <div data-testid="tooltip" />),
Line: vi.fn(),
};
});
describe('BedtimePlot', () => {
const props = {
from_date: '2024-01-01',
to_date: '2024-01-07',
wakeup_ref: '07:00',
bedtime_ref: '20:00',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state', () => {
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: undefined,
isLoading: true,
} as any);
render(<BedtimePlot {...props} />);
expect(screen.getByText(/Loading plot.../i)).toBeInTheDocument();
});
it('renders empty state when no data is available', () => {
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: { days: [] },
isLoading: false,
} as any);
render(<BedtimePlot {...props} />);
expect(screen.getByText(/No data available for this period./i)).toBeInTheDocument();
});
it('renders chart and statistics correctly with data', () => {
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: {
days: [
{ date: today, bedtime_minutes: 0 }, // Exactly at ref (20:00)
{ date: yesterday, bedtime_minutes: -60 }, // 19:00
{ date: '2024-01-05', bedtime_minutes: 60 }, // 21:00
],
},
isLoading: false,
} as any);
render(<BedtimePlot {...props} />);
expect(screen.getByText(/Bedtime/i)).toBeInTheDocument();
// Verify statistical calculations
expect(screen.getByText(/19:00/i)).toBeInTheDocument();
expect(screen.getByText(/21:00/i)).toBeInTheDocument();
const timeValues = screen.getAllByText(/20:00/i);
expect(timeValues).toHaveLength(2);
});
it('renders correctly when all data points are null', () => {
const today = new Date().toISOString().split('T')[0];
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: {
days: [
{ date: today, bedtime_minutes: null },
],
},
isLoading: false,
} as any);
render(<BedtimePlot {...props} />);
expect(screen.getByText(/Bedtime/i)).toBeInTheDocument();
expect(screen.queryByText(/Earliest/i)).not.toBeInTheDocument();
});
it('verifies YAxis tickFormatter handles numeric values', () => {
const today = new Date().toISOString().split('T')[0];
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: {
days: [
{ date: today, bedtime_minutes: 30 },
],
},
isLoading: false,
} as any);
render(<BedtimePlot {...props} />);
const yAxisCall = vi.mocked(YAxis).mock.calls[0][0];
expect(yAxisCall.tickFormatter(30)).toBe('20:30');
});
it('verifies Tooltip formatter handles various value types', () => {
const today = new Date().toISOString().split('T')[0];
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: {
days: [{ date: today, bedtime_minutes: 30 }],
},
isLoading: false,
} as any);
render(<BedtimePlot {...props} />);
const tooltipCall = vi.mocked(Tooltip).mock.calls[0][0];
expect(tooltipCall.formatter(30)).toEqual(['20:30', 'Bedtime']);
expect(tooltipCall.formatter('N/A')).toEqual(['N/A', 'Bedtime']);
});
});
@@ -0,0 +1,149 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import DailySummary from '../../../../src/components/stats/DailySummary';
import { useStats } from '../../../../src/hooks/useStats';
import { XAxis, Tooltip } from 'recharts';
import { formatMinutes } from '../../../../src/utils/time';
vi.mock('../../../../src/hooks/useStats', () => ({
useStats: vi.fn(),
}));
vi.mock('recharts', async () => {
const actual = await vi.importActual<typeof import('recharts')>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) => <div data-testid="responsive-container">{children}</div>,
BarChart: ({ children }: any) => <div data-testid="bar-chart">{children}</div>,
XAxis: vi.fn(({ tickFormatter }) => <div data-testid="x-axis" data-tick-formatter={String(tickFormatter)} />),
YAxis: vi.fn(() => <div data-testid="y-axis" />),
CartesianGrid: vi.fn(),
Tooltip: vi.fn(({ formatter }) => <div data-testid="tooltip" />),
Bar: vi.fn(),
};
});
describe('DailySummary', () => {
const props = {
from_date: '2024-01-01',
to_date: '2024-01-02',
day_start: '07:00',
day_end: '20:00',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state', () => {
vi.mocked(useStats).mockReturnValue({
dailySummary: undefined,
isLoading: true,
} as any);
render(<DailySummary {...props} />);
expect(screen.getByText(/Loading summary.../i)).toBeInTheDocument();
});
it('renders empty state when dailySummary.days is undefined', () => {
vi.mocked(useStats).mockReturnValue({
dailySummary: undefined,
isLoading: false,
} as any);
render(<DailySummary {...props} />);
expect(screen.getByText(/No data available for this period./i)).toBeInTheDocument();
});
it('renders empty state when dailySummary.days is an empty array', () => {
vi.mocked(useStats).mockReturnValue({
dailySummary: { days: [] },
isLoading: false,
} as any);
render(<DailySummary {...props} />);
expect(screen.getByText(/No data available for this period./i)).toBeInTheDocument();
});
it('renders chart and average stats correctly with multiple days', () => {
vi.mocked(useStats).mockReturnValue({
dailySummary: {
days: [
{ date: '2024-01-01', total_minutes: 480, day_minutes: 240, night_minutes: 240 },
{ date: '2024-01-02', total_minutes: 540, day_minutes: 300, night_minutes: 240 },
],
},
isLoading: false,
} as any);
render(<DailySummary {...props} />);
expect(screen.getByText(/Daily Summary/i)).toBeInTheDocument();
// Totals: (480 + 540) / 2 = 510 mins -> 8h 30m
// Day: (240 + 300) / 2 = 270 mins -> 4h 30m
// Night: (240 + 240) / 2 = 240 mins -> 4h 0m
expect(screen.getByText(/8h 30m/i)).toBeInTheDocument();
expect(screen.getByText(/4h 30m/i)).toBeInTheDocument();
expect(screen.getByText(/4h 0m/i)).toBeInTheDocument();
});
it('handles single day data correctly', () => {
vi.mocked(useStats).mockReturnValue({
dailySummary: {
days: [
{ date: '2024-01-01', total_minutes: 600, day_minutes: 300, night_minutes: 300 },
],
},
isLoading: false,
} as any);
render(<DailySummary {...props} />);
expect(screen.getByText(/10h 0m/i)).toBeInTheDocument();
const avgTimes = screen.getAllByText(/5h 0m/i);
expect(avgTimes).toHaveLength(2);
});
it('uses correct formatters for XAxis and Tooltip', () => {
vi.mocked(useStats).mockReturnValue({
dailySummary: {
days: [
{ date: '2024-01-01', total_minutes: 480, day_minutes: 240, night_minutes: 240 },
],
},
isLoading: false,
} as any);
render(<DailySummary {...props} />);
// Check XAxis tickFormatter
const xAxisCall = vi.mocked(XAxis).mock.calls[0][0];
expect(xAxisCall.tickFormatter).toBeDefined();
expect(xAxisCall.tickFormatter(10)).toBe('10h');
expect(xAxisCall.tickFormatter(5.5)).toBe('6h');
// Check Tooltip formatter
const tooltipCall = vi.mocked(Tooltip).mock.calls[0][0];
expect(tooltipCall.formatter).toBeDefined();
// Test Tooltip formatter with a single number (e.g., daySleep)
const [val1, name1] = tooltipCall.formatter(8, 'daySleep');
expect(val1).toBe('8h 0m');
expect(name1).toBe('Day');
const [val2, name2] = tooltipCall.formatter(7, 'nightSleep');
expect(val2).toBe('7h 0m');
expect(name2).toBe('Night');
// Test Tooltip formatter with an array
const [val3, name3] = tooltipCall.formatter([10], 'daySleep');
expect(val3).toBe('10h 0m');
expect(name3).toBe('Day');
// Test Tooltip formatter with undefined/null
const [val4, name4] = tooltipCall.formatter(undefined, 'daySleep');
expect(val4).toBe('0m');
expect(name4).toBe('Day');
});
});
@@ -0,0 +1,98 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { Pagination as PaginationComponent } from '../../../../src/components/stats/Pagination';
describe('Pagination', () => {
const onPageChange = vi.fn();
it('returns null when totalPages <= 1', () => {
const { container } = render(
<PaginationComponent currentPage={1} totalPages={1} onPageChange={onPageChange} />
);
expect(container.firstChild).toBeNull();
});
it('renders all page numbers when totalPages <= 5', () => {
render(<PaginationComponent currentPage={2} totalPages={5} onPageChange={onPageChange} />);
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('2')).toHaveClass('bg-blue-600');
});
it('handles ellipsis correctly when currentPage is in the middle of a large set', () => {
// totalPages = 10, currentPage = 5
// Expected: [1, '...', 4, 5, 6, '...', 10]
render(<PaginationComponent currentPage={5} totalPages={10} onPageChange={onPageChange} />);
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('6')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
const allButtons = screen.getAllByRole('button');
const pageButtons = allButtons.filter(btn => !['Previous page', 'Next page'].includes(btn.getAttribute('aria-label') || ''));
expect(pageButtons).toHaveLength(5); // 1, 4, 5, 6, 10
});
it('handles large page set with ellipsis at start and end correctly', () => {
// totalPages = 10, currentPage = 2
// Expected: [1, 2, 3, '...', 10]
render(<PaginationComponent currentPage={2} totalPages={10} onPageChange={onPageChange} />);
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
const allButtons = screen.getAllByRole('button');
const pageButtons = allButtons.filter(btn => !['Previous page', 'Next page'].includes(btn.getAttribute('aria-label') || ''));
expect(pageButtons).toHaveLength(4); // 1, 2, 3, 10
});
it('handles large page set where current page is near the end', () => {
// totalPages = 10, currentPage = 9
// Expected: [1, '...', 8, 9, 10]
render(<PaginationComponent currentPage={9} totalPages={10} onPageChange={onPageChange} />);
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('8')).toBeInTheDocument();
expect(screen.getByText('9')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
const allButtons = screen.getAllByRole('button');
const pageButtons = allButtons.filter(btn => !['Previous page', 'Next page'].includes(btn.getAttribute('aria-label') || ''));
expect(pageButtons).toHaveLength(4); // 1, 8, 9, 10
});
it('calls onPageChange with correct page when a page number is clicked', () => {
render(<PaginationComponent currentPage={2} totalPages={5} onPageChange={onPageChange} />);
fireEvent.click(screen.getByText('4'));
expect(onPageChange).toHaveBeenCalledWith(4);
});
it('calls onPageChange with correct page when "Previous" is clicked', () => {
render(<PaginationComponent currentPage={2} totalPages={5} onPageChange={onPageChange} />);
fireEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(onPageChange).toHaveBeenCalledWith(1);
});
it('calls onPageChange with correct page when "Next" is clicked', () => {
render(<PaginationComponent currentPage={4} totalPages={5} onPageChange={onPageChange} />);
fireEvent.click(screen.getByRole('button', { name: /next/i }));
expect(onPageChange).toHaveBeenCalledWith(5);
});
it('disables "Previous" button on first page', () => {
render(<PaginationComponent currentPage={1} totalPages={5} onPageChange={onPageChange} />);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
});
it('disables "Next" button on last page', () => {
render(<PaginationComponent currentPage={5} totalPages={5} onPageChange={onPageChange} />);
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
});
@@ -1,55 +1,316 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SessionList } from '../../../../src/components/stats/SessionList';
import { useSessions } from '../../../../src/hooks/useSleepSession';
import { useSettings } from '../../../../src/hooks/useSettings';
vi.mock('../../../../src/hooks/useSleepSession', () => ({
useSessions: () => ({
sessions: [
{
id: 1,
start_time: '2026-06-04T11:58:00',
end_time: '2026-06-04T13:58:00',
notes: null,
created_at: '2026-06-04T11:58:00',
updated_at: '2026-06-04T11:58:00',
},
],
isLoading: false,
updateSession: vi.fn(),
deleteSession: vi.fn(),
isDeleting: false,
}),
vi.mock('../../../../src/hooks/useSleepSession');
vi.mock('../../../../src/hooks/useSettings');
// Mock Pagination component to simplify integration testing
vi.mock('../../../../src/components/stats/Pagination', () => ({
Pagination: vi.fn(({ currentPage, totalPages, onPageChange }) => (
<div data-testid="pagination-mock">
<span>{currentPage} / {totalPages}</span>
<button onClick={() => onPageChange(currentPage + 1)}>Next Page</button>
</div>
)),
}));
vi.mock('../../../../src/hooks/useSettings', () => ({
useSettings: () => ({ settings: { sessionsPerPage: 10 } }),
}));
const mockSession = {
id: 1,
start_time: '2026-06-04T11:58:00', // UTC
end_time: '2026-06-04T13:58:00', // UTC
notes: null,
created_at: '2026-06-04T11:58:00',
updated_at: '2026-06-04T11:58:00',
};
const originalTZ = process.env.TZ;
describe('SessionList', () => {
const mockUpdateSession = vi.fn();
const mockDeleteSession = vi.fn();
beforeAll(() => {
process.env.TZ = 'Europe/Stockholm';
});
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementation for useSettings
vi.mocked(useSettings).mockReturnValue({
settings: { sessionsPerPage: 10 } as any,
});
afterAll(() => {
process.env.TZ = originalTZ;
});
// Mock window.confirm and alert to prevent actual popups during tests
vi.spyOn(window, 'confirm').mockImplementation(() => true);
vi.spyOn(window, 'alert').mockImplementation(() => {});
});
describe('SessionList edit timezone', () => {
it('pre-fills edit inputs with local Stockholm time, not UTC', () => {
const { container } = render(<SessionList />);
it('displays loading state', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [],
isLoading: true,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
const clickableRow = container.querySelector('.cursor-pointer') as HTMLElement;
expect(clickableRow).not.toBeNull();
render(<SessionList />);
expect(screen.getByText(/Loading sessions.../i)).toBeInTheDocument();
});
it('displays empty state when no sessions are recorded', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
expect(screen.getByText(/No sessions recorded./i)).toBeInTheDocument();
});
it('renders session rows with correct start time and duration', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
// Check that the session row is rendered
const row = screen.getByTestId('session-row');
expect(row).toBeInTheDocument();
// Check for presence of duration (13:58 - 11:58 = 2h 0m)
// Note: formatMinutes(120) -> "2h 0m"
// Using getAllByText because duration is rendered twice (desktop and mobile views)
const durations = screen.getAllByText(/2h 0m/i);
expect(durations.length).toBeGreaterThan(0);
});
it('handles editing flow: start, cancel and save', async () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
// 1. Start Editing
const row = screen.getByTestId('session-row');
fireEvent.click(row);
// Verify edit inputs are visible
expect(screen.getByLabelText(/Start Time/i)).toBeInTheDocument();
expect(screen.getByLabelText(/End Time/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
// 2. Cancel Edit
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
expect(mockUpdateSession).not.toHaveBeenCalled();
// Should be back in view mode (row should be visible again)
expect(screen.getByTestId('session-row')).toBeInTheDocument();
// 3. Save Edit
fireEvent.click(screen.getByTestId('session-row')); // Re-enter edit mode
const startInput = screen.getByLabelText(/Start Time/i) as HTMLInputElement;
const endInput = screen.getByLabelText(/End Time/i) as HTMLInputElement;
const newStart = '2026-06-04T14:00';
const newEnd = '2026-06-04T15:00';
fireEvent.change(startInput, { target: { value: newStart } });
fireEvent.change(endInput, { target: { value: newEnd } });
fireEvent.click(screen.getByRole('button', { name: /Save/i }));
await waitFor(() => {
expect(mockUpdateSession).toHaveBeenCalledWith({
sessionId: 1,
payload: expect.objectContaining({
start_time: expect.any(String),
end_time: expect.any(String)
})
});
});
// Check if it exited edit mode (row is back)
expect(screen.getByTestId('session-row')).toBeInTheDocument();
});
it('handles deletion flow', async () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
// Clicking the delete button (Trash2 icon)
const deleteButtons = screen.getAllByTitle('Delete session');
fireEvent.click(deleteButtons[0]);
// Verify confirmation was called
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to delete this sleep session?');
// If confirmed (mockImplementation returns true)
await waitFor(() => {
expect(mockDeleteSession).toHaveBeenCalledWith(1);
});
});
it('disables delete button when isDeleting is true', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: true, // DELETING!
} as any);
render(<SessionList />);
const deleteButtons = screen.getAllByTitle('Delete session');
expect(deleteButtons[0]).toBeDisabled();
});
it('integrates with pagination when sessions exceed page size', () => {
const manySessions = Array.from({ length: 15 }, (_, i) => ({
...mockSession,
id: i + 1,
}));
vi.mocked(useSessions).mockReturnValue({
sessions: manySessions,
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
// Pagination should be rendered (using the mocked test-id)
expect(screen.getByTestId('pagination-mock')).toBeInTheDocument();
});
it('verifies timezone accuracy for Stockholm/UTC', () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
const clickableRow = screen.getByTestId('session-row');
fireEvent.click(clickableRow);
const inputs = container.querySelectorAll('input[type="datetime-local"]');
expect(inputs).toHaveLength(2);
const startTimeInput = screen.getByLabelText(/Start Time/i) as HTMLInputElement;
const endTimeInput = screen.getByLabelText(/End Time/i) as HTMLInputElement;
// Backend sends 11:58 UTC (no Z). Stockholm is UTC+2, so local time is 13:58.
expect((inputs[0] as HTMLInputElement).value).toBe('2026-06-04T13:58');
// Backend sends 13:58 UTC → Stockholm 15:58
expect((inputs[1] as HTMLInputElement).value).toBe('2026-06-04T15:58');
// Stockholm is UTC+2 in June (daylight savings).
// mockSession start_time: '2026-06-04T11:58:00' (UTC)
// Stockholm local time: 2026-06-04T13:58
expect(startTimeInput.value).toBe('2026-06-04T13:58');
expect(endTimeInput.value).toBe('2026-06-04T15:58');
});
it('handles updateSession error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: vi.fn().mockRejectedValue(new Error('Update failed')),
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
const row = screen.getByTestId('session-row');
fireEvent.click(row);
const saveButton = screen.getByRole('button', { name: /Save/i });
fireEvent.click(saveButton);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('Failed to update session');
expect(consoleSpy).toHaveBeenCalled();
});
consoleSpy.mockRestore();
alertSpy.mockRestore();
});
it('handles deleteSession error gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: vi.fn().mockRejectedValue(new Error('Delete failed')),
isDeleting: false,
} as any);
render(<SessionList />);
const deleteButtons = screen.getAllByTitle('Delete session');
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('Failed to delete session');
expect(consoleSpy).toHaveBeenCalled();
});
consoleSpy.mockRestore();
alertSpy.mockRestore();
});
it('renders in-progress session correctly (without end time)', () => {
const inProgressSession = { ...mockSession, end_time: null } as any;
vi.mocked(useSessions).mockReturnValue({
sessions: [inProgressSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
render(<SessionList />);
expect(screen.getByText(/In progress/i)).toBeInTheDocument();
expect(screen.getAllByText(/—/i).length).toBeGreaterThan(0);
});
it('handles deletion cancellation', async () => {
vi.mocked(useSessions).mockReturnValue({
sessions: [mockSession],
isLoading: false,
updateSession: mockUpdateSession,
deleteSession: mockDeleteSession,
isDeleting: false,
} as any);
vi.spyOn(window, 'confirm').mockImplementation(() => false);
render(<SessionList />);
const deleteButtons = screen.getAllByTitle('Delete session');
fireEvent.click(deleteButtons[0]);
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to delete this sleep session?');
await waitFor(() => {
expect(mockDeleteSession).not.toHaveBeenCalled();
});
});
});
@@ -1,8 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import Timetable from '../../../../src/components/stats/SleepTimetable';
import { useStats } from '../../../../src/hooks/useStats';
@@ -12,69 +11,199 @@ vi.mock('../../../../src/hooks/useStats', () => ({
describe('SleepTimetable', () => {
const defaultProps = {
from_date: '2025-04-18',
to_date: '2025-04-25',
from_date: '2025-04-18', // Friday
to_date: '2025-04-21', // Monday
day_start: '22:00',
day_end: '08:00',
};
beforeEach(() => {
vi.clearAllMocks();
// Default to desktop width
vi.stubGlobal('innerWidth', 1024);
});
it('renders loading state', () => {
(useStats as any).mockReturnValue({ timetable: null, isLoading: true });
render(<Timetable {...defaultProps} />);
expect(screen.getByText(/Loading timetable.../i)).toBeInTheDocument();
afterEach(() => {
vi.useRealTimers();
});
it('renders empty state when no data is available', () => {
(useStats as any).mockReturnValue({ timetable: { slots: [] }, isLoading: false });
render(<Timetable {...defaultProps} />);
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
});
it('shows sleeping status for intervals spanning midnight across days', () => {
const day1 = '2025-04-18';
const day2 = '2025-04-19';
(useStats as any).mockReturnValue({
timetable: {
slots: [
{
date: day1,
sleeping: true,
slot_start: '23:00',
slot_end: '23:45',
},
{
date: day2,
sleeping: true,
slot_start: '00:00',
slot_end: '01:00',
},
],
},
isLoading: false,
describe('Loading & Empty States', () => {
it('renders loading state', () => {
(useStats as any).mockReturnValue({ timetable: null, isLoading: true });
render(<Timetable {...defaultProps} />);
expect(screen.getByText(/Loading timetable.../i)).toBeInTheDocument();
});
const testProps = {
from_date: day1,
to_date: day2,
day_start: '22:00',
day_end: '08:00',
it('renders empty state when timetable.slots is empty', () => {
(useStats as any).mockReturnValue({ timetable: { slots: [] }, isLoading: false });
render(<Timetable {...defaultProps} />);
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
});
it('renders empty state when timetable is null', () => {
(useStats as any).mockReturnValue({ timetable: null, isLoading: false });
render(<Timetable {...defaultProps} />);
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
});
});
describe('Date Range Edge Cases', () => {
it('handles a single day range correctly', () => {
const singleDayProps = { ...defaultProps, from_date: '2025-04-18', to_date: '2025-04-18' };
(useStats as any).mockReturnValue({
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '10:00', slot_end: '11:00' }] },
isLoading: false
});
render(<Timetable {...singleDayProps} />);
// Check if it renders the single day header
expect(screen.getByText(/18/i)).toBeInTheDocument();
});
it('handles a large date range', () => {
const largeRangeProps = { ...defaultProps, from_date: '2025-01-01', to_date: '2025-01-31' };
(useStats as any).mockReturnValue({
timetable: { slots: [{ date: '2025-01-05', sleeping: true, slot_start: '10:00', slot_end: '11:00' }] },
isLoading: false
});
render(<Timetable {...largeRangeProps} />);
// Ensure it doesn't crash and renders the title
expect(screen.getByText(/Sleep Timetable/i)).toBeInTheDocument();
});
it('handles empty date range (start > end)', () => {
const invalidRangeProps = { ...defaultProps, from_date: '2025-04-21', to_date: '2025-04-18' };
(useStats as any).mockReturnValue({ timetable: { slots: [] }, isLoading: false });
render(<Timetable {...invalidRangeProps} />);
// Should gracefully handle empty days array (no crash) and show no data
expect(screen.getByText(/No data available/i)).toBeInTheDocument();
});
});
describe('Responsive Tiers', () => {
const mockTimetable = {
slots: [
{ date: '2025-04-18', sleeping: true, slot_start: '23:00', slot_end: '07:00' },
],
};
vi.stubGlobal('innerWidth', 1024); // Desktop mode
const { container } = render(<Timetable {...testProps} />);
it('Desktop Tier (>= 768px): shows all days', () => {
(useStats as any).mockReturnValue({ timetable: mockTimetable, isLoading: false });
vi.stubGlobal('innerWidth', 1024);
render(<Timetable {...defaultProps} />);
// Check Day 1: 23:00
const cellD1_2300 = container.querySelector(`[data-testid="cell-${day1}-23:00"]`);
expect(cellD1_2300).toHaveClass('bg-green-500');
expect(screen.getByText(/18/i)).toBeInTheDocument();
expect(screen.getByText(/19/i)).toBeInTheDocument();
expect(screen.getByText(/20/i)).toBeInTheDocument();
expect(screen.getByText(/21/i)).toBeInTheDocument();
});
// Check Day 2: 00:15
const cellD2_0015 = container.querySelector(`[data-testid="cell-${day2}-00:15"]`);
expect(cellD2_0015).toHaveClass('bg-green-500');
it('Medium Tier (400-768px): shows tab groups and updates on click', () => {
(useStats as any).mockReturnValue({ timetable: mockTimetable, isLoading: false });
vi.stubGlobal('innerWidth', 500);
const { container } = render(<Timetable {...defaultProps} />);
act(() => {
window.dispatchEvent(new Event('resize'));
});
const tabs = screen.getAllByRole('button');
expect(tabs.length).toBeGreaterThan(0);
const tab1 = tabs[0];
expect(tab1.textContent).toMatch(/19/);
expect(tab1.textContent).toMatch(/21/);
expect(tab1).toHaveClass('bg-blue-600');
const tab2 = tabs[1];
act(() => {
fireEvent.click(tab2);
});
expect(tab2).toHaveClass('bg-blue-600');
expect(container.querySelector('[data-testid^="cell-2025-04-18-"]')).toBeInTheDocument();
expect(container.querySelector('[data-testid^="cell-2025-04-21-"]')).not.toBeInTheDocument();
});
it('Small Tier (< 400px): shows single day tabs', () => {
(useStats as any).mockReturnValue({ timetable: mockTimetable, isLoading: false });
vi.stubGlobal('innerWidth', 300);
const { container } = render(<Timetable {...defaultProps} />);
act(() => {
window.dispatchEvent(new Event('resize'));
});
expect(screen.getByRole('button', { name: /21/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /20/i })).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole('button', { name: /20/i }));
});
expect(container.querySelector('[data-testid^="cell-2025-04-20-"]')).toBeInTheDocument();
expect(container.querySelector('[data-testid^="cell-2025-04-21-"]')).not.toBeInTheDocument();
});
});
});
describe('Grid Accuracy', () => {
it('renders sleeping cells correctly', () => {
const day = '2025-04-18';
(useStats as any).mockReturnValue({
timetable: {
slots: [
{ date: day, sleeping: true, slot_start: '10:00', slot_end: '10:30' }
],
},
isLoading: false,
});
vi.stubGlobal('innerWidth', 1024);
const { container } = render(<Timetable {...defaultProps} />);
expect(container.querySelector(`[data-testid="cell-${day}-10:00"]`)).toHaveClass('bg-green-500');
expect(container.querySelector(`[data-testid="cell-${day}-10:15"]`)).toHaveClass('bg-green-500');
expect(container.querySelector(`[data-testid="cell-${day}-10:30"]`)).toHaveClass('bg-gray-50');
});
it('handles midnight spanning correctly using two slots (as implemented)', () => {
const day1 = '2025-04-18';
const day2 = '2025-04-19';
(useStats as any).mockReturnValue({
timetable: {
slots: [
{ date: day1, sleeping: true, slot_start: '23:00', slot_end: '00:00' },
{ date: day2, sleeping: true, slot_start: '00:00', slot_end: '01:00' },
],
},
isLoading: false,
});
vi.stubGlobal('innerWidth', 1024);
const { container } = render(<Timetable {...defaultProps} />);
expect(container.querySelector(`[data-testid="cell-${day1}-23:00"]`)).toHaveClass('bg-green-500');
expect(container.querySelector(`[data-testid="cell-${day2}-00:00"]`)).toHaveClass('bg-green-500');
});
});
describe('Time Marker', () => {
it('renders red marker at correct position', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-04-18T12:00:00'));
(useStats as any).mockReturnValue({
timetable: { slots: [{ date: '2025-04-18', sleeping: true, slot_start: '11:00', slot_end: '13:00' }] },
isLoading: false,
});
vi.stubGlobal('innerWidth', 1024);
const { container } = render(<Timetable {...defaultProps} />);
const marker = container.querySelector('.bg-red-500');
expect(marker).toBeInTheDocument();
expect(marker).toHaveStyle({ top: '384px' });
vi.useRealTimers();
});
});
});
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import SummaryCards from '../../../../src/components/stats/SummaryCards';
describe('SummaryCards', () => {
const mockSummary = {
total_sleeps: 10,
avg_sleeps_per_day: 2.5,
total_sleeps_all_time: 100,
avg_sleeps_per_day_all_time: 2.5,
};
it('renders null when no summary is provided', () => {
const { container } = render(<SummaryCards summary={undefined} />);
expect(container.firstChild).toBeNull();
});
it('renders all summary data correctly', () => {
render(<SummaryCards summary={mockSummary} />);
expect(screen.getByText('10')).toBeInTheDocument();
const avgSleeps = screen.getAllByText('2.5');
expect(avgSleeps).toHaveLength(2);
expect(screen.getByText('100')).toBeInTheDocument();
});
it('displays correct labels', () => {
render(<SummaryCards summary={mockSummary} />);
expect(screen.getByText(/Total sleeps in period/i)).toBeInTheDocument();
expect(screen.getByText(/Avg sleeps per day in period/i)).toBeInTheDocument();
expect(screen.getByText(/Total sleeps \(all time\)/i)).toBeInTheDocument();
expect(screen.getByText(/Avg sleeps per day \(all time\)/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,122 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import WakeupPlot from '../../../../src/components/stats/WakeupPlot';
import { useStats } from '../../../../src/hooks/useStats';
import { YAxis, Tooltip } from 'recharts';
vi.mock('../../../../src/hooks/useStats', () => ({
useStats: vi.fn(),
}));
vi.mock('recharts', async () => {
const actual = await vi.importActual<typeof import('recharts')>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) => <div data-testid="responsive-container">{children}</div>,
LineChart: ({ children }: any) => <div data-testid="line-chart">{children}</div>,
XAxis: vi.fn(({ tickFormatter }) => <div data-testid="x-axis" data-tick-formatter={String(tickFormatter)} />),
YAxis: vi.fn(({ tickFormatter }) => <div data-testid="y-axis" data-tick-formatter={String(tickFormatter)} />),
CartesianGrid: vi.fn(),
Tooltip: vi.fn(({ formatter, labelFormatter }) => <div data-testid="tooltip" />),
Line: vi.fn(),
};
});
describe('WakeupPlot', () => {
const props = {
from_date: '2024-01-01',
to_date: '2024-01-07',
wakeup_ref: '07:00',
bedtime_ref: '20:00',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state', () => {
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: undefined,
isLoading: true,
} as any);
render(<WakeupPlot {...props} />);
expect(screen.getByText(/Loading plot.../i)).toBeInTheDocument();
});
it('renders empty state when no data is available', () => {
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: { days: [] },
isLoading: false,
} as any);
render(<WakeupPlot {...props} />);
expect(screen.getByText(/No data available for this period./i)).toBeInTheDocument();
});
it('renders chart and statistics correctly with data', () => {
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: {
days: [
{ date: today, wakeup_minutes: 0 }, // Today (7:00 + 0m = 07:00)
{ date: yesterday, wakeup_minutes: 60 }, // 1d ago (7:00 + 60m = 08:00)
],
},
isLoading: false,
} as any);
render(<WakeupPlot {...props} />);
expect(screen.getByText(/Wakeup Times/i)).toBeInTheDocument();
// Check stats labels exist
expect(screen.getByText(/Earliest/i)).toBeInTheDocument();
expect(screen.getByText(/Latest/i)).toBeInTheDocument();
expect(screen.getByText(/Average/i)).toBeInTheDocument();
expect(screen.getByText(/Median/i)).toBeInTheDocument();
// Verify statistical calculations
expect(screen.getByText(/07:00/i)).toBeInTheDocument(); // Earliest
expect(screen.getByText(/08:00/i)).toBeInTheDocument(); // Latest
// Since Average and Median both result in 07:30, we use getAllByText
const averageAndMedianTimes = screen.getAllByText(/07:30/i);
expect(averageAndMedianTimes).toHaveLength(2);
});
it('verifies YAxis tickFormatter handles numeric values', () => {
const today = new Date().toISOString().split('T')[0];
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: {
days: [{ date: today, wakeup_minutes: 30 }],
},
isLoading: false,
} as any);
render(<WakeupPlot {...props} />);
const yAxisCall = vi.mocked(YAxis).mock.calls[0][0];
expect(yAxisCall.tickFormatter(30)).toBe('07:30');
});
it('verifies Tooltip formatter handles various value types', () => {
const today = new Date().toISOString().split('T')[0];
vi.mocked(useStats).mockReturnValue({
wakeupBedtime: {
days: [{ date: today, wakeup_minutes: 30 }],
},
isLoading: false,
} as any);
render(<WakeupPlot {...props} />);
const tooltipCall = vi.mocked(Tooltip).mock.calls[0][0];
expect(tooltipCall.formatter(30)).toEqual(['07:30', 'Wakeup']);
expect(tooltipCall.formatter('N/A')).toEqual(['N/A', 'Wakeup']);
});
});
@@ -0,0 +1,119 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import ClockPicker from '../../../../src/components/tracker/ClockPicker';
describe('ClockPicker', () => {
const onChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders initial hour and minute correctly', () => {
render(<ClockPicker hour={10} minute={30} onChange={onChange} />);
const hourButton = screen.getByRole('button', { name: /hour/i });
const minuteButton = screen.getByRole('button', { name: /minute/i });
expect(hourButton).toHaveClass('bg-white');
expect(minuteButton).toHaveClass('text-gray-500');
});
it('switches between hour and minute mode via buttons', () => {
render(<ClockPicker hour={10} minute={30} onChange={onChange} />);
const minuteButton = screen.getByRole('button', { name: /minute/i });
fireEvent.click(minuteButton);
expect(minuteButton).toHaveClass('bg-white');
expect(screen.getByRole('button', { name: /hour/i })).toHaveClass('text-gray-500');
});
it('automatically switches to minute mode after selecting an hour', async () => {
render(<ClockPicker hour={10} minute={30} onChange={onChange} />);
const dial = screen.getByRole('button', { name: /hour/i }).parentElement?.nextElementSibling as HTMLElement;
// Mock getBoundingClientRect to simulate a center point
const rect = { left: 0, top: 0, width: 256, height: 256 };
vi.spyOn(dial, 'getBoundingClientRect').mockReturnValue(rect as DOMRect);
// Click at the very top (should be 0:00)
// Angle for 0 is 90 degrees in the component logic.
// Center is (128, 128). Top point is (128, 0).
// x = 0, y = -128. atan2(-128, 0) = -90 deg. + 90 = 0 deg.
fireEvent.click(dial, { clientX: 128, clientY: 0 });
expect(onChange).toHaveBeenCalledWith(0, 30);
// Fast-forward timers for the setTimeout in NumberDialPicker
act(() => {
vi.runAllTimers();
});
// Verify mode changed to minute
expect(screen.getByRole('button', { name: /minute/i })).toHaveClass('bg-white');
act(() => {
vi.runAllTimers();
});
// Verify mode changed to minute
expect(screen.getByRole('button', { name: /minute/i })).toHaveClass('bg-white');
vi.runAllTimers();
// Verify mode changed to minute
expect(screen.getByRole('button', { name: /minute/i })).toHaveClass('bg-white');
});
it('updates the correct value based on current mode', () => {
const { rerender } = render(<ClockPicker hour={10} minute={30} onChange={onChange} />);
const dial = screen.getByRole('button', { name: /hour/i }).parentElement?.nextElementSibling as HTMLElement;
vi.spyOn(dial, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, width: 256, height: 256 } as DOMRect);
// 1. Test Hour Update (Mode is Hour)
// Click at the right side (90 degrees in math).
// Center is (128, 128). Right point is (256, 128).
// x = 128, y = 0. atan2(0, 128) = 0 deg. + 90 = 90 deg.
// Step for hours is 360/24 = 15. 90/15 = index 6.
fireEvent.click(dial, { clientX: 256, clientY: 128 });
expect(onChange).toHaveBeenCalledWith(6, 30);
vi.clearAllMocks();
// 2. Test Minute Update (Switch to minute mode)
fireEvent.click(screen.getByRole('button', { name: /minute/i }));
// Click at the bottom (180 degrees in math).
// Center is (128, 128). Bottom point is (128, 256).
// x = 0, y = 128. atan2(128, 0) = 90 deg. + 90 = 180 deg.
// Step for minutes is 360/60 = 6. 180/6 = index 30.
fireEvent.click(dial, { clientX: 128, clientY: 256 });
expect(onChange).toHaveBeenCalledWith(10, 30); // Wait, the call should be (hour, val)
// Re-checking implementation: handleNumberChange calls handleTimeChange(hour, val) when mode is minute
// Since initial hour was 10:
expect(onChange).toHaveBeenLastCalledWith(10, 30);
});
it('handles circular rotation correctly (angle < 0)', () => {
render(<ClockPicker hour={10} minute={30} onChange={onChange} />);
const dial = screen.getByRole('button', { name: /hour/i }).parentElement?.nextElementSibling as HTMLElement;
vi.spyOn(dial, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, width: 256, height: 256 } as DOMRect);
// Click slightly "behind" the start (e.g., 350 degrees)
// Center (128, 128). Point slightly above center-left.
// x = -10, y = -128. atan2(-128, -10) is approx -94 deg. + 90 = -4 deg.
// Code handles angle < 0 by adding 360 -> 356 deg.
fireEvent.click(dial, { clientX: 118, clientY: 0 });
// Verify it didn't crash and called onChange with a valid index
expect(onChange).toHaveBeenCalled();
const lastCall = vi.mocked(onChange).mock.calls[0][0];
expect(lastCall).toBeGreaterThanOrEqual(0);
expect(lastCall).toBeLessThan(24);
});
});
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import TimeAdjuster from '../../../../src/components/tracker/TimeAdjuster';
// Mock time utils to avoid timezone issues in tests
vi.mock('../../../src/utils/time', () => ({
toLocalDatetimeInputValue: vi.fn((d) => d.toISOString().slice(0, 16)),
toUTCFromLocalInput: vi.fn((val) => val), // simple identity for testing
}));
describe('TimeAdjuster', () => {
const onChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders with default value if provided', () => {
const defaultValue = '2024-01-01T12:00';
render(<TimeAdjuster onTimeChange={onChange} defaultValue={defaultValue} />);
const input = screen.getByLabelText(/Time/i) as HTMLInputElement;
expect(input.value).toBe(defaultValue);
});
it('calls onTimeChange when input value changes', async () => {
render(<TimeAdjuster onTimeChange={onChange} />);
const input = screen.getByLabelText(/Time/i) as HTMLInputElement;
fireEvent.change(input, { target: { value: '2024-01-01T13:00' } });
expect(onChange).toHaveBeenLastCalledWith('2024-01-01T12:00:00.000Z');
});
it('resets to now when reset button is clicked', () => {
const mockNow = new Date('2024-01-01T10:00:00');
vi.useFakeTimers();
vi.setSystemTime(mockNow);
render(<TimeAdjuster onTimeChange={onChange} />);
const resetButton = screen.getByText(/Reset to now/i);
fireEvent.click(resetButton);
const input = screen.getByLabelText(/Time/i) as HTMLInputElement;
// The component calls toLocalDatetimeInputValue(new Date())
// Our mock returns the iso string slice.
expect(input.value).toBe('2024-01-01T10:00');
expect(onChange).toHaveBeenCalled();
vi.useRealTimers();
});
});