'validation', 'items_processed' => 500]; $progress = JobProgress::withPercentage($percentage, $message, $metadata); expect($progress->percentage)->toBe($percentage); expect($progress->message)->toBe($message); expect($progress->metadata)->toBe($metadata); }); it('rejects empty progress messages', function () { expect(fn () => JobProgress::withPercentage(Percentage::zero(), '')) ->toThrow(\InvalidArgumentException::class, 'Progress message cannot be empty'); expect(fn () => JobProgress::withPercentage(Percentage::zero(), ' ')) ->toThrow(\InvalidArgumentException::class, 'Progress message cannot be empty'); }); it('is readonly and immutable', function () { $progress = JobProgress::starting('Test job starting'); $reflection = new ReflectionClass($progress); expect($reflection->isReadOnly())->toBeTrue(); // All properties should be readonly foreach (['percentage', 'message', 'metadata'] as $prop) { $property = $reflection->getProperty($prop); expect($property->isReadOnly())->toBeTrue("Property {$prop} should be readonly"); } }); }); describe('Factory Methods', function () { it('creates starting progress', function () { $progress = JobProgress::starting(); expect($progress->percentage->getValue())->toBe(0.0); expect($progress->message)->toBe('Job starting...'); expect($progress->isStarting())->toBeTrue(); expect($progress->isCompleted())->toBeFalse(); expect($progress->isFailed())->toBeFalse(); }); it('creates starting progress with custom message', function () { $message = 'Email job initializing...'; $progress = JobProgress::starting($message); expect($progress->percentage->getValue())->toBe(0.0); expect($progress->message)->toBe($message); expect($progress->isStarting())->toBeTrue(); }); it('creates completed progress', function () { $progress = JobProgress::completed(); expect($progress->percentage->getValue())->toBe(100.0); expect($progress->message)->toBe('Job completed successfully'); expect($progress->isCompleted())->toBeTrue(); expect($progress->isStarting())->toBeFalse(); expect($progress->isFailed())->toBeFalse(); }); it('creates completed progress with custom message', function () { $message = 'All emails sent successfully'; $progress = JobProgress::completed($message); expect($progress->percentage->getValue())->toBe(100.0); expect($progress->message)->toBe($message); expect($progress->isCompleted())->toBeTrue(); }); it('creates failed progress', function () { $progress = JobProgress::failed(); expect($progress->percentage->getValue())->toBe(0.0); expect($progress->message)->toBe('Job failed'); expect($progress->isFailed())->toBeTrue(); expect($progress->isCompleted())->toBeFalse(); expect($progress->isStarting())->toBeFalse(); expect($progress->metadata['status'])->toBe('failed'); }); it('creates failed progress with custom message', function () { $message = 'Email service unavailable'; $progress = JobProgress::failed($message); expect($progress->message)->toBe($message); expect($progress->isFailed())->toBeTrue(); }); it('creates progress from ratio', function () { $progress = JobProgress::fromRatio(25, 100, 'Processing items', ['current_item' => 25]); expect($progress->percentage->getValue())->toBe(25.0); expect($progress->message)->toBe('Processing items'); expect($progress->metadata['current_item'])->toBe(25); }); it('handles edge cases in fromRatio', function () { // Zero total $progress = JobProgress::fromRatio(0, 0, 'No items to process'); expect($progress->percentage->getValue())->toBe(0.0); // All items processed $progress = JobProgress::fromRatio(100, 100, 'All items processed'); expect($progress->percentage->getValue())->toBe(100.0); expect($progress->isCompleted())->toBeTrue(); }); }); describe('Status Check Methods', function () { it('correctly identifies completed status', function () { $completed = JobProgress::completed(); $partial = JobProgress::withPercentage(Percentage::fromValue(50.0), 'Half done'); $starting = JobProgress::starting(); expect($completed->isCompleted())->toBeTrue(); expect($partial->isCompleted())->toBeFalse(); expect($starting->isCompleted())->toBeFalse(); }); it('correctly identifies failed status', function () { $failed = JobProgress::failed(); $completed = JobProgress::completed(); $starting = JobProgress::starting(); expect($failed->isFailed())->toBeTrue(); expect($completed->isFailed())->toBeFalse(); expect($starting->isFailed())->toBeFalse(); }); it('correctly identifies starting status', function () { $starting = JobProgress::starting(); $partial = JobProgress::withPercentage(Percentage::fromValue(10.0), 'Just started'); $failed = JobProgress::failed(); $completed = JobProgress::completed(); expect($starting->isStarting())->toBeTrue(); expect($partial->isStarting())->toBeFalse(); expect($failed->isStarting())->toBeFalse(); // Failed is not starting expect($completed->isStarting())->toBeFalse(); }); it('handles edge cases in status detection', function () { // Zero percentage but not starting due to metadata $zeroButNotStarting = JobProgress::withPercentage( Percentage::zero(), 'Waiting for dependencies', ['status' => 'waiting'] ); expect($zeroButNotStarting->isStarting())->toBeTrue(); // Still starting since not failed // Custom failed status $customFailed = JobProgress::withPercentage( Percentage::fromValue(50.0), 'Failed during processing', ['status' => 'failed'] ); expect($customFailed->isFailed())->toBeTrue(); expect($customFailed->isCompleted())->toBeFalse(); }); }); describe('Immutable Transformations', function () { beforeEach(function () { $this->originalProgress = JobProgress::withPercentage( Percentage::fromValue(25.0), 'Quarter complete', ['step' => 'validation'] ); }); it('withMetadata() creates new instance with merged metadata', function () { $newMetadata = ['items_processed' => 250, 'errors' => 0]; $updated = $this->originalProgress->withMetadata($newMetadata); expect($updated)->not->toBe($this->originalProgress); expect($updated->percentage)->toBe($this->originalProgress->percentage); expect($updated->message)->toBe($this->originalProgress->message); expect($updated->metadata['step'])->toBe('validation'); // Original metadata preserved expect($updated->metadata['items_processed'])->toBe(250); // New metadata added expect($updated->metadata['errors'])->toBe(0); // Original should be unchanged expect($this->originalProgress->metadata)->toBe(['step' => 'validation']); }); it('withMetadata() overwrites conflicting keys', function () { $newMetadata = ['step' => 'processing']; // Conflicts with existing key $updated = $this->originalProgress->withMetadata($newMetadata); expect($updated->metadata['step'])->toBe('processing'); // New value wins }); it('withUpdatedProgress() creates new instance with updated progress', function () { $newPercentage = Percentage::fromValue(75.0); $newMessage = 'Three quarters complete'; $updated = $this->originalProgress->withUpdatedProgress($newPercentage, $newMessage); expect($updated)->not->toBe($this->originalProgress); expect($updated->percentage)->toBe($newPercentage); expect($updated->message)->toBe($newMessage); expect($updated->metadata)->toBe($this->originalProgress->metadata); // Metadata preserved // Original should be unchanged expect($this->originalProgress->percentage->getValue())->toBe(25.0); expect($this->originalProgress->message)->toBe('Quarter complete'); }); it('can chain transformations', function () { $final = $this->originalProgress ->withUpdatedProgress(Percentage::fromValue(50.0), 'Half complete') ->withMetadata(['processed_items' => 500]); expect($final->percentage->getValue())->toBe(50.0); expect($final->message)->toBe('Half complete'); expect($final->metadata['step'])->toBe('validation'); // Original preserved expect($final->metadata['processed_items'])->toBe(500); // New added // Original should be completely unchanged expect($this->originalProgress->percentage->getValue())->toBe(25.0); expect($this->originalProgress->message)->toBe('Quarter complete'); expect($this->originalProgress->metadata)->toBe(['step' => 'validation']); }); }); describe('Array Conversion', function () { it('toArray() provides comprehensive progress information', function () { $progress = JobProgress::withPercentage( Percentage::fromValue(75.5), 'Processing emails', ['batch_id' => 123, 'errors' => 2] ); $array = $progress->toArray(); expect($array)->toHaveKey('percentage'); expect($array)->toHaveKey('percentage_formatted'); expect($array)->toHaveKey('message'); expect($array)->toHaveKey('metadata'); expect($array)->toHaveKey('is_completed'); expect($array)->toHaveKey('is_failed'); expect($array)->toHaveKey('is_starting'); expect($array['percentage'])->toBe(75.5); expect($array['percentage_formatted'])->toBe('75.5%'); expect($array['message'])->toBe('Processing emails'); expect($array['metadata'])->toBe(['batch_id' => 123, 'errors' => 2]); expect($array['is_completed'])->toBeFalse(); expect($array['is_failed'])->toBeFalse(); expect($array['is_starting'])->toBeFalse(); }); it('toArray() handles different progress states', function () { $states = [ 'starting' => JobProgress::starting(), 'completed' => JobProgress::completed(), 'failed' => JobProgress::failed(), ]; foreach ($states as $stateName => $progress) { $array = $progress->toArray(); expect($array)->toBeArray(); expect($array)->toHaveKey('is_completed'); expect($array)->toHaveKey('is_failed'); expect($array)->toHaveKey('is_starting'); switch ($stateName) { case 'starting': expect($array['is_starting'])->toBeTrue(); expect($array['is_completed'])->toBeFalse(); expect($array['is_failed'])->toBeFalse(); break; case 'completed': expect($array['is_completed'])->toBeTrue(); expect($array['is_starting'])->toBeFalse(); expect($array['is_failed'])->toBeFalse(); break; case 'failed': expect($array['is_failed'])->toBeTrue(); expect($array['is_starting'])->toBeFalse(); expect($array['is_completed'])->toBeFalse(); break; } } }); }); }); describe('Job Progress Tracking System Mock', function () { beforeEach(function () { // Create a mock progress tracker for testing $this->progressTracker = new class () { private array $progressEntries = []; public function updateProgress(string $jobId, JobProgress $progress, ?string $stepName = null): void { $this->progressEntries[$jobId][] = [ 'progress' => $progress, 'step_name' => $stepName, 'timestamp' => time(), 'id' => uniqid(), ]; } public function getCurrentProgress(string $jobId): ?JobProgress { if (! isset($this->progressEntries[$jobId]) || empty($this->progressEntries[$jobId])) { return null; } $entries = $this->progressEntries[$jobId]; return end($entries)['progress']; } public function getProgressHistory(string $jobId): array { return $this->progressEntries[$jobId] ?? []; } public function markJobCompleted(string $jobId, string $message = 'Job completed successfully'): void { $this->updateProgress($jobId, JobProgress::completed($message)); } public function markJobFailed(string $jobId, string $message = 'Job failed', ?\Throwable $exception = null): void { $metadata = []; if ($exception) { $metadata['exception_type'] = get_class($exception); $metadata['exception_message'] = $exception->getMessage(); } $progress = JobProgress::failed($message)->withMetadata($metadata); $this->updateProgress($jobId, $progress); } public function getProgressForJobs(array $jobIds): array { $result = []; foreach ($jobIds as $jobId) { $current = $this->getCurrentProgress($jobId); if ($current !== null) { $result[$jobId] = $current; } } return $result; } public function getJobsAboveProgress(float $minPercentage): array { $result = []; foreach ($this->progressEntries as $jobId => $entries) { $current = end($entries)['progress']; if ($current->percentage->getValue() >= $minPercentage) { $result[] = ['job_id' => $jobId, 'progress' => $current]; } } return $result; } }; }); describe('Progress Tracking Operations', function () { it('can track job progress updates', function () { $jobId = JobId::generate()->toString(); // Track job progression $this->progressTracker->updateProgress($jobId, JobProgress::starting('Job initialized')); $this->progressTracker->updateProgress($jobId, JobProgress::fromRatio(25, 100, 'Processing batch 1')); $this->progressTracker->updateProgress($jobId, JobProgress::fromRatio(50, 100, 'Processing batch 2')); $this->progressTracker->updateProgress($jobId, JobProgress::fromRatio(75, 100, 'Processing batch 3')); $this->progressTracker->markJobCompleted($jobId, 'All batches processed'); $history = $this->progressTracker->getProgressHistory($jobId); expect(count($history))->toBe(5); $current = $this->progressTracker->getCurrentProgress($jobId); expect($current->isCompleted())->toBeTrue(); expect($current->message)->toBe('All batches processed'); }); it('can track job with steps', function () { $jobId = JobId::generate()->toString(); $steps = [ 'validation' => 'Validating input data', 'processing' => 'Processing records', 'notification' => 'Sending notifications', 'cleanup' => 'Cleaning up temporary files', ]; foreach ($steps as $stepName => $message) { $this->progressTracker->updateProgress( $jobId, JobProgress::fromRatio(array_search($stepName, array_keys($steps)) + 1, count($steps), $message), $stepName ); } $history = $this->progressTracker->getProgressHistory($jobId); expect(count($history))->toBe(4); // Check step names are tracked $stepNames = array_map(fn ($entry) => $entry['step_name'], $history); expect($stepNames)->toBe(['validation', 'processing', 'notification', 'cleanup']); }); it('can mark jobs as failed with exception details', function () { $jobId = JobId::generate()->toString(); $this->progressTracker->updateProgress($jobId, JobProgress::starting('Starting email job')); $this->progressTracker->updateProgress($jobId, JobProgress::fromRatio(10, 100, 'Connecting to email service')); // Simulate failure with exception $exception = new \RuntimeException('Email service unavailable'); $this->progressTracker->markJobFailed($jobId, 'Failed to connect to email service', $exception); $current = $this->progressTracker->getCurrentProgress($jobId); expect($current->isFailed())->toBeTrue(); expect($current->message)->toBe('Failed to connect to email service'); expect($current->metadata['exception_type'])->toBe('RuntimeException'); expect($current->metadata['exception_message'])->toBe('Email service unavailable'); }); it('handles jobs with no progress', function () { $nonExistentJobId = JobId::generate()->toString(); $current = $this->progressTracker->getCurrentProgress($nonExistentJobId); expect($current)->toBeNull(); $history = $this->progressTracker->getProgressHistory($nonExistentJobId); expect($history)->toBe([]); }); }); describe('Bulk Progress Operations', function () { it('can get progress for multiple jobs', function () { $jobIds = [ JobId::generate()->toString(), JobId::generate()->toString(), JobId::generate()->toString(), ]; // Add progress for some jobs $this->progressTracker->updateProgress($jobIds[0], JobProgress::fromRatio(25, 100, 'Job 1 progress')); $this->progressTracker->updateProgress($jobIds[1], JobProgress::fromRatio(75, 100, 'Job 2 progress')); // Job 3 has no progress $bulkProgress = $this->progressTracker->getProgressForJobs($jobIds); expect(count($bulkProgress))->toBe(2); expect($bulkProgress[$jobIds[0]]->percentage->getValue())->toBe(25.0); expect($bulkProgress[$jobIds[1]]->percentage->getValue())->toBe(75.0); expect(isset($bulkProgress[$jobIds[2]]))->toBeFalse(); }); it('can find jobs above certain progress threshold', function () { $jobs = [ JobId::generate()->toString() => 10.0, JobId::generate()->toString() => 50.0, JobId::generate()->toString() => 80.0, JobId::generate()->toString() => 95.0, ]; foreach ($jobs as $jobId => $progress) { $this->progressTracker->updateProgress( $jobId, JobProgress::fromRatio((int)$progress, 100, "Progress at {$progress}%") ); } $jobsAbove60 = $this->progressTracker->getJobsAboveProgress(60.0); expect(count($jobsAbove60))->toBe(2); // 80% and 95% $jobsAbove90 = $this->progressTracker->getJobsAboveProgress(90.0); expect(count($jobsAbove90))->toBe(1); // Only 95% $jobsAbove100 = $this->progressTracker->getJobsAboveProgress(100.0); expect(count($jobsAbove100))->toBe(0); // None at 100% }); it('handles empty job lists gracefully', function () { $emptyResult = $this->progressTracker->getProgressForJobs([]); expect($emptyResult)->toBe([]); $noJobs = $this->progressTracker->getJobsAboveProgress(50.0); expect($noJobs)->toBe([]); }); }); describe('Progress Tracking Edge Cases', function () { it('handles rapid progress updates', function () { $jobId = JobId::generate()->toString(); // Simulate rapid updates for ($i = 0; $i <= 100; $i += 10) { $this->progressTracker->updateProgress( $jobId, JobProgress::fromRatio($i, 100, "Progress at {$i}%") ); } $history = $this->progressTracker->getProgressHistory($jobId); expect(count($history))->toBe(11); // 0, 10, 20, ..., 100 $current = $this->progressTracker->getCurrentProgress($jobId); expect($current->isCompleted())->toBeTrue(); }); it('maintains progress order', function () { $jobId = JobId::generate()->toString(); $progressUpdates = [ ['percentage' => 0, 'message' => 'Starting'], ['percentage' => 25, 'message' => 'Quarter done'], ['percentage' => 50, 'message' => 'Half done'], ['percentage' => 75, 'message' => 'Three quarters done'], ['percentage' => 100, 'message' => 'Completed'], ]; foreach ($progressUpdates as $update) { $this->progressTracker->updateProgress( $jobId, JobProgress::fromRatio($update['percentage'], 100, $update['message']) ); // Small delay to ensure different timestamps usleep(1000); } $history = $this->progressTracker->getProgressHistory($jobId); $messages = array_map(fn ($entry) => $entry['progress']->message, $history); expect($messages)->toBe([ 'Starting', 'Quarter done', 'Half done', 'Three quarters done', 'Completed', ]); }); it('handles concurrent job tracking', function () { $jobIds = [ 'job_a' => JobId::generate()->toString(), 'job_b' => JobId::generate()->toString(), 'job_c' => JobId::generate()->toString(), ]; // Simulate concurrent progress updates foreach ($jobIds as $label => $jobId) { $this->progressTracker->updateProgress($jobId, JobProgress::starting("Starting {$label}")); } foreach ($jobIds as $label => $jobId) { $this->progressTracker->updateProgress($jobId, JobProgress::fromRatio(50, 100, "{$label} half done")); } // Complete jobs at different times $this->progressTracker->markJobCompleted($jobIds['job_a'], 'Job A completed'); $this->progressTracker->markJobFailed($jobIds['job_b'], 'Job B failed'); $this->progressTracker->markJobCompleted($jobIds['job_c'], 'Job C completed'); // Verify independent tracking $progressA = $this->progressTracker->getCurrentProgress($jobIds['job_a']); $progressB = $this->progressTracker->getCurrentProgress($jobIds['job_b']); $progressC = $this->progressTracker->getCurrentProgress($jobIds['job_c']); expect($progressA->isCompleted())->toBeTrue(); expect($progressB->isFailed())->toBeTrue(); expect($progressC->isCompleted())->toBeTrue(); // Each job should have its own history expect(count($this->progressTracker->getProgressHistory($jobIds['job_a'])))->toBe(3); expect(count($this->progressTracker->getProgressHistory($jobIds['job_b'])))->toBe(3); expect(count($this->progressTracker->getProgressHistory($jobIds['job_c'])))->toBe(3); }); }); }); describe('Job Progress Integration Scenarios', function () { beforeEach(function () { $this->emailJob = new class () { public function __construct( public array $recipients = ['test@example.com'], public string $subject = 'Test Email', public string $template = 'newsletter' ) { } public function getRecipientCount(): int { return count($this->recipients); } }; $this->reportJob = new class () { public function __construct( public string $reportType = 'sales', public array $criteria = ['period' => 'monthly'], public int $totalSteps = 5 ) { } public function getSteps(): array { return [ 'data_collection' => 'Collecting data from database', 'data_processing' => 'Processing and aggregating data', 'chart_generation' => 'Generating charts and graphs', 'pdf_creation' => 'Creating PDF document', 'distribution' => 'Distributing report to stakeholders', ]; } }; }); it('demonstrates email job progress tracking', function () { $jobId = JobId::generate()->toString(); $progressTracker = new class () { private array $progressEntries = []; public function updateProgress(string $jobId, JobProgress $progress, ?string $stepName = null): void { $this->progressEntries[$jobId][] = ['progress' => $progress, 'step_name' => $stepName]; } public function getCurrentProgress(string $jobId): ?JobProgress { if (! isset($this->progressEntries[$jobId])) { return null; } return end($this->progressEntries[$jobId])['progress']; } }; $totalRecipients = count($this->emailJob->recipients); // Start email job $progressTracker->updateProgress( $jobId, JobProgress::starting('Initializing email job'), 'initialization' ); // Template preparation $progressTracker->updateProgress( $jobId, JobProgress::fromRatio(1, 4, 'Preparing email template'), 'template_preparation' ); // Recipient validation $progressTracker->updateProgress( $jobId, JobProgress::fromRatio(2, 4, 'Validating recipient addresses'), 'recipient_validation' ); // Email sending $progressTracker->updateProgress( $jobId, JobProgress::fromRatio(3, 4, 'Sending emails'), 'email_sending' ); // Completion $progressTracker->updateProgress( $jobId, JobProgress::completed('All emails sent successfully'), 'completion' ); $finalProgress = $progressTracker->getCurrentProgress($jobId); expect($finalProgress->isCompleted())->toBeTrue(); expect($finalProgress->message)->toBe('All emails sent successfully'); }); it('demonstrates report generation progress tracking', function () { $jobId = JobId::generate()->toString(); $progressTracker = new class () { private array $progressEntries = []; public function updateProgress(string $jobId, JobProgress $progress, ?string $stepName = null): void { $this->progressEntries[$jobId][] = ['progress' => $progress, 'step_name' => $stepName]; } public function getProgressHistory(string $jobId): array { return $this->progressEntries[$jobId] ?? []; } }; $steps = $this->reportJob->getSteps(); $totalSteps = count($steps); $currentStep = 0; foreach ($steps as $stepName => $description) { $currentStep++; $progressTracker->updateProgress( $jobId, JobProgress::fromRatio($currentStep, $totalSteps, $description), $stepName ); } $history = $progressTracker->getProgressHistory($jobId); expect(count($history))->toBe($totalSteps); // Verify step progression $stepNames = array_map(fn ($entry) => $entry['step_name'], $history); expect($stepNames)->toBe(array_keys($steps)); // Verify progress percentages $percentages = array_map(fn ($entry) => $entry['progress']->percentage->getValue(), $history); expect($percentages)->toBe([20.0, 40.0, 60.0, 80.0, 100.0]); }); it('demonstrates error handling with progress tracking', function () { $jobId = JobId::generate()->toString(); $progressTracker = new class () { private array $progressEntries = []; public function updateProgress(string $jobId, JobProgress $progress, ?string $stepName = null): void { $this->progressEntries[$jobId][] = ['progress' => $progress, 'step_name' => $stepName]; } public function getCurrentProgress(string $jobId): ?JobProgress { if (! isset($this->progressEntries[$jobId])) { return null; } return end($this->progressEntries[$jobId])['progress']; } }; // Start processing $progressTracker->updateProgress( $jobId, JobProgress::starting('Starting data processing job'), 'initialization' ); $progressTracker->updateProgress( $jobId, JobProgress::fromRatio(1, 3, 'Loading data from database'), 'data_loading' ); // Simulate error during processing $exception = new \RuntimeException('Database connection lost'); $failedProgress = JobProgress::failed('Processing failed due to database error') ->withMetadata([ 'exception_type' => get_class($exception), 'exception_message' => $exception->getMessage(), 'failed_at_step' => 'data_processing', 'items_processed' => 150, 'total_items' => 500, ]); $progressTracker->updateProgress($jobId, $failedProgress, 'data_processing'); $currentProgress = $progressTracker->getCurrentProgress($jobId); expect($currentProgress->isFailed())->toBeTrue(); expect($currentProgress->metadata['items_processed'])->toBe(150); expect($currentProgress->metadata['exception_type'])->toBe('RuntimeException'); }); });