Resolviendo la kata Bowling Game

Enunciado de la kata

La kata consiste en crear un programa para calcular las puntuaciones de un jugador en un juego de los Bolos, aunque para evitar complicarla mucho solo se calcula el resultado final y no se hacen validaciones sobre las puntuaciones.

Un breve recordatorio de las reglas:

  • Cada juego tiene 10 turnos de 2 lanzamientos cada uno.
  • En cada turno se cuentan los bolos que han caído y ese número es la puntuación
    • 0 puntos es un gutter
    • Si se tiran todos los bolos entre los dos intentos es un spare, y se suma como bonus la puntuación del siguiente lanzamiento
    • Si se tiran todos los bolos en el primer lanzamiento es un strike, y se suma como bonus la puntuación de los siguientes dos lanzamientos
  • Si el strike o el spare se logran en el último frame habrá lanzamientos extra

Lenguaje y enfoque

Para hacer esta kata he escogido Ruby y RSpec. Posiblemente, notes que tengo cierta preferencia por los frameworks de test de la familia *Spec, pero es que han sido diseñados pensando en TDD, considerando el test como especificación lo que ayuda mucho a salirse del marco de pensar en los tests como QA.

Dicho eso, no hay ningún problema en usar cualquier otro framework de testing, como los de la familia *Unit.

Por otro lado, emplearemos orientación a objetos.

Iniciando el juego

A estas alturas, el primer test debería ser suficiente para forzarnos a definir e instanciar la clase:

1 require 'rspec'
2 
3 RSpec.describe 'A Bowling Game' do
4   it 'should start a new game' do
5     BowlingGame.new
6   end
7 end

El test fallará, obligándonos a escribir el código de producción mínimo para que llegue a pasar.

1 class BowlingGame
2 
3 end

Y una vez que hemos hecho pasar el test, movemos la clase a su propio archivo y la requerimos:

1 require 'rspec'
2 require_relative '../src/bowling_game'
3 
4 RSpec.describe 'A Bowling Game' do
5   it 'should start a new game' do
6     BowlingGame.new
7   end
8 end

Estamos listas para el siguiente paso.

Lancemos la bola

Para que nuestro BowlingGame sea útil necesitaremos al menos dos cosas:

  • Una forma de indicar el resultado de un lanzamiento, pasando el número de bolos derribado, que sería un command. Un command provoca un efecto en el estado de un objeto, pero no devuelve nada por lo que necesitamos una vía alternativa de observar ese efecto.
  • Una forma de obtener la puntuación en un momento dado, que sería una query. Una query devuelve una respuesta, por lo que podemos verificar que es la que esperamos.

Puede que te preguntes: ¿Cuál de las dos deberíamos atacar primero?

No hay una regla fija, pero una forma de verlo puede ser la siguiente:

Los métodos query devuelven un resultado y su efecto puede testearse, pero hay que tener en cuenta en este punto asegurarnos de que la respuesta esperada no nos dificultará crear nuevos tests que fallen.

Por contra, los métodos command podemos introducirlos con un mínimo de código, sin tener que estar pendientes de sus efectos en futuros tests, salvo asegurarnos de que los parámetros que reciban son válidos.

Así que vamos a empezar introduciendo un método para lanzar la bola, que simplemente espera recibir el número de bolos derribado, que puede ser 0. Pero para forzar eso debemos escribir un test primero:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5   it 'should start a new game' do
 6     game = BowlingGame.new
 7   end
 8 
 9   it 'should roll a ball knocking down 0 pins' do
10     game = BowlingGame.new
11     game.roll 0
12   end
13 end

Y el código suficiente para hacer que el test pase es simplemente definir el método. Básicamente, lo que tenemos es que podemos comunicarle a BowlingGame que hemos lanzado la bola.

1 class BowlingGame
2   def roll(pins_down)
3 
4   end
5 end

Hora de refactorizar

En esta kata vamos a prestar especial atención a la fase de refactor. Hay que buscar un equilibrio para que ciertos refactors no nos condicionen las posibilidades de hacer evolucionar el código. Del mismo modo que la optimización prematura es un smell, la sobre ingeniería prematura también lo es.

