make(Environment::class, [ 'defaultTimezone' => 'UTC', 'language' => 'en-US', ]); app()->instance(Environment::class, $environmentMock); $languageMock = $this->createMock(Language::class); $languageMock->method('__')->willReturnCallback(fn ($index) => [ 'language.dateformat' => 'm/d/Y', 'language.timeformat' => 'h:i A', ][$index] ?? null); app()->instance(Language::class, $languageMock); CarbonImmutable::mixin(new CarbonMacros('UTC', 'en_US', 'm/d/Y', 'h:i A')); app()->instance(DateTimeHelper::class, new DateTimeHelper); CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-08 12:00:00', 'UTC')); $this->ticketsRepo = $this->createMock(TicketsRepo::class); $this->analyzer = new CapacityAnalyzer($this->ticketsRepo); // Default status vocabulary: 3=NEW, 4=INPROGRESS, 0=DONE, -1=DONE(archived), // 7 = a CUSTOM done status with a positive id. $this->ticketsRepo->method('getStateLabels')->willReturn([ 3 => ['statusType' => 'NEW', 'name' => 'New'], 4 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress'], 0 => ['statusType' => 'DONE', 'name' => 'Done'], -1 => ['statusType' => 'DONE', 'name' => 'Archived'], 7 => ['statusType' => 'DONE', 'name' => 'Shipped'], ]); } protected function tearDown(): void { CarbonImmutable::setTestNow(); app()->forgetInstance(DateTimeHelper::class); parent::tearDown(); } /** * One-week period so availableHours equals the weekly allocation exactly. */ private function oneWeekPeriod(): ReportPeriod { return ReportPeriod::fromRequest(['preset' => 'custom', 'from' => '06/01/2026', 'to' => '06/07/2026']); } /** * @param array> $tickets */ private function withTickets(array $tickets, int $projectId = 10): void { // The analyzer batches its ticket reads into one getAllByProjectIds() // call keyed by projectId; the single-project cases here all use id 10. $this->ticketsRepo->method('getAllByProjectIds')->willReturn([$projectId => $tickets]); } private function summaryWithWeeklyAllocation(int $projectId, float $weeklyHours, int $people = 1): ResourceSummary { $persons = []; for ($i = 0; $i < $people; $i++) { $persons[] = new PersonAllocation( itemId: $i + 1, userId: $i + 1, displayName: 'Person '.($i + 1), capacity: 40.0, allocations: [$projectId => $weeklyHours / $people], ); } return new ResourceSummary([$projectId], $persons, [], [], 40.0 * $people, $weeklyHours, 0.0, 0.0); } public function test_custom_done_statuses_are_excluded_from_demand(): void { $this->withTickets([ // Custom DONE status (positive id) — must NOT count as open demand. ['id' => 1, 'status' => 7, 'planHours' => 100.0, 'storypoints' => 0], // Default done + archived — excluded. ['id' => 2, 'status' => 0, 'planHours' => 50.0, 'storypoints' => 0], ['id' => 3, 'status' => -1, 'planHours' => 25.0, 'storypoints' => 0], // Open work — the only demand. ['id' => 4, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0], ]); $rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']); $this->assertSame(1, $rows[10]['openTicketCount']); $this->assertEqualsWithDelta(10.0, $rows[10]['budgetedHours'], 0.001); } public function test_trust_signal_bands_drive_reference_demand(): void { // Low coverage (1 of 4 open tickets budgeted = 0.25 < 0.30) with effort present // -> trust 'effort', referenceDemand = storypoints × hoursPerPoint. $this->withTickets([ ['id' => 1, 'status' => 3, 'planHours' => 8.0, 'storypoints' => 5], ['id' => 2, 'status' => 3, 'planHours' => 0, 'storypoints' => 5], ['id' => 3, 'status' => 3, 'planHours' => 0, 'storypoints' => 5], ['id' => 4, 'status' => 4, 'planHours' => 0, 'storypoints' => 5], ]); $rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']); $this->assertSame('effort', $rows[10]['trustSignal']); $this->assertEqualsWithDelta(20 * CapacityAnalyzer::DEFAULT_HOURS_PER_POINT, $rows[10]['referenceDemand'], 0.001); } public function test_high_coverage_agreeing_estimates_trust_budgeted(): void { // Full coverage, divergence below threshold (40 budgeted vs 40 effort hours). $this->withTickets([ ['id' => 1, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 5], ['id' => 2, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 5], ]); $rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']); $this->assertSame('budgeted', $rows[10]['trustSignal']); $this->assertEqualsWithDelta(40.0, $rows[10]['referenceDemand'], 0.001); } public function test_diverging_estimates_flag_mixed_and_take_conservative_max(): void { // Full coverage but effort (10sp × 4h = 40h) vs budgeted (100h) diverge > 0.4 -> mixed, max() wins. $this->withTickets([ ['id' => 1, 'status' => 3, 'planHours' => 50.0, 'storypoints' => 5], ['id' => 2, 'status' => 3, 'planHours' => 50.0, 'storypoints' => 5], ]); $rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'P']); $this->assertSame('mixed', $rows[10]['trustSignal']); $this->assertEqualsWithDelta(100.0, $rows[10]['referenceDemand'], 0.001); } /** * @dataProvider verdictBoundaryProvider */ public function test_verdict_boundaries(float $demandHours, float $weeklyAvailable, string $expectedVerdict): void { // Single fully-budgeted open ticket -> trust 'budgeted' -> referenceDemand = planHours. $this->withTickets([ ['id' => 1, 'status' => 3, 'planHours' => $demandHours, 'storypoints' => 0], ]); $rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, $weeklyAvailable), [10 => 'P']); $this->assertSame($expectedVerdict, $rows[10]['verdict']); } public static function verdictBoundaryProvider(): array { return [ 'no capacity' => [100.0, 0.0, 'no_capacity'], 'critical: gap ratio > 0.25' => [130.0, 100.0, 'critical'], 'tight: 0 < ratio <= 0.25' => [110.0, 100.0, 'tight'], 'balanced: -0.5 <= ratio <= 0' => [90.0, 100.0, 'balanced'], 'buffer: ratio < -0.5' => [40.0, 100.0, 'buffer'], ]; } public function test_projects_without_work_or_people_are_skipped_as_noise(): void { $this->withTickets([]); $rows = $this->analyzer->analyzeProjects([10], $this->oneWeekPeriod(), ResourceSummary::empty([10]), [10 => 'P']); $this->assertSame([], $rows); } /** * The N+1 guard: tickets for every analyzed project are pulled in a SINGLE * getAllByProjectIds() call, not one getAllByProjectId() per project. This * pins the batching so a future refactor can't quietly reintroduce the * per-project round-trip. */ public function test_tickets_are_fetched_in_one_batched_call_for_all_projects(): void { $this->ticketsRepo->expects($this->once()) ->method('getAllByProjectIds') ->with($this->equalTo([10, 20, 30])) ->willReturn([ 10 => [['id' => 1, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0]], 20 => [['id' => 2, 'status' => 3, 'planHours' => 20.0, 'storypoints' => 0]], 30 => [['id' => 3, 'status' => 3, 'planHours' => 30.0, 'storypoints' => 0]], ]); $summary = new ResourceSummary( [10, 20, 30], [new PersonAllocation( itemId: 1, userId: 1, displayName: 'P', capacity: 40.0, allocations: [10 => 10.0, 20 => 10.0, 30 => 10.0], )], [], [], 40.0, 30.0, 0.0, 0.0, ); $rows = $this->analyzer->analyzeProjects( [10, 20, 30], $this->oneWeekPeriod(), $summary, [10 => 'A', 20 => 'B', 30 => 'C'], ); $this->assertSame([10, 20, 30], array_keys($rows)); $this->assertEqualsWithDelta(10.0, $rows[10]['budgetedHours'], 0.001); $this->assertEqualsWithDelta(30.0, $rows[30]['budgetedHours'], 0.001); } /** * Only the reportable projects (those present in $projectNames) reach the * batched fetch — the skip that used to sit inside the per-project loop now * shapes the single WHERE IN, so we don't over-fetch container projects. */ public function test_only_reportable_projects_are_batched(): void { $this->ticketsRepo->expects($this->once()) ->method('getAllByProjectIds') ->with($this->equalTo([10])) // 20 is not in $projectNames → excluded ->willReturn([10 => [['id' => 1, 'status' => 3, 'planHours' => 10.0, 'storypoints' => 0]]]); $this->analyzer->analyzeProjects( [10, 20], $this->oneWeekPeriod(), $this->summaryWithWeeklyAllocation(10, 20.0), [10 => 'A'], // 20 omitted on purpose ); } // ─── Program rollup: supply is capacity, not booked hours ──────── // // aggregateByProgram measures how much a program COULD do (capacity), // not how much is already booked against it. This keeps the rollup // consistent with the report's headline utilization tile, which reads // allocated/capacity — before this, a program with real headroom and // little booked reported no_capacity and could escalate as a false gap // on the strategy report. verdict()/trustSignal() themselves are // covered above; these pin the supply figure feeding them. public function test_program_supply_uses_capacity_not_allocated_hours(): void { // 40h capacity, only 5h booked against the program → supply is 40. $row = $this->rollup( [$this->personCap(1, capacity: 40.0, allocations: [7 => 5.0])], childIds: [7], ); $this->assertEqualsWithDelta(40.0, $row['availableHours'], 0.001); $this->assertSame(1, $row['peopleCount']); } public function test_program_supply_counts_a_person_on_two_child_projects_once(): void { $row = $this->rollup( [$this->personCap(1, capacity: 40.0, allocations: [7 => 5.0, 8 => 5.0])], childIds: [7, 8], ); $this->assertEqualsWithDelta(40.0, $row['availableHours'], 0.001); $this->assertSame(1, $row['peopleCount']); } public function test_program_supply_deducts_commitments_outside_the_program(): void { // 40h capacity, 10h here, 15h on a project outside this program → // 25h is what this program could still claim. $row = $this->rollup( [$this->personCap(1, capacity: 40.0, allocations: [7 => 10.0, 99 => 15.0])], childIds: [7], ); $this->assertEqualsWithDelta(25.0, $row['availableHours'], 0.001); } public function test_outside_commitments_never_push_a_person_below_zero(): void { // Person 1 is over-committed elsewhere beyond their capacity: they // bring nothing here, but must not subtract from person 2. $row = $this->rollup( [ $this->personCap(1, capacity: 40.0, allocations: [7 => 1.0, 99 => 100.0]), $this->personCap(2, capacity: 20.0, allocations: [7 => 5.0]), ], childIds: [7], ); $this->assertEqualsWithDelta(20.0, $row['availableHours'], 0.001, 'clamped at 0, not -60'); } public function test_people_not_allocated_to_the_program_contribute_nothing(): void { $row = $this->rollup( [$this->personCap(1, capacity: 40.0, allocations: [99 => 10.0])], childIds: [7], ); $this->assertEqualsWithDelta(0.0, $row['availableHours'], 0.001); $this->assertSame(0, $row['peopleCount']); } /** * The regression this change exists for: a program with real headroom * and little booked used to read no_capacity because supply was measured * as hours-already-allocated. With capacity as supply it reads as buffer * — there IS room. (supply 40 vs demand 10 → ratio -0.75 → buffer.) */ public function test_program_with_headroom_and_light_booking_reads_as_buffer(): void { $row = $this->rollup( [$this->personCap(1, capacity: 40.0, allocations: [7 => 0.5])], childIds: [7], budgetedHours: 10.0, ); $this->assertNotSame('no_capacity', $row['verdict']); $this->assertSame('buffer', $row['verdict']); } /** * Work planned with nobody assigned stays no_capacity, distinct from * critical: it is an authoring gap ("assign people"), not a capacity * crisis, and painting it red trains readers to ignore red. */ public function test_program_with_no_people_is_no_capacity_not_critical(): void { $row = $this->rollup([], childIds: [7], budgetedHours: 400.0); $this->assertSame('no_capacity', $row['verdict']); } private function personCap(int $itemId, float $capacity, array $allocations): PersonAllocation { return new PersonAllocation( itemId: $itemId, userId: $itemId, displayName: 'Person '.$itemId, capacity: $capacity, allocations: $allocations, ); } /** * Runs aggregateByProgram for one program over the given children, * feeding pre-built project rows so the ticket/demand side is fixed and * the assertions are about the capacity side. Demand sits on the first * child so totals are predictable regardless of child count. * * @param array $people * @param int[] $childIds * @return array */ private function rollup(array $people, array $childIds, float $budgetedHours = 50.0): array { $projectRows = []; foreach ($childIds as $cid) { $isFirst = $cid === $childIds[0]; $projectRows[$cid] = [ 'projectId' => $cid, 'name' => 'Child '.$cid, 'openTicketCount' => $isFirst ? 10 : 0, 'ticketsWithBudget' => $isFirst ? 10 : 0, 'ticketsWithEffort' => 0, 'budgetedHours' => $isFirst ? $budgetedHours : 0.0, 'effortPoints' => 0.0, 'verdict' => 'balanced', // aggregateByProgram sorts children by verdict rank ]; } $resources = new ResourceSummary([7, 8], $people, [], [], 0.0, 0.0, 0.0, 0.0); $rows = $this->analyzer->aggregateByProgram( $projectRows, [2 => $childIds], [2 => ['id' => 2, 'name' => 'Program 2']], $resources, $this->oneWeekPeriod(), ); return $rows[2]; } }