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).
This commit is contained in:
180
backend/tests/Unit/ReconciliationCalculatorTest.php
Normal file
180
backend/tests/Unit/ReconciliationCalculatorTest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMonthPlan;
|
||||
use App\Services\ReconciliationCalculator;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ReconciliationCalculatorTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ReconciliationCalculator $calculator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
160
backend/tests/Unit/VarianceCalculatorTest.php
Normal file
160
backend/tests/Unit/VarianceCalculatorTest.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMonthPlan;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\VarianceCalculator;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class VarianceCalculatorTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private VarianceCalculator $calculator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user