Files
chatroom/tests/Feature/RiddleQuizControllerTest.php
T

662 lines
23 KiB
PHP
Raw Normal View History

<?php
/**
* 文件功能:猜成语控制器与后台参数测试
*
* 覆盖猜成语后台过期时间配置、手动出题清理超时回合、
* 以及超时后禁止继续答题的关键行为。
*/
namespace Tests\Feature;
use App\Models\GameConfig;
use App\Models\Riddle;
use App\Models\RiddleGameRound;
use App\Models\Room;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\RiddleGameService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* 类功能:验证猜成语后台配置与回合过期逻辑。
*/
class RiddleQuizControllerTest extends TestCase
{
/**
* 方法功能:为本组测试准备隔离表结构,不触碰本地业务数据库。
*/
protected function setUp(): void
{
parent::setUp();
$this->rebuildTestingTables();
}
/**
* 方法功能:验证后台可以保存题目过期时间配置。
*/
public function test_service_uses_legacy_idiom_config_for_brain_teaser_scope_and_interval(): void
{
$room = Room::create(['room_name' => '测试房间']);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 88,
'reward_exp' => 66,
'auto_start_interval' => 9,
'expire_minutes' => 7,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
/** @var RiddleGameService $service */
$service = $this->app->make(RiddleGameService::class);
$this->assertSame(9, $service->getAutoStartInterval(Riddle::TYPE_BRAIN_TEASER));
$this->assertSame(7, $service->getExpireMinutes(Riddle::TYPE_BRAIN_TEASER));
$this->assertSame([$room->id], $service->getScopedRoomIds(Riddle::TYPE_BRAIN_TEASER));
}
/**
* 方法功能:验证 single 房间范围模式只取配置中的首个房间。
*/
public function test_service_only_uses_first_room_when_scope_mode_is_single(): void
{
$roomOne = Room::create(['room_name' => '测试房间1']);
$roomTwo = Room::create(['room_name' => '测试房间2']);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 3,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$roomOne->id, $roomTwo->id],
],
]);
/** @var RiddleGameService $service */
$service = $this->app->make(RiddleGameService::class);
$this->assertSame([$roomOne->id], $service->getScopedRoomIds(Riddle::TYPE_IDIOM));
}
/**
* 方法功能:验证手动出题前会清理已超时的旧回合,避免阻塞新题。
*/
public function test_start_clears_expired_round_before_creating_new_round(): void
{
$this->mockChatStateService();
$admin = $this->createSiteOwner();
$room = Room::create(['room_name' => '猜成语房间']);
$idiom = $this->createActiveIdiom();
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜成语',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 50,
'reward_exp' => 30,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$expiredRound = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $idiom->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
'reward_gold' => 50,
'reward_exp' => 30,
'started_at' => now()->subMinutes(10),
]);
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
'room_id' => $room->id,
]);
$response->assertOk()
->assertJsonPath('status', 'success');
$expiredRound->refresh();
$this->assertSame('ended', $expiredRound->status);
$this->assertNotNull($expiredRound->ended_at);
$this->assertDatabaseCount('idiom_game_rounds', 2);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
]);
}
/**
* 方法功能:验证旧路由在传入脑筋急转弯题型时也能正常手动开题。
*/
public function test_start_supports_brain_teaser_quiz_type_on_legacy_route(): void
{
$this->mockChatStateService();
$admin = $this->createSiteOwner();
$room = Room::create(['room_name' => '猜谜房间']);
$brainTeaser = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 66,
'reward_exp' => 44,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.quiz_type', Riddle::TYPE_BRAIN_TEASER)
->assertJsonPath('data.hint', $brainTeaser->hint);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $room->id,
'idiom_id' => $brainTeaser->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'status' => 'active',
]);
}
/**
* 方法功能:验证手动出题会结束当前同题型旧回合并直接开启新题。
*/
public function test_start_manually_ends_previous_active_round_of_same_quiz_type(): void
{
$this->mockChatStateService();
$admin = $this->createSiteOwner();
$room = Room::create(['room_name' => '手动出题房间']);
$questionOne = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
$questionTwo = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '回声',
hint: '🧠 你喊它也喊,你停它就停,山谷里最常见,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 66,
'reward_exp' => 44,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$oldRound = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $questionOne->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'status' => 'active',
'reward_gold' => 66,
'reward_exp' => 44,
'started_at' => now()->subMinute(),
]);
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.quiz_type', Riddle::TYPE_BRAIN_TEASER);
$oldRound->refresh();
$this->assertSame('ended', $oldRound->status);
$this->assertNotNull($oldRound->ended_at);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'status' => 'active',
]);
$this->assertSame(2, RiddleGameRound::query()->where('room_id', $room->id)->count());
$this->assertSame(1, RiddleGameRound::query()
->where('room_id', $room->id)
->where('quiz_type', Riddle::TYPE_BRAIN_TEASER)
->where('status', 'active')
->count());
}
/**
* 方法功能:验证游戏总开关关闭时返回明确提示,而不是误报题库为空。
*/
public function test_start_returns_clear_message_when_riddle_activity_is_disabled(): void
{
$this->mockChatStateService();
$admin = $this->createSiteOwner();
$room = Room::create(['room_name' => '猜谜房间']);
$this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => false,
'params' => [
'reward_gold' => 66,
'reward_exp' => 44,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$response = $this->actingAs($admin)->postJson(route('riddle-quiz.start'), [
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
]);
$response->assertStatus(400)
->assertJsonPath('status', 'error')
->assertJsonPath('message', '猜谜活动未开启,请先到游戏管理中开启后再出题。');
}
/**
* 方法功能:验证回合超时后不能继续答题,也不会发放奖励。
*/
public function test_answer_rejects_expired_round_and_marks_it_ended(): void
{
$this->mockChatStateService();
$player = User::factory()->create([
'username' => '答题用户',
'exp_num' => 10,
]);
$room = Room::create(['room_name' => '答题房间']);
$idiom = $this->createActiveIdiom();
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜成语',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 20,
'reward_exp' => 15,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$round = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $idiom->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
'reward_gold' => 20,
'reward_exp' => 15,
'started_at' => now()->subMinutes(6),
]);
$response = $this->actingAs($player)->postJson(route('riddle-quiz.answer'), [
'round_id' => $round->id,
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'answer' => '画蛇添足',
]);
$response->assertStatus(400)
->assertJsonPath('status', 'error')
->assertJsonPath('message', '该回合已超时结束');
$round->refresh();
$player->refresh();
$this->assertSame('ended', $round->status);
$this->assertNotNull($round->ended_at);
$this->assertNull($round->winner_id);
$this->assertSame(10, $player->exp_num);
}
2026-04-30 16:49:25 +08:00
/**
* 方法功能:验证同一房间两种题型同时存在时,答对一题不会结算另一题。
*/
public function test_answer_only_ends_matching_round_when_two_quiz_types_are_active(): void
{
$this->mockChatStateService();
$player = User::factory()->create([
'username' => '破题用户',
'exp_num' => 10,
]);
$room = Room::create(['room_name' => '双题房间']);
$idiom = $this->createQuestion(
type: Riddle::TYPE_IDIOM,
answer: '画蛇添足',
hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语',
);
$brainTeaser = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 20,
'reward_exp' => 15,
'auto_start_interval' => 0,
'expire_minutes' => 5,
'room_scope_mode' => 'single',
'room_ids' => [$room->id],
],
]);
$idiomRound = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $idiom->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
'reward_gold' => 20,
'reward_exp' => 15,
'started_at' => now(),
]);
$brainTeaserRound = RiddleGameRound::create([
'room_id' => $room->id,
'idiom_id' => $brainTeaser->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'status' => 'active',
'reward_gold' => 20,
'reward_exp' => 15,
'started_at' => now(),
]);
$response = $this->actingAs($player)->postJson(route('riddle-quiz.answer'), [
'round_id' => $idiomRound->id,
'room_id' => $room->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'answer' => '画蛇添足',
]);
$response->assertOk()
->assertJsonPath('status', 'success')
->assertJsonPath('data.quiz_round_id', $idiomRound->id);
$idiomRound->refresh();
$brainTeaserRound->refresh();
$this->assertSame('answered', $idiomRound->status);
$this->assertSame('破题用户', $idiomRound->winner_username);
$this->assertSame('active', $brainTeaserRound->status);
$this->assertNull($brainTeaserRound->winner_username);
}
/**
* 方法功能:验证自动出题会按房间和题型两个维度独立判断。
*/
public function test_auto_start_checks_room_and_quiz_type_independently(): void
{
$this->mockChatStateService();
$roomOne = Room::create(['room_name' => '一号房']);
$roomTwo = Room::create(['room_name' => '二号房']);
$idiom = $this->createQuestion(
type: Riddle::TYPE_IDIOM,
answer: '画蛇添足',
hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语',
);
$brainTeaser = $this->createQuestion(
type: Riddle::TYPE_BRAIN_TEASER,
answer: '影子',
hint: '🧠 天天跟着你走,白天有晚上无,看得见摸不着,是什么?',
);
GameConfig::create([
'game_key' => 'idiom',
'name' => '猜谜活动',
'icon' => '🧩',
'enabled' => true,
'params' => [
'reward_gold' => 30,
'reward_exp' => 18,
'auto_start_interval' => 1,
'expire_minutes' => 5,
'room_scope_mode' => 'multiple',
'room_ids' => [$roomOne->id, $roomTwo->id],
],
]);
// 一号房已有进行中的成语题,只允许系统补开脑筋急转弯题。
RiddleGameRound::create([
'room_id' => $roomOne->id,
'idiom_id' => $idiom->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'status' => 'active',
'reward_gold' => 30,
'reward_exp' => 18,
'started_at' => now()->subSeconds(30),
]);
/** @var RiddleGameService $service */
$service = $this->app->make(RiddleGameService::class);
$startedCount = $service->autoStartEligibleRounds();
$this->assertSame(3, $startedCount);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $roomOne->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'idiom_id' => $idiom->id,
'status' => 'active',
]);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $roomOne->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'idiom_id' => $brainTeaser->id,
'status' => 'active',
]);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $roomTwo->id,
'quiz_type' => Riddle::TYPE_IDIOM,
'idiom_id' => $idiom->id,
'status' => 'active',
]);
$this->assertDatabaseHas('idiom_game_rounds', [
'room_id' => $roomTwo->id,
'quiz_type' => Riddle::TYPE_BRAIN_TEASER,
'idiom_id' => $brainTeaser->id,
'status' => 'active',
]);
}
/**
* 方法功能:创建站长账号,满足后台权限与手动出题权限要求。
*/
private function createSiteOwner(): User
{
return User::factory()->create([
'id' => 1,
'user_level' => 100,
'username' => '站长',
]);
}
/**
* 方法功能:创建一条可用于猜成语测试的启用题目。
*/
private function createActiveIdiom(): Riddle
{
return $this->createQuestion(
type: Riddle::TYPE_IDIOM,
answer: '画蛇添足',
hint: '🧩 四人比赛画蛇,最慢的那个反而多此一举。猜一成语',
);
}
/**
* 方法功能:按指定题型创建一条启用中的测试题目。
*/
private function createQuestion(string $type, string $answer, string $hint): Riddle
{
return Riddle::create([
'type' => $type,
'answer' => $answer,
'hint' => $hint,
'sort' => 1,
'is_active' => true,
]);
}
/**
* 方法功能:重建本组测试所需的最小表结构。
*/
private function rebuildTestingTables(): void
{
Schema::disableForeignKeyConstraints();
Schema::dropIfExists('idiom_game_rounds');
Schema::dropIfExists('idioms');
2026-04-30 16:49:25 +08:00
Schema::dropIfExists('user_currency_logs');
Schema::dropIfExists('game_configs');
Schema::dropIfExists('rooms');
Schema::dropIfExists('users');
Schema::enableForeignKeyConstraints();
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('username')->nullable();
$table->string('email')->nullable();
$table->string('password')->nullable();
$table->string('remember_token', 100)->nullable();
$table->unsignedTinyInteger('sex')->default(1);
$table->text('custom_join_message')->nullable();
$table->text('custom_leave_message')->nullable();
$table->integer('user_level')->default(1);
$table->integer('exp_num')->default(0);
2026-04-30 16:49:25 +08:00
$table->integer('jjb')->default(0);
$table->integer('meili')->default(0);
$table->timestamps();
});
Schema::create('rooms', function (Blueprint $table): void {
$table->id();
$table->string('room_name')->nullable();
$table->string('room_owner')->nullable();
$table->timestamps();
});
Schema::create('game_configs', function (Blueprint $table): void {
$table->id();
$table->string('game_key')->unique();
$table->string('name')->nullable();
$table->string('icon')->nullable();
$table->text('description')->nullable();
$table->boolean('enabled')->default(false);
$table->json('params')->nullable();
$table->timestamps();
});
Schema::create('idioms', function (Blueprint $table): void {
$table->id();
$table->string('type', 30)->default(Riddle::TYPE_IDIOM);
$table->string('answer', 50);
$table->string('hint', 255);
$table->boolean('is_active')->default(true);
$table->integer('sort')->default(0);
$table->timestamps();
});
Schema::create('idiom_game_rounds', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('room_id');
$table->unsignedBigInteger('idiom_id');
$table->string('quiz_type', 30)->default(Riddle::TYPE_IDIOM);
$table->string('status', 20)->default('pending');
$table->integer('reward_gold')->default(0);
$table->integer('reward_exp')->default(0);
$table->unsignedBigInteger('winner_id')->nullable();
$table->string('winner_username', 50)->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('ended_at')->nullable();
$table->timestamps();
});
2026-04-30 16:49:25 +08:00
Schema::create('user_currency_logs', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id')->index();
$table->string('username', 50);
$table->string('currency', 10);
$table->integer('amount');
$table->integer('balance_after');
$table->string('source', 30)->index();
$table->string('remark', 200)->nullable();
$table->unsignedBigInteger('room_id')->nullable();
$table->timestamp('created_at')->nullable()->index();
});
}
/**
* 方法功能:替换聊天室状态服务,避免测试依赖真实 Redis。
*/
private function mockChatStateService(): void
{
$chatStateService = \Mockery::mock(ChatStateService::class);
$chatStateService->shouldReceive('nextMessageId')->andReturn(1);
$chatStateService->shouldReceive('pushMessage')->andReturnNull();
$this->app->instance(ChatStateService::class, $chatStateService);
}
}