New_Micro_Learning/backend/tests/Feature/Learner/LearnerPlayerTest.php

152 خطوط
13 KiB
PHP

<?php
namespace Tests\Feature\Learner;
use App\Models\User;
use App\Modules\Assessments\Domain\Assessment;
use App\Modules\Assessments\Domain\AssessmentAttempt;
use App\Modules\Assignments\Application\AssignmentResolver;
use App\Modules\Assignments\Domain\Assignment;
use App\Modules\Courses\Domain\Block;
use App\Modules\Courses\Domain\Course;
use App\Modules\Courses\Domain\CourseModule;
use App\Modules\Courses\Domain\CourseVersion;
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
use App\Modules\Courses\Domain\Lesson;
use App\Modules\Identity\Domain\Enums\UserRole;
use App\Modules\Learner\Domain\LearningEvent;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class LearnerPlayerTest extends TestCase
{
use RefreshDatabase;
public function test_learner_only_sees_assigned_published_content_and_player_payload(): void
{
[$learner, $assignment, $version, $lesson, $block] = $this->foundation();
Sanctum::actingAs($learner);
$this->getJson('/api/v1/learner/home')->assertOk()->assertJsonCount(1, 'data.assigned')->assertJsonPath('data.assigned.0.id', $assignment->getKey())
->assertJsonPath('data.assigned.0.lessonCount', 1)->assertJsonPath('data.assigned.0.remainingMinutes', 10)
->assertJsonPath('data.assigned.0.currentLessonTitle', 'PPE')->assertJsonPath('data.assigned.0.favorite', false);
$this->getJson("/api/v1/learner/assignments/{$assignment->getKey()}?courseVersionId={$version->getKey()}&lessonId={$lesson->getKey()}")
->assertOk()->assertJsonPath('data.lesson.presentationMode', 'flow')->assertJsonPath('data.blocks.0.id', $block->getKey());
$outsider = User::factory()->create(['organization_id' => $learner->organization_id, 'role' => UserRole::Learner]);
Sanctum::actingAs($outsider);
$this->getJson('/api/v1/learner/assignments/'.$assignment->getKey())->assertNotFound();
}
public function test_offline_event_sync_is_idempotent_and_completion_updates_assignment(): void
{
[$learner, $assignment, $version, $lesson, $block] = $this->foundation();
Sanctum::actingAs($learner);
$eventId = Str::uuid()->toString();
$event = ['id' => $eventId, 'type' => 'block.completed', 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'blockId' => $block->getKey(), 'occurredAt' => now()->toISOString()];
$this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', false);
$event['type'] = 'lesson.completed';
$event['id'] = Str::uuid()->toString();
$this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.progress', 100)->assertJsonPath('data.events.0.completed', true);
$this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', true);
$this->assertDatabaseCount('learning_events', 2);
$this->assertDatabaseHas('assignment_users', ['assignment_id' => $assignment->getKey(), 'user_id' => $learner->getKey(), 'status' => 'completed', 'progress' => 100]);
}
public function test_notes_and_bookmarks_are_private_and_tenant_scoped(): void
{
[$learner, $assignment, $version, $lesson, $block] = $this->foundation();
Sanctum::actingAs($learner);
$input = ['courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey()];
$this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/notes', [...$input, 'body' => 'نکته شخصی'])->assertCreated();
$this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/bookmarks', [...$input, 'blockId' => $block->getKey()])->assertOk()->assertJsonPath('data.bookmarked', true);
$this->getJson("/api/v1/learner/assignments/{$assignment->getKey()}?courseVersionId={$version->getKey()}&lessonId={$lesson->getKey()}")->assertJsonPath('data.notes.0.body', 'نکته شخصی')->assertJsonPath('data.bookmarks.0', $block->getKey());
}
public function test_highlights_favorites_discussion_replies_and_reactions_are_real_and_scoped(): void
{
[$learner, $assignment, $version, $lesson, $block] = $this->foundation();
Sanctum::actingAs($learner);
$context = ['courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey()];
$this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/highlights', [...$context, 'blockId' => $block->getKey(), 'quote' => 'Safety first', 'color' => 'yellow'])->assertCreated();
$this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/favorite')->assertOk()->assertJsonPath('data.favorite', true);
$comment = $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/discussions', [...$context, 'body' => 'پرسش من'])->assertCreated()->json('data.id');
$this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/discussions', [...$context, 'body' => 'پاسخ من', 'parentId' => $comment])->assertCreated();
$this->postJson('/api/v1/learner/discussions/'.$comment.'/reactions', ['reaction' => 'helpful'])->assertOk()->assertJsonPath('data.active', true);
$this->getJson("/api/v1/learner/assignments/{$assignment->getKey()}?courseVersionId={$version->getKey()}&lessonId={$lesson->getKey()}")->assertJsonPath('data.favorite', true)->assertJsonPath('data.highlights.0.quote', 'Safety first')->assertJsonCount(2, 'data.discussions');
}
public function test_offline_note_event_is_idempotent(): void
{
[$learner, $assignment, $version, $lesson] = $this->foundation();
Sanctum::actingAs($learner);
$event = ['id' => Str::uuid()->toString(), 'type' => 'note.created', 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'payload' => ['body' => 'یادداشت آفلاین'], 'occurredAt' => now()->toISOString()];
$this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', false);
$this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', true);
$this->assertDatabaseCount('learner_notes', 1);
$this->assertDatabaseHas('learner_notes', ['learner_id' => $learner->getKey(), 'body' => 'یادداشت آفلاین']);
}
public function test_assessment_score_is_calculated_server_side(): void
{
[$learner, $assignment, $version, $lesson, $block] = $this->foundation();
$block->update(['type' => 'single_choice', 'data' => ['prompt' => 'پاسخ؟', 'options' => ['صحیح', 'غلط'], 'answerIndex' => 0]]);
Sanctum::actingAs($learner);
$event = ['id' => Str::uuid()->toString(), 'type' => 'block.interacted', 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'blockId' => $block->getKey(), 'payload' => ['response' => [1], 'score' => 1], 'occurredAt' => now()->toISOString()];
$this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk();
$this->assertDatabaseHas('block_progress', ['block_id' => $block->getKey(), 'learner_id' => $learner->getKey(), 'score' => 0]);
}
public function test_progress_report_uses_real_assessments_events_and_capability_scores(): void
{
[$learner, $assignment, $version] = $this->foundation();
$assessment = Assessment::query()->create(['organization_id' => $learner->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'آزمون ایمنی عمومی', 'settings' => ['passScore' => 70, 'maxAttempts' => 3]]);
AssessmentAttempt::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'assessment_id' => $assessment->getKey(), 'attempt_number' => 1, 'status' => 'completed', 'score' => .75, 'started_at' => now()->subWeek()->subHour(), 'completed_at' => now()->subWeek()]);
AssessmentAttempt::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'assessment_id' => $assessment->getKey(), 'attempt_number' => 2, 'status' => 'completed', 'score' => .85, 'started_at' => now()->subHour(), 'completed_at' => now()]);
$this->learningEvent($learner, $assignment, $version, now()->subWeek(), 1800, 'previous-session');
$this->learningEvent($learner, $assignment, $version, now(), 3600, 'current-session');
$taxonomyType = (string) Str::ulid(); $taxonomyNode = (string) Str::ulid();
DB::table('taxonomy_types')->insert(['id' => $taxonomyType, 'organization_id' => $learner->organization_id, 'key' => 'skill', 'name' => 'مهارت', 'status' => 'active', 'created_at' => now(), 'updated_at' => now()]);
DB::table('taxonomy_nodes')->insert(['id' => $taxonomyNode, 'organization_id' => $learner->organization_id, 'taxonomy_type_id' => $taxonomyType, 'name' => 'ایمنی عملی', 'status' => 'active', 'created_at' => now(), 'updated_at' => now()]);
DB::table('capability_scores')->insert(['id' => (string) Str::ulid(), 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'taxonomy_node_id' => $taxonomyNode, 'score' => 78, 'confidence' => .9, 'confidence_level' => 'high', 'evidence_count' => 4, 'last_evidence_at' => now(), 'trend' => 3, 'scoring_model_version' => 'test-v1', 'explanation' => json_encode(['source' => 'test']), 'calculated_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
Sanctum::actingAs($learner);
$this->getJson('/api/v1/learner/progress')->assertOk()
->assertJsonPath('data.summary.averageAssessment', 80)
->assertJsonPath('data.summary.assessmentDelta', 10)
->assertJsonPath('data.summary.learningMinutes', 60)
->assertJsonPath('data.summary.learningMinutesDelta', 100)
->assertJsonPath('data.skills.0.name', 'ایمنی عملی')
->assertJsonPath('data.skills.0.score', 78)
->assertJsonPath('data.recentAssessments.0.score', 85)
->assertJsonPath('data.insight.source', 'capability_scores');
}
private function learningEvent(User $learner, Assignment $assignment, CourseVersion $version, mixed $occurredAt, int $durationSeconds, string $session): void
{
LearningEvent::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'client_event_id' => Str::uuid()->toString(), 'event_type' => 'course.opened', 'schema_version' => 1, 'session_id' => $session, 'assignment_id' => $assignment->getKey(), 'course_version_id' => $version->getKey(), 'payload' => ['durationSeconds' => $durationSeconds], 'occurred_at' => $occurredAt, 'received_at' => $occurredAt]);
}
private function foundation(): array
{
$designer = User::factory()->create(['role' => UserRole::CourseDesigner]);
$learner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]);
$course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Safety', 'slug' => 'safety', 'status' => 'published', 'created_by' => $designer->getKey()]);
$version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Published, 'title' => 'Safety', 'description' => 'Safety course', 'settings' => ['language' => 'fa'], 'completion_rules' => ['mode' => 'all', 'rules' => [['type' => 'all_required_lessons']]], 'published_at' => now()]);
$module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Basics', 'position' => 1]);
$lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'PPE', 'position' => 1, 'presentation_mode' => 'flow', 'settings' => ['durationMinutes' => 10]]);
$block = Block::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), 'type' => 'heading', 'schema_version' => 1, 'data' => ['text' => 'Safety first', 'level' => 2], 'position' => 1, 'revision' => 1]);
$assignment = Assignment::query()->create(['organization_id' => $designer->organization_id, 'assignable_type' => 'course', 'assignable_id' => $version->getKey(), 'target_type' => 'individual', 'target_id' => $learner->getKey(), 'status' => 'active', 'mandatory' => true, 'assigned_by' => $designer->getKey()]);
app(AssignmentResolver::class)->sync($assignment);
return [$learner, $assignment, $version, $lesson, $block];
}
}