El código de producción no ofrece todavía ninguna oportunidad de refactor, pero los tests empiezan a mostrar un patrón. El objeto game podría vivir como variable de instancia e inicializarse en un método setup de la especificación o test case. En este caso, usamos before.

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should start a new game' do
11     game = BowlingGame.new
12   end
13 
14   it 'should roll a ball knocking down 0 pins' do
15     @game.roll 0
16   end
17 end

Y esto hace que el primer test sea redundante:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 end

Con esto la especificación será más manejable.

Contando los puntos

Toca introducir un método para poder saber el marcador del juego. Lo reclamamos mediante un test que falle:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score after a gutter roll' do
15     @game.roll 0
16     expect(@game.score).to eq(0)
17   end
18 end

El test fallará porque no existe el método score.

1 class BowlingGame
2   def roll(pins_down)
3 
4   end
5 
6   def score
7     
8   end
9 end

Y seguirá fallando porque tiene que devolver 0. Lo mínimo para lograr que pase es:

1 class BowlingGame
2   def roll(pins_down)
3 
4   end
5 
6   def score
7     0
8   end
9 end

El peor lanzador del mundo

Muchas soluciones de la kata van directamente a este punto donde vamos a empezar a definir el comportamiento de BowlingGame tras los 20 lanzamientos. Nosotros hemos escogido un camino con pasos más pequeños y vamos a ver qué implica.

Nuestro siguiente test intentará hacer posible obtener un marcador tras 20 lanzamientos. Una forma de hacerlo es simularlos y lo más sencillo sería que todos ellos fuesen fallidos, es decir, que no tirasen ningún bolo con lo que el marcador final sería 0.

Este parece un buen test para empezar:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score after a gutter roll' do
15     @game.roll 0
16     expect(@game.score).to eq(0)
17   end
18 
19   it 'should score a gutter game' do
20     20.times do
21       @game.roll 0
22     end
23     expect(@game.score).to eq(0)
24   end
25 end

Pero no lo es. Lo ejecutamos y pasa a la primera.

Este test no nos obliga a introducir cambios en el código de producción porque no falla. En el fondo es el mismo test que teníamos antes. Sin embargo, en algunos sentidos es un test mejor, ya que nuestro objetivo es que score nos devuelva los resultados tras la totalidad de lanzamientos.

Organizando el código

Simplemente, eliminamos el test anterior por redundante, ya que ese comportamiento estaría implícito en el que acabamos de definir.

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13   
14   it 'should score a gutter game' do
15     20.times do
16       @game.roll 0
17     end
18     expect(@game.score).to eq(0)
19   end
20 end

Como el test no nos ha requerido escribir código de producción, necesitamos un test que sí falle.

Enseñando a contar a nuestro juego

Lo mejor sería esperar un resultado distinto a cero en score para vernos obligadas a implementar nuevo código de producción. De todos los resultados posibles de un juego completo de bolos quizá el más sencillo de probar sea el caso en el que todos los lanzamientos acaban con un único bolo derribado. De este modo, esperamos que la puntuación final sea 20, y no hay posibilidad de que se generen puntos o tiradas extra.

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     20.times do
16       @game.roll 0
17     end
18     expect(@game.score).to eq(0)
19   end
20 
21   it 'should score all ones' do
22     20.times do
23       @game.roll 1
24     end
25     expect(@game.score).to eq(20)
26   end
27 end

Este test ya falla porque no hay nada que acumule los puntos obtenidos en cada lanzamiento. Por tanto, necesitamos tener esa variable, que se inicie a cero y que vaya acumulando los resultados.

Pero… un momento. Eso ¿no son muchas cosas?

Un paso atrás para llegar más lejos

Repasemos, para pasar el test que tenemos ahora fallando necesitamos:

  • Añadir una variable en la clase para almacenar las puntuaciones
  • Iniciarla a 0
  • Acumular en ella los resultados

Son muchas cosas para añadir en un solo ciclo mientras tenemos un test fallando.

