fix financial and certificates
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Grading;
|
||||
|
||||
use App\Services\Grading\Validation\ScoreValueValidator;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ScoreValueValidatorTest extends TestCase
|
||||
{
|
||||
public function test_blank_score_remains_null(): void
|
||||
{
|
||||
$validator = new ScoreValueValidator();
|
||||
|
||||
$this->assertNull($validator->normalizeNullable('', 100));
|
||||
$this->assertNull($validator->normalizeNullable(null, 100));
|
||||
}
|
||||
|
||||
public function test_score_must_be_inside_range(): void
|
||||
{
|
||||
$validator = new ScoreValueValidator();
|
||||
|
||||
$this->assertSame(95.0, $validator->normalizeNullable('95', 100));
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$validator->normalizeNullable(101, 100);
|
||||
}
|
||||
|
||||
public function test_status_inference_preserves_legacy_pending_behavior(): void
|
||||
{
|
||||
$validator = new ScoreValueValidator();
|
||||
|
||||
$this->assertSame('scored', $validator->inferStatus(80.0));
|
||||
$this->assertSame('pending', $validator->inferStatus(null, false));
|
||||
$this->assertSame('excused', $validator->inferStatus(null, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\CertificateRecord;
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\StudentDecision;
|
||||
use App\Models\User;
|
||||
use App\Services\Certificates\CertificateAdminService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CertificateAdminServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private CertificateAdminService $service;
|
||||
|
||||
private string $schoolYear = '2025-2026';
|
||||
|
||||
private ClassSection $section;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Configuration::query()->create([
|
||||
'config_key' => 'school_year',
|
||||
'config_value' => $this->schoolYear,
|
||||
]);
|
||||
|
||||
$this->section = ClassSection::factory()->create([
|
||||
'class_section_id' => 501,
|
||||
'class_section_name' => 'Grade 5-A',
|
||||
]);
|
||||
|
||||
$this->service = app(CertificateAdminService::class);
|
||||
}
|
||||
|
||||
public function test_dashboard_groups_students_and_uses_saved_decisions_as_source_of_truth(): void
|
||||
{
|
||||
$passStudent = $this->enrollStudent('Amina', 'Pass', 'Female');
|
||||
$repeatStudent = $this->enrollStudent('Bilal', 'Repeat', 'Male');
|
||||
$pendingStudent = $this->enrollStudent('Celine', 'Pending', 'Female');
|
||||
|
||||
StudentDecision::query()->create([
|
||||
'student_id' => $passStudent->id,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_name' => $this->section->class_section_name,
|
||||
'year_score' => 95.50,
|
||||
'decision' => 'Pass',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
StudentDecision::query()->create([
|
||||
'student_id' => $repeatStudent->id,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_name' => $this->section->class_section_name,
|
||||
'year_score' => 58.25,
|
||||
'decision' => 'Repeat Class',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
CertificateRecord::query()->create([
|
||||
'certificate_number' => 'ARSS-2025-2026-0001',
|
||||
'verification_token' => 'existingtoken',
|
||||
'student_id' => $passStudent->id,
|
||||
'student_name' => 'Amina Pass',
|
||||
'grade' => 'Grade 5',
|
||||
'cert_date' => '2026-06-05',
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_id' => $this->section->class_section_id,
|
||||
'issued_at' => now(),
|
||||
]);
|
||||
|
||||
$payload = $this->service->dashboard($this->schoolYear);
|
||||
|
||||
$this->assertSame($this->schoolYear, $payload['school_year']);
|
||||
$this->assertCount(1, $payload['grade_groups']);
|
||||
$this->assertSame('Grade 5', $payload['grade_groups'][0]['label']);
|
||||
$this->assertTrue($payload['grade_groups'][0]['has_pass']);
|
||||
$this->assertTrue($payload['grade_groups'][0]['fully_done']);
|
||||
|
||||
$section = $payload['grade_groups'][0]['sections'][0];
|
||||
$this->assertSame(3, $section['student_count']);
|
||||
$this->assertSame(1, $section['pass_count']);
|
||||
$this->assertSame(1, $section['cert_count']);
|
||||
$this->assertSame(0, $section['remaining_count']);
|
||||
|
||||
$students = collect($section['students'])->keyBy('student_id');
|
||||
|
||||
$this->assertTrue($students[$passStudent->id]['eligible']);
|
||||
$this->assertSame(['Pass'], $students[$passStudent->id]['decision_labels']);
|
||||
$this->assertSame('ARSS-2025-2026-0001', $students[$passStudent->id]['certificate_number']);
|
||||
|
||||
$this->assertFalse($students[$repeatStudent->id]['eligible']);
|
||||
$this->assertSame(['Repeat Class'], $students[$repeatStudent->id]['decision_labels']);
|
||||
|
||||
$this->assertFalse($students[$pendingStudent->id]['eligible']);
|
||||
$this->assertSame('pending', $students[$pendingStudent->id]['decision_state']);
|
||||
}
|
||||
|
||||
public function test_issue_certificates_reuses_existing_record_and_creates_new_one_for_unissued_pass_students(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$existingStudent = $this->enrollStudent('Adam', 'Existing', 'Male');
|
||||
$newStudent = $this->enrollStudent('Dina', 'New', 'Female');
|
||||
|
||||
foreach ([$existingStudent, $newStudent] as $student) {
|
||||
StudentDecision::query()->create([
|
||||
'student_id' => $student->id,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_name' => $this->section->class_section_name,
|
||||
'year_score' => 90.00,
|
||||
'decision' => 'Pass',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
}
|
||||
|
||||
CertificateRecord::query()->create([
|
||||
'certificate_number' => 'ARSS-2025-2026-0001',
|
||||
'verification_token' => 'persistedtoken',
|
||||
'student_id' => $existingStudent->id,
|
||||
'student_name' => 'Adam Existing',
|
||||
'grade' => 'Grade 5',
|
||||
'cert_date' => '2026-06-05',
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_section_id' => $this->section->class_section_id,
|
||||
'issued_by' => $admin->id,
|
||||
'issued_at' => now(),
|
||||
]);
|
||||
|
||||
$payload = $this->service->issueCertificates(
|
||||
[$existingStudent->id, $newStudent->id],
|
||||
'06/05/2026',
|
||||
$this->section->class_section_id,
|
||||
$this->schoolYear,
|
||||
$admin->id
|
||||
);
|
||||
|
||||
$this->assertSame('06/05/2026', $payload['cert_date_display']);
|
||||
$this->assertCount(2, $payload['students']);
|
||||
|
||||
$issued = collect($payload['students'])->keyBy('id');
|
||||
|
||||
$this->assertSame('ARSS-2025-2026-0001', $issued[$existingStudent->id]['cert_number']);
|
||||
$this->assertNotSame('', $issued[$existingStudent->id]['verify_token']);
|
||||
$this->assertSame('ARSS-2025-2026-0002', $issued[$newStudent->id]['cert_number']);
|
||||
$this->assertNotSame('', $issued[$newStudent->id]['verify_token']);
|
||||
|
||||
$this->assertSame(2, CertificateRecord::query()->where('school_year', $this->schoolYear)->count());
|
||||
|
||||
$newRecord = CertificateRecord::query()
|
||||
->where('student_id', $newStudent->id)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($newRecord);
|
||||
$this->assertSame('Grade 5', $newRecord->grade);
|
||||
$this->assertSame('2026-06-05', $newRecord->cert_date?->format('Y-m-d'));
|
||||
$this->assertSame($admin->id, $newRecord->issued_by);
|
||||
}
|
||||
|
||||
public function test_audit_log_returns_year_summary_and_filtered_records(): void
|
||||
{
|
||||
CertificateRecord::query()->create([
|
||||
'certificate_number' => 'ARSS-2025-2026-0001',
|
||||
'verification_token' => 'token-a',
|
||||
'student_id' => 1,
|
||||
'student_name' => 'Ali One',
|
||||
'grade' => 'Grade 5',
|
||||
'cert_date' => '2026-06-05',
|
||||
'school_year' => '2025-2026',
|
||||
'issued_at' => now(),
|
||||
]);
|
||||
|
||||
CertificateRecord::query()->create([
|
||||
'certificate_number' => 'ARSS-2024-2025-0001',
|
||||
'verification_token' => 'token-b',
|
||||
'student_id' => 2,
|
||||
'student_name' => 'Maya Two',
|
||||
'grade' => 'Grade 4',
|
||||
'cert_date' => '2025-06-01',
|
||||
'school_year' => '2024-2025',
|
||||
'issued_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$payload = $this->service->auditLog('2025-2026');
|
||||
|
||||
$this->assertSame('2025-2026', $payload['school_year']);
|
||||
$this->assertCount(1, $payload['records']);
|
||||
$this->assertSame('ARSS-2025-2026-0001', $payload['records'][0]['certificate_number']);
|
||||
$this->assertCount(2, $payload['year_summary']);
|
||||
}
|
||||
|
||||
private function enrollStudent(string $firstname, string $lastname, string $gender): Student
|
||||
{
|
||||
$student = Student::factory()->create([
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'gender' => $gender,
|
||||
'school_year' => $this->schoolYear,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
StudentClass::query()->create([
|
||||
'student_id' => $student->id,
|
||||
'class_section_id' => $this->section->class_section_id,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => 'Spring',
|
||||
'is_event_only' => 0,
|
||||
]);
|
||||
|
||||
return $student;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class PromotionEligibilityServiceTest extends TestCase
|
||||
$studentId = $this->seedStudent();
|
||||
$this->seedClassChain();
|
||||
$this->seedAssignment($studentId, 701, '2025-2026');
|
||||
$this->seedScores($studentId, '2025-2026', 80.0, 75.0);
|
||||
$this->seedDecision($studentId, '2025-2026', 77.5, 'promoted');
|
||||
|
||||
$service = $this->makeService();
|
||||
$record = $service->evaluateStudent($studentId, '2025-2026', 1);
|
||||
@@ -41,7 +41,7 @@ class PromotionEligibilityServiceTest extends TestCase
|
||||
$studentId = $this->seedStudent();
|
||||
$this->seedClassChain();
|
||||
$this->seedAssignment($studentId, 701, '2025-2026');
|
||||
$this->seedScores($studentId, '2025-2026', 50.0, 55.0);
|
||||
$this->seedDecision($studentId, '2025-2026', 52.5, 'retained');
|
||||
|
||||
$service = $this->makeService();
|
||||
$record = $service->evaluateStudent($studentId, '2025-2026', 1);
|
||||
@@ -72,7 +72,7 @@ class PromotionEligibilityServiceTest extends TestCase
|
||||
$studentId = $this->seedStudent();
|
||||
$this->seedClassChain();
|
||||
$this->seedAssignment($studentId, 901, '2025-2026');
|
||||
$this->seedScores($studentId, '2025-2026', 90.0, 90.0);
|
||||
$this->seedDecision($studentId, '2025-2026', 90.0, 'promoted');
|
||||
|
||||
// Mark Youth as terminal
|
||||
\App\Models\LevelProgression::query()->where('current_level_name', 'Youth')->update(['is_terminal' => 1]);
|
||||
@@ -90,7 +90,7 @@ class PromotionEligibilityServiceTest extends TestCase
|
||||
$studentId = $this->seedStudent();
|
||||
$this->seedClassChain();
|
||||
$this->seedAssignment($studentId, 701, '2025-2026');
|
||||
$this->seedScores($studentId, '2025-2026', 80.0, 80.0);
|
||||
$this->seedDecision($studentId, '2025-2026', 80.0, 'promoted');
|
||||
|
||||
$service = $this->makeService();
|
||||
$service->evaluateStudent($studentId, '2025-2026', 99);
|
||||
@@ -159,11 +159,18 @@ class PromotionEligibilityServiceTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedScores(int $studentId, string $schoolYear, float $fall, float $spring): void
|
||||
private function seedDecision(int $studentId, string $schoolYear, float $score, string $decision): void
|
||||
{
|
||||
DB::table('semester_scores')->insert([
|
||||
['student_id' => $studentId, 'class_section_id' => 0, 'school_year' => $schoolYear, 'semester' => 'fall', 'semester_score' => $fall],
|
||||
['student_id' => $studentId, 'class_section_id' => 0, 'school_year' => $schoolYear, 'semester' => 'spring', 'semester_score' => $spring],
|
||||
DB::table('student_decisions')->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_name' => '3A',
|
||||
'year_score' => $score,
|
||||
'decision' => $decision,
|
||||
'source' => 'manual',
|
||||
'notes' => 'Seeded test decision',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\SemesterScore;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Services\Administrator\Trophy\TrophyReportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TrophyReportServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected TrophyReportService $service;
|
||||
|
||||
protected string $schoolYear = '2025-2026';
|
||||
|
||||
protected ClassSection $section;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->service = app(TrophyReportService::class);
|
||||
|
||||
Configuration::query()->create([
|
||||
'config_key' => 'school_year',
|
||||
'config_value' => $this->schoolYear,
|
||||
]);
|
||||
|
||||
$this->section = ClassSection::factory()->create([
|
||||
'class_section_id' => 401,
|
||||
'class_section_name' => 'Grade 4A',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_calculate_threshold_enforces_minimum_of_three_winners(): void
|
||||
{
|
||||
$result = $this->service->calculateThreshold([50, 60, 70, 80, 90], 95.0);
|
||||
|
||||
$this->assertSame('min3_reduced', $result['method']);
|
||||
$this->assertSame(3, $result['winners']);
|
||||
$this->assertSame(70.0, $result['threshold']);
|
||||
}
|
||||
|
||||
public function test_projection_marks_students_without_fall_scores_as_not_projected(): void
|
||||
{
|
||||
$first = $this->enrollStudent('Amina', 'Top', 'Female', 'S-1', 98.0);
|
||||
$second = $this->enrollStudent('Bilal', 'Strong', 'Male', 'S-2', 95.0);
|
||||
$third = $this->enrollStudent('Celine', 'Good', 'Female', 'S-3', 90.0);
|
||||
$fourth = $this->enrollStudent('David', 'Missing', 'Male', 'S-4', null);
|
||||
|
||||
$payload = $this->service->projection($this->schoolYear, 75.0);
|
||||
|
||||
$this->assertSame($this->schoolYear, $payload['selected_year']);
|
||||
$this->assertSame(75.0, $payload['selected_percentile']);
|
||||
$this->assertSame([$this->schoolYear], $payload['years']);
|
||||
$this->assertCount(1, $payload['class_results']);
|
||||
|
||||
$classResult = $payload['class_results'][0];
|
||||
|
||||
$this->assertSame(4, $classResult['student_count']);
|
||||
$this->assertSame(3, $classResult['scored_count']);
|
||||
$this->assertSame(3, $classResult['trophy_count']);
|
||||
$this->assertSame(90.0, $classResult['threshold']);
|
||||
$this->assertSame(4, $payload['summary']['students']);
|
||||
$this->assertSame(3, $payload['summary']['trophies']);
|
||||
|
||||
$students = collect($classResult['students'])->keyBy('student_id');
|
||||
|
||||
$this->assertTrue($students[$first->id]['projected_trophy']);
|
||||
$this->assertTrue($students[$second->id]['projected_trophy']);
|
||||
$this->assertTrue($students[$third->id]['projected_trophy']);
|
||||
$this->assertFalse($students[$fourth->id]['projected_trophy']);
|
||||
$this->assertNull($students[$fourth->id]['fall_score']);
|
||||
}
|
||||
|
||||
public function test_final_report_classifies_confirmed_surprise_and_missed_winners(): void
|
||||
{
|
||||
$this->enrollStudent('Adam', 'Confirmed', 'Male', 'S-10', 99.0, 97.0);
|
||||
$this->enrollStudent('Basma', 'Confirmed', 'Female', 'S-11', 95.0, 93.0);
|
||||
$missed = $this->enrollStudent('Cyrus', 'Missed', 'Male', 'S-12', 92.0, 68.0);
|
||||
$surprise = $this->enrollStudent('Dina', 'Surprise', 'Female', 'S-13', 88.0, 100.0);
|
||||
$this->enrollStudent('Evan', 'None', 'Male', 'S-14', 70.0, 50.0);
|
||||
|
||||
$payload = $this->service->final($this->schoolYear, 75.0);
|
||||
|
||||
$this->assertCount(1, $payload['class_results']);
|
||||
$this->assertSame(3, $payload['summary']['predicted']);
|
||||
$this->assertSame(3, $payload['summary']['actual']);
|
||||
$this->assertSame(2, $payload['summary']['confirmed']);
|
||||
$this->assertSame(1, $payload['summary']['surprises']);
|
||||
$this->assertSame(1, $payload['summary']['missed']);
|
||||
$this->assertSame(67.0, $payload['summary']['accuracy']);
|
||||
$this->assertSame(3, $payload['winner_gender_summary']['total']);
|
||||
$this->assertSame(1, $payload['winner_gender_summary']['boys']);
|
||||
$this->assertSame(2, $payload['winner_gender_summary']['girls']);
|
||||
$this->assertCount(3, $payload['winner_stickers']);
|
||||
|
||||
$classResult = $payload['class_results'][0];
|
||||
|
||||
$this->assertSame(92.0, $classResult['fall_threshold']);
|
||||
$this->assertSame(94.0, $classResult['year_threshold']);
|
||||
$this->assertSame(2, $classResult['confirmed']);
|
||||
$this->assertSame(1, $classResult['surprises']);
|
||||
$this->assertSame(1, $classResult['missed']);
|
||||
$this->assertSame(67.0, $classResult['accuracy']);
|
||||
|
||||
$students = collect($classResult['students'])->keyBy('student_id');
|
||||
|
||||
$this->assertSame('missed', $students[$missed->id]['status']);
|
||||
$this->assertTrue($students[$missed->id]['predicted']);
|
||||
$this->assertFalse($students[$missed->id]['actual']);
|
||||
|
||||
$this->assertSame('surprise', $students[$surprise->id]['status']);
|
||||
$this->assertFalse($students[$surprise->id]['predicted']);
|
||||
$this->assertTrue($students[$surprise->id]['actual']);
|
||||
}
|
||||
|
||||
private function enrollStudent(
|
||||
string $firstname,
|
||||
string $lastname,
|
||||
string $gender,
|
||||
string $schoolId,
|
||||
?float $fallScore,
|
||||
?float $springScore = null
|
||||
): Student {
|
||||
$student = Student::factory()->create([
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'gender' => $gender,
|
||||
'school_id' => $schoolId,
|
||||
'school_year' => $this->schoolYear,
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
StudentClass::query()->create([
|
||||
'student_id' => $student->id,
|
||||
'school_id' => null,
|
||||
'class_section_id' => $this->section->class_section_id,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => 'Fall',
|
||||
'is_event_only' => 0,
|
||||
]);
|
||||
|
||||
if ($fallScore !== null) {
|
||||
SemesterScore::query()->create([
|
||||
'student_id' => $student->id,
|
||||
'school_id' => $schoolId,
|
||||
'class_section_id' => $this->section->class_section_id,
|
||||
'semester_score' => $fallScore,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => $this->schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($springScore !== null) {
|
||||
SemesterScore::query()->create([
|
||||
'student_id' => $student->id,
|
||||
'school_id' => $schoolId,
|
||||
'class_section_id' => $this->section->class_section_id,
|
||||
'semester_score' => $springScore,
|
||||
'semester' => 'Spring',
|
||||
'school_year' => $this->schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
return $student;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user