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.