QuickQuiz/tests/Feature/QuizFlowTest.php

274 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Paper;
use App\Models\QuestionBank;
use App\Models\SchoolClass;
use App\Models\User;
use App\Services\QuestionImportService;
use App\Services\QuizService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
use Tymon\JWTAuth\Facades\JWTAuth;
final class QuizFlowTest extends TestCase
{
use RefreshDatabase;
public function test_practice_answer_updates_wrong_question_state(): void
{
$user = User::factory()->create(['role' => 'user']);
$bank = QuestionBank::create([
'owner_id' => $user->id,
'name' => '测试题库',
'visibility' => 'public',
'is_active' => true,
]);
app(QuestionImportService::class)->importJsonText($bank, $user, file_get_contents(base_path('question.json')));
$service = app(QuizService::class);
$attempt = $service->startPractice($user, $bank, 'practice', ['limit' => 100]);
$item = $attempt->items()->with('question.options')->firstOrFail();
$wrongOption = $item->question->options->firstWhere('is_correct', false);
$service->answer($user, $attempt, $item->question_id, [$wrongOption->id]);
$this->assertDatabaseHas('wrong_questions', [
'user_id' => $user->id,
'question_id' => $item->question_id,
'wrong_count' => 1,
]);
}
public function test_paper_attempt_uses_override_score_and_enforces_attempt_limit(): void
{
$user = User::factory()->create(['role' => 'user']);
$bank = QuestionBank::create([
'owner_id' => $user->id,
'name' => '固定试卷题库',
'visibility' => 'public',
'is_active' => true,
]);
app(QuestionImportService::class)->importJsonText($bank, $user, file_get_contents(base_path('question.json')));
$question = $bank->questions()->with('options')->firstOrFail();
$paper = Paper::create([
'owner_id' => $user->id,
'question_bank_id' => $bank->id,
'title' => '限次测试',
'attempt_limit' => 1,
'is_active' => true,
]);
$paper->questions()->attach($question->id, ['score' => 5, 'sort' => 0]);
$service = app(QuizService::class);
$attempt = $service->startPaper($user, $paper);
$item = $attempt->items()->with('question.options')->firstOrFail();
$correctOptionIds = $item->question->options->where('is_correct', true)->pluck('id')->all();
$service->answer($user, $attempt, $item->question_id, $correctOptionIds);
$submitted = $service->submit($user, $attempt);
$this->assertSame('5.00', $submitted->score);
$this->expectException(ValidationException::class);
$service->startPaper($user, $paper);
}
public function test_wrong_question_is_marked_mastered_after_three_correct_answers(): void
{
$user = User::factory()->create(['role' => 'user']);
$bank = QuestionBank::create([
'owner_id' => $user->id,
'name' => '错题题库',
'visibility' => 'public',
'is_active' => true,
]);
app(QuestionImportService::class)->importJsonText($bank, $user, file_get_contents(base_path('question.json')));
$service = app(QuizService::class);
$attempt = $service->startPractice($user, $bank, 'practice', ['limit' => 1]);
$item = $attempt->items()->with('question.options')->firstOrFail();
$wrongOption = $item->question->options->firstWhere('is_correct', false);
$correctOptionIds = $item->question->options->where('is_correct', true)->pluck('id')->all();
$service->answer($user, $attempt, $item->question_id, [$wrongOption->id]);
for ($i = 0; $i < 3; $i++) {
$nextAttempt = $service->startPractice($user, $bank, 'practice', ['limit' => 100]);
$service->answer($user, $nextAttempt, $item->question_id, $correctOptionIds);
}
$this->assertDatabaseMissing('wrong_questions', [
'user_id' => $user->id,
'question_id' => $item->question_id,
'mastered_at' => null,
]);
}
public function test_attempt_position_can_be_saved_and_resumed(): void
{
$user = User::factory()->create(['role' => 'user', 'password' => bcrypt('password')]);
$bank = QuestionBank::create([
'owner_id' => $user->id,
'name' => '续做题库',
'visibility' => 'public',
'is_active' => true,
]);
app(QuestionImportService::class)->importJsonText($bank, $user, file_get_contents(base_path('question.json')));
$attempt = app(QuizService::class)->startPractice($user, $bank, 'practice', ['limit' => 3]);
$token = $this->postJson('/api/auth/login', [
'email' => $user->email,
'password' => 'password',
])->assertOk()->json('data.token');
$this->assertSame($user->id, JWTAuth::setToken($token)->authenticate()->id);
$this->assertSame($user->id, $attempt->user_id);
$response = $this->withToken($token)
->putJson("/api/app/attempts/{$attempt->id}/position", [
'current_index' => 1,
]);
$response
->assertOk()
->assertJsonPath('data.current_index', 1);
$this->withToken($token)
->getJson("/api/app/attempts/{$attempt->id}")
->assertOk()
->assertJsonPath('data.current_index', 1);
}
public function test_private_bank_cannot_be_started_without_share(): void
{
$owner = User::factory()->create(['role' => 'teacher']);
$student = User::factory()->create(['role' => 'user']);
$bank = $this->bankWithQuestions($owner, ['visibility' => 'private']);
$token = JWTAuth::fromUser($student);
$this->withToken($token)
->postJson("/api/app/banks/{$bank->id}/attempts", [
'mode' => 'practice',
'limit' => 1,
])
->assertForbidden();
}
public function test_user_share_allows_starting_private_bank(): void
{
$owner = User::factory()->create(['role' => 'teacher']);
$student = User::factory()->create(['role' => 'user']);
$bank = $this->bankWithQuestions($owner, ['visibility' => 'assigned']);
DB::table('bank_shares')->insert([
'question_bank_id' => $bank->id,
'target_type' => 'user',
'target_id' => $student->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->withToken(JWTAuth::fromUser($student))
->postJson("/api/app/banks/{$bank->id}/attempts", [
'mode' => 'practice',
'limit' => 1,
])
->assertOk()
->assertJsonPath('data.question_bank_id', $bank->id);
}
public function test_class_share_allows_resources_and_paper_attempts(): void
{
$owner = User::factory()->create(['role' => 'teacher']);
$student = User::factory()->create(['role' => 'user']);
$bank = $this->bankWithQuestions($owner, ['visibility' => 'assigned']);
$class = SchoolClass::create([
'owner_id' => $owner->id,
'name' => '一班',
'join_code' => 'CLASS001',
'is_active' => true,
]);
$class->members()->attach($student->id, ['role' => 'student']);
DB::table('bank_shares')->insert([
'question_bank_id' => $bank->id,
'target_type' => 'class',
'target_id' => $class->id,
'created_at' => now(),
'updated_at' => now(),
]);
$paper = Paper::create([
'owner_id' => $owner->id,
'question_bank_id' => $bank->id,
'title' => '班级测试',
'is_active' => true,
]);
$paper->questions()->attach($bank->questions()->firstOrFail()->id, ['sort' => 0]);
$token = JWTAuth::fromUser($student);
$this->withToken($token)
->getJson('/api/app/resources')
->assertOk()
->assertJsonPath('data.banks.0.id', $bank->id)
->assertJsonPath('data.papers.0.id', $paper->id);
$this->withToken($token)
->postJson("/api/app/papers/{$paper->id}/attempts")
->assertOk()
->assertJsonPath('data.paper_id', $paper->id);
}
public function test_public_bank_can_be_started_by_student(): void
{
$owner = User::factory()->create(['role' => 'teacher']);
$student = User::factory()->create(['role' => 'user']);
$bank = $this->bankWithQuestions($owner, ['visibility' => 'public']);
$this->withToken(JWTAuth::fromUser($student))
->postJson("/api/app/banks/{$bank->id}/attempts", [
'mode' => 'practice',
'limit' => 1,
])
->assertOk()
->assertJsonPath('data.question_bank_id', $bank->id);
}
public function test_private_unshared_paper_cannot_be_started(): void
{
$owner = User::factory()->create(['role' => 'teacher']);
$student = User::factory()->create(['role' => 'user']);
$bank = $this->bankWithQuestions($owner, ['visibility' => 'private']);
$paper = Paper::create([
'owner_id' => $owner->id,
'question_bank_id' => $bank->id,
'title' => '私有测试',
'is_active' => true,
]);
$paper->questions()->attach($bank->questions()->firstOrFail()->id, ['sort' => 0]);
$this->withToken(JWTAuth::fromUser($student))
->postJson("/api/app/papers/{$paper->id}/attempts")
->assertForbidden();
}
/**
* @param array<string, mixed> $attributes
*/
private function bankWithQuestions(User $owner, array $attributes = []): QuestionBank
{
$bank = QuestionBank::create($attributes + [
'owner_id' => $owner->id,
'name' => '权限题库',
'visibility' => 'public',
'is_active' => true,
]);
app(QuestionImportService::class)->importJsonText($bank, $owner, file_get_contents(base_path('question.json')));
return $bank;
}
}