El caso es que, en realidad, podríamos olvidar este test un momento y volver al estado anterior cuando estábamos todavía en verde. Para ello comentamos el nuevo test de modo que no se ejecute.

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     20.times do
16       @game.roll 0
17     end
18     expect(@game.score).to eq(0)
19   end
20 
21   # it 'should score all ones' do
22   #   20.times do
23   #     @game.roll 1
24   #   end
25   #   expect(@game.score).to eq(20)
26   # end
27 end

Y ahora procedemos al refactor. Empezamos cambiando la constante 0 por una variable:

1 class BowlingGame
2   def roll(pins_down)
3 
4   end
5 
6   def score
7     @score = 0
8   end
9 end

Podemos mejorar este código, guardando en la variable los puntos obtenidos en el lanzamiento. Este código sigue haciendo pasar el test y es un cambio mínimo:

1 class BowlingGame
2   def roll(pins_down)
3     @score = pins_down
4   end
5 
6   def score
7     @score
8   end
9 end

Recuperando un test anulado

Ahora sí que lanzamos el cuarto test y vemos de nuevo que falla:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     20.times do
16       @game.roll 0
17     end
18     expect(@game.score).to eq(0)
19   end
20 
21   it 'should score all ones' do
22     20.times do
23       @game.roll 1
24     end
25     expect(@game.score).to eq(20)
26   end
27 end

El cambio necesario en el código es más pequeño ahora. Tenemos que iniciar la variable en construcción, de modo que cada juego empieza en cero y va acumulando puntos. Fíjate que aparte del constructor nos basta con añadir un signo +.

 1 class BowlingGame
 2 
 3   def initialize
 4     @score = 0
 5   end
 6 
 7   def roll(pins_down)
 8     @score += pins_down
 9   end
10 
11   def score
12     @score
13   end
14 end

De nuevo en verde, sabiendo que ya acumulamos los puntos.

Poniéndonos más cómodas

Al observar los tests vemos que puede ser útil tener un método para lanzar varias veces la bola con el mismo resultado. Así que lo extraemos y, por supuesto, lo utilizamos:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     roll_many 20, 0
16     expect(@game.score).to eq(0)
17   end
18 
19   it 'should score all ones' do
20     roll_many 20, 1
21     expect(@game.score).to eq(20)
22   end
23 
24   def roll_many(times, pins_down)
25     times.times do
26       @game.roll pins_down
27     end
28   end
29 end

Cómo manejar un spare

Ahora que ya sabemos que nuestro BowlingGame es capaz de acumular los puntos obtenidos en cada lanzamiento es momento de seguir avanzando. Podemos empezar a tratar casos especiales como por ejemplo, cómo se procesa un spare, es decir, tumbar los diez bolos con dos lanzamientos en un frame.

Así que escribimos un test que simule esa situación. Lo más sencillo es imaginar que el spare ocurre en el primer frame y que el resultado del tercer lanzamiento es el bonus. Para que sea más fácil, el resto de lanzamientos hasta completar el juego serán 0, con lo que no introducimos puntuaciones extrañas.

He aquí un test posible:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     roll_many 20, 0
16     expect(@game.score).to eq(0)
17   end
18 
19   it 'should score all ones' do
20     roll_many 20, 1
21     expect(@game.score).to eq(20)
22   end
23 
24   it 'should score an spare' do
25     @game.roll 5
26     @game.roll 5
27     @game.roll 3
28     roll_many 17, 0
29     expect(@game.score).to eq(16)
30   end
31 
32   def roll_many(times, pins_down)
33     times.times do
34       @game.roll pins_down
35     end
36   end
37 end

El test falla porque score nos devuelve 13 puntos cuando deberían ser 16. Ahora mismo no existe un mecanismo que cuente el lanzamiento posterior al spare como bonus.

El problema es que nos hace falta contar los puntos no por lanzamiento, sino por frame, para poder saber si un frame ha dado un spare o no y actuar en consecuencia. Además, ya no nos basta con ir sumando los puntos, sino que debemos pasar la responsabilidad del recuento al método score, de modo que roll se limite a almacenar los parciales y sea score quien gestione la lógica de calcular por frame.

De nuevo nos vemos en la necesidad de cambiar primero la estructura del código sin cambiar el comportamiento antes de introducir el nuevo test. Por tanto, anulamos este test y refactorizamos con el anterior como red de seguridad para introducir el concepto de frame en el recuento.

