import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { PlanningTools } from '../../src/tools/planning.js';
import { IntervalsClient } from '../../src/clients/intervals.js';
import { TrainerRoadClient } from '../../src/clients/trainerroad.js';
import type { PlannedWorkout } from '../../src/types/index.js';
vi.mock('../../src/clients/intervals.js');
vi.mock('../../src/clients/trainerroad.js');
describe('PlanningTools', () => {
let tools: PlanningTools;
let mockIntervalsClient: IntervalsClient;
let mockTrainerRoadClient: TrainerRoadClient;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-12-15T12:00:00Z'));
mockIntervalsClient = new IntervalsClient({ apiKey: 'test', athleteId: 'test' });
mockTrainerRoadClient = new TrainerRoadClient({ calendarUrl: 'https://test.com' });
tools = new PlanningTools(mockIntervalsClient, mockTrainerRoadClient);
});
afterEach(() => {
vi.useRealTimers();
});
describe('getUpcomingWorkouts', () => {
const trainerroadWorkouts: PlannedWorkout[] = [
{
id: 'tr-1',
date: '2024-12-16T09:00:00Z',
name: 'Sweet Spot Base',
expected_tss: 88,
source: 'trainerroad',
},
{
id: 'tr-2',
date: '2024-12-18T09:00:00Z',
name: 'VO2max Intervals',
expected_tss: 75,
source: 'trainerroad',
},
];
const intervalsWorkouts: PlannedWorkout[] = [
{
id: 'int-1',
date: '2024-12-17T17:00:00Z',
name: 'Easy Run',
expected_tss: 35,
source: 'intervals.icu',
},
{
id: 'int-2',
date: '2024-12-19T08:00:00Z',
name: 'Long Ride',
expected_tss: 120,
source: 'intervals.icu',
},
];
it('should return workouts from both sources', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trainerroadWorkouts);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue(intervalsWorkouts);
const result = await tools.getUpcomingWorkouts({ days: 7 });
expect(result.trainerroad).toHaveLength(2);
expect(result.intervals).toHaveLength(2);
expect(result.merged).toHaveLength(4);
});
it('should sort merged workouts by date', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trainerroadWorkouts);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue(intervalsWorkouts);
const result = await tools.getUpcomingWorkouts({ days: 7 });
const dates = result.merged.map((w) => new Date(w.date).getTime());
for (let i = 1; i < dates.length; i++) {
expect(dates[i]).toBeGreaterThanOrEqual(dates[i - 1]);
}
});
it('should deduplicate similar workouts', async () => {
const duplicateWorkout: PlannedWorkout = {
id: 'int-dup',
date: '2024-12-16T09:00:00Z',
name: 'Sweet Spot Base',
expected_tss: 88,
source: 'intervals.icu',
};
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trainerroadWorkouts);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue([duplicateWorkout]);
const result = await tools.getUpcomingWorkouts({ days: 7 });
expect(result.merged).toHaveLength(2); // Only TR workouts, duplicate removed
expect(result.merged.find((w) => w.source === 'intervals.icu')).toBeUndefined();
});
it('should handle TrainerRoad client not configured', async () => {
const toolsWithoutTr = new PlanningTools(mockIntervalsClient, null);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue(intervalsWorkouts);
const result = await toolsWithoutTr.getUpcomingWorkouts({ days: 7 });
expect(result.trainerroad).toEqual([]);
expect(result.intervals).toHaveLength(2);
expect(result.merged).toHaveLength(2);
});
it('should handle errors gracefully', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockRejectedValue(new Error('Failed'));
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue(intervalsWorkouts);
const result = await tools.getUpcomingWorkouts({ days: 7 });
expect(result.trainerroad).toEqual([]);
expect(result.intervals).toHaveLength(2);
});
it('should use correct date range', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue([]);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue([]);
await tools.getUpcomingWorkouts({ days: 7 });
expect(mockIntervalsClient.getPlannedEvents).toHaveBeenCalledWith(
'2024-12-15',
'2024-12-22'
);
});
});
describe('getPlannedWorkoutDetails', () => {
const trWorkout: PlannedWorkout = {
id: 'tr-1',
date: '2024-12-16T09:00:00Z',
name: 'Sweet Spot Base',
description: 'Hard intervals',
expected_tss: 88,
expected_if: 0.88,
source: 'trainerroad',
};
const intWorkout: PlannedWorkout = {
id: 'int-1',
date: '2024-12-16T17:00:00Z',
name: 'Easy Run',
expected_tss: 35,
source: 'intervals.icu',
};
it('should find workout by ID from TrainerRoad', async () => {
vi.mocked(mockTrainerRoadClient.getUpcomingWorkouts).mockResolvedValue([trWorkout]);
const result = await tools.getPlannedWorkoutDetails({
workout_id: 'tr-1',
source: 'trainerroad',
});
expect(result).toEqual(trWorkout);
});
it('should find workout by ID from Intervals.icu', async () => {
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue([intWorkout]);
const result = await tools.getPlannedWorkoutDetails({
workout_id: 'int-1',
source: 'intervals.icu',
});
expect(result).toEqual(intWorkout);
});
it('should search both sources when source not specified', async () => {
vi.mocked(mockTrainerRoadClient.getUpcomingWorkouts).mockResolvedValue([trWorkout]);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue([intWorkout]);
const result = await tools.getPlannedWorkoutDetails({
workout_id: 'tr-1',
});
expect(result).toEqual(trWorkout);
});
it('should find workout by date from TrainerRoad', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue([trWorkout]);
const result = await tools.getPlannedWorkoutDetails({
date: '2024-12-16',
source: 'trainerroad',
});
expect(result).toEqual(trWorkout);
});
it('should find workout by date from Intervals.icu', async () => {
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue([intWorkout]);
const result = await tools.getPlannedWorkoutDetails({
date: '2024-12-16',
source: 'intervals.icu',
});
expect(result).toEqual(intWorkout);
});
it('should prefer TrainerRoad when searching by date without source', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue([trWorkout]);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue([intWorkout]);
const result = await tools.getPlannedWorkoutDetails({
date: '2024-12-16',
});
expect(result?.source).toBe('trainerroad');
});
it('should return null when no match found', async () => {
vi.mocked(mockTrainerRoadClient.getUpcomingWorkouts).mockResolvedValue([]);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue([]);
const result = await tools.getPlannedWorkoutDetails({
workout_id: 'nonexistent',
});
expect(result).toBeNull();
});
it('should return null when no parameters provided', async () => {
const result = await tools.getPlannedWorkoutDetails({});
expect(result).toBeNull();
});
it('should parse natural language date', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue([]);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue([]);
await tools.getPlannedWorkoutDetails({
date: 'tomorrow',
});
expect(mockTrainerRoadClient.getPlannedWorkouts).toHaveBeenCalledWith(
'2024-12-16',
'2024-12-16'
);
});
});
});