make(Environment::class, [ 'defaultTimezone' => 'UTC', 'language' => 'en-US', ]); app()->instance(Environment::class, $environmentMock); $languageMock = $this->createMock(Language::class); $languageMock->method('__')->willReturnCallback(function ($index) { $map = [ 'language.dateformat' => 'm/d/Y', 'language.timeformat' => 'h:i A', ]; return $map[$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->repository = $this->createMock(ReportEngineRepository::class); $this->ticketRepository = $this->createMock(TicketRepository::class); $this->projectService = $this->createMock(ProjectService::class); $this->goalService = $this->createMock(GoalcanvasService::class); $this->service = new ReportEngine( $this->repository, $this->ticketRepository, $this->projectService, $this->goalService, ); $permissionService = $this->createMock(PermissionService::class); $permissionService->method('currentUserCan')->willReturn(true); $this->service->setPermissionService($permissionService); // Status vocabulary for project 10: 1 = NEW, 2 = INPROGRESS, 3 = DONE. $this->ticketRepository->method('getStateLabels')->willReturn([ 1 => ['statusType' => 'NEW', 'name' => 'New'], 2 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress'], 3 => ['statusType' => 'DONE', 'name' => 'Done'], ]); } protected function tearDown(): void { CarbonImmutable::setTestNow(); app()->forgetInstance(DateTimeHelper::class); parent::tearDown(); } /** * @param array $overrides */ private function milestone(int $id, array $overrides = []): object { return (object) array_merge([ 'id' => $id, 'headline' => 'Milestone '.$id, 'description' => '', 'outcomeImpact' => null, 'date' => '2026-01-01 00:00:00', 'projectId' => 10, 'status' => 1, 'editFrom' => null, 'editTo' => null, 'modified' => null, 'projectName' => 'Project 10', 'type' => 'milestone', 'tags' => 'var(--grey)', ], $overrides); } private function lastQuarter(): ReportPeriod { // With test-now 2026-07-08 UTC this is Apr 1 – Jun 30 2026 (UTC). return ReportPeriod::lastQuarter(); } public function test_milestones_bucket_into_completed_overdue_in_progress_and_upcoming(): void { $this->repository->method('getMilestonesForProjects')->willReturn([ // Done, history transition inside the period -> completed. $this->milestone(1, ['status' => 3]), // Done, history transition long before the period -> allDone only. $this->milestone(2, ['status' => 3]), // Open with a past due date -> overdue. $this->milestone(3, ['editFrom' => '2026-05-01 00:00:00', 'editTo' => '2026-06-01 00:00:00']), // Open, starting two months after the period -> upcoming (Q3 2026). $this->milestone(4, ['editFrom' => '2026-08-15 00:00:00', 'editTo' => '2026-09-15 00:00:00']), // Open and completely unscheduled -> in progress. $this->milestone(5), // Open, starting after the two-quarter horizon -> dropped from upcoming. $this->milestone(6, ['editFrom' => '2027-06-01 00:00:00', 'editTo' => '2027-07-01 00:00:00']), ]); $this->repository->method('getStatusHistoryForTickets')->willReturn([ (object) ['ticketId' => 1, 'changeValue' => '2', 'dateModified' => '2026-05-01 09:00:00'], (object) ['ticketId' => 1, 'changeValue' => '3', 'dateModified' => '2026-05-10 09:00:00'], (object) ['ticketId' => 2, 'changeValue' => '3', 'dateModified' => '2026-01-15 09:00:00'], ]); $this->repository->method('getTasksForMilestones')->willReturn([]); $this->repository->method('getDueDateChangesForTickets')->willReturn([]); $report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter()); $this->assertSame([1], array_map(fn ($m) => $m->id, $report['completed'])); $this->assertSame('2026-05-10 09:00:00', $report['completed'][0]->completedOn->format('Y-m-d H:i:s')); $this->assertSame([3], array_map(fn ($m) => $m->id, $report['overdue'])); $this->assertSame([5], array_map(fn ($m) => $m->id, $report['inProgress'])); $this->assertSame([4], array_map(fn ($m) => $m->id, $report['upcoming'])); $this->assertArrayHasKey('Q3 2026', $report['upcomingByQuarter']); $this->assertCount(2, $report['allDone']); } public function test_completion_date_falls_back_to_due_date_then_modified_without_history(): void { $this->repository->method('getMilestonesForProjects')->willReturn([ // No history, has a due date inside the period. $this->milestone(1, ['status' => 3, 'editTo' => '2026-05-20 00:00:00']), // No history, no due date, modified inside the period. $this->milestone(2, ['status' => 3, 'modified' => '2026-06-15 08:00:00']), // History exists but never transitions into DONE -> falls back to modified. $this->milestone(3, ['status' => 3, 'modified' => '2026-06-20 08:00:00']), ]); $this->repository->method('getStatusHistoryForTickets')->willReturn([ (object) ['ticketId' => 3, 'changeValue' => '2', 'dateModified' => '2026-06-01 09:00:00'], ]); $this->repository->method('getTasksForMilestones')->willReturn([]); $this->repository->method('getDueDateChangesForTickets')->willReturn([]); $report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter()); $completedOn = []; foreach ($report['completed'] as $milestone) { $completedOn[$milestone->id] = $milestone->completedOn->format('Y-m-d H:i:s'); } $this->assertSame('2026-05-20 00:00:00', $completedOn[1]); $this->assertSame('2026-06-15 08:00:00', $completedOn[2]); $this->assertSame('2026-06-20 08:00:00', $completedOn[3]); } public function test_milestone_progress_uses_weighted_task_scores(): void { $this->repository->method('getMilestonesForProjects')->willReturn([ $this->milestone(1, ['editFrom' => '2026-06-01 00:00:00', 'editTo' => '2026-09-01 00:00:00']), ]); $this->repository->method('getStatusHistoryForTickets')->willReturn([]); $this->repository->method('getDueDateChangesForTickets')->willReturn([]); $this->repository->method('getTasksForMilestones')->willReturn([ // Done: 5 points × priority-1 factor 2 = 10. Open: 5 × factor 1 (priority 5) = 5. (object) ['id' => 100, 'headline' => 'Done task', 'status' => 3, 'projectId' => 10, 'milestoneid' => 1, 'storypoints' => 5, 'priority' => 1, 'editTo' => null, 'dateToFinish' => null], (object) ['id' => 101, 'headline' => 'Open task', 'status' => 1, 'projectId' => 10, 'milestoneid' => 1, 'storypoints' => 5, 'priority' => 5, 'editTo' => null, 'dateToFinish' => null], ]); $report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter()); $milestone = $report['inProgress'][0]; $this->assertEqualsWithDelta(66.67, $milestone->percentDone, 0.01); $this->assertSame(['done' => 1, 'total' => 2], $milestone->taskStats); // Key tasks list leads with completed work. $this->assertSame(100, $milestone->keyTasks[0]->id); $this->assertTrue($milestone->keyTasks[0]->isDone); } public function test_slippage_reports_milestones_pushed_out_and_added_mid_period(): void { $this->repository->method('getMilestonesForProjects')->willReturn([ // Open, now due after the period, with an in-period due-date change -> pushed out. $this->milestone(1, ['editFrom' => '2026-04-10 00:00:00', 'editTo' => '2026-09-15 00:00:00']), // Created mid-period -> added. $this->milestone(2, ['date' => '2026-05-05 00:00:00', 'editFrom' => '2026-05-05 00:00:00', 'editTo' => '2026-12-01 00:00:00']), ]); $this->repository->method('getStatusHistoryForTickets')->willReturn([]); $this->repository->method('getTasksForMilestones')->willReturn([]); $this->repository->method('getDueDateChangesForTickets')->willReturn([ (object) ['ticketId' => 1, 'changeValue' => '2026-09-15 00:00:00', 'dateModified' => '2026-06-10 09:00:00'], (object) ['ticketId' => 1, 'changeValue' => '2026-09-15 00:00:00', 'dateModified' => '2026-06-20 09:00:00'], ]); $report = $this->service->getMilestoneReportForProjects([10], $this->lastQuarter()); $this->assertCount(1, $report['slippage']['pushedOut']); $this->assertSame(1, $report['slippage']['pushedOut'][0]->id); $this->assertSame(2, $report['slippage']['pushedOut'][0]->dueDateMoves); $this->assertSame([2], array_map(fn ($m) => $m->id, $report['slippage']['addedMidPeriod'])); } public function test_goal_report_resolves_rollups_and_progress(): void { $this->repository->method('getGoalsForProjects')->willReturn([ (object) ['id' => 1, 'title' => 'Graduates', 'description' => '', 'status' => 'status_ontrack', 'metricType' => 'count', 'startValue' => 0.0, 'currentValue' => 42.0, 'endValue' => 60.0, 'setting' => '', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null], (object) ['id' => 2, 'title' => 'Rollup KPI', 'description' => '', 'status' => 'status_atrisk', 'metricType' => 'count', 'startValue' => 0.0, 'currentValue' => 0.0, 'endValue' => 100.0, 'setting' => 'linkAndReport', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null], ]); $this->goalService->method('getChildGoalsForReporting')->with(2)->willReturn(25.0); // The engine batches milestone-chip hydration up front; stub it so the // test exercises the rollup/progress path without relying on a mock's // default null return. $this->goalService->method('getMilestonesForGoals')->willReturn([1 => [], 2 => []]); $report = $this->service->getGoalReportForProjects([10]); $this->assertEqualsWithDelta(70.0, $report['goals'][0]->goalProgress, 0.01); $this->assertEqualsWithDelta(25.0, $report['goals'][1]->currentValue, 0.01); $this->assertEqualsWithDelta(25.0, $report['goals'][1]->goalProgress, 0.01); $this->assertSame(['ontrack' => 1, 'atrisk' => 1, 'miss' => 0], $report['counts']); } public function test_status_updates_group_by_project_and_respect_limit(): void { $this->repository->method('getStatusUpdatesForProjects')->willReturn([ (object) ['id' => 1, 'projectId' => 10, 'text' => 'newest', 'date' => '2026-06-20 10:00:00', 'status' => 'green', 'authorFirstname' => 'A', 'authorLastname' => 'B', 'authorProfileId' => null], (object) ['id' => 2, 'projectId' => 10, 'text' => 'older', 'date' => '2026-05-01 10:00:00', 'status' => 'yellow', 'authorFirstname' => 'A', 'authorLastname' => 'B', 'authorProfileId' => null], (object) ['id' => 3, 'projectId' => 11, 'text' => 'other project', 'date' => '2026-05-02 10:00:00', 'status' => 'green', 'authorFirstname' => 'C', 'authorLastname' => 'D', 'authorProfileId' => null], ]); $updates = $this->service->getStatusUpdatesForProjects([10, 11], $this->lastQuarter(), 1); $this->assertCount(1, $updates[10]); $this->assertSame('newest', $updates[10][0]->text); $this->assertCount(1, $updates[11]); } public function test_project_summaries_flag_stale_and_alerting_projects(): void { $this->repository->method('getProjectsMeta')->willReturn([ 10 => (object) ['id' => 10, 'name' => 'Fresh red project', 'details' => '

Some html description

', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'], 11 => (object) ['id' => 11, 'name' => 'Silent project', 'details' => '', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'], ]); $this->repository->method('getLatestStatusUpdateForProjects')->willReturn([ 10 => (object) ['projectId' => 10, 'text' => 'Behind on hiring', 'date' => '2026-07-01 10:00:00', 'status' => 'red', 'authorFirstname' => 'A', 'authorLastname' => 'B'], // Project 11 has no status update at all. ]); $this->projectService->method('getProjectProgress')->willReturn(['percent' => 40.0, 'estimatedCompletionDate' => false, 'plannedCompletionDate' => '']); $summaries = $this->service->getProjectSummaries([10, 11]); $this->assertSame('red', $summaries[10]->latestStatus); $this->assertFalse($summaries[10]->isStale); $this->assertSame('Some html description', $summaries[10]->descriptionExcerpt); $this->assertNull($summaries[11]->latestStatus); $this->assertTrue($summaries[11]->isStale); } public function test_effort_totals_by_project_and_milestone(): void { $this->repository->method('getHoursLoggedForProjects')->willReturn([ (object) ['projectId' => 10, 'milestoneId' => 1, 'loggedHours' => 12.5], (object) ['projectId' => 10, 'milestoneId' => 0, 'loggedHours' => 3.0], (object) ['projectId' => 11, 'milestoneId' => 2, 'loggedHours' => 4.25], ]); $effort = $this->service->getEffortForProjects([10, 11], $this->lastQuarter()); $this->assertEqualsWithDelta(19.75, $effort['total'], 0.001); $this->assertEqualsWithDelta(15.5, $effort['byProject'][10], 0.001); $this->assertEqualsWithDelta(12.5, $effort['byMilestone'][1], 0.001); $this->assertArrayNotHasKey(0, $effort['byMilestone']); } public function test_build_report_composes_needs_attention_and_deltas(): void { $this->repository->method('getMilestonesForProjects')->willReturn([ // Completed this period. $this->milestone(1, ['status' => 3]), // Completed in the prior period (feeds the delta). $this->milestone(2, ['status' => 3]), // Overdue -> needs attention. $this->milestone(3, ['editTo' => '2026-06-01 00:00:00']), ]); $this->repository->method('getStatusHistoryForTickets')->willReturn([ (object) ['ticketId' => 1, 'changeValue' => '3', 'dateModified' => '2026-05-10 09:00:00'], (object) ['ticketId' => 2, 'changeValue' => '3', 'dateModified' => '2026-02-10 09:00:00'], ]); $this->repository->method('getTasksForMilestones')->willReturn([]); $this->repository->method('getDueDateChangesForTickets')->willReturn([]); $this->repository->method('getGoalsForProjects')->willReturn([ (object) ['id' => 1, 'title' => 'At-risk goal', 'description' => '', 'status' => 'status_atrisk', 'metricType' => '', 'startValue' => 0.0, 'currentValue' => 1.0, 'endValue' => 10.0, 'setting' => '', 'milestoneId' => '', 'kpi' => '', 'startDate' => null, 'endDate' => null, 'canvasId' => 5, 'projectId' => 10, 'boardTitle' => 'Goals', 'milestoneHeadline' => null], ]); $this->repository->method('getStatusUpdatesForProjects')->willReturn([]); $this->repository->method('getHoursLoggedForProjects')->willReturn([]); $this->repository->method('getProjectsMeta')->willReturn([ 10 => (object) ['id' => 10, 'name' => 'Project', 'details' => '', 'clientId' => 1, 'state' => 0, 'start' => null, 'end' => null, 'type' => 'project', 'parent' => null, 'clientName' => 'Client'], ]); $this->repository->method('getLatestStatusUpdateForProjects')->willReturn([]); $this->projectService->method('getProjectProgress')->willReturn(['percent' => 10.0, 'estimatedCompletionDate' => false, 'plannedCompletionDate' => '']); $report = $this->service->buildReport([10], $this->lastQuarter()); $this->assertSame(1, $report['stats']['completed']); $this->assertSame(1, $report['deltas']['completedPrior']); $this->assertSame(0, $report['deltas']['completedDelta']); $this->assertCount(1, $report['needsAttention']['overdueMilestones']); $this->assertCount(1, $report['needsAttention']['goalsAtRisk']); // No status update ever -> the project is flagged as silent. $this->assertCount(1, $report['needsAttention']['staleProjects']); } }