Introduciendo el concepto de frame

Primero regresamos al test anterior, anulando temporalmente el que está fallando:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     roll_many 20, 0
16     expect(@game.score).to eq(0)
17   end
18 
19   it 'should score all ones' do
20     roll_many 20, 1
21     expect(@game.score).to eq(20)
22   end
23 
24   # it 'should score an spare' do
25   #   @game.roll 5
26   #   @game.roll 5
27   #   @game.roll 3
28   #   roll_many 17, 0
29   #   expect(@game.score).to eq(16)
30   # end
31 
32   def roll_many(times, pins_down)
33     times.times do
34       @game.roll pins_down
35     end
36   end
37 end

Vamos por el refactor. En primer lugar, cambiamos el nombre de la variable:

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = 0
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls += pins_down
 9   end
10 
11   def score
12     @rolls
13   end
14 end

Los tests siguen pasando. Ahora cambiamos su significado y movemos la suma a score:

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   def score
12     score = 0
13     @rolls.each do |roll|
14       score += roll
15     end
16     score
17   end
18 end

Comprobamos que los tests siguen pasando. Puede ser buen momento para introducir el concepto de frame. Sabemos que hay un máximo de 10 frames.

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   def score
12     score = 0
13     roll = 0
14 
15     10.times do
16       frame_score = @rolls[roll] + @rolls[roll+1]
17       score += frame_score
18       roll += 2
19     end
20     score
21   end
22 end

Con este cambio los tests siguen pasando y ya tenemos acceso a la puntuación por frame. Parece que estamos listos para volver a introducir el test anterior.

Seguimos manejando el spare

Volvemos a activar el test que falla.

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     roll_many 20, 0
16     expect(@game.score).to eq(0)
17   end
18 
19   it 'should score all ones' do
20     roll_many 20, 1
21     expect(@game.score).to eq(20)
22   end
23 
24   it 'should score an spare' do
25       @game.roll 5
26       @game.roll 5
27       @game.roll 3
28       roll_many 17, 0
29       expect(@game.score).to eq(16)
30     end
31 
32   def roll_many(times, pins_down)
33     times.times do
34       @game.roll pins_down
35     end
36   end
37 end

Ahora estamos en mejor disposición para introducir el comportamiento deseado con un cambio bastante pequeño:

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   def score
12     score = 0
13     roll = 0
14 
15     10.times do
16       frame_score = @rolls[roll] + @rolls[roll + 1]
17       if frame_score == 10
18         frame_score += @rolls[roll + 2]
19       end
20       score += frame_score
21       roll += 2
22     end
23     score
24   end
25 end

Añadiendo un bloque if es suficiente para hacer pasar el test.

Eliminando números mágicos y otros refactors

En este punto en que ya tenemos los tests pasando podemos hacer varias mejoras en el código. Vamos por partes:

Demos significado a algunos números mágicos en el código de producción:

 1 class BowlingGame
 2   
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   FRAMES_IN_A_GAME = 10
12   ALL_PINS_DOWN = 10
13 
14   def score
15     score = 0
16     roll = 0
17 
18     FRAMES_IN_A_GAME.times do
19       frame_score = @rolls[roll] + @rolls[roll + 1]
20       if frame_score == ALL_PINS_DOWN
21         frame_score += @rolls[roll + 2]
22       end
23       score += frame_score
24       roll += 2
25     end
26     score
27   end
28 end

El cálculo de la puntuación en el frame podría extraerse a un método y ahorrarnos la variable temporal de paso:

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   FRAMES_IN_A_GAME = 10
12   ALL_PINS_DOWN = 10
13 
14   def score
15     score = 0
16     roll = 0
17 
18     FRAMES_IN_A_GAME.times do
19       score += frame_score(roll)
20       roll += 2
21     end
22     score
23   end
24 
25   private
26 
27   def frame_score(roll)
28     frame_score = @rolls[roll] + @rolls[roll + 1]
29     if frame_score == ALL_PINS_DOWN
30       frame_score += @rolls[roll + 2]
31     end
32     frame_score
33   end
34 end

Podemos darle significado a la suma de los puntos en cada lanzamiento del frame, así como a la pregunta de si se trata de un spare o no, y rubyficar un poco el código:

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   FRAMES_IN_A_GAME = 10
12   ALL_PINS_DOWN = 10
13 
14   def score
15     score = 0
16     roll = 0
17 
18     FRAMES_IN_A_GAME.times do
19       frame_score = base_frame_score(roll)
20       frame_score += @rolls[roll + 2] if spare? frame_score
21       score += frame_score
22       roll += 2
23     end
24     score
25   end
26 
27   private
28 
29   def spare?(frame_score)
30     frame_score == ALL_PINS_DOWN
31   end
32 
33   def base_frame_score(roll)
34     @rolls[roll] + @rolls[roll + 1]
35   end
36 end

Lo cierto es que esto nos está pidiendo a gritos extraer todo a una clase Frame, pero ahora no lo vamos a hacer, pues podríamos caer en un smell por exceso de diseño.

Por otro lado, mirando el test, podemos detectar algunos puntos de mejora. Como ser más explícitos en el ejemplo:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     roll_many 20, 0
16     expect(@game.score).to eq(0)
17   end
18 
19   it 'should score all ones' do
20     roll_many 20, 1
21     expect(@game.score).to eq(20)
22   end
23 
24   it 'should score an spare' do
25     roll_spare
26     @game.roll 3
27     roll_many 17, 0
28     expect(@game.score).to eq(16)
29   end
30 
31   def roll_many(times, pins_down)
32     times.times do
33       @game.roll pins_down
34     end
35   end
36 
37   def roll_spare
38     @game.roll 5
39     @game.roll 5
40   end
41 end

Y con esto damos por terminado el refactor. A continuación, queremos tratar el caso del strike.

Strike!

Strike es conseguir tumbar todos los bolos en un único lanzamiento. En ese caso, el bonus consiste en los puntos obtenidos en los siguientes dos lanzamientos. El próximo test nos propone un ejemplo de eso:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     roll_many 20, 0
16     expect(@game.score).to eq(0)
17   end
18 
19   it 'should score all ones' do
20     roll_many 20, 1
21     expect(@game.score).to eq(20)
22   end
23 
24   it 'should score an spare' do
25     roll_spare
26     @game.roll 3
27     roll_many 17, 0
28     expect(@game.score).to eq(16)
29   end
30 
31   it 'should score an strike' do
32     @game.roll(10)
33     @game.roll(4)
34     @game.roll(3)
35     roll_many 17, 0
36     expect(@game.score).to eq(24)
37   end
38 
39   def roll_many(times, pins_down)
40     times.times do
41       @game.roll pins_down
42     end
43   end
44 
45   def roll_spare
46     @game.roll 5
47     @game.roll 5
48   end
49 end

En esta ocasión el test falla porque el código de producción calcula un total de 17 puntos (los 10 del strike más los 7 de los dos siguientes lanzamientos). Sin embargo, debería contar esos 7 dos veces: el bonus y la puntuación normal.

Ahora mismo tenemos todo lo necesario en el código de producción y, en principio, no tenemos que volver atrás. Tan solo introducir los cambios necesarios. Fundamentalmente, nos interesa detectar que se ha realizado el strike.

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   FRAMES_IN_A_GAME = 10
12   ALL_PINS_DOWN = 10
13 
14   def score
15     score = 0
16     roll = 0
17 
18     FRAMES_IN_A_GAME.times do
19       if @rolls[roll] == 10
20         frame_score = 10 + @rolls[roll + 1] + @rolls[roll + 2]
21         roll += 1
22       else
23         frame_score = base_frame_score roll
24         frame_score += @rolls[roll + 2] if spare? frame_score
25         roll += 2
26       end
27       score += frame_score
28     end
29 
30     score
31   end
32 
33   private
34 
35   def spare?(frame_score)
36     frame_score == ALL_PINS_DOWN
37   end
38 
39   def base_frame_score(roll)
40     @rolls[roll] + @rolls[roll + 1]
41   end
42 end

Reorganizando el conocimiento del juego

