A try of overhauling the frontend tests, bailed after 24h+
This commit is contained in:
@@ -1,83 +1,116 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import SleepButton from '../../src/components/tracker/SleepButton';
|
||||
import { useSleepSession } from '../../src/hooks/useSleepSession';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { renderWithProviders } from '../test-utils';
|
||||
|
||||
vi.mock('../../src/hooks/useSleepSession', () => ({
|
||||
useSleepSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
describe('SleepButton', () => {
|
||||
let queryClient: any;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders "Start Sleep" when no active session', async () => {
|
||||
(useSleepSession as any).mockReturnValue({
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: null,
|
||||
isCreating: false,
|
||||
isEnding: false,
|
||||
});
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<SleepButton time="2024-01-01T12:00" />);
|
||||
expect(screen.getByRole('button', { name: /Start Sleep/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Stop Sleep" when session is active', async () => {
|
||||
(useSleepSession as any).mockReturnValue({
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: { id: 1, start_time: '2024-01-01T12:00' },
|
||||
isCreating: false,
|
||||
isEnding: false,
|
||||
});
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<SleepButton time="2024-01-01T12:00" />);
|
||||
expect(screen.getByRole('button', { name: /Stop Sleep/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls createSession when no active session and button is clicked', async () => {
|
||||
const createSessionMock = vi.fn();
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: null,
|
||||
isCreating: false,
|
||||
isEnding: false,
|
||||
createSession: createSessionMock,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<SleepButton time="2024-01-01T12:00" />);
|
||||
const button = screen.getByRole('button', { name: /Start Sleep/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(createSessionMock).toHaveBeenCalledWith({ start_time: '2024-01-01T12:00' }));
|
||||
});
|
||||
|
||||
it('calls endSession when session is active and button is clicked', async () => {
|
||||
const endSessionMock = vi.fn();
|
||||
(useSleepSession as any).mockReturnValue({
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: { id: 1, start_time: '2024-01-01T12:00' },
|
||||
isCreating: false,
|
||||
isEnding: false,
|
||||
endSession: endSessionMock,
|
||||
});
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<SleepButton time="2024-01-01T14:00" />);
|
||||
const button = screen.getByRole('button', { name: /Stop Sleep/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(endSessionMock).toHaveBeenCalled());
|
||||
await waitFor(() => expect(endSessionMock).toHaveBeenCalledWith({ sessionId: 1, endTime: '2024-01-01T14:00' }));
|
||||
});
|
||||
|
||||
it('handles endSession error gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const endSessionMock = vi.fn().mockRejectedValue(new Error('Failed to stop'));
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: { id: 1, start_time: '2024-01-01T12:00' },
|
||||
isCreating: false,
|
||||
isEnding: false,
|
||||
endSession: endSessionMock,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<SleepButton time="2024-01-01T14:00" />);
|
||||
const button = screen.getByRole('button', { name: /Stop Sleep/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('Failed to update sleep session:', 'Failed to stop'));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles createSession error gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const createSessionMock = vi.fn().mockRejectedValue(new Error('Failed to start'));
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: null,
|
||||
isCreating: false,
|
||||
isEnding: false,
|
||||
createSession: createSessionMock,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<SleepButton time="2024-01-01T14:00" />);
|
||||
const button = screen.getByRole('button', { name: /Start Sleep/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('Failed to update sleep session:', 'Failed to start'));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('shows alert when time is invalid', async () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
(useSleepSession as any).mockReturnValue({
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: null,
|
||||
isCreating: false,
|
||||
isEnding: false,
|
||||
});
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<SleepButton time="" />);
|
||||
const button = screen.getByRole('button', { name: /Start Sleep/i });
|
||||
@@ -86,4 +119,32 @@ describe('SleepButton', () => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('Please select a valid time.');
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders "Processing..." and disables button when isCreating is true', async () => {
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: null,
|
||||
isCreating: true,
|
||||
isEnding: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<SleepButton time="2024-01-01T12:00" />);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByText(/Processing.../i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Processing..." and disables button when isEnding is true', async () => {
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: { id: 1, start_time: '2024-01-01T12:00' },
|
||||
isCreating: false,
|
||||
isEnding: true,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(<SleepButton time="2024-01-01T14:00" />);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByText(/Processing.../i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,83 +1,86 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import StatusBanner from '../../src/components/tracker/StatusBanner';
|
||||
import { useSleepSession } from '../../src/hooks/useSleepSession';
|
||||
import { formatElapsedTime } from '../../src/utils/time';
|
||||
|
||||
// Mock the hook
|
||||
vi.mock('../../src/hooks/useSleepSession', () => ({
|
||||
useSleepSession: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('StatusBanner - Timezone Fix', () => {
|
||||
describe('StatusBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('formatElapsedTime', () => {
|
||||
it('should show correct elapsed time for session started ~49 minutes ago', () => {
|
||||
vi.setSystemTime(new Date('2026-04-24T08:51:00Z'));
|
||||
it('renders loading state when isLoading is true', () => {
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: null,
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
const result = formatElapsedTime('2026-04-24T08:02:00');
|
||||
|
||||
expect(result).toBe('49m');
|
||||
});
|
||||
|
||||
it('should show hours and minutes for longer sessions', () => {
|
||||
vi.setSystemTime(new Date('2026-04-24T10:02:00Z'));
|
||||
|
||||
const result = formatElapsedTime('2026-04-24T08:02:00');
|
||||
|
||||
expect(result).toBe('2h 0m');
|
||||
});
|
||||
|
||||
it('should show minutes only for sessions under an hour', () => {
|
||||
vi.setSystemTime(new Date('2026-04-24T08:07:00Z'));
|
||||
|
||||
const result = formatElapsedTime('2026-04-24T08:02:00');
|
||||
|
||||
expect(result).toBe('5m');
|
||||
});
|
||||
render(<StatusBanner />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('StatusBanner display', () => {
|
||||
it('shows correct elapsed time for active session', () => {
|
||||
vi.setSystemTime(new Date('2026-04-24T08:51:00Z'));
|
||||
it('renders sleeping state when an active session exists', () => {
|
||||
const startTime = '2026-06-10T10:00:00Z';
|
||||
// Set "now" to 1 hour and 30 minutes after start_time
|
||||
const now = new Date('2026-06-10T11:30:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: {
|
||||
id: 4,
|
||||
start_time: '2026-04-24T08:02:00',
|
||||
end_time: null,
|
||||
notes: null,
|
||||
created_at: '2026-04-24T08:02:28.942902',
|
||||
updated_at: '2026-04-24T08:02:28.942927',
|
||||
},
|
||||
isLoading: false,
|
||||
} as any);
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: { start_time: startTime },
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<StatusBanner />);
|
||||
render(<StatusBanner />);
|
||||
|
||||
expect(screen.getByText(/Baby is sleeping/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\(49m\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Baby is sleeping')).toBeInTheDocument();
|
||||
expect(screen.getByText(/😴/)).toBeInTheDocument();
|
||||
// formatElapsedTime logic: diff is 90m -> "1h 30m"
|
||||
expect(screen.getByText('(1h 30m)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates elapsed time as time passes', async () => {
|
||||
const startTime = '2026-06-10T10:00:00Z';
|
||||
// Start at 5 minutes after
|
||||
const now = new Date('2026-06-10T10:05:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: { start_time: startTime },
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<StatusBanner />);
|
||||
|
||||
expect(screen.getByText('(5m)')).toBeInTheDocument();
|
||||
|
||||
// Advance time by 10 minutes
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('shows awake state when no active session', () => {
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: null,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
// Now it should be 15m
|
||||
expect(screen.getByText('(15m)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
render(<StatusBanner />);
|
||||
it('renders awake state when no active session and not loading', () => {
|
||||
vi.mocked(useSleepSession).mockReturnValue({
|
||||
activeSession: null,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
expect(screen.getByText(/Baby is awake/)).toBeInTheDocument();
|
||||
});
|
||||
render(<StatusBanner />);
|
||||
|
||||
expect(screen.getByText('Baby is awake')).toBeInTheDocument();
|
||||
expect(screen.getByText(/👶/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,108 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import TimeAdjuster from '../../src/components/tracker/TimeAdjuster';
|
||||
import { toLocalDatetimeInputValue } from '../../src/utils/time';
|
||||
|
||||
describe('TimeAdjuster', () => {
|
||||
it('renders with default value', () => {
|
||||
const { container } = render(<TimeAdjuster onTimeChange={() => {}} />);
|
||||
const input = container.querySelector('input');
|
||||
expect(input).toBeInTheDocument();
|
||||
const MOCK_NOW = new Date('2024-06-10T12:00:00Z');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(MOCK_NOW);
|
||||
});
|
||||
|
||||
it('calls onTimeChange when value changes', () => {
|
||||
const handleChange = vi.fn();
|
||||
const { container } = render(<TimeAdjuster onTimeChange={handleChange} />);
|
||||
const input = container.querySelector('input');
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
|
||||
fireEvent.change(input!, { target: { value: '2024-01-01T12:00' } });
|
||||
expect(handleChange).toHaveBeenCalledWith(new Date('2024-01-01T12:00').toISOString());
|
||||
});
|
||||
|
||||
it('resets to now when reset button is clicked', async () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TimeAdjuster onTimeChange={handleChange} />);
|
||||
it('initializes with the current time when no defaultValue is provided', () => {
|
||||
render(<TimeAdjuster onTimeChange={() => {}} />);
|
||||
const input = screen.getByLabelText(/Time/i) as HTMLInputElement;
|
||||
|
||||
// The value should be the local format of MOCK_NOW
|
||||
expect(input.value).toBe(toLocalDatetimeInputValue(MOCK_NOW));
|
||||
});
|
||||
|
||||
it('initializes with the provided defaultValue converted to local format', () => {
|
||||
const defaultValue = '2024-01-01T10:00:00Z';
|
||||
const expectedLocal = toLocalDatetimeInputValue(new Date(defaultValue));
|
||||
|
||||
render(<TimeAdjuster onTimeChange={() => {}} defaultValue={defaultValue} />);
|
||||
const input = screen.getByLabelText(/Time/i) as HTMLInputElement;
|
||||
|
||||
expect(input.value).toBe(expectedLocal);
|
||||
});
|
||||
|
||||
it('calls onTimeChange with UTC time when manual value is changed via input', async () => {
|
||||
const onTimeChange = vi.fn();
|
||||
render(<TimeAdjuster onTimeChange={onTimeChange} />);
|
||||
const input = screen.getByLabelText(/Time/i) as HTMLInputElement;
|
||||
|
||||
// Change to a specific local time
|
||||
const newLocalTime = '2024-06-11T15:45';
|
||||
fireEvent.change(input, { target: { value: newLocalTime } });
|
||||
|
||||
// onTimeChange should be called with the UTC equivalent
|
||||
// We need to be careful about how Date(newLocalTime) interprets it.
|
||||
// Without timezone, it's usually treated as local time.
|
||||
const expectedUTC = new Date(newLocalTime).toISOString();
|
||||
|
||||
// Since onTimeChange is in a useEffect, we might need to wait or use vi.runAllTimers()
|
||||
// But standard React state updates + useEffect should be synchronous enough for fireEvent in Vitest
|
||||
expect(onTimeChange).toHaveBeenCalledWith(expectedUTC);
|
||||
});
|
||||
|
||||
it('resets to current time when "Reset to now" button is clicked', () => {
|
||||
const onTimeChange = vi.fn();
|
||||
render(<TimeAdjuster onTimeChange={onTimeChange} />);
|
||||
const input = screen.getByLabelText(/Time/i) as HTMLInputElement;
|
||||
|
||||
// 1. Change value to something else
|
||||
const differentTime = '2023-01-01T00:00';
|
||||
fireEvent.change(input, { target: { value: differentTime } });
|
||||
expect(input.value).toBe(differentTime);
|
||||
|
||||
// 2. Click reset
|
||||
const resetButton = screen.getByText(/Reset to now/i);
|
||||
|
||||
fireEvent.click(resetButton);
|
||||
expect(handleChange).toHaveBeenCalled();
|
||||
|
||||
// 3. Verify input is back to MOCK_NOW's local format
|
||||
expect(input.value).toBe(toLocalDatetimeInputValue(MOCK_NOW));
|
||||
|
||||
// 4. Verify onTimeChange was called with MOCK_NOW's UTC format
|
||||
expect(onTimeChange).toHaveBeenLastCalledWith(MOCK_NOW.toISOString());
|
||||
});
|
||||
|
||||
it('handles invalid date input gracefully (returns empty string)', async () => {
|
||||
// Since we changed the implementation, it should no longer throw.
|
||||
// We can't easily test the useEffect error anymore because it doesn't throw,
|
||||
// but we can check if onTimeChange is called with "" for invalid input.
|
||||
const handleChange = vi.fn();
|
||||
const { container } = render(<TimeAdjuster onTimeChange={handleChange} />);
|
||||
const input = container.querySelector('input');
|
||||
it('handles invalid date input gracefully by calling onTimeChange with an empty string', async () => {
|
||||
const onTimeChange = vi.fn();
|
||||
render(<TimeAdjuster onTimeChange={onTimeChange} />);
|
||||
const input = screen.getByLabelText(/Time/i) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input!, { target: { value: '2026-06-0' } });
|
||||
// We might need to wait for the effect
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(handleChange).toHaveBeenCalledWith("");
|
||||
// Simulate invalid input (though datetime-local usually prevents this via UI,
|
||||
// we test the logic in toUTCFromLocalInput)
|
||||
fireEvent.change(input, { target: { value: 'not-a-date' } });
|
||||
|
||||
// Wait for useEffect
|
||||
await vi.waitFor(() => {
|
||||
expect(onTimeChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onTimeChange if the input value is same as current (initialization check)', () => {
|
||||
// The component has an effect on [value, onTimeChange].
|
||||
// On mount, it will call onTimeChange because value is set in useState.
|
||||
const onTimeChange = vi.fn();
|
||||
render(<TimeAdjuster onTimeChange={onTimeChange} />);
|
||||
|
||||
// Should be called once on mount due to useEffect [value]
|
||||
expect(onTimeChange).toHaveBeenCalledTimes(1);
|
||||
expect(onTimeChange).toHaveBeenCalledWith(MOCK_NOW.toISOString());
|
||||
});
|
||||
|
||||
it('does not call onTimeChange if it is not provided (edge case for coverage)', () => {
|
||||
// @ts-ignore - testing the branch where onTimeChange might be undefined
|
||||
render(<TimeAdjuster onTimeChange={undefined} />);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as client from '../../../src/api/client';
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
describe('utility functions', () => {
|
||||
it('localHHMMToUTCHHMM converts correctly (assuming UTC+0 for simplicity in test, or handling offset)', () => {
|
||||
const result = client.localHHMMToUTCHHMM('12:00');
|
||||
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('localToUTC converts to ISO string', () => {
|
||||
const dateStr = '2026-01-01T12:00';
|
||||
const result = client.localToUTC(dateStr);
|
||||
expect(result).toBe(new Date(dateStr).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch functions', () => {
|
||||
describe('fetchSummary', () => {
|
||||
it('returns data on success', async () => {
|
||||
const mockData = { total: 10 };
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
} as any);
|
||||
|
||||
const result = await client.fetchSummary('2026-01-01', '2026-01-02');
|
||||
expect(result).toEqual(mockData);
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/stats/summary'));
|
||||
});
|
||||
|
||||
it('throws error on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
await expect(client.fetchSummary('2026-01-01', '2026-01-02')).rejects.toThrow('Failed to fetch summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDailySummary', () => {
|
||||
it('returns data on success and verifies query params', async () => {
|
||||
const mockData = { some: 'data' } as any;
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
} as any);
|
||||
|
||||
const from_date = '2026-01-01';
|
||||
const to_date = '2026-01-02';
|
||||
const day_start = '22:00';
|
||||
const day_end = '08:00';
|
||||
const timezone_offset = -360;
|
||||
|
||||
const result = await client.fetchDailySummary(from_date, to_date, day_start, day_end, timezone_offset);
|
||||
expect(result).toEqual(mockData);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
from_date,
|
||||
to_date,
|
||||
day_start,
|
||||
day_end,
|
||||
timezone_offset: timezone_offset.toString()
|
||||
});
|
||||
const expectedUrl = `/api/stats/daily-summary?${params.toString()}`;
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining(expectedUrl));
|
||||
});
|
||||
|
||||
it('throws error on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
await expect(client.fetchDailySummary('2026-01-01', '2026-01-02', '22:00', '08:00')).rejects.toThrow('Failed to fetch daily-summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTimetable', () => {
|
||||
it('returns data on success and verifies query params', async () => {
|
||||
const mockData = { some: 'timetable' } as any;
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
} as any);
|
||||
|
||||
const from_date = '2026-01-01';
|
||||
const to_date = '2026-01-02';
|
||||
const day_start = '22:00';
|
||||
const day_end = '08:00';
|
||||
const timezone_offset = 0;
|
||||
|
||||
const result = await client.fetchTimetable(from_date, to_date, day_start, day_end, timezone_offset);
|
||||
expect(result).toEqual(mockData);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
from_date,
|
||||
to_date,
|
||||
day_start,
|
||||
day_end,
|
||||
timezone_offset: timezone_offset.toString()
|
||||
});
|
||||
const expectedUrl = `/api/stats/timetable?${params.toString()}`;
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining(expectedUrl));
|
||||
});
|
||||
|
||||
it('throws error on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
await expect(client.fetchTimetable('2026-01-01', '2026-01-02', '22:00', '08:00')).rejects.toThrow('Failed to fetch timetable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWakeSleepTimes', () => {
|
||||
it('returns data on success and transforms time correctly', async () => {
|
||||
const mockData = { wakeup_ref: '06:30', bedtime_ref: '21:00' };
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
} as any);
|
||||
|
||||
const offsetSpy = vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-60);
|
||||
|
||||
try {
|
||||
const result = await client.fetchWakeSleepTimes('2026-01-01', '2026-01-02', '07:30', '22:00', 0);
|
||||
expect(result).toEqual(mockData);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('from_date=2026-01-01&to_date=2026-01-02&wakeup_ref=06%3A30&bedtime_ref=21%3A00&timezone_offset=0')
|
||||
);
|
||||
} finally {
|
||||
offsetSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws error on failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
await expect(client.fetchWakeSleepTimes('2026-01-01', '2026-01-02', '07:30', '22:00')).rejects.toThrow('Failed to fetch wake-sleep-times');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchActiveSession', () => {
|
||||
it('returns session on success', async () => {
|
||||
const mockSession = { id: 1, start_time: '2026-01-01T12:00', end_time: null };
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockSession,
|
||||
} as any);
|
||||
|
||||
const result = await client.fetchActiveSession();
|
||||
expect(result).toEqual(mockSession);
|
||||
});
|
||||
|
||||
it('returns null on 404/not-ok', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
const result = await client.fetchActiveSession();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSessions', () => {
|
||||
it('returns session list on success', async () => {
|
||||
const mockResponse = { sessions: [{ id: 1, start_time: '2026-01-01T12:00' }] };
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as any);
|
||||
|
||||
const result = await client.fetchSessions();
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/sessions'));
|
||||
});
|
||||
|
||||
it('throws error on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
await expect(client.fetchSessions()).rejects.toThrow('Failed to fetch sessions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session mutations', () => {
|
||||
it('createSession sends POST request with body', async () => {
|
||||
const payload = { start_time: '2026-01-01T12:00' };
|
||||
const mockResponse = { id: 1, ...payload };
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as any);
|
||||
|
||||
const result = await client.createSession(payload as any);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/sessions'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('createSession throws error on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
await expect(client.createSession({ start_time: '2026-01-01T12:00' } as any)).rejects.toThrow('Failed to create session');
|
||||
});
|
||||
|
||||
it('endSession sends PATCH request', async () => {
|
||||
const sessionId = 1;
|
||||
const payload = { end_time: '2026-01-01T14:00' };
|
||||
const mockResponse = { id: 1, ...payload };
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as any);
|
||||
|
||||
const result = await client.endSession(sessionId, payload as any);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/api/sessions/${sessionId}/end`),
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('endSession throws error on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
await expect(client.endSession(1, { end_time: '2026-01-01T14:00' } as any)).rejects.toThrow('Failed to end session');
|
||||
});
|
||||
|
||||
it('deleteSession sends DELETE request', async () => {
|
||||
const sessionId = 1;
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
} as any);
|
||||
|
||||
await client.deleteSession(sessionId);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/api/sessions/${sessionId}`),
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
);
|
||||
});
|
||||
|
||||
it('deleteSession throws error on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
await expect(client.deleteSession(1)).rejects.toThrow('Failed to delete session');
|
||||
});
|
||||
|
||||
it('updateSession sends PATCH request', async () => {
|
||||
const sessionId = 1;
|
||||
const payload = { start_time: '2026-01-01T10:00' };
|
||||
const mockResponse = { id: 1, ...payload };
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as any);
|
||||
|
||||
const result = await client.updateSession(sessionId, payload as any);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/api/sessions/${sessionId}`),
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('updateSession throws error on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
} as any);
|
||||
|
||||
await expect(client.updateSession(1, { start_time: '2026-01-01T10:00' } as any)).rejects.toThrow('Failed to update session');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,11 @@ describe('buildTimetableSlots', () => {
|
||||
expect(result['2024-01-15']).toHaveLength(2);
|
||||
expect(result['2024-01-16']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const result = buildTimetableSlots([]);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeWakeupMinutes', () => {
|
||||
@@ -41,6 +46,26 @@ describe('computeWakeupMinutes', () => {
|
||||
const result = computeWakeupMinutes(days, '04:00');
|
||||
expect(result[0].time).toBe('N/A');
|
||||
});
|
||||
|
||||
it('handles midnight crossover', () => {
|
||||
const days: WakeupBedtimeDay[] = [
|
||||
{ date: '2024-01-15', wakeup_minutes: 60, bedtime_minutes: null },
|
||||
];
|
||||
const result = computeWakeupMinutes(days, '23:00');
|
||||
expect(result[0].time).toBe('00:00');
|
||||
});
|
||||
|
||||
it('handles mixed data (valid, null, zero)', () => {
|
||||
const days: WakeupBedtimeDay[] = [
|
||||
{ date: '2024-01-15', wakeup_minutes: 120, bedtime_minutes: null },
|
||||
{ date: '2024-01-16', wakeup_minutes: null, bedtime_minutes: null },
|
||||
{ date: '2024-01-17', wakeup_minutes: 0, bedtime_minutes: null },
|
||||
];
|
||||
const result = computeWakeupMinutes(days, '04:00');
|
||||
expect(result[0]).toEqual({ date: '2024-01-15', minutes: 120, time: '06:00' });
|
||||
expect(result[1]).toEqual({ date: '2024-01-16', minutes: 0, time: 'N/A' });
|
||||
expect(result[2]).toEqual({ date: '2024-01-17', minutes: 0, time: '04:00' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeBedtimeMinutes', () => {
|
||||
@@ -51,6 +76,26 @@ describe('computeBedtimeMinutes', () => {
|
||||
const result = computeBedtimeMinutes(days, '19:00');
|
||||
expect(result[0].time).toBe('20:15');
|
||||
});
|
||||
|
||||
it('handles midnight crossover', () => {
|
||||
const days: WakeupBedtimeDay[] = [
|
||||
{ date: '2024-01-15', wakeup_minutes: null, bedtime_minutes: 60 },
|
||||
];
|
||||
const result = computeBedtimeMinutes(days, '23:00');
|
||||
expect(result[0].time).toBe('00:00');
|
||||
});
|
||||
|
||||
it('handles mixed data (valid, null, zero)', () => {
|
||||
const days: WakeupBedtimeDay[] = [
|
||||
{ date: '2024-01-15', wakeup_minutes: null, bedtime_minutes: 120 },
|
||||
{ date: '2024-01-16', wakeup_minutes: null, bedtime_minutes: null },
|
||||
{ date: '2024-01-17', wakeup_minutes: null, bedtime_minutes: 0 },
|
||||
];
|
||||
const result = computeBedtimeMinutes(days, '19:00');
|
||||
expect(result[0]).toEqual({ date: '2024-01-15', minutes: 120, time: '21:00' });
|
||||
expect(result[1]).toEqual({ date: '2024-01-16', minutes: 0, time: 'N/A' });
|
||||
expect(result[2]).toEqual({ date: '2024-01-17', minutes: 0, time: '19:00' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeDailySummaryData', () => {
|
||||
@@ -110,4 +155,4 @@ describe('filterDaysWithData', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].date).toBe('2024-01-15');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useSettings } from '../../../src/hooks/useSettings';
|
||||
import { SettingsProvider } from '../../../src/hooks/SettingsProvider';
|
||||
|
||||
describe('useSettings hook', () => {
|
||||
// Mock implementation of localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
})();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', localStorageMock);
|
||||
vi.clearAllMocks();
|
||||
// Clear the mock store
|
||||
(localStorage as any).clear();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SettingsProvider>{children}</SettingsProvider>
|
||||
);
|
||||
|
||||
it('should return default settings when wrapped in SettingsProvider', () => {
|
||||
const { result } = renderHook(() => useSettings(), { wrapper });
|
||||
|
||||
expect(result.current.settings).toEqual({
|
||||
dayStart: '07:00',
|
||||
dayEnd: '20:00',
|
||||
wakeupRef: '04:00',
|
||||
bedtimeRef: '19:00',
|
||||
statsInterval: 7,
|
||||
sessionsPerPage: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error when used outside of SettingsProvider', () => {
|
||||
expect(() => renderHook(() => useSettings())).toThrow(
|
||||
'useSettings must be used within a SettingsProvider'
|
||||
);
|
||||
});
|
||||
|
||||
it('should update settings correctly via the update function', () => {
|
||||
const { result } = renderHook(() => useSettings(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.update('dayStart', '08:30');
|
||||
});
|
||||
|
||||
expect(result.current.settings.dayStart).toBe('08:30');
|
||||
// Verify other settings remain unchanged
|
||||
expect(result.current.settings.dayEnd).toBe('20:00');
|
||||
});
|
||||
|
||||
it('should persist updated settings to localStorage', () => {
|
||||
const setItemSpy = vi.spyOn(localStorage, 'setItem');
|
||||
const { result } = renderHook(() => useSettings(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.update('statsInterval', 14);
|
||||
});
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(
|
||||
'baby-sleep-settings',
|
||||
expect.stringContaining('"statsInterval":14')
|
||||
);
|
||||
});
|
||||
|
||||
it('should initialize with settings from localStorage if available', () => {
|
||||
const storedSettings = {
|
||||
dayStart: '10:00',
|
||||
dayEnd: '22:00',
|
||||
wakeupRef: '06:00',
|
||||
bedtimeRef: '21:00',
|
||||
statsInterval: 5,
|
||||
sessionsPerPage: 20,
|
||||
};
|
||||
localStorage.setItem('baby-sleep-settings', JSON.stringify(storedSettings));
|
||||
|
||||
const { result } = renderHook(() => useSettings(), { wrapper });
|
||||
|
||||
expect(result.current.settings).toEqual(storedSettings);
|
||||
});
|
||||
|
||||
it('should fallback to default settings if localStorage contains invalid JSON', () => {
|
||||
localStorage.setItem('baby-sleep-settings', 'invalid-json');
|
||||
|
||||
const { result } = renderHook(() => useSettings(), { wrapper });
|
||||
|
||||
expect(result.current.settings).toEqual({
|
||||
dayStart: '07:00',
|
||||
dayEnd: '20:00',
|
||||
wakeupRef: '04:00',
|
||||
bedtimeRef: '19:00',
|
||||
statsInterval: 7,
|
||||
sessionsPerPage: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty localStorage by using default settings', () => {
|
||||
localStorage.clear();
|
||||
const { result } = renderHook(() => useSettings(), { wrapper });
|
||||
expect(result.current.settings).toEqual({
|
||||
dayStart: '07:00',
|
||||
dayEnd: '20:00',
|
||||
wakeupRef: '04:00',
|
||||
bedtimeRef: '19:00',
|
||||
statsInterval: 7,
|
||||
sessionsPerPage: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useSleepSession, useSessions } from '../../../src/hooks/useSleepSession';
|
||||
import * as api from '../../../src/api/client';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { SleepSessionRead, SessionListResponse } from '../../../src/types';
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('../../../src/api/client', () => ({
|
||||
fetchActiveSession: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
endSession: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
updateSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
vi.mock('../../../src/api/client', () => ({
|
||||
fetchActiveSession: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
endSession: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
updateSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock React Query as requested
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>();
|
||||
return {
|
||||
...actual,
|
||||
useQuery: vi.fn(),
|
||||
useMutation: vi.fn(),
|
||||
useQueryClient: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useSleepSession and useSessions hooks', () => {
|
||||
const mockInvalidateQueries = vi.fn();
|
||||
const mockQueryClient = { invalidateQueries: mockInvalidateQueries };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(useQueryClient as any).mockReturnValue(mockQueryClient);
|
||||
});
|
||||
|
||||
describe('useSleepSession', () => {
|
||||
const mockSession: SleepSessionRead = {
|
||||
id: 1,
|
||||
start_time: '2026-06-10T10:00:00Z',
|
||||
end_time: null,
|
||||
notes: 'Testing session',
|
||||
created_at: '2026-06-10T10:00:00Z',
|
||||
updated_at: '2026-06-10T10:00:00Z',
|
||||
};
|
||||
|
||||
it('Test Case 6 (State): should return correct loading and session states', () => {
|
||||
(useQuery as any).mockReturnValue({
|
||||
data: mockSession,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn }: { mutationFn: (args: any) => Promise<any> }) => ({
|
||||
mutateAsync: vi.fn().mockImplementation(mutationFn),
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useSleepSession());
|
||||
|
||||
expect(result.current.activeSession).toEqual(mockSession);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('Test Case 1: should successfully create a session and invalidate queries', async () => {
|
||||
(useQuery as any).mockReturnValue({ data: null, isLoading: false, isFetching: false });
|
||||
(api.createSession as any).mockResolvedValue(mockSession);
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: { mutationFn: (args: any) => Promise<any>, onSuccess?: () => void }) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const res = await mutationFn(args);
|
||||
if (onSuccess) onSuccess();
|
||||
return res;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useSleepSession());
|
||||
|
||||
await result.current.createSession({ start_time: '2026-06-10T10:00:00Z' } as any);
|
||||
|
||||
expect(api.createSession).toHaveBeenCalled();
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['activeSession'] });
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['sessions'] });
|
||||
});
|
||||
|
||||
it('Test Case 2: should successfully end a session and invalidate queries', async () => {
|
||||
(useQuery as any).mockReturnValue({ data: mockSession, isLoading: false, isFetching: false });
|
||||
(api.endSession as any).mockResolvedValue({ ...mockSession, end_time: '2026-06-10T12:00:00Z' });
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: { mutationFn: (args: any) => Promise<any>, onSuccess?: () => void }) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const res = await mutationFn(args);
|
||||
if (onSuccess) onSuccess();
|
||||
return res;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useSleepSession());
|
||||
|
||||
await result.current.endSession({ sessionId: 1, endTime: '2026-06-10T12:00:00Z' } as any);
|
||||
|
||||
expect(api.endSession).toHaveBeenCalled();
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['activeSession'] });
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['sessions'] });
|
||||
});
|
||||
|
||||
it('Test Case 5 (Error Handling): should propagate errors during session creation', async () => {
|
||||
(useQuery as any).mockReturnValue({ data: null, isLoading: false, isFetching: false });
|
||||
const error = new Error('API Error');
|
||||
(api.createSession as any).mockRejectedValue(error);
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn }: { mutationFn: (args: any) => Promise<any> }) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
return await mutationFn(args);
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useSleepSession());
|
||||
|
||||
await expect(result.current.createSession({ start_time: '...' } as any)).rejects.toThrow('API Error');
|
||||
});
|
||||
|
||||
it('Test Case 6 (State): should show isCreating when mutation is pending', () => {
|
||||
(useQuery as any).mockReturnValue({ data: null, isLoading: false, isFetching: false });
|
||||
(useMutation as any).mockReturnValueOnce({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
}).mockReturnValueOnce({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSleepSession());
|
||||
expect(result.current.isCreating).toBe(true);
|
||||
});
|
||||
|
||||
it('Test Case 6 (State): should show isEnding when mutation is pending', () => {
|
||||
(useQuery as any).mockReturnValue({ data: mockSession, isLoading: false, isFetching: false });
|
||||
(useMutation as any).mockReturnValueOnce({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}).mockReturnValueOnce({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSleepSession());
|
||||
expect(result.current.isEnding).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSessions', () => {
|
||||
const mockSessionsResponse: SessionListResponse = {
|
||||
sessions: [
|
||||
{ id: 1, start_time: '2026-06-10T10:00:00Z', end_time: null, notes: null, created_at: '...', updated_at: '...' },
|
||||
{ id: 2, start_time: '2026-06-10T10:00:00Z', end_time: null, notes: null, created_at: '...', updated_at: '...' },
|
||||
],
|
||||
total: 2,
|
||||
};
|
||||
|
||||
it('Test Case 6 (State): should return correct loading and sessions states', () => {
|
||||
(useQuery as any).mockReturnValue({
|
||||
data: mockSessionsResponse,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
(useMutation as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessions());
|
||||
|
||||
expect(result.current.sessions).toEqual(mockSessionsResponse.sessions);
|
||||
expect(result.current.total).toBe(2);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('Test Case 3: should successfully delete a session and invalidate queries', async () => {
|
||||
(useQuery as any).mockReturnValue({ data: mockSessionsResponse, isLoading: false });
|
||||
(api.deleteSession as any).mockResolvedValue(undefined);
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: { mutationFn: (args: any) => Promise<any>, onSuccess?: () => void }) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const res = await mutationFn(args);
|
||||
if (onSuccess) onSuccess();
|
||||
return res;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useSessions());
|
||||
|
||||
await (result.current.deleteSession as any)(1);
|
||||
|
||||
expect(api.deleteSession).toHaveBeenCalledWith(1);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['sessions'] });
|
||||
});
|
||||
|
||||
it('Test Case 4: should successfully update a session and invalidate queries', async () => {
|
||||
(useQuery as any).mockReturnValue({ data: mockSessionsResponse, isLoading: false });
|
||||
const updatePayload = { end_time: '2026-06-10T12:00:00Z' };
|
||||
(api.updateSession as any).mockResolvedValue({ id: 1, ...updatePayload } as any);
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn, onSuccess }: { mutationFn: (args: any) => Promise<any>, onSuccess?: () => void }) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
const res = await mutationFn(args);
|
||||
if (onSuccess) onSuccess();
|
||||
return res;
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useSessions());
|
||||
|
||||
await (result.current.updateSession as any)({ sessionId: 1, payload: updatePayload });
|
||||
|
||||
expect(api.updateSession).toHaveBeenCalledWith(1, updatePayload);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['sessions'] });
|
||||
});
|
||||
|
||||
it('Test Case 5 (Error Handling): should propagate errors during session deletion', async () => {
|
||||
(useQuery as any).mockReturnValue({ data: mockSessionsResponse, isLoading: false });
|
||||
const error = new Error('Delete failed');
|
||||
(api.deleteSession as any).mockRejectedValue(error);
|
||||
|
||||
(useMutation as any).mockImplementation(({ mutationFn }: { mutationFn: (args: any) => Promise<any> }) => ({
|
||||
mutateAsync: async (args: any) => {
|
||||
return await mutationFn(args);
|
||||
},
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useSessions());
|
||||
|
||||
await expect((result.current.deleteSession as any)(1)).rejects.toThrow('Delete failed');
|
||||
});
|
||||
|
||||
it('Test Case 6 (State): should show isDeleting when mutation is pending', () => {
|
||||
(useQuery as any).mockReturnValue({ data: mockSessionsResponse, isLoading: false });
|
||||
(useMutation as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessions());
|
||||
expect(result.current.isDeleting).toBe(true);
|
||||
});
|
||||
it('Test Case 7 (Data Undefined): should return empty sessions and 0 total when data is undefined', () => {
|
||||
(useQuery as any).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
(useMutation as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessions());
|
||||
|
||||
expect(result.current.sessions).toEqual([]);
|
||||
expect(result.current.total).toBe(0);
|
||||
});
|
||||
|
||||
it('Test Case 8 (Error Handling): should handle error from fetchSessions gracefully', () => {
|
||||
(useQuery as any).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Fetch failed'),
|
||||
});
|
||||
|
||||
(useMutation as any).mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessions());
|
||||
|
||||
expect(result.current.sessions).toEqual([]);
|
||||
expect(result.current.total).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useStats, useSummary } from '../../../src/hooks/useStats';
|
||||
import * as api from '../../../src/api/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// Mock the api client
|
||||
vi.mock('../../../src/api/client', () => ({
|
||||
fetchDailySummary: vi.fn(),
|
||||
fetchTimetable: vi.fn(),
|
||||
fetchWakeSleepTimes: vi.fn(),
|
||||
fetchSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('useStats', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return data and correctly transform daily stats on success', async () => {
|
||||
const mockDailySummary = {
|
||||
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 },
|
||||
],
|
||||
};
|
||||
const mockTimetable = { slots: [] };
|
||||
const mockWakeupBedtime = { days: [] };
|
||||
|
||||
vi.mocked(api.fetchDailySummary).mockResolvedValueOnce(mockDailySummary);
|
||||
vi.mocked(api.fetchTimetable).mockResolvedValueOnce(mockTimetable);
|
||||
vi.mocked(api.fetchWakeSleepTimes).mockResolvedValueOnce(mockWakeupBedtime);
|
||||
|
||||
const { result } = renderHook(() => useStats('2024-01-01', '2024-01-02'), { wrapper });
|
||||
|
||||
// Initially loading
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
// Verify raw data
|
||||
expect(result.current.dailySummary).toEqual(mockDailySummary);
|
||||
|
||||
// Verify transformation (total, day/night split in hours)
|
||||
expect(result.current.transformedDailySummary).toEqual([
|
||||
{ date: '2024-01-01', daySleep: 4, nightSleep: 4, total: 8 },
|
||||
{ date: '2024-01-02', daySleep: 5, nightSleep: 4, total: 9 },
|
||||
]);
|
||||
|
||||
expect(result.current.timetable).toEqual(mockTimetable);
|
||||
expect(result.current.wakeupBedtime).toEqual(mockWakeupBedtime);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty data from the API', async () => {
|
||||
const mockEmptyDailySummary = { days: [] };
|
||||
const mockTimetable = { slots: [] };
|
||||
const mockWakeupBedtime = { days: [] };
|
||||
|
||||
vi.mocked(api.fetchDailySummary).mockResolvedValueOnce(mockEmptyDailySummary);
|
||||
vi.mocked(api.fetchTimetable).mockResolvedValueOnce(mockTimetable);
|
||||
vi.mocked(api.fetchWakeSleepTimes).mockResolvedValueOnce(mockWakeupBedtime);
|
||||
|
||||
const { result } = renderHook(() => useStats('2024-01-01', '2024-01-02'), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.dailySummary).toEqual(mockEmptyDailySummary);
|
||||
expect(result.current.transformedDailySummary).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle error state when the API request fails', async () => {
|
||||
const error = new Error('API Failure');
|
||||
vi.mocked(api.fetchDailySummary).mockRejectedValueOnce(error);
|
||||
vi.mocked(api.fetchTimetable).mockResolvedValueOnce({ slots: [] });
|
||||
vi.mocked(api.fetchWakeSleepTimes).mockResolvedValueOnce({ days: [] });
|
||||
|
||||
const { result } = renderHook(() => useStats('2024-01-01', '2024-01-02'), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBeDefined();
|
||||
expect(result.current.error).toEqual(error);
|
||||
});
|
||||
|
||||
it('should handle loading state transitions', async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(api.fetchDailySummary).mockReturnValueOnce(pendingPromise as any);
|
||||
vi.mocked(api.fetchTimetable).mockResolvedValueOnce({ slots: [] });
|
||||
vi.mocked(api.fetchWakeSleepTimes).mockResolvedValueOnce({ days: [] });
|
||||
|
||||
const { result } = renderHook(() => useStats('2024-01-01', '2024-01-02'), { wrapper });
|
||||
|
||||
// Should be loading while promise is pending
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise!({ days: [] });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSummary', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return data on success', async () => {
|
||||
const mockSummary = {
|
||||
total_sleeps: 10,
|
||||
avg_sleeps_per_day: 2,
|
||||
total_sleeps_all_time: 100,
|
||||
avg_sleeps_per_day_all_time: 2.5,
|
||||
};
|
||||
vi.mocked(api.fetchSummary).mockResolvedValueOnce(mockSummary);
|
||||
|
||||
const { result } = renderHook(() => useSummary('2024-01-01', '2024-01-02'), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data).toEqual(mockSummary);
|
||||
expect(api.fetchSummary).toHaveBeenCalledWith('2024-01-01', '2024-01-02');
|
||||
});
|
||||
|
||||
it('should handle error on failure', async () => {
|
||||
const error = new Error('API Failure');
|
||||
vi.mocked(api.fetchSummary).mockRejectedValueOnce(error);
|
||||
|
||||
const { result } = renderHook(() => useSummary('2024-01-01', '2024-01-02'), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(result.current.error).toEqual(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
formatMinutesAfter,
|
||||
adjustForTimezone,
|
||||
timeToMinutes,
|
||||
formatMinutes,
|
||||
toLocalDatetimeInputValue,
|
||||
formatElapsedTime,
|
||||
formatDateTime,
|
||||
toUTCFromLocalInput,
|
||||
getDurationMinutes,
|
||||
clampToDay,
|
||||
parseTime,
|
||||
getDaysAgo,
|
||||
getToday,
|
||||
getLocalOffsetFromReference,
|
||||
parseBackendDateTime
|
||||
} from '../../../src/utils/time';
|
||||
|
||||
describe('formatMinutesAfter', () => {
|
||||
it('formats minutes correctly with default reference (04:00)', () => {
|
||||
expect(formatMinutesAfter(15)).toBe('04:15');
|
||||
expect(formatMinutesAfter(-15)).toBe('03:45');
|
||||
});
|
||||
|
||||
it('formats minutes correctly with custom reference', () => {
|
||||
expect(formatMinutesAfter(15, '07:00')).toBe('07:15');
|
||||
expect(formatMinutesAfter(-15, '07:00')).toBe('06:45');
|
||||
});
|
||||
|
||||
it('handles wrap around midnight', () => {
|
||||
expect(formatMinutesAfter(1425, '04:00')).toBe('03:45');
|
||||
expect(formatMinutesAfter(-240, '04:00')).toBe('00:00');
|
||||
expect(formatMinutesAfter(-255, '04:00')).toBe('23:45');
|
||||
});
|
||||
|
||||
it('returns N/A for null minutes', () => {
|
||||
expect(formatMinutesAfter(null)).toBe('N/A');
|
||||
});
|
||||
|
||||
it('handles large number of minutes', () => {
|
||||
expect(formatMinutesAfter(1440, '04:00')).toBe('04:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeToMinutes', () => {
|
||||
it('converts HH:MM to total minutes', () => {
|
||||
expect(timeToMinutes('00:00')).toBe(0);
|
||||
expect(timeToMinutes('04:00')).toBe(240);
|
||||
expect(timeToMinutes('23:59')).toBe(1439);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMinutes', () => {
|
||||
it('formats minutes into h m string', () => {
|
||||
expect(formatMinutes(90)).toBe('1h 30m');
|
||||
expect(formatMinutes(45)).toBe('45m');
|
||||
expect(formatMinutes(0)).toBe('0m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjustForTimezone', () => {
|
||||
it('adjusts minutes by timezone offset', () => {
|
||||
const offset = new Date().getTimezoneOffset();
|
||||
expect(adjustForTimezone(0)).toBe(-offset);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toLocalDatetimeInputValue', () => {
|
||||
it('formats date to YYYY-MM-DDTHH:mm', () => {
|
||||
const date = new Date(2024, 5, 9, 14, 30);
|
||||
const result = toLocalDatetimeInputValue(date);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatElapsedTime', () => {
|
||||
it('formats elapsed time correctly', () => {
|
||||
const now = new Date();
|
||||
const then = new Date(now.getTime() - 90 * 60000); // 90 mins ago
|
||||
const iso = then.toISOString();
|
||||
expect(formatElapsedTime(iso)).toBe('1h 30m');
|
||||
|
||||
const then2 = new Date(now.getTime() - 45 * 60000); // 45 mins ago
|
||||
const iso2 = then2.toISOString();
|
||||
expect(formatElapsedTime(iso2)).toBe('45m');
|
||||
});
|
||||
|
||||
it('handles ISO strings with spaces instead of T', () => {
|
||||
const now = new Date();
|
||||
const then = new Date(now.getTime() - 10 * 60000);
|
||||
const iso = then.toISOString().replace('T', ' ');
|
||||
expect(formatElapsedTime(iso)).toBe('10m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('formats date object to YYYY-MM-DD HH:mm', () => {
|
||||
const date = new Date(2024, 5, 9, 14, 30);
|
||||
const result = formatDateTime(date);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('formats ISO string to YYYY-MM-DD HH:mm', () => {
|
||||
const iso = '2024-06-09T14:30:00Z';
|
||||
const result = formatDateTime(iso);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('handles strings without Z by appending it', () => {
|
||||
const iso = '2024-06-09T14:30:00';
|
||||
const result = formatDateTime(iso);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUTCFromLocalInput', () => {
|
||||
it('converts local datetime input to ISO UTC string', () => {
|
||||
const input = '2024-06-09T14:30';
|
||||
const result = toUTCFromLocalInput(input);
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/);
|
||||
});
|
||||
|
||||
it('returns empty string for invalid input', () => {
|
||||
expect(toUTCFromLocalInput('invalid')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDurationMinutes', () => {
|
||||
it('calculates duration correctly between two dates', () => {
|
||||
const start = '2024-06-09T10:00:00Z';
|
||||
const end = '2024-06-09T11:30:00Z';
|
||||
expect(getDurationMinutes(start, end)).toBe(90);
|
||||
});
|
||||
|
||||
it('calculates duration correctly between date objects', () => {
|
||||
const start = new Date('2024-06-09T10:00:00Z');
|
||||
const end = new Date('2024-06-09T11:30:00Z');
|
||||
expect(getDurationMinutes(start, end)).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampToDay', () => {
|
||||
it('clamps to start of day', () => {
|
||||
const date = new Date(2024, 5, 9, 14, 30);
|
||||
const clamped = clampToDay(date, 'start');
|
||||
expect(clamped.getHours()).toBe(0);
|
||||
expect(clamped.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps to end of day', () => {
|
||||
const date = new Date(2024, 5, 9, 14, 30);
|
||||
const clamped = clampToDay(date, 'end');
|
||||
expect(clamped.getHours()).toBe(23);
|
||||
expect(clamped.getMinutes()).toBe(59);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTime', () => {
|
||||
it('parses HH:MM string to Date object on today', () => {
|
||||
const timeStr = '14:30';
|
||||
const parsed = parseTime(timeStr);
|
||||
const now = new Date();
|
||||
expect(parsed.getHours()).toBe(14);
|
||||
expect(parsed.getMinutes()).toBe(30);
|
||||
expect(parsed.getDate()).toBe(now.getDate());
|
||||
});
|
||||
|
||||
it('parses HH:MM string to Date object on a specific date', () => {
|
||||
const baseDate = new Date(2024, 5, 9);
|
||||
const parsed = parseTime('14:30', baseDate);
|
||||
expect(parsed.getFullYear()).toBe(2024);
|
||||
expect(parsed.getMonth()).toBe(5); // June is 5 in JS Date
|
||||
expect(parsed.getDate()).toBe(9);
|
||||
expect(parsed.getHours()).toBe(14);
|
||||
expect(parsed.getMinutes()).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDaysAgo', () => {
|
||||
it('returns ISO date string for N days ago', () => {
|
||||
const today = getToday();
|
||||
expect(getDaysAgo(0)).toBe(today);
|
||||
// Note: this depends on the current date, so we can only check format and relative-ish
|
||||
expect(getDaysAgo(1)).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToday', () => {
|
||||
it('returns today in YYYY-MM-DD format', () => {
|
||||
const today = getToday();
|
||||
const expected = new Date().toISOString().split('T')[0];
|
||||
expect(today).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalOffsetFromReference', () => {
|
||||
it('calculates local offset from reference', () => {
|
||||
const utcMinutes = 1440 / 2; // Midday UTC (720 mins)
|
||||
const reference = '04:00'; // 240 mins
|
||||
// adjustForTimezone(720) = 720 - offset
|
||||
// timeToMinutes('04:00') = 240
|
||||
// result = (720 - offset) - 240 = 480 - offset
|
||||
const offset = new Date().getTimezoneOffset();
|
||||
expect(getLocalOffsetFromReference(utcMinutes, reference)).toBe(480 - offset);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBackendDateTime', () => {
|
||||
it('parses ISO string correctly', () => {
|
||||
const iso = '2024-06-09T14:30:00Z';
|
||||
const parsed = parseBackendDateTime(iso);
|
||||
expect(parsed.toISOString()).toBe(new Date(iso).toISOString());
|
||||
});
|
||||
|
||||
it('appends Z to strings without timezone', () => {
|
||||
const iso = '2024-06-09T14:30:00';
|
||||
const parsed = parseBackendDateTime(iso);
|
||||
expect(parsed.toISOString()).toBe(new Date(iso + 'Z').toISOString());
|
||||
});
|
||||
|
||||
it('handles already Z-terminated strings', () => {
|
||||
const iso = '2024-06-09T14:30:00Z';
|
||||
const parsed = parseBackendDateTime(iso);
|
||||
expect(parsed.toISOString()).toBe(new Date(iso).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user