Monday, March 27, 2006

TDDing a Card Game part 5

In part three We started with four questions.

  • What happens when player makes an invalid move?
  • How does Player draw from its stock pile?
  • How does Player get notified of the end of game play?
  • Does player notify Game when its out of cards?
We developed how Player draws a card in that entry. One thing that has been bugging me is that we don't have an actual Game class yet. We do have most of an interface or protocol that the Game class will implement, but it is completely contained in the SpitPlayerRules TestCase. Should I extract it now? Should I have stubbed a Game class out earlier instead of using the test case as a double? Should I wait a little longer before extracting it? I think I'll wait for now because I want the interface between Player and Game to emerge a little bit more. Of course this might not be the best idea.

Bad Move

What happens when player makes an invalid move? An invalid move could be trying to draw a card when the Player already has five cards in its hand or trying to play a card out of sequence. Thinking ahead a bit and looking at the last entry, we know at some point we want to let third parties write Players, so I don't think we want Player to be responsible for enforcing the five card in the hand limit. The other invalid move is part of the interface between player and game, and is addressed by the rules of the game. Specifically, the Players will turn a new spit card when both Player's are stuck. We are, for the moment, dealing with one player, so we will focus on how a Player notifies the Game that it's stuck. Let's express that in Ruby

class SpitPlayerRules < Test::Unit::TestCase
  def setup 
    @player = Player.new(self)
    #Game double's state
    @pile_one = [7]
    @pile_two = [7]
    @stock = []
  end
  def test_when_stuck_player_asks_for_new_spit
    @spit_reserve = [6]
    @player.hand = [2, 2, 3, 4, 5]
    @player.play
    assert @spit_reserve.empty?, "player did not ask for a new spit"
  end
end

To Prefactor or Not To Prefactor

I expect that Game needs to notify Player when a move is invalid, and that will introduce duplication in the game double because it must implement the logic that is in SpitPile. There are at least two options here. One, we could press on and let the actual duplication show us what we need. Two, we could back out the failing test and replace the array, @pile_one, with a SpitPile. In this case I'm going with option two because I think the need for SpitPile is clear. Under other circumstances I might choose differently.

Looking over the current code I noticed that @pile_two is unused, so I'll remove it. Moving on to the replacement. We need to change the initialization of @pile_one in the setup and the #<< in spit_on_one. I'll show that and run the tests.

class SpitPlayerRules < Test::Unit::TestCase
  def setup 
    @player = Player.new(self)
    #Game double's state
    @pile_one = SpitPile.new(7)
    @stock = []
  end
  # game methods
  def spit_on_one( card )
    @pile_one << card
  end
  def draw
    @stock.pop
  end
  def draw?
    ! @stock.empty?
  end 

D'oh! The #test_run_without_draw fails. The message points to the following

class SpitPlayerRules < Test::Unit::TestCase
  def test_run_without_draw
    @player.hand = [ 2, 3, 4, 5, 6 ]
    @player.play
    assert_equal [7, 6, 5, 4, 3, 2] , @pile_one
  end
Obviously SpitPile is not an Array, so that comparison won't work. I'll just add a test for a SpitPile#to_a method, and then implement and use it.
class SpitPileRules < Test::Unit::TestCase
  def setup
    @pile = SpitPile.new( 2 )
  end
  def test_to_a
    assert @pile.play(3)
    assert_equal [2, 3], @pile.to_a
  end
end
class SpitPile
  def initialize( first_card )
    @pile = [ first_card ]
  end
  def to_a
    @pile
  end 
end
class SpitPlayerRules < Test::Unit::TestCase
  def test_run_without_draw
    @player.hand = [ 2, 3, 4, 5, 6 ]
    @player.play
    assert_equal [7, 6, 5, 4, 3, 2] , @pile_one.to_a
  end

Passing the Test

Okay, let's restore the test for an invalid move and a stuck player and see if we can make it pass.

class SpitPlayerRules < Test::Unit::TestCase
  def setup 
    @player = Player.new(self)
    #Game double's state
    @pile_one = SpitPile.new(7)
    @stock = []
    @spit_reserve = []
  end
  def test_when_stuck_player_asks_for_new_spit
    @spit_reserve = [6]
    @player.hand = [2, 2, 3, 4, 5]
    @player.play
    assert @spit_reserve.empty?, "player did not ask for a new spit"
  end
end
I had a little trouble making that pass. Mostly because when I first wrote the test I wrote
@spit_reserve = [6]
That didn't provide the Player with enough valid moves. Changing it to
@spit_reserve = [3, 6]
solved that problem. Let's review the code that makes the test pass. It's in Player#spit, which is called from Player#play
class Player
  attr_accessor :hand
  def initialize( game )
    @game = game
  end
  def spit
    card_to_play = @hand.pop 
    unless @game.spit_on_one(card_to_play) 
      @invalid_moves += 1
      @hand.unshift(card_to_play)
    end
    if @invalid_moves == 5
      @game.stuck
      @invalid_moves = 0
    end   
  end
end
As you may notice the logic here is crufty. I think that it's okay for now because we are using this Player to explore the interaction between game and Player. I fully expect to make a few other Player class for AI and human play. This simple Player is essentially just playing cards in the order they are in @hand and counting how many @invalid_moves he gets. Then he tells the game when he's stuck. This required that I add the following to the game double and SpitPile.
class SpitPlayerRules < Test::Unit::TestCase
  # game methods
  def stuck
    @pile_one.new_spit(@spit_reserve.pop)
  end 
end
class SpitPile
  def new_spit( card )
    @pile << card
  end
end
And now the tests are all passing.

Stopping For Today

I'm still concerned about not having a game class, but I can see its shape a little more clearly now. I think one more tweak to the Player class and then I'll extract the game. I'm out of time for now, so that'll have to wait for the next entry. I might even get ambitious and get a new entry up before next week. Until then.

0 comments: