| import asyncio | |
| import pytest | |
| from app import Card, GameError, Player, Room, create_deck_for_tests, rooms, run_auto_draw | |
| def make_room(player_count=3): | |
| players = [ | |
| Player(id=f"p{i}", name=f"P{i}", token=f"t{i}", connected=True) | |
| for i in range(player_count) | |
| ] | |
| return Room(code="TEST", host_player_id="p0", total_players=player_count, players=players) | |
| def test_deck_has_108_cards(): | |
| deck = create_deck_for_tests() | |
| assert len(deck) == 108 | |
| assert sum(1 for c in deck if c.color == "wild" and c.value == "wild") == 4 | |
| assert sum(1 for c in deck if c.color == "wild" and c.value == "wild_draw4") == 4 | |
| def test_start_game_with_15_players_is_legal(): | |
| room = make_room(15) | |
| event = room.start_game("p0") | |
| assert event["type"] == "game_started" | |
| assert room.phase == "playing" | |
| assert all(len(p.hand) == 7 for p in room.players) | |
| assert len(room.discard_pile) == 1 | |
| assert room.discard_pile[-1].color != "wild" | |
| assert len(room.deck) == 2 | |
| def test_reshuffle_keeps_top_discard(): | |
| room = make_room() | |
| top = Card("top", "red", "5") | |
| old = Card("old", "blue", "7") | |
| room.discard_pile = [old, top] | |
| room.deck = [] | |
| room.reshuffle_deck() | |
| assert room.discard_pile == [top] | |
| assert room.deck == [old] | |
| def test_normal_play_rules_and_draw_stacking(): | |
| room = make_room() | |
| room.phase = "playing" | |
| room.current_player_id = "p0" | |
| room.current_color = "red" | |
| room.discard_pile = [Card("top", "red", "3")] | |
| assert room.is_valid_play(Card("same_color", "red", "9")) | |
| assert room.is_valid_play(Card("same_value", "blue", "3")) | |
| assert room.is_valid_play(Card("wild", "wild", "wild")) | |
| assert not room.is_valid_play(Card("bad", "green", "8")) | |
| room.pending_draw = 2 | |
| room.discard_pile = [Card("draw_top", "red", "draw2")] | |
| assert room.is_valid_play(Card("stack2", "blue", "draw2")) | |
| assert room.is_valid_play(Card("stack4", "wild", "wild_draw4")) | |
| assert not room.is_valid_play(Card("number", "red", "3")) | |
| def test_strict_jump_in_requires_color_and_value(): | |
| room = make_room() | |
| room.phase = "playing" | |
| room.discard_pile = [Card("top", "red", "7")] | |
| assert room.is_strict_jump_in(Card("same", "red", "7")) | |
| assert not room.is_strict_jump_in(Card("value_only", "blue", "7")) | |
| assert not room.is_strict_jump_in(Card("color_only", "red", "8")) | |
| room.discard_pile = [Card("wild_top", "wild", "wild_draw4")] | |
| assert room.is_strict_jump_in(Card("wild_same", "wild", "wild_draw4")) | |
| assert not room.is_strict_jump_in(Card("wild_other", "wild", "wild")) | |
| def test_jump_in_interrupts_and_continues_from_jumper(): | |
| room = make_room(4) | |
| room.phase = "playing" | |
| room.current_player_id = "p0" | |
| room.current_color = "red" | |
| room.discard_pile = [Card("top", "red", "5")] | |
| room.players[2].hand = [Card("jump", "red", "5")] | |
| room.play_card("p2", "jump", expected_top_card_id="top", expected_version=0) | |
| assert room.current_player_id == "p3" | |
| assert room.discard_pile[-1].id == "jump" | |
| def test_jump_in_with_reverse_uses_jumper_as_turn_source(): | |
| room = make_room(4) | |
| room.phase = "playing" | |
| room.current_player_id = "p0" | |
| room.direction = 1 | |
| room.current_color = "green" | |
| room.discard_pile = [Card("top", "green", "reverse")] | |
| room.players[2].hand = [Card("jump", "green", "reverse")] | |
| room.play_card("p2", "jump", expected_top_card_id="top", expected_version=0) | |
| assert room.direction == -1 | |
| assert room.current_player_id == "p1" | |
| def test_second_simultaneous_jump_in_revalidates_top_card(): | |
| room = make_room(4) | |
| room.phase = "playing" | |
| room.current_player_id = "p0" | |
| room.current_color = "red" | |
| room.discard_pile = [Card("top", "red", "5")] | |
| room.players[2].hand = [Card("jump1", "red", "5")] | |
| room.players[3].hand = [Card("jump2", "red", "5")] | |
| room.play_card("p2", "jump1", expected_top_card_id="top", expected_version=0) | |
| with pytest.raises(GameError): | |
| room.version += 1 | |
| room.play_card("p3", "jump2", expected_top_card_id="top", expected_version=0) | |
| def test_bot_jump_in_revalidation_shape(): | |
| room = make_room(4) | |
| room.players[2].is_bot = True | |
| room.phase = "playing" | |
| room.current_player_id = "p0" | |
| room.current_color = "red" | |
| room.discard_pile = [Card("top", "red", "5")] | |
| bot_card = Card("bot_jump", "red", "5") | |
| room.players[2].hand = [bot_card] | |
| assert any(room.is_strict_jump_in(card) for card in room.players[2].hand) | |
| room.discard_pile.append(Card("changed", "blue", "9")) | |
| assert not any(room.is_strict_jump_in(card) for card in room.players[2].hand) | |
| def test_auto_draw_no_playable_human_draws_and_advances(): | |
| room = make_room() | |
| room.code = "AUTO1" | |
| room.phase = "playing" | |
| room.current_player_id = "p0" | |
| room.current_color = "red" | |
| room.discard_pile = [Card("top", "red", "5")] | |
| room.players[0].hand = [Card("bad", "green", "8")] | |
| room.players[1].hand = [Card("p1_playable", "red", "1")] | |
| room.deck = [Card("drawn", "blue", "9")] | |
| rooms[room.code] = room | |
| try: | |
| asyncio.run(run_auto_draw(room.code, "p0", room.version, "top", "red", 0, delay=0)) | |
| assert [card.id for card in room.players[0].hand] == ["bad", "drawn"] | |
| assert room.current_player_id == "p1" | |
| assert room.version == 1 | |
| finally: | |
| rooms.pop(room.code, None) | |
| def test_auto_draw_takes_pending_penalty_and_clears_it(): | |
| room = make_room() | |
| room.code = "AUTO2" | |
| room.phase = "playing" | |
| room.current_player_id = "p0" | |
| room.current_color = "red" | |
| room.pending_draw = 2 | |
| room.discard_pile = [Card("top", "red", "draw2")] | |
| room.players[0].hand = [Card("bad", "green", "8")] | |
| room.players[1].hand = [Card("p1_playable", "red", "1")] | |
| room.deck = [Card("drawn1", "blue", "9"), Card("drawn2", "yellow", "1")] | |
| rooms[room.code] = room | |
| try: | |
| asyncio.run(run_auto_draw(room.code, "p0", room.version, "top", "red", 2, delay=0)) | |
| assert len(room.players[0].hand) == 3 | |
| assert room.pending_draw == 0 | |
| assert room.current_player_id == "p1" | |
| finally: | |
| rooms.pop(room.code, None) | |
| def test_auto_draw_revalidates_before_drawing(): | |
| room = make_room() | |
| room.code = "AUTO3" | |
| room.phase = "playing" | |
| room.current_player_id = "p0" | |
| room.current_color = "red" | |
| room.discard_pile = [Card("top", "red", "5")] | |
| room.players[0].hand = [Card("playable", "red", "8")] | |
| room.deck = [Card("drawn", "blue", "9")] | |
| rooms[room.code] = room | |
| try: | |
| asyncio.run(run_auto_draw(room.code, "p0", room.version, "top", "red", 0, delay=0)) | |
| assert [card.id for card in room.players[0].hand] == ["playable"] | |
| assert room.current_player_id == "p0" | |
| room.players[0].hand = [Card("bad", "green", "8")] | |
| room.version += 1 | |
| asyncio.run(run_auto_draw(room.code, "p0", 0, "top", "red", 0, delay=0)) | |
| assert [card.id for card in room.players[0].hand] == ["bad"] | |
| assert room.current_player_id == "p0" | |
| finally: | |
| rooms.pop(room.code, None) | |