create(); $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); $projectStatus = ProjectStatus::factory()->create(['name' => 'Active']); $project = Project::factory()->create(['status_id' => $projectStatus->id]); // Create allocation: 100 hours allocated Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'allocated_hours' => 100, ]); // Create actual: 80 hours logged Actual::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'hours_logged' => 80, ]); $service = app(ActualsService::class); $result = $service->calculateVariance($project->id, $teamMember->id, '2026-02'); // Variance = ((80 - 100) / 100) * 100 = -20% expect($result['allocated'])->toBe(100.0) ->and($result['actual'])->toBe(80.0) ->and($result['variance_percentage'])->toBe(-20.0) ->and($result['indicator'])->toBe('yellow'); }); test('calculate_variance handles zero allocation', function () { $role = Role::factory()->create(); $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); $projectStatus = ProjectStatus::factory()->create(['name' => 'Active']); $project = Project::factory()->create(['status_id' => $projectStatus->id]); // No allocation, but actual hours logged Actual::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'hours_logged' => 40, ]); $service = app(ActualsService::class); $result = $service->calculateVariance($project->id, $teamMember->id, '2026-02'); // When allocation is 0 but actual is > 0, variance is 100% expect($result['allocated'])->toBe(0.0) ->and($result['actual'])->toBe(40.0) ->and($result['variance_percentage'])->toBe(100.0) ->and($result['indicator'])->toBe('red'); }); test('calculate_variance handles both zero', function () { $role = Role::factory()->create(); $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); $projectStatus = ProjectStatus::factory()->create(['name' => 'Active']); $project = Project::factory()->create(['status_id' => $projectStatus->id]); // No allocation, no actual hours $service = app(ActualsService::class); $result = $service->calculateVariance($project->id, $teamMember->id, '2026-02'); // When both are 0, variance is 0% expect($result['allocated'])->toBe(0.0) ->and($result['actual'])->toBe(0.0) ->and($result['variance_percentage'])->toBe(0.0) ->and($result['indicator'])->toBe('green'); }); test('calculate_variance handles positive_variance', function () { $role = Role::factory()->create(); $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); $projectStatus = ProjectStatus::factory()->create(['name' => 'Active']); $project = Project::factory()->create(['status_id' => $projectStatus->id]); Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'allocated_hours' => 80, ]); Actual::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'hours_logged' => 100, ]); $service = app(ActualsService::class); $result = $service->calculateVariance($project->id, $teamMember->id, '2026-02'); // Variance = ((100 - 80) / 80) * 100 = 25% expect($result['allocated'])->toBe(80.0) ->and($result['actual'])->toBe(100.0) ->and($result['variance_percentage'])->toBe(25.0) ->and($result['indicator'])->toBe('red'); }); // ============================================================================ // INDICATOR THRESHOLD TESTS // ============================================================================ test('get_indicator returns green for small variance', function () { $service = app(ActualsService::class); // Green: |variance| <= 5% $reflection = new ReflectionClass($service); $method = $reflection->getMethod('getIndicator'); $method->setAccessible(true); expect($method->invoke($service, 0.0))->toBe('green') ->and($method->invoke($service, 5.0))->toBe('green') ->and($method->invoke($service, -5.0))->toBe('green') ->and($method->invoke($service, 3.5))->toBe('green') ->and($method->invoke($service, -2.1))->toBe('green'); }); test('get_indicator returns yellow for medium variance', function () { $service = app(ActualsService::class); // Yellow: 5% < |variance| <= 20% $reflection = new ReflectionClass($service); $method = $reflection->getMethod('getIndicator'); $method->setAccessible(true); expect($method->invoke($service, 6.0))->toBe('yellow') ->and($method->invoke($service, 20.0))->toBe('yellow') ->and($method->invoke($service, -10.0))->toBe('yellow') ->and($method->invoke($service, -15.5))->toBe('yellow') ->and($method->invoke($service, 19.9))->toBe('yellow'); }); test('get_indicator returns red for large variance', function () { $service = app(ActualsService::class); // Red: |variance| > 20% $reflection = new ReflectionClass($service); $method = $reflection->getMethod('getIndicator'); $method->setAccessible(true); expect($method->invoke($service, 21.0))->toBe('red') ->and($method->invoke($service, -21.0))->toBe('red') ->and($method->invoke($service, 50.0))->toBe('red') ->and($method->invoke($service, -100.0))->toBe('red') ->and($method->invoke($service, 1000.0))->toBe('red'); }); // ============================================================================ // PROJECT STATUS TESTS // ============================================================================ test('get_inactive_project_statuses returns expected values', function () { $service = app(ActualsService::class); $statuses = $service->getInactiveProjectStatuses(); expect($statuses)->toBe(['Done', 'Cancelled', 'Closed']) ->and(count($statuses))->toBe(3); }); test('can_log_to_inactive_projects respects config', function () { $service = app(ActualsService::class); // Default config value should be false config(['actuals.allow_actuals_on_inactive_projects' => false]); expect($service->canLogToInactiveProjects())->toBeFalse(); // When enabled, should return true config(['actuals.allow_actuals_on_inactive_projects' => true]); expect($service->canLogToInactiveProjects())->toBeTrue(); }); // ============================================================================ // EDGE CASE TESTS // ============================================================================ test('calculate_variance sums multiple allocations', function () { $role = Role::factory()->create(); $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); $projectStatus = ProjectStatus::factory()->create(['name' => 'Active']); $project = Project::factory()->create(['status_id' => $projectStatus->id]); // Multiple allocations for the same project/member/month Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'allocated_hours' => 60, ]); Actual::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'hours_logged' => 100, ]); $service = app(ActualsService::class); $result = $service->calculateVariance($project->id, $teamMember->id, '2026-02'); // Total allocated: 100, Actual: 100, Variance: 0% expect($result['allocated'])->toBe(100.0) ->and($result['actual'])->toBe(100.0) ->and($result['variance_percentage'])->toBe(0.0) ->and($result['indicator'])->toBe('green'); }); test('calculate_variance sums multiple actuals', function () { $role = Role::factory()->create(); $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); $projectStatus = ProjectStatus::factory()->create(['name' => 'Active']); $project = Project::factory()->create(['status_id' => $projectStatus->id]); Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'allocated_hours' => 100, ]); // Multiple actual entries (simulating multiple time logs) Actual::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'hours_logged' => 40, ]); Actual::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'hours_logged' => 60, ]); $service = app(ActualsService::class); $result = $service->calculateVariance($project->id, $teamMember->id, '2026-02'); // Total allocated: 100, Total actual: 100, Variance: 0% expect($result['allocated'])->toBe(100.0) ->and($result['actual'])->toBe(100.0) ->and($result['variance_percentage'])->toBe(0.0) ->and($result['indicator'])->toBe('green'); }); test('calculate_variance handles decimal precision', function () { $role = Role::factory()->create(); $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); $projectStatus = ProjectStatus::factory()->create(['name' => 'Active']); $project = Project::factory()->create(['status_id' => $projectStatus->id]); Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'allocated_hours' => 33.33, ]); Actual::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'hours_logged' => 66.66, ]); $service = app(ActualsService::class); $result = $service->calculateVariance($project->id, $teamMember->id, '2026-02'); // Variance = ((66.66 - 33.33) / 33.33) * 100 = 100% expect($result['variance_percentage'])->toBe(100.0) ->and($result['indicator'])->toBe('red'); }); test('calculate_variance only_matches_exact_month', function () { $role = Role::factory()->create(); $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); $projectStatus = ProjectStatus::factory()->create(['name' => 'Active']); $project = Project::factory()->create(['status_id' => $projectStatus->id]); // Allocation in January Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-01-01', 'allocated_hours' => 100, ]); // Actual in February Actual::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $teamMember->id, 'month' => '2026-02-01', 'hours_logged' => 80, ]); $service = app(ActualsService::class); // Querying February should only find the actual, not the January allocation $result = $service->calculateVariance($project->id, $teamMember->id, '2026-02'); expect($result['allocated'])->toBe(0.0) ->and($result['actual'])->toBe(80.0) ->and($result['variance_percentage'])->toBe(100.0); });