166 lines
5.6 KiB
PHP
166 lines
5.6 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Contracts\AiService;
|
|
use App\Jobs\AnalyzeStudentPerformance;
|
|
use App\Models\Lesson;
|
|
use App\Models\Question;
|
|
use App\Models\QuestionOption;
|
|
use App\Models\Quiz;
|
|
use App\Models\User;
|
|
use App\Services\ProgressTrackingService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
class StudentTrackingTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $student;
|
|
|
|
private Lesson $lesson;
|
|
|
|
private Quiz $quiz;
|
|
|
|
private Question $question;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->student = User::factory()->create();
|
|
$this->lesson = Lesson::factory()->create(['duration_seconds' => 600]);
|
|
$this->quiz = Quiz::factory()->for($this->lesson)->create();
|
|
$this->question = Question::factory()->for($this->quiz)->create([
|
|
'explanation' => 'لأن القانون ينص على ذلك',
|
|
]);
|
|
|
|
QuestionOption::query()->create(['question_id' => $this->question->id, 'body' => 'A', 'is_correct' => true]);
|
|
QuestionOption::query()->create(['question_id' => $this->question->id, 'body' => 'B', 'is_correct' => false]);
|
|
}
|
|
|
|
#[Test]
|
|
public function heartbeat_updates_progress_and_completes_lesson(): void
|
|
{
|
|
$response = $this->postJson('/api/v1/progress/heartbeat', [
|
|
'lesson_id' => $this->lesson->id,
|
|
'position_seconds' => 300,
|
|
], $this->authHeaders());
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('watched_percentage', 50)
|
|
->assertJsonPath('completed', false);
|
|
|
|
$this->postJson('/api/v1/progress/heartbeat', [
|
|
'lesson_id' => $this->lesson->id,
|
|
'position_seconds' => 580,
|
|
], $this->authHeaders())->assertOk()->assertJsonPath('completed', true);
|
|
|
|
$this->assertDatabaseCount('lesson_progress', 1);
|
|
}
|
|
|
|
#[Test]
|
|
public function quiz_attempt_is_graded_server_side_and_updates_mastery(): void
|
|
{
|
|
$wrongOption = QuestionOption::query()->where('is_correct', false)->firstOrFail();
|
|
$rightOption = QuestionOption::query()->where('is_correct', true)->firstOrFail();
|
|
|
|
$response = $this->postJson("/api/v1/lessons/{$this->lesson->id}/quizzes/{$this->quiz->id}/attempts", [
|
|
'answers' => [
|
|
['question_id' => $this->question->id, 'selected_option_id' => $wrongOption->id, 'time_spent_seconds' => 12],
|
|
],
|
|
], $this->authHeaders());
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('score', 0)
|
|
->assertJsonPath('results.0.is_correct', false)
|
|
->assertJsonPath('results.0.explanation', 'لأن القانون ينص على ذلك');
|
|
|
|
$mastery = $this->student->conceptMastery()->firstOrFail();
|
|
$this->assertSame(0.0, (float) $mastery->mastery_score);
|
|
$this->assertSame(1, (int) $mastery->wrong_count);
|
|
|
|
$retry = $this->postJson("/api/v1/lessons/{$this->lesson->id}/quizzes/{$this->quiz->id}/attempts", [
|
|
'answers' => [
|
|
['question_id' => $this->question->id, 'selected_option_id' => $rightOption->id],
|
|
],
|
|
], $this->authHeaders());
|
|
|
|
$retry->assertOk()->assertJsonPath('score', 100);
|
|
|
|
$mastery->refresh();
|
|
$this->assertSame(30.0, (float) $mastery->mastery_score);
|
|
$this->assertSame(1, (int) $mastery->correct_count);
|
|
}
|
|
|
|
#[Test]
|
|
public function analysis_endpoint_queues_job(): void
|
|
{
|
|
Queue::fake();
|
|
|
|
$response = $this->postJson('/api/v1/students/me/analysis', [], $this->authHeaders());
|
|
|
|
$response->assertAccepted();
|
|
Queue::assertPushed(AnalyzeStudentPerformance::class);
|
|
}
|
|
|
|
#[Test]
|
|
public function analysis_job_stores_ai_report_via_fake_service(): void
|
|
{
|
|
$this->app->bind(AiService::class, fn () => new class implements AiService
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $performance
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function analyzeStudentPerformance(array $performance): array
|
|
{
|
|
return [
|
|
'weak_concepts' => [['name' => 'الاشتقاق', 'reason' => 'أخطاء متكررة', 'severity' => 'high']],
|
|
'remedial_lesson_ids' => [],
|
|
'recommended_actions' => ['إعادة مشاهدة الدرس الأول'],
|
|
'motivation_note' => 'استمر',
|
|
'summary' => 'ملخص تجريبي',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $activity
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function generateParentWeeklyReport(array $activity): array
|
|
{
|
|
return [];
|
|
}
|
|
});
|
|
|
|
(new AnalyzeStudentPerformance($this->student->id))
|
|
->handle(app(ProgressTrackingService::class), app(AiService::class));
|
|
|
|
$this->assertDatabaseHas('ai_reports', [
|
|
'user_id' => $this->student->id,
|
|
'type' => 'weakness_analysis',
|
|
]);
|
|
}
|
|
|
|
#[Test]
|
|
public function heartbeat_requires_authentication(): void
|
|
{
|
|
$this->postJson('/api/v1/progress/heartbeat', [
|
|
'lesson_id' => $this->lesson->id,
|
|
'position_seconds' => 10,
|
|
])->assertUnauthorized();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function authHeaders(): array
|
|
{
|
|
return ['Authorization' => 'Bearer '.$this->student->api_token];
|
|
}
|
|
}
|