$name, 'data' => $data, 'options' => $options]; } } } namespace Tests\App\Controllers\View { use App\Controllers\View\TuitionForecastController; use App\Libraries\Tuition\TuitionForecastService; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Test\CIUnitTestCase; class ForecastRequestStub { public function __construct( private array $get = [], private array $post = [] ) { } public function getGet(?string $key = null) { if ($key === null) { return $this->get; } return $this->get[$key] ?? null; } public function getPost(?string $key = null) { if ($key === null) { return $this->post; } return $this->post[$key] ?? null; } } class FakeForecastService extends TuitionForecastService { public array $calls = []; public function __construct() { } public function calculate(string $schoolYear = '', string $semester = '', string $mode = 'compare', array $options = []): array { $this->calls[] = compact('schoolYear', 'semester', 'mode', 'options'); return [ 'school_year' => $schoolYear !== '' ? $schoolYear : '2025-2026', 'semester' => $semester, 'calculator_mode' => $mode, 'options' => [ 'include_withdrawn_mode' => $options['include_withdrawn_mode'] ?? 'refund_deadline', 'include_payment_pending' => $options['include_payment_pending'] ?? '0', 'include_event_only' => $options['include_event_only'] ?? '0', 'include_paid_invoices' => $options['include_paid_invoices'] ?? '0', 'unit_price' => $options['unit_price'] ?? '370.00', ], 'summary' => [ 'family_count' => 1, 'student_count' => 2, 'billable_student_count' => 2, 'old_projected_tuition' => '550.00', 'new_projected_tuition' => '650.00', 'old_projected_income' => '550.00', 'new_projected_income' => '650.00', 'unit_price' => '370.00', 'difference' => '100.00', ], 'families' => [[ 'parent_name' => 'Layla Yusuf', 'student_count' => 2, 'billable_student_count' => 2, 'old_total' => '550.00', 'new_total' => '650.00', 'difference' => '100.00', 'warnings' => ['warning'], 'student_details' => [[ 'student_name' => 'Student One', 'grade_level' => '1', 'billable' => true, 'excluded_reason' => '', 'old_rule' => 'full', 'old_amount' => '350.00', 'new_rule' => 'full', 'new_amount' => '370.00', ]], ]], ]; } public function getAvailableSchoolYears(): array { return ['2025-2026']; } public function getAvailableSemesters(): array { return ['Fall', 'Spring']; } } class TestableTuitionForecastController extends TuitionForecastController { public function __construct() { } public function setForecastService(TuitionForecastService $service): self { $this->forecastService = $service; return $this; } public function setRequestObject($request): self { $this->request = $request; return $this; } public function setResponseObject(ResponseInterface $response): self { $this->response = $response; return $this; } } class TuitionForecastControllerTest extends CIUnitTestCase { private TestableTuitionForecastController $controller; private FakeForecastService $service; protected function setUp(): void { parent::setUp(); $this->service = new FakeForecastService(); $this->controller = (new TestableTuitionForecastController()) ->setForecastService($this->service) ->setResponseObject(service('response')); } public function testIndexBuildsForecastViewDataFromQueryParameters(): void { $request = new ForecastRequestStub([ 'school_year' => '2026-2027', 'semester' => 'Spring', 'calculator_mode' => 'new', 'include_withdrawn_mode' => 'always', 'include_payment_pending' => '1', 'include_event_only' => '1', 'include_paid_invoices' => '1', 'unit_price' => '395.00', ]); $result = $this->controller->setRequestObject($request)->index(); $this->assertSame('administrator/tuition_forecast', $result['view']); $this->assertSame('2026-2027', $result['data']['filters']['school_year']); $this->assertSame('Spring', $result['data']['filters']['semester']); $this->assertSame('new', $result['data']['filters']['calculator_mode']); $this->assertSame('1', $result['data']['filters']['include_payment_pending']); $this->assertSame('395.00', $result['data']['filters']['unit_price']); $this->assertSame('always', $this->service->calls[0]['options']['include_withdrawn_mode']); } public function testCalculateReturnsJsonPayload(): void { $request = new ForecastRequestStub([], [ 'school_year' => '2025-2026', 'semester' => 'Fall', 'calculator_mode' => 'compare', 'include_withdrawn_mode' => 'refund_deadline', 'include_payment_pending' => '1', 'include_event_only' => '0', 'include_paid_invoices' => '1', 'unit_price' => '390.00', ]); $response = $this->controller->setRequestObject($request)->calculate(); $payload = json_decode((string) $response->getBody(), true); $this->assertSame('2025-2026', $payload['school_year']); $this->assertSame('Fall', $payload['semester']); $this->assertSame('compare', $payload['calculator_mode']); $this->assertSame('390.00', $payload['options']['unit_price']); } public function testExportCsvBuildsDownloadWithSummaryAndFamilyRows(): void { $request = new ForecastRequestStub([ 'school_year' => '2025-2026', 'calculator_mode' => 'compare', ]); $response = $this->controller->setRequestObject($request)->exportCsv(); $body = (string) $response->getBody(); $this->assertStringContainsString('text/csv', $response->getHeaderLine('Content-Type')); $this->assertStringContainsString('tuition_forecast_2025-2026_all-year_compare.csv', $response->getHeaderLine('Content-Disposition')); $this->assertStringContainsString('Summary', $body); $this->assertStringContainsString('All Year', $body); $this->assertStringContainsString('Old Projected Income', $body); $this->assertStringContainsString('Layla Yusuf', $body); $this->assertStringContainsString('Student One', $body); } } }