El código de producción que tenemos ahora nos permite pasar los tests y, por tanto, estamos en disposición de arreglar su estructura.

Empecemos haciendo algunas cosas más explícitas sobre el strike:

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   FRAMES_IN_A_GAME = 10
12   ALL_PINS_DOWN = 10
13 
14   def score
15     score = 0
16     roll = 0
17 
18     FRAMES_IN_A_GAME.times do
19       if strike? roll
20         frame_score = 10 + base_frame_score(roll + 1)
21         roll += 1
22       else
23         frame_score = base_frame_score roll
24         frame_score += @rolls[roll + 2] if spare? frame_score
25         roll += 2
26       end
27       score += frame_score
28     end
29 
30     score
31   end
32 
33   private
34 
35   def strike?(roll)
36     @rolls[roll] == ALL_PINS_DOWN
37   end
38 
39   def spare?(frame_score)
40     frame_score == ALL_PINS_DOWN
41   end
42 
43   def base_frame_score(roll)
44     @rolls[roll] + @rolls[roll + 1]
45   end
46 end

La estructura de cálculo de la puntuación del frame resulta poco clara, así que vamos a volver atrás y dejarlo también más expresivo:

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   FRAMES_IN_A_GAME = 10
12   ALL_PINS_DOWN = 10
13 
14   def score
15     score = 0
16     roll = 0
17 
18     FRAMES_IN_A_GAME.times do
19       if strike? roll
20         frame_score = 10 + base_frame_score(roll + 1)
21         roll += 1
22       elsif spare? base_frame_score roll
23         frame_score = 10 + @rolls[roll + 2]
24         roll += 2
25       else
26         frame_score = base_frame_score roll
27         roll += 2
28       end
29       score += frame_score
30     end
31 
32     score
33   end
34 
35   private
36 
37   def strike?(roll)
38     @rolls[roll] == ALL_PINS_DOWN
39   end
40 
41   def spare?(frame_score)
42     frame_score == ALL_PINS_DOWN
43   end
44 
45   def base_frame_score(roll)
46     @rolls[roll] + @rolls[roll + 1]
47   end
48 end

Este refactor deja en evidencia que strike? y spare? tienen una estructura diferente, lo que dificulta su comprensión y su manejo. Cambiamos spare para igualarlos y de paso quitamos también números mágicos.

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   FRAMES_IN_A_GAME = 10
12   ALL_PINS_DOWN = 10
13 
14   def score
15     score = 0
16     roll = 0
17 
18     FRAMES_IN_A_GAME.times do
19       if strike? roll
20         frame_score = ALL_PINS_DOWN + base_frame_score(roll + 1)
21         roll += 1
22       elsif spare? roll
23         frame_score = ALL_PINS_DOWN + @rolls[roll + 2]
24         roll += 2
25       else
26         frame_score = base_frame_score roll
27         roll += 2
28       end
29       score += frame_score
30     end
31 
32     score
33   end
34 
35   private
36 
37   def strike?(roll)
38     ALL_PINS_DOWN == @rolls[roll]
39   end
40 
41   def spare?(roll)
42     ALL_PINS_DOWN == base_frame_score(roll)
43   end
44 
45   def base_frame_score(roll)
46     @rolls[roll] + @rolls[roll + 1]
47   end
48 end

Ahora podemos extraer métodos que hagan más explícitos los cálculos:

 1 class BowlingGame
 2 
 3   def initialize
 4     @rolls = []
 5   end
 6 
 7   def roll(pins_down)
 8     @rolls.push pins_down
 9   end
10 
11   FRAMES_IN_A_GAME = 10
12   ALL_PINS_DOWN = 10
13 
14   def score
15     score = 0
16     roll = 0
17 
18     FRAMES_IN_A_GAME.times do
19       if strike? roll
20         frame_score = strike_score roll
21         roll += 1
22       elsif spare? roll
23         frame_score = spare_score roll
24         roll += 2
25       else
26         frame_score = base_frame_score roll
27         roll += 2
28       end
29       score += frame_score
30     end
31 
32     score
33   end
34 
35   private
36 
37   def spare_score(roll)
38     ALL_PINS_DOWN + @rolls[roll + 2]
39   end
40 
41   def strike_score(roll)
42     ALL_PINS_DOWN + base_frame_score(roll + 1)
43   end
44 
45   def strike?(roll)
46     ALL_PINS_DOWN == @rolls[roll]
47   end
48 
49   def spare?(roll)
50     ALL_PINS_DOWN == base_frame_score(roll)
51   end
52 
53   def base_frame_score(roll)
54     @rolls[roll] + @rolls[roll + 1]
55   end
56 end

