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

This commit is contained in:
2026-06-11 09:09:02 +02:00
parent a9e1e96622
commit bb58389675
33 changed files with 3257 additions and 275 deletions
+91 -30
View File
@@ -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();
});
});
+58 -55
View File
@@ -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();
});
});
+91 -29
View File
@@ -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} />);
});
});
+299
View File
@@ -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');
});
});
});
});
+46 -1
View File
@@ -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);
});
});
});
+162
View File
@@ -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);
});
});
+228
View File
@@ -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());
});
});