A try of overhauling the frontend tests, bailed after 24h+
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user