La mejor jugadora del mundo

En principio, el desarrollo que tenemos es suficiente. Sin embargo, nos conviene tener algún test que lo certifique. Por ejemplo, este nuevo test corresponde a un juego perfecto: todos los lanzamientos son strikes:

 1 require 'rspec'
 2 require_relative '../src/bowling_game'
 3 
 4 RSpec.describe 'A Bowling Game' do
 5 
 6   before do
 7     @game = BowlingGame.new
 8   end
 9 
10   it 'should roll a ball knocking down 0 pins' do
11     @game.roll 0
12   end
13 
14   it 'should score a gutter game' do
15     roll_many 20, 0
16     expect(@game.score).to eq(0)
17   end
18 
19   it 'should score all ones' do
20     roll_many 20, 1
21     expect(@game.score).to eq(20)
22   end
23 
24   it 'should score an spare' do
25     roll_spare
26     @game.roll 3
27     roll_many 17, 0
28     expect(@game.score).to eq(16)
29   end
30 
31   it 'should score an strike' do
32     @game.roll(10)
33     @game.roll(4)
34     @game.roll(3)
35     roll_many 17, 0
36     expect(@game.score).to eq(24)
37   end
38 
39   def roll_many(times, pins_down)
40     times.times do
41       @game.roll pins_down
42     end
43   end
44 
45   it 'should score perfect game' do
46     roll_many 12, 10
47     expect(@game.score).to eq(300)
48   end
49 
50   def roll_spare
51     @game.roll 5
52     @game.roll 5
53   end
54 end

Al ejecutarlo, el test pasa, lo que nos confirma que BowlingGame funciona como esperamos.

Con todos los test pasando y la funcionalidad completamente implementada, podemos hacer evolucionar el código hacia un mejor diseño. En el siguiente ejemplo hemos extraído una clase Rolls que básicamente es un array al que le hemos añadido los métodos de cálculo de puntos que habíamos ido extrayendo:

 1 class Rolls<Array
 2   ALL_PINS_DOWN = 10
 3 
 4   def spare_score(roll)
 5     ALL_PINS_DOWN + self[roll + 2]
 6   end
 7 
 8   def strike_score(roll)
 9     ALL_PINS_DOWN + base_frame_score(roll + 1)
10   end
11 
12   def strike?(roll)
13     ALL_PINS_DOWN == self[roll]
14   end
15 
16   def spare?(roll)
17     ALL_PINS_DOWN == base_frame_score(roll)
18   end
19 
20   def base_frame_score(roll)
21     self[roll] + self[roll + 1]
22   end
23 end
24 
25 
26 class BowlingGame
27 
28   def initialize
29     @rolls = Rolls.new
30   end
31 
32   def roll(pins_down)
33     @rolls.push pins_down
34   end
35 
36   FRAMES_IN_A_GAME = 10
37 
38   def score
39     score = 0
40     roll = 0
41 
42     FRAMES_IN_A_GAME.times do
43       if @rolls.strike? roll
44         frame_score = @rolls.strike_score roll
45         roll += 1
46       elsif @rolls.spare? roll
47         frame_score = @rolls.spare_score roll
48         roll += 2
49       else
50         frame_score = @rolls.base_frame_score roll
51         roll += 2
52       end
53       score += frame_score
54     end
55 
56     score
57   end
58 end

Qué hemos aprendido con esta kata

  • El refactor es la etapa del diseño en TDD clásica, es el momento en que una vez que hemos implementado un comportamiento, reorganizamos el código para que se exprese mejor.
  • Hay que aprovechar las oportunidades de refactor en cuanto las detectamos.
  • Refactorizamos tanto el test como el código de producción.