From 7fa5b9061c55a7d142487b3b925fb10a905c04c4 Mon Sep 17 00:00:00 2001 From: Santhosh Janardhanan Date: Sun, 8 Mar 2026 18:22:40 -0400 Subject: [PATCH] test(backend): add comprehensive tests for reporting and allocation Add test coverage for: - ReportTest: 9 tests for reporting API (payload, view types, filters, validation) - ProjectMonthPlanTest: CRUD operations for monthly planning - UntrackedAllocationTest: untracked allocation handling - ReconciliationCalculatorTest: plan vs estimate reconciliation logic - VarianceCalculatorTest: variance and status calculations All tests passing (157 total). --- .../tests/Feature/ProjectMonthPlanTest.php | 134 +++++++ backend/tests/Feature/ReportTest.php | 367 ++++++++++++++++++ .../tests/Feature/UntrackedAllocationTest.php | 113 ++++++ .../Unit/ReconciliationCalculatorTest.php | 180 +++++++++ backend/tests/Unit/VarianceCalculatorTest.php | 160 ++++++++ 5 files changed, 954 insertions(+) create mode 100644 backend/tests/Feature/ProjectMonthPlanTest.php create mode 100644 backend/tests/Feature/ReportTest.php create mode 100644 backend/tests/Feature/UntrackedAllocationTest.php create mode 100644 backend/tests/Unit/ReconciliationCalculatorTest.php create mode 100644 backend/tests/Unit/VarianceCalculatorTest.php diff --git a/backend/tests/Feature/ProjectMonthPlanTest.php b/backend/tests/Feature/ProjectMonthPlanTest.php new file mode 100644 index 00000000..bc9f443a --- /dev/null +++ b/backend/tests/Feature/ProjectMonthPlanTest.php @@ -0,0 +1,134 @@ +create([ + 'email' => 'manager@test.com', + 'password' => bcrypt('password123'), + 'role' => 'manager', + 'active' => true, + ]); + + $response = $this->postJson('/api/auth/login', [ + 'email' => 'manager@test.com', + 'password' => 'password123', + ]); + + return $response->json('access_token'); + } + + public function test_bulk_update_creates_new_plan_records(): void + { + $token = $this->loginAsManager(); + $project = Project::factory()->create(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->putJson('/api/project-month-plans/bulk', [ + 'year' => 2026, + 'items' => [ + [ + 'project_id' => $project->id, + 'month' => '2026-01', + 'planned_hours' => 100, + ], + ], + ]); + + $response->assertStatus(200); + } + + public function test_validation_rejects_invalid_data(): void + { + $token = $this->loginAsManager(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->putJson('/api/project-month-plans/bulk', [ + 'year' => 2026, + 'items' => [ + [ + 'project_id' => 'invalid-uuid', + 'month' => '2026-01', + 'planned_hours' => 100, + ], + ], + ]); + + $response->assertStatus(422); + } + + public function test_index_returns_existing_plan_for_month(): void + { + $token = $this->loginAsManager(); + $project = Project::factory()->create(); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-02-01', + 'planned_hours' => 50, + ]); + + $projectData = $this->fetchProjectPlan($token, $project); + + $this->assertNotNull($projectData['months']['2026-02-01']); + } + + public function test_index_returns_null_for_missing_month(): void + { + $token = $this->loginAsManager(); + $project = Project::factory()->create(); + + $projectData = $this->fetchProjectPlan($token, $project); + + $this->assertNull($projectData['months']['2026-03-01']); + } + + public function test_bulk_update_roundtrip_populates_month(): void + { + $token = $this->loginAsManager(); + $project = Project::factory()->create(); + + $this->withHeader('Authorization', "Bearer {$token}") + ->putJson('/api/project-month-plans/bulk', [ + 'year' => 2026, + 'items' => [ + [ + 'project_id' => $project->id, + 'month' => '2026-02', + 'planned_hours' => 72, + ], + ], + ]) + ->assertStatus(200); + + $projectData = $this->fetchProjectPlan($token, $project); + + $this->assertNotNull($projectData['months']['2026-02-01']); + $this->assertEquals(72, $projectData['plan_sum']); + } + + private function fetchProjectPlan(string $token, Project $project): array + { + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/project-month-plans?year=2026'); + + $response->assertStatus(200); + + $projectData = collect($response->json('data'))->firstWhere('project_id', $project->id); + + $this->assertNotNull($projectData); + + return $projectData; + } +} diff --git a/backend/tests/Feature/ReportTest.php b/backend/tests/Feature/ReportTest.php new file mode 100644 index 00000000..f5f7270e --- /dev/null +++ b/backend/tests/Feature/ReportTest.php @@ -0,0 +1,367 @@ +create([ + 'email' => 'manager@test.com', + 'password' => bcrypt('password123'), + 'role' => 'manager', + 'active' => true, + ]); + + $response = $this->postJson('/api/auth/login', [ + 'email' => 'manager@test.com', + 'password' => 'password123', + ]); + + return $response->json('access_token'); + } + + // Task 4.1: Reporting payload includes lifecycle total, month plan, month execution, and variances + public function test_reporting_payload_includes_all_required_data(): void + { + $token = $this->loginAsManager(); + + // Create project with approved estimate + $project = Project::factory()->create([ + 'approved_estimate' => 3000, + ]); + + // Create month plans + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => 1200, + ]); + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-02-01', + 'planned_hours' => 1400, + ]); + + // Create team member + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + + // Create allocation + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $member->id, + 'month' => '2026-01-01', + 'allocated_hours' => 1300, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/reports/allocations?start_date=2026-01-01&end_date=2026-02-28'); + + $response->assertStatus(200); + + // Verify top-level structure + $response->assertJsonStructure([ + 'period' => ['start', 'end'], + 'view_type', + 'projects' => [ + '*' => [ + 'id', + 'code', + 'title', + 'approved_estimate', + 'lifecycle_status', + 'plan_sum', + 'period_planned', + 'period_allocated', + 'period_variance', + 'period_status', + 'months' => [ + '*' => [ + 'month', + 'planned_hours', + 'is_blank', + 'allocated_hours', + 'variance', + 'status', + ], + ], + ], + ], + 'members' => [ + '*' => [ + 'id', + 'name', + 'period_allocated', + 'projects' => [ + '*' => [ + 'project_id', + 'project_code', + 'project_title', + 'total_hours', + ], + ], + ], + ], + 'aggregates' => [ + 'total_planned', + 'total_allocated', + 'total_variance', + 'status', + ], + ]); + + // Verify data values + $data = $response->json(); + $projectData = collect($data['projects'])->firstWhere('id', $project->id); + + $this->assertEquals(3000.0, $projectData['approved_estimate']); + $this->assertEquals(2600.0, $projectData['plan_sum']); // 1200 + 1400 + $this->assertEquals(2600.0, $projectData['period_planned']); + $this->assertEquals(1300.0, $projectData['period_allocated']); + $this->assertEquals(-1300.0, $projectData['period_variance']); + $this->assertEquals('UNDER', $projectData['period_status']); + $this->assertEquals('UNDER', $projectData['lifecycle_status']); + } + + // Task 4.2: Historical/current/future month slices are consistent + public function test_view_type_did_for_past_months(): void + { + $token = $this->loginAsManager(); + Project::factory()->create(); + + $now = Carbon::now(); + $pastStart = $now->copy()->subMonths(3)->startOfMonth(); + $pastEnd = $now->copy()->subMonth()->endOfMonth(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson("/api/reports/allocations?start_date={$pastStart->format('Y-m-d')}&end_date={$pastEnd->format('Y-m-d')}"); + + $response->assertStatus(200); + $response->assertJsonPath('view_type', 'did'); + } + + public function test_view_type_is_for_current_month(): void + { + $token = $this->loginAsManager(); + Project::factory()->create(); + + $now = Carbon::now(); + $currentStart = $now->copy()->startOfMonth(); + $currentEnd = $now->copy()->endOfMonth(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson("/api/reports/allocations?start_date={$currentStart->format('Y-m-d')}&end_date={$currentEnd->format('Y-m-d')}"); + + $response->assertStatus(200); + $response->assertJsonPath('view_type', 'is'); + } + + public function test_view_type_will_for_future_months(): void + { + $token = $this->loginAsManager(); + Project::factory()->create(); + + $now = Carbon::now(); + $futureStart = $now->copy()->addMonth()->startOfMonth(); + $futureEnd = $now->copy()->addMonths(3)->endOfMonth(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson("/api/reports/allocations?start_date={$futureStart->format('Y-m-d')}&end_date={$futureEnd->format('Y-m-d')}"); + + $response->assertStatus(200); + $response->assertJsonPath('view_type', 'will'); + } + + // Task 4.4: Distinguish blank plan vs explicit zero + public function test_distinguishes_blank_plan_from_explicit_zero(): void + { + $token = $this->loginAsManager(); + + $project = Project::factory()->create(); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + + // January: explicit plan of 100 + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => 100, + ]); + + // February: explicit plan of 0 + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-02-01', + 'planned_hours' => 0, + ]); + + // March: blank (no plan entry) + + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $member->id, + 'month' => '2026-01-01', + 'allocated_hours' => 80, + ]); + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $member->id, + 'month' => '2026-02-01', + 'allocated_hours' => 50, + ]); + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $member->id, + 'month' => '2026-03-01', + 'allocated_hours' => 30, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/reports/allocations?start_date=2026-01-01&end_date=2026-03-31'); + + $response->assertStatus(200); + $data = $response->json(); + $projectData = collect($data['projects'])->firstWhere('id', $project->id); + + $january = collect($projectData['months'])->firstWhere('month', '2026-01'); + $february = collect($projectData['months'])->firstWhere('month', '2026-02'); + $march = collect($projectData['months'])->firstWhere('month', '2026-03'); + + // January: explicit 100 + $this->assertEquals(100.0, $january['planned_hours']); + $this->assertFalse($january['is_blank']); + + // February: explicit 0 + $this->assertEquals(0.0, $february['planned_hours']); + $this->assertFalse($february['is_blank']); + + // March: blank + $this->assertNull($march['planned_hours']); + $this->assertTrue($march['is_blank']); + } + + public function test_filters_by_project_ids(): void + { + $token = $this->loginAsManager(); + + $project1 = Project::factory()->create(); + $project2 = Project::factory()->create(); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + + ProjectMonthPlan::create([ + 'project_id' => $project1->id, + 'month' => '2026-01-01', + 'planned_hours' => 100, + ]); + ProjectMonthPlan::create([ + 'project_id' => $project2->id, + 'month' => '2026-01-01', + 'planned_hours' => 200, + ]); + + Allocation::factory()->create([ + 'project_id' => $project1->id, + 'team_member_id' => $member->id, + 'month' => '2026-01-01', + 'allocated_hours' => 80, + ]); + Allocation::factory()->create([ + 'project_id' => $project2->id, + 'team_member_id' => $member->id, + 'month' => '2026-01-01', + 'allocated_hours' => 150, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson("/api/reports/allocations?start_date=2026-01-01&end_date=2026-01-31&project_ids[]={$project1->id}"); + + $response->assertStatus(200); + $data = $response->json(); + + $this->assertCount(1, $data['projects']); + $this->assertEquals($project1->id, $data['projects'][0]['id']); + $this->assertEquals(100.0, $data['aggregates']['total_planned']); + } + + public function test_includes_untracked_allocations(): void + { + $token = $this->loginAsManager(); + + $project = Project::factory()->create(); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => 200, + ]); + + // Tracked allocation + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $member->id, + 'month' => '2026-01-01', + 'allocated_hours' => 100, + ]); + + // Untracked allocation + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => null, + 'month' => '2026-01-01', + 'allocated_hours' => 50, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/reports/allocations?start_date=2026-01-01&end_date=2026-01-31'); + + $response->assertStatus(200); + $data = $response->json(); + + // Project should have 150 total (100 tracked + 50 untracked) + $projectData = collect($data['projects'])->firstWhere('id', $project->id); + $this->assertEquals(150.0, $projectData['period_allocated']); + + // Members should include untracked row + $untrackedRow = collect($data['members'])->firstWhere('name', 'Untracked'); + $this->assertNotNull($untrackedRow); + $this->assertEquals(50.0, $untrackedRow['period_allocated']); + } + + public function test_validates_required_date_parameters(): void + { + $token = $this->loginAsManager(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/reports/allocations'); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['start_date', 'end_date']); + } + + public function test_validates_end_date_after_start_date(): void + { + $token = $this->loginAsManager(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/reports/allocations?start_date=2026-03-01&end_date=2026-01-01'); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['end_date']); + } +} diff --git a/backend/tests/Feature/UntrackedAllocationTest.php b/backend/tests/Feature/UntrackedAllocationTest.php new file mode 100644 index 00000000..6b3ca05e --- /dev/null +++ b/backend/tests/Feature/UntrackedAllocationTest.php @@ -0,0 +1,113 @@ +create([ + 'email' => 'manager@test.com', + 'password' => bcrypt('password123'), + 'role' => 'manager', + 'active' => true, + ]); + + $response = $this->postJson('/api/auth/login', [ + 'email' => 'manager@test.com', + 'password' => 'password123', + ]); + + return $response->json('access_token'); + } + + public function test_create_allocation_accepts_null_team_member_id(): void + { + $token = $this->loginAsManager(); + $project = Project::factory()->create(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/allocations', [ + 'project_id' => $project->id, + 'team_member_id' => null, + 'month' => '2026-02', + 'allocated_hours' => 40, + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('allocations', [ + 'project_id' => $project->id, + 'team_member_id' => null, + 'allocated_hours' => 40, + ]); + } + + public function test_bulk_create_accepts_mixed_tracked_and_untracked(): void + { + $token = $this->loginAsManager(); + $project = Project::factory()->create(); + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/allocations/bulk', [ + 'allocations' => [ + [ + 'project_id' => $project->id, + 'team_member_id' => $teamMember->id, + 'month' => '2026-02', + 'allocated_hours' => 40, + ], + [ + 'project_id' => $project->id, + 'team_member_id' => null, + 'month' => '2026-02', + 'allocated_hours' => 30, + ], + ], + ]); + + $response->assertStatus(201); + $this->assertDatabaseCount('allocations', 2); + } + + public function test_partial_bulk_persists_valid_rows(): void + { + $token = $this->loginAsManager(); + $project = Project::factory()->create(); + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/allocations/bulk', [ + 'allocations' => [ + [ + 'project_id' => $project->id, + 'team_member_id' => $teamMember->id, + 'month' => '2026-02', + 'allocated_hours' => 40, + ], + [ + 'project_id' => 'invalid-uuid', + 'team_member_id' => $teamMember->id, + 'month' => '2026-02', + 'allocated_hours' => 20, + ], + ], + ]); + + $response->assertStatus(201); + $response->assertJsonPath('summary.created', 1); + $response->assertJsonPath('summary.failed', 1); + $this->assertDatabaseCount('allocations', 1); + } +} diff --git a/backend/tests/Unit/ReconciliationCalculatorTest.php b/backend/tests/Unit/ReconciliationCalculatorTest.php new file mode 100644 index 00000000..fe78964d --- /dev/null +++ b/backend/tests/Unit/ReconciliationCalculatorTest.php @@ -0,0 +1,180 @@ +calculator = new ReconciliationCalculator; + } + + // 1.1: Unit test - reconciliation status OVER + public function test_returns_over_when_plan_sum_exceeds_approved_estimate(): void + { + $project = Project::factory()->create([ + 'approved_estimate' => 1000, + ]); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => 600, + ]); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-02-01', + 'planned_hours' => 600, + ]); + + $status = $this->calculator->calculateStatus($project); + + $this->assertEquals('OVER', $status); + } + + // 1.2: Unit test - reconciliation status UNDER + public function test_returns_under_when_plan_sum_is_less_than_approved_estimate(): void + { + $project = Project::factory()->create([ + 'approved_estimate' => 1000, + ]); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => 400, + ]); + + $status = $this->calculator->calculateStatus($project); + + $this->assertEquals('UNDER', $status); + } + + // 1.3: Unit test - reconciliation status MATCH + public function test_returns_match_when_plan_sum_equals_approved_estimate(): void + { + $project = Project::factory()->create([ + 'approved_estimate' => 1000, + ]); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => 500, + ]); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-02-01', + 'planned_hours' => 500, + ]); + + $status = $this->calculator->calculateStatus($project); + + $this->assertEquals('MATCH', $status); + } + + // Additional test: decimal-safe MATCH + public function test_returns_match_with_decimal_precision(): void + { + $project = Project::factory()->create([ + 'approved_estimate' => 100.50, + ]); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => 50.25, + ]); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-02-01', + 'planned_hours' => 50.25, + ]); + + $status = $this->calculator->calculateStatus($project); + + $this->assertEquals('MATCH', $status); + } + + // Test: blank/null planned_hours are excluded from sum + public function test_excludes_null_planned_hours_from_sum(): void + { + $project = Project::factory()->create([ + 'approved_estimate' => 500, + ]); + + // Create plan with null planned_hours (blank cell) + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => null, + ]); + + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-02-01', + 'planned_hours' => 500, + ]); + + $status = $this->calculator->calculateStatus($project); + $planSum = $this->calculator->calculatePlanSum($project); + + $this->assertEquals(500, $planSum); + $this->assertEquals('MATCH', $status); + } + + // Test: no approved estimate returns UNDER + public function test_returns_under_when_no_approved_estimate(): void + { + $project = Project::factory()->create([ + 'approved_estimate' => 0, + ]); + + $status = $this->calculator->calculateStatus($project); + + $this->assertEquals('UNDER', $status); + } + + // Test: calculateForProjects returns array with correct structure + public function test_calculate_for_projects_returns_correct_structure(): void + { + $project1 = Project::factory()->create(['approved_estimate' => 1000]); + $project2 = Project::factory()->create(['approved_estimate' => 500]); + + ProjectMonthPlan::create([ + 'project_id' => $project1->id, + 'month' => '2026-01-01', + 'planned_hours' => 1000, + ]); + + ProjectMonthPlan::create([ + 'project_id' => $project2->id, + 'month' => '2026-01-01', + 'planned_hours' => 300, + ]); + + $projects = Project::whereIn('id', [$project1->id, $project2->id])->get(); + $results = $this->calculator->calculateForProjects($projects, 2026); + + $this->assertArrayHasKey($project1->id, $results); + $this->assertArrayHasKey($project2->id, $results); + $this->assertEquals(1000, $results[$project1->id]['plan_sum']); + $this->assertEquals('MATCH', $results[$project1->id]['status']); + $this->assertEquals(300, $results[$project2->id]['plan_sum']); + $this->assertEquals('UNDER', $results[$project2->id]['status']); + } +} diff --git a/backend/tests/Unit/VarianceCalculatorTest.php b/backend/tests/Unit/VarianceCalculatorTest.php new file mode 100644 index 00000000..be0f2b47 --- /dev/null +++ b/backend/tests/Unit/VarianceCalculatorTest.php @@ -0,0 +1,160 @@ +calculator = new VarianceCalculator; + } + + // 2.1: Unit test - row variance uses selected month planned value + public function test_row_variance_uses_planned_month_value(): void + { + $project = Project::factory()->create(); + + // Create month plan for Jan 2026 + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => 100, + ]); + + // Create allocation totaling 120 hours using factory for proper date handling + $allocation = Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => null, // untracked + 'month' => '2026-01-01', + 'allocated_hours' => 120, + ]); + + $result = $this->calculator->calculateRowVariance($project->id, '2026-01'); + + $this->assertEquals(120, $result['allocated_total']); + $this->assertEquals(100, $result['planned_month']); + $this->assertEquals(20, $result['variance']); + $this->assertEquals('OVER', $result['status']); + } + + // 2.2: Unit test - blank month plan treated as zero for row variance + public function test_blank_month_plan_treated_as_zero(): void + { + $project = Project::factory()->create(); + + // No month plan created (blank) + + // Create allocation of 50 hours + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => null, + 'month' => '2026-01-01', + 'allocated_hours' => 50, + ]); + + $result = $this->calculator->calculateRowVariance($project->id, '2026-01'); + + // Blank plan = 0, so variance = 50 - 0 = 50 + $this->assertEquals(50, $result['allocated_total']); + $this->assertEquals(0, $result['planned_month']); + $this->assertEquals(50, $result['variance']); + $this->assertEquals('OVER', $result['status']); + } + + // 2.3: Unit test - column variance uses member month capacity + public function test_column_variance_uses_member_capacity(): void + { + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); + + // Create allocation for member + Allocation::factory()->create([ + 'project_id' => Project::factory()->create()->id, + 'team_member_id' => $teamMember->id, + 'month' => '2026-01-01', + 'allocated_hours' => 120, + ]); + + // Mock capacity service - we'll test basic logic here + $mockCapacityService = $this->createMock(\App\Services\CapacityService::class); + $mockCapacityService->method('calculateIndividualCapacity') + ->willReturn(['hours' => 160]); + + $result = $this->calculator->calculateColumnVariance($teamMember->id, '2026-01', $mockCapacityService); + + $this->assertEquals(120, $result['allocated']); + $this->assertEquals(160, $result['capacity']); + $this->assertEquals(-40, $result['variance']); + $this->assertEquals('UNDER', $result['status']); + } + + // Additional test: MATCH status when variance is zero + public function test_determine_status_returns_match_when_variance_is_zero(): void + { + $status = $this->calculator->determineStatus(0); + $this->assertEquals('MATCH', $status); + } + + // Additional test: get planned hours returns zero for non-existent plan + public function test_get_planned_hours_returns_zero_for_no_plan(): void + { + $project = Project::factory()->create(); + + $plannedHours = $this->calculator->getPlannedHoursForMonth($project->id, '2026-01'); + + $this->assertEquals(0, $plannedHours); + } + + // Additional test: untracked allocation is included in row variance + public function test_untracked_allocation_included_in_row_variance(): void + { + $project = Project::factory()->create(); + + // Create month plan + ProjectMonthPlan::create([ + 'project_id' => $project->id, + 'month' => '2026-01-01', + 'planned_hours' => 100, + ]); + + // Create tracked allocation + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); + + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $teamMember->id, + 'month' => '2026-01-01', + 'allocated_hours' => 60, + ]); + + // Create untracked allocation (team_member_id = null) + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => null, + 'month' => '2026-01-01', + 'allocated_hours' => 60, + ]); + + $result = $this->calculator->calculateRowVariance($project->id, '2026-01'); + + // Total should include both tracked and untracked + $this->assertEquals(120, $result['allocated_total']); + $this->assertEquals(20, $result['variance']); + $this->assertEquals('OVER', $result['status']); + } +}