scheduler = Mockery::mock(SchedulerService::class); $this->componentId = ComponentId::create('scheduler-timeline', 'test'); $this->initialState = SchedulerState::empty(); }); afterEach(function () { Mockery::close(); }); it('polls and updates state with scheduler data', function () { $now = Timestamp::now(); $in30Minutes = $now->add(Duration::fromMinutes(30)); $in2Hours = $now->add(Duration::fromHours(2)); $task1 = new ScheduledTask( id: 'backup-task', schedule: CronSchedule::fromExpression('0 2 * * *'), callback: fn() => true, nextRun: $in30Minutes ); $task2 = new ScheduledTask( id: 'cleanup-task', schedule: IntervalSchedule::every(Duration::fromHours(1)), callback: fn() => true, nextRun: $in2Hours ); $this->scheduler->shouldReceive('getScheduledTasks') ->once() ->andReturn([$task1, $task2]); $this->scheduler->shouldReceive('getDueTasks') ->once() ->with(Mockery::type(Timestamp::class)) ->andReturn([]); $component = new SchedulerTimelineComponent( id: $this->componentId, state: $this->initialState, scheduler: $this->scheduler ); $newState = $component->poll(); expect($newState)->toBeInstanceOf(SchedulerState::class); expect($newState->totalScheduledTasks)->toBe(2); expect($newState->dueTasks)->toBe(0); expect($newState->upcomingTasks)->toHaveCount(2); expect($newState->nextExecution)->not->toBeNull(); }); it('identifies due tasks correctly', function () { $now = Timestamp::now(); $past = $now->sub(Duration::fromMinutes(5)); $dueTask = new ScheduledTask( id: 'due-task', schedule: IntervalSchedule::every(Duration::fromMinutes(10)), callback: fn() => true, nextRun: $past ); $this->scheduler->shouldReceive('getScheduledTasks') ->once() ->andReturn([$dueTask]); $this->scheduler->shouldReceive('getDueTasks') ->once() ->andReturn([$dueTask]); $component = new SchedulerTimelineComponent( id: $this->componentId, state: $this->initialState, scheduler: $this->scheduler ); $newState = $component->poll(); expect($newState->dueTasks)->toBe(1); expect($newState->upcomingTasks[0]['is_due'])->toBeTrue(); }); it('formats time until execution correctly for less than 1 minute', function () { $now = Timestamp::now(); $in30Seconds = $now->add(Duration::fromSeconds(30)); $task = new ScheduledTask( id: 'imminent-task', schedule: IntervalSchedule::every(Duration::fromMinutes(1)), callback: fn() => true, nextRun: $in30Seconds ); $this->scheduler->shouldReceive('getScheduledTasks') ->once() ->andReturn([$task]); $this->scheduler->shouldReceive('getDueTasks') ->once() ->andReturn([]); $component = new SchedulerTimelineComponent( id: $this->componentId, state: $this->initialState, scheduler: $this->scheduler ); $newState = $component->poll(); expect($newState->upcomingTasks[0]['next_run_relative'])->toBe('Less than 1 minute'); }); it('formats time until execution correctly for hours and minutes', function () { $now = Timestamp::now(); $in5Hours30Min = $now->add(Duration::fromMinutes(330)); // 5.5 hours $task = new ScheduledTask( id: 'future-task', schedule: CronSchedule::fromExpression('30 17 * * *'), callback: fn() => true, nextRun: $in5Hours30Min ); $this->scheduler->shouldReceive('getScheduledTasks') ->once() ->andReturn([$task]); $this->scheduler->shouldReceive('getDueTasks') ->once() ->andReturn([]); $component = new SchedulerTimelineComponent( id: $this->componentId, state: $this->initialState, scheduler: $this->scheduler ); $newState = $component->poll(); $relativeTime = $newState->upcomingTasks[0]['next_run_relative']; expect($relativeTime)->toContain('5 hours'); expect($relativeTime)->toContain('30 min'); }); it('formats time until execution correctly for days and hours', function () { $now = Timestamp::now(); $in2Days4Hours = $now->add(Duration::fromHours(52)); // 2 days, 4 hours $task = new ScheduledTask( id: 'distant-task', schedule: CronSchedule::fromExpression('0 0 * * 0'), // Weekly callback: fn() => true, nextRun: $in2Days4Hours ); $this->scheduler->shouldReceive('getScheduledTasks') ->once() ->andReturn([$task]); $this->scheduler->shouldReceive('getDueTasks') ->once() ->andReturn([]); $component = new SchedulerTimelineComponent( id: $this->componentId, state: $this->initialState, scheduler: $this->scheduler ); $newState = $component->poll(); $relativeTime = $newState->upcomingTasks[0]['next_run_relative']; expect($relativeTime)->toContain('2 days'); expect($relativeTime)->toContain('4 hours'); }); it('has correct poll interval', function () { $component = new SchedulerTimelineComponent( id: $this->componentId, state: $this->initialState, scheduler: $this->scheduler ); expect($component->getPollInterval())->toBe(30000); }); it('returns correct render data', function () { $upcomingTasks = [ [ 'id' => 'task-1', 'schedule_type' => 'cron', 'next_run' => '2024-01-15 13:00:00', 'next_run_relative' => '30 min', 'is_due' => false, ], ]; $state = new SchedulerState( totalScheduledTasks: 5, dueTasks: 1, upcomingTasks: $upcomingTasks, nextExecution: '2024-01-15 13:00:00', statistics: ['total_executions' => 100], lastUpdated: '2024-01-15 12:00:00' ); $component = new SchedulerTimelineComponent( id: $this->componentId, state: $state, scheduler: $this->scheduler ); $renderData = $component->getRenderData(); expect($renderData->templatePath)->toBe('livecomponent-scheduler-timeline'); expect($renderData->data)->toHaveKey('componentId'); expect($renderData->data)->toHaveKey('pollInterval'); expect($renderData->data['pollInterval'])->toBe(30000); expect($renderData->data['totalScheduledTasks'])->toBe(5); expect($renderData->data['dueTasks'])->toBe(1); }); it('handles no scheduled tasks', function () { $this->scheduler->shouldReceive('getScheduledTasks') ->once() ->andReturn([]); $this->scheduler->shouldReceive('getDueTasks') ->once() ->andReturn([]); $component = new SchedulerTimelineComponent( id: $this->componentId, state: $this->initialState, scheduler: $this->scheduler ); $newState = $component->poll(); expect($newState->totalScheduledTasks)->toBe(0); expect($newState->dueTasks)->toBe(0); expect($newState->upcomingTasks)->toBe([]); expect($newState->nextExecution)->toBeNull(); }); it('limits upcoming tasks to 10 items', function () { $tasks = []; $now = Timestamp::now(); for ($i = 0; $i < 20; $i++) { $tasks[] = new ScheduledTask( id: "task-{$i}", schedule: IntervalSchedule::every(Duration::fromMinutes($i + 1)), callback: fn() => true, nextRun: $now->add(Duration::fromMinutes($i + 1)) ); } $this->scheduler->shouldReceive('getScheduledTasks') ->once() ->andReturn($tasks); $this->scheduler->shouldReceive('getDueTasks') ->once() ->andReturn([]); $component = new SchedulerTimelineComponent( id: $this->componentId, state: $this->initialState, scheduler: $this->scheduler ); $newState = $component->poll(); expect($newState->totalScheduledTasks)->toBe(20); expect($newState->upcomingTasks)->toHaveCount(10); // Limited to 10 }); it('detects schedule type correctly', function () { $now = Timestamp::now(); $cronTask = new ScheduledTask( id: 'cron-task', schedule: CronSchedule::fromExpression('0 * * * *'), callback: fn() => true, nextRun: $now->add(Duration::fromHours(1)) ); $intervalTask = new ScheduledTask( id: 'interval-task', schedule: IntervalSchedule::every(Duration::fromMinutes(30)), callback: fn() => true, nextRun: $now->add(Duration::fromMinutes(30)) ); $this->scheduler->shouldReceive('getScheduledTasks') ->once() ->andReturn([$cronTask, $intervalTask]); $this->scheduler->shouldReceive('getDueTasks') ->once() ->andReturn([]); $component = new SchedulerTimelineComponent( id: $this->componentId, state: $this->initialState, scheduler: $this->scheduler ); $newState = $component->poll(); expect($newState->upcomingTasks[0]['schedule_type'])->toBe('cron'); expect($newState->upcomingTasks[1]['schedule_type'])->toBe('interval'); }); });