Resolviendo la kata Greetings

Enunciado de la kata

El enunciado de esta kata es muy simple. Se trata de crear una función pura greet() que devuelva un string con un saludo. Se le pasa como parámetro el nombre de la persona a la que saludar.

Seguidamente, se van añadiendo requisitos que nos obligarán a extender el algoritmo para darles soporte únicamente a través de la entrada y salida de esta función. Para cada requisito se nos proporciona un ejemplo. Son los siguientes:

Requisitos input output
1. Interpolar nombre en un saludo sencillo “Bob” Hello, Bob.
2. Si no se pasa nombre, retornar alguna fórmula genérica null Hello, my friend.
3. Si nos gritan, contestar con un grito “JERRY” HELLO, JERRY!
4. Manejar dos nombres “Jill”, “Jane” Hello, Jill and Jane.
5. Manejar cualquier número de nombras, con coma estilo Oxford “Amy”, “Brian”, “Charlotte” Hello, Amy, Brian, and Charlotte.
6. Permitir mezclar nombres normales y gritados, pero separar las respuestas. “Amy”, “BRIAN”, “Charlotte” Hello, Amy and Charlotte. AND HELLO BRIAN!
7. Si un nombre contiene una coma, separarlo “Bob”, “Charlie, Dianne” Hello, Bob, Charlie, and Dianne.
8. Permitir escapar las comas de #7 “Bob”, “\“Charlie, Dianne\”” Hello, Bob and Charlie, Dianne.

Lenguaje y enfoque

Esta kata la vamos a resolver en Scala con el framework FunSite. La escribiremos usando un enfoque funcional.

Saludo básico

La forma en que se presenta esta kata nos proporciona prácticamente todos los casos de test que necesitamos. A estas alturas creo que podemos dar un salto relativamente grande para empezar.

Este es nuestro primer test en el que suponemos que la función será un método de la clase Greetings en el package greetings.

1 import greetings.Greetings
2 import org.scalatest.FunSuite
3 
4 class GreetingTest extends FunSuite {
5   test("Require the function") {
6     assert(Greetings.greet("Bob") === "Hello, Bob.")
7   }
8 }

En cualquier caso, al usar lenguajes que son muy estrictos en el tipado muchas veces no podríamos empezar por tests más pequeños, pues el propio compilador nos obligaría a introducir más código. Pero, por otra parte, el tipado estricto nos permite ignorar con seguridad esos mismos tests. De hecho, puedes considerar que el sistema de tipado estricto es, en cierto modo, un sistema de testing.

El test fallará, como era de esperar. En este caso crearemos el código mínimo necesario para hacerlo pasar de una sola vez:

1 package greetings
2 
3 object Greetings {
4   def greet(value: String): String = {
5     "Hello, Bob."
6   }
7 }

Scala no nos permite definir la función sin argumentos y usarla pasándole alguno, por lo que nos vemos obligadas a incorporarlo en la signatura. Por lo demás, devolvemos el string esperado por el test para que se ponga en verde.

Saludo genérico

El segundo caso es gestionar la situación en que no nos pasan ningún nombre, por lo que el saludo deberá ser genérico.

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9     test("Act when name is null") {
10       assert(Greetings.greet() === "Hello, my friend.")
11     }
12 }

Lo primero que observamos es que el test fallará debido a que greet espera un parámetro que no le pasamos. Esto nos está indicando que debería ser opcional.

Nuestra primera intención sería corregir eso y permitir que se pueda pasar un parámetro opcional. Pero hay que tener en cuenta que si lo hacemos, el test seguirá fallando.

Por tanto, lo que vamos a hacer es descartar de momento este último test y refactorizar el código que tenemos mientras mantenemos el primer test pasando.

Usar el parámetro

Desactivamos el test:

 1 import org.scalatest.FunSuite
 2 
 3 class GreetingTest extends FunSuite {
 4   test("Require the function") {
 5     assert(Greetings.greet("Bob") === "Hello, Bob.")
 6   }
 7 
 8 //    test("Act when name is null") {
 9 //      assert(Greetings.greet() === "Hello, my friend.")
10 //    }
11 }

Y hacemos el refactor. En Scala es posible poner valores por defecto eliminando la necesidad de pasar un parámetro.

1 package greetings
2 
3 object Greetings {
4   def greet(name: String = "Bob"): String = {
5     "Hello, Bob."
6   }
7 }

Nos faltaría hacer un uso efectivo del parámetro, en este caso mediante una interpolación.

1 package greetings
2 
3 object Greetings {
4   def greet(name: String = "Bob"): String = {
5     s"Hello, $name."
6   }
7 }

Un saludo genérico

Volvemos a activar el segundo test para poder implementar el requisito número dos, que consiste en permitir un saludo genérico si no se pasan valores:

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9   test("Act when name is null") {
10     assert(Greetings.greet() === "Hello, my friend.")
11   }
12 }

El test no pasará, pero el cambio necesario para que sí lo haga es muy sencillo:

1 package greetings
2 
3 object Greetings {
4   def greet(name: String = "my friend"): String = {
5     s"Hello, $name."
6   }
7 }

Es muy importante fijarse en este detalle. El cambio que hemos realizado ha sido muy pequeño, pero para que pudiese ser pequeño hemos hecho antes el refactor protegiéndonos con el test anterior. Es muy habitual intentar hacer ese refactor con el nuevo test fallando, pero esa es una mala práctica porque si refactorizamos mientras el test falla no podemos tener seguridad sobre lo que estamos haciendo.

Responder a gritos

Este tercer test introduce el nuevo requisito de responder de manera diferente a los nombres expresados por completo en mayúsculas:

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9   test("Act when name is null") {
10     assert(Greetings.greet() === "Hello, my friend.")
11   }
12 
13   test("Should manage shout") {
14     assert(Greetings.greet("JERRY") === "HELLO, JERRY!")
15   }
16 }

Nos aseguramos de que el test falla por el motivo correcto antes de pasar a escribir el código de producción. Este es un enfoque posible:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(name: String = "my friend"): String = {
 5     if (name.toUpperCase == name) {
 6       return s"HELLO, $name!"
 7     }
 8     s"Hello, $name."
 9   }
10 }

Llegadas a este punto vamos a ver qué oportunidades tenemos de hacer refactor. Esto nos lleva a esta solución tan sencilla:

1 package greetings
2 
3 object Greetings {
4   def greet(name: String = "my friend"): String = {
5     if (name.toUpperCase() == name) s"HELLO, $name!" else s"Hello, $nam\
6 e."
7   }
8 }

De momento no hay mucho más que podamos hacer con la información que tenemos hasta ahora por lo que vamos a examinar el siguiente requisito.

Poder saludar a dos personas

El requisito cuatro nos pide manejar dos nombres, lo que cambia ligeramente la cadena de saludo. Por supuesto, nos proporciona un ejemplo con el que hacer un test.

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9   test("Act when name is null") {
10     assert(Greetings.greet() === "Hello, my friend.")
11   }
12 
13   test("Should manage shout") {
14     assert(Greetings.greet("JERRY") === "HELLO, JERRY!")
15   }
16 
17   test("Should manage two names") {
18     assert(Greetings.greet("Jill", "Jane") === "Hello, Jill and Jane.")
19   }
20 }

Es posible que al escribir el test el propio IDE te haya advertido de que no es correcto pasar dos argumentos cuando la signatura de la función solo permite uno, que además es opcional. Si no es así, la ejecución del test fallará al no poder compilar.

Como ya hemos visto en otras ocasiones la mejor forma de afrontar esto es retroceder al test anterior y hacer un refactor con el que prevenir el problema. Así que anulamos temporalmente el test que acabamos de introducir.

Preparándose para varios nombres

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9   test("Act when name is null") {
10     assert(Greetings.greet() === "Hello, my friend.")
11   }
12 
13   test("Should manage shout") {
14     assert(Greetings.greet("JERRY") === "HELLO, JERRY!")
15   }
16 
17 //  test("Should manage two names") {
18 //    assert(Greetings.greet("Jill", "Jane") === "Hello, Jill and Jane.\
19 ")
20 //  }
21 }

Y refactorizamos a una implementación que nos permita introducir dos parámetros. La forma más fácil de hacerlo es usando splat parameters. Sin embargo, eso nos forzará a cambiar el algoritmo, ya que los parámetros se presentarán como un objeto Seq de String. Además de eso, cambiamos el nombre del parámetro.

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     if (person.isEmpty) return "Hello, my friend."
 6 
 7     val name = person.last
 8 
 9     if (name.toUpperCase() == name) s"HELLO, $name!" else s"Hello, $nam\
10 e."
11   }
12 }

Esta es una reimplementación ingenua, suficiente para permitirnos pasar el test, pero que podríamos desarrollar a un estilo más propio del lenguaje. Una de las mejores cosas que nos proporciona TDD es justamente esta facilidad para que podamos bosquejar implementaciones funcionales, aunque sean toscas, pero que nos ayudan a reflexionar sobre el problema y experimentar soluciones alternativas.

Para mejorarla un poco vamos primero a extraer la condición del if a una función anidada, con lo que no solo es más expresiva, sino también más fácil de reutilizar llegado el caso:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val name = person.last
11 
12     if (isShouting(name)) s"HELLO, $name!" else s"Hello, $name."
13   }
14 }

La cuestión ahora, ¿nos conviene retomar el cuarto test o deberíamos seguir con el refactor para dar soporte a los cambios que necesitamos?

Un refactor antes de seguir

El último refactor nos ha permitido dar soporte a una lista de nombres, pero necesitaríamos cambiar el enfoque para poder manejar listas de nombres gritando.

Hasta ahora distinguimos si hay que gritar cuando montamos el saludo. Sin embargo, es posible que nos interese separar primero los nombres en función si han de ser gritados o no.

Así que lo que hacemos es repartir la lista de nombres en dos, según si son gritados o no, y adaptamos el resto del código a eso.

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     if (normal.nonEmpty)
13       s"Hello, ${normal.last}."
14     else if (shout.nonEmpty)
15       s"HELLO, ${shout.last}!"
16     else ""
17   }
18 }

Con esto deberíamos estar mejor preparadas para afrontar el cuarto test, así que lo reactivamos.

Reintroduciendo un test

Al volver a activar el cuarto test ocurre lo que podíamos predecir: se hará el saludo a una sola persona, que será precisamente la última de las dos.

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9   test("Act when name is null") {
10     assert(Greetings.greet() === "Hello, my friend.")
11   }
12 
13   test("Should manage shout") {
14     assert(Greetings.greet("JERRY") === "HELLO, JERRY!")
15   }
16 
17   test("Should manage two names") {
18     assert(Greetings.greet("Jill", "Jane") === "Hello, Jill and Jane.")
19   }
20 }

El resultado es:

1 Expected :"Hello, J[ill and J]ane."
2 Actual   :"Hello, J[]ane."

Es decir, el test falla por la razón correcta, indicándonos que tenemos que introducir un cambio que se ocupe de procesar la lista de nombres y concatenarla. Gracias a los refactors anteriores es fácil de introducir:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     if (normal.nonEmpty)
13       s"Hello, ${normal.mkString(" and ")}."
14     else if (shout.nonEmpty)
15       s"HELLO, ${shout.last}!"
16     else ""
17   }
18 }

Es importante fijarse en que en este punto no intentamos adelantarnos a los próximos requisitos, sino que resolvemos el problema actual. Solo cuando introduzcamos el próximo test y con ello aprendamos cosas nuevas sobre el comportamiento que estamos implementando en la función nos plantearemos volver atrás a refactorizar los cambios previos que podamos necesitar.

Manejar un número indeterminado de nombres

El quinto requisito consiste en manejar un número indeterminado de nombres, con un pequeño cambio en el formato del saludo. Introducimos un nuevo test que lo especifica:

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9   test("Act when name is null") {
10     assert(Greetings.greet() === "Hello, my friend.")
11   }
12 
13   test("Should manage shout") {
14     assert(Greetings.greet("JERRY") === "HELLO, JERRY!")
15   }
16 
17   test("Should manage two names") {
18     assert(Greetings.greet("Jill", "Jane") === "Hello, Jill and Jane.")
19   }
20 
21   test("Should manage several names") {
22     assert(Greetings.greet("Amy", "Brian", "Charlotte") === "Hello, Amy\
23 , Brian, and Charlotte.")
24   }
25 }

El resultado del test es:

1 Expected :"Hello, Amy[, Brian,] and Charlotte."
2 Actual   :"Hello, Amy[ and Brian] and Charlotte."

Podemos empezar por el siguiente cambio:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     if (normal.nonEmpty)
13       s"Hello, ${normal.mkString(", ")}."
14     else if (shout.nonEmpty)
15       s"HELLO, ${shout.last}!"
16     else ""
17   }
18 }

Esto rompe el test anterior y tampoco pasa el nuevo, que nos indica que el último elemento de la lista requiere un trato especial:

1 Expected :"Hello, Amy, Brian, [and ]Charlotte."
2 Actual   :"Hello, Amy, Brian, []Charlotte."

Hagamos eso literalmente, es decir: separemos el último elemento:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     if (normal.nonEmpty)
13       s"Hello, ${normal.init.mkString(", ")}, and ${normal.last}."
14     else if (shout.nonEmpty)
15       s"HELLO, ${shout.last}!"
16     else ""
17   }
18 }

Sin embargo, este cambio hace pasar el último test, a la vez que provoca que fallen el anterior y el primero. El problema es que en el caso del saludo normal y el del saludo a dos personas no pueden seguir el mismo patrón. Estamos destapando un agujero para tapar otro.

Puesto que estamos haciendo fallar tests que ya estaban pasando lo mejor es que volvamos al punto del código en que los cuatro tests anteriores se cumplían.

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     if (normal.nonEmpty)
13       s"Hello, ${normal.mkString(" and ")}."
14     else if (shout.nonEmpty)
15       s"HELLO, ${shout.last}!"
16     else ""
17   }
18 }

Lo que nos indica este recorrido de ida y vuelta es que hay dos tipos de casos que tienen tratamiento diferente.

  • Listas de 2 o menos nombres.
  • Listas de más de 2 nombres.

Lo más sencillo es reconocer eso y abrazarlo en el propio código:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     if (normal.nonEmpty)
13       if (normal.length <= 2)
14         s"Hello, ${normal.mkString(" and ")}."
15       else
16         s"Hello, ${normal.init.mkString(", ")}, and ${normal.last}."
17     else if (shout.nonEmpty)
18       s"HELLO, ${shout.last}!"
19     else ""
20   }
21 }

De nuevo, una implementación tosca e ingenua nos permite hacer pasar todos los tests, acudiendo a un mecanismo tan simple como es el de posponer la generalización. Es ahora, al haber logrado el comportamiento deseado cuando podemos intentar a analizar el problema y buscar un algoritmo más general.

Como queremos centrarnos en la parte del algoritmo que concatena los nombres dentro del saludo vamos a hacer primero el siguiente refactor, extrayendo a una función inline el bloque de código que nos interesa:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     def concatenate = {
13       if (normal.length <= 2)
14         s"${normal.mkString(" and ")}."
15       else
16         s"${normal.init.mkString(", ")}, and ${normal.last}."
17     }
18 
19     if (normal.nonEmpty)
20       s"Hello, ${concatenate}"
21     else if (shout.nonEmpty)
22       s"HELLO, ${shout.last}!"
23     else ""
24   }
25 }

Lo más interesante es haber aislado específicamente la concatenación de nombres. Vamos a hacer un par de cambios más. Ahora mismo actuamos directamente sobre la secuencia normal que está en el ámbito de la función greet y, por tanto, es global dentro de la función concatenate:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     def concatenate(names: Seq[String]) = {
13       if (names.length <= 2)
14         s"${names.mkString(" and ")}"
15       else
16         s"${names.init.mkString(", ")}, and ${names.last}"
17     }
18 
19     if (normal.nonEmpty)
20       s"Hello, ${concatenate(normal)}."
21     else if (shout.nonEmpty)
22       s"HELLO, ${shout.last}!"
23     else ""
24   }
25 }

Tras habernos asegurado de que los tests siguen pasando, vamos a hacer explícitos los diferentes casos que se tratan. Ahora mismo, la lista de un solo nombre queda cubierta de forma implícita por el caso de dos nombres. Nuestro objetivo es tratar de entender mejor las regularidades en los tres supuestos:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     def concatenate(names: Seq[String]) = {
13       if (names.length == 1)
14         s"${names.last}"
15       else if (names.length == 2)
16         s"${names.mkString(" and ")}"
17       else
18         s"${names.init.mkString(", ")}, and ${names.last}"
19     }
20 
21     if (normal.nonEmpty)
22       s"Hello, ${concatenate(normal)}."
23     else if (shout.nonEmpty)
24       s"HELLO, ${shout.last}!"
25     else ""
26   }
27 }

Demos un pequeño paso más en el caso de dos nombres:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8     if (person.isEmpty) return "Hello, my friend."
 9 
10     val (shout, normal) = person.partition(isShouting)
11 
12     def concatenate(names: Seq[String]) = {
13       if (names.length == 1)
14         s"${names.last}"
15       else if (names.length == 2)
16         s"${names.head} and ${names.last}"
17       else
18         s"${names.init.mkString(", ")}, and ${names.last}"
19     }
20 
21     if (normal.nonEmpty)
22       s"Hello, ${concatenate(normal)}."
23     else if (shout.nonEmpty)
24       s"HELLO, ${shout.last}!"
25     else ""
26   }
27 }

En Scala esto se puede expresar de manera más sucinta usando match... case:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8 
 9     def concatenate(names: Seq[String]) = {
10       names.length match {
11         case 1 => s"${names.last}"
12         case 2 => s"${names.head} and ${names.last}"
13         case _ => s"${names.init.mkString(", ")}, and ${names.last}"
14       }
15     }
16 
17     if (person.isEmpty) return "Hello, my friend."
18 
19     val (shout, normal) = person.partition(isShouting)
20 
21     if (normal.nonEmpty)
22       s"Hello, ${concatenate(normal)}."
23     else if (shout.nonEmpty)
24       s"HELLO, ${shout.last}!"
25     else ""
26   }
27 }

Y un poquito más:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8 
 9     def concatenate(names: Seq[String]) = {
10       s"${names.length match {
11         case 1 => ""
12         case 2 => s"${names.head} and "
13         case _ => s"${names.init.mkString(", ")}, and "
14       }}${names.last}"
15     }
16 
17     if (person.isEmpty) return "Hello, my friend."
18 
19     val (shout, normal) = person.partition(isShouting)
20 
21     if (normal.nonEmpty)
22       s"Hello, ${concatenate(normal)}."
23     else if (shout.nonEmpty)
24       s"HELLO, ${shout.last}!"
25     else ""
26   }
27 }

Gritar a los gritones, pero solo a ellos

En el test anterior nos hemos enfrentado al problema de generalizar el algoritmo para cualquier número de casos y hacerlo más expresivo sin romper la funcionalidad conseguida hasta aquel momento. Toca introducir un nuevo requisito mediante un nuevo test:

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9   test("Act when name is null") {
10     assert(Greetings.greet() === "Hello, my friend.")
11   }
12 
13   test("Should manage shout") {
14     assert(Greetings.greet("JERRY") === "HELLO, JERRY!")
15   }
16 
17   test("Should manage two names") {
18     assert(Greetings.greet("Jill", "Jane") === "Hello, Jill and Jane.")
19   }
20 
21   test("Should manage several names") {
22     assert(Greetings.greet("Amy", "Brian", "Charlotte") === "Hello, Amy\
23 , Brian, and Charlotte.")
24   }
25 
26   test("Should shout to shouters") {
27     assert(Greetings.greet("Amy", "BRIAN", "Charlotte") === "Hello, Amy\
28  and Charlotte. AND HELLO, BRIAN!")
29   }
30 }

Este test falla, como cabría esperar. Es interesante que ya nos habíamos preparado para este caso y tratábamos los saludos “gritones” de forma separada. Por lo que deducimos del ejemplo, podríamos aplicar el mismo tratamiento que a los “no gritones”, teniendo en cuenta que pueden aparecer los dos casos simultáneamente. Después de un par de intentos, llegamos a esto:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8 
 9     def concatenate(names: Seq[String]) = {
10       s"${names.length match {
11         case 1 => ""
12         case 2 => s"${names.head} and "
13         case _ => s"${names.init.mkString(", ")}, and "
14       }}${names.last}"
15     }
16 
17     if (person.isEmpty) return "Hello, my friend."
18 
19     val (shout, normal) = person.partition(isShouting)
20 
21     s"${if (normal.nonEmpty) s"Hello, ${concatenate(normal)}." else ""}\
22 ${if (shout.nonEmpty) s"${if (normal.nonEmpty) " AND " else ""}HELLO, $\
23 {concatenate(shout)}!" else ""}"
24   }
25 }

Separar nombres que contienen comas

El siguiente requisito que se nos pide es separar los nombres que contienen comas. Para hacernos una idea esto viene siendo como permitir pasar los nombres con un número indeterminado de strings como en forma de un único string conteniendo varios nombres. Esto no altera realmente el modo en que generamos el saludo, sino más bien al modo en que preparamos los datos recibidos.

Nos toca, por tanto, añadir un test que ejemplifique el nuevo requisito:

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9   test("Act when name is null") {
10     assert(Greetings.greet() === "Hello, my friend.")
11   }
12 
13   test("Should manage shout") {
14     assert(Greetings.greet("JERRY") === "HELLO, JERRY!")
15   }
16 
17   test("Should manage two names") {
18     assert(Greetings.greet("Jill", "Jane") === "Hello, Jill and Jane.")
19   }
20 
21   test("Should manage several names") {
22     assert(Greetings.greet("Amy", "Brian", "Charlotte") === "Hello, Amy\
23 , Brian, and Charlotte.")
24   }
25 
26   test("Should shout to shouters") {
27     assert(Greetings.greet("Amy", "BRIAN", "Charlotte") === "Hello, Amy\
28  and Charlotte. AND HELLO, BRIAN!")
29   }
30 
31   test("Should separate names with comma") {
32     assert(Greetings.greet("Bob", "Charlie, Dianne") === "Hello, Bob, C\
33 harlie, and Dianne.")
34   }
35 }

Ejecutamos el test para comprobar que no pasa y nos planteamos cómo resolver este nuevo caso.

En principio, podríamos recorrer la lista de personas y hacer un split de cada una de ellas por la coma. Como esto generará una colección de colecciones, la aplanamos. En Scala hay métodos para todo eso:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8 
 9     def concatenate(names: Seq[String]) = {
10       s"${names.length match {
11         case 1 => ""
12         case 2 => s"${names.head} and "
13         case _ => s"${names.init.mkString(", ")}, and "
14       }}${names.last}"
15     }
16 
17     if (person.isEmpty) return "Hello, my friend."
18 
19     val personsList = person.flatMap(name => name.split(",").map(_.trim\
20 ))
21 
22     val (shout, normal) = personsList.partition(isShouting)
23 
24     s"${if (normal.nonEmpty) s"Hello, ${concatenate(normal)}." else ""}\
25 ${if (shout.nonEmpty) s"${if (normal.nonEmpty) " AND " else ""}HELLO, $\
26 {concatenate(shout)}!" else ""}"
27   }
28 }

Y he aquí que el test pasa sin problemas.

Una vez que hemos visto que la solución funciona, refactorizamos un poco el código:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8 
 9     def concatenate(names: Seq[String]) = {
10       s"${names.length match {
11         case 1 => ""
12         case 2 => s"${names.head} and "
13         case _ => s"${names.init.mkString(", ")}, and "
14       }}${names.last}"
15     }
16 
17     if (person.isEmpty) return "Hello, my friend."
18 
19     val (shout, normal) = person
20       .flatMap(_.split(",").map(_.trim))
21       .partition(isShouting)
22 
23     s"${if (normal.nonEmpty) s"Hello, ${concatenate(normal)}." else ""}\
24 ${if (shout.nonEmpty) s"${if (normal.nonEmpty) " AND " else ""}HELLO, $\
25 {concatenate(shout)}!" else ""}"
26   }
27 }

Escapar comas

El octavo requisito consiste en permitir que se evite el comportamiento anterior si la entrada de texto está escapada. Veamos el caso en forma de test:

 1 import greetings.Greetings
 2 import org.scalatest.FunSuite
 3 
 4 class GreetingTest extends FunSuite {
 5   test("Require the function") {
 6     assert(Greetings.greet("Bob") === "Hello, Bob.")
 7   }
 8 
 9   test("Act when name is null") {
10     assert(Greetings.greet() === "Hello, my friend.")
11   }
12 
13   test("Should manage shout") {
14     assert(Greetings.greet("JERRY") === "HELLO, JERRY!")
15   }
16 
17   test("Should manage two names") {
18     assert(Greetings.greet("Jill", "Jane") === "Hello, Jill and Jane.")
19   }
20 
21   test("Should manage several names") {
22     assert(Greetings.greet("Amy", "Brian", "Charlotte") === "Hello, Amy\
23 , Brian, and Charlotte.")
24   }
25 
26   test("Should shout to shouters") {
27     assert(Greetings.greet("Amy", "BRIAN", "Charlotte") === "Hello, Amy\
28  and Charlotte. AND HELLO, BRIAN!")
29   }
30 
31   test("Should separate names with comma") {
32     assert(Greetings.greet("Bob", "Charlie, Dianne") === "Hello, Bob, C\
33 harlie, and Dianne.")
34   }
35 
36   test("Should not separate names with comma if escaped") {
37     assert(Greetings.greet("Bob", "\"Charlie, Dianne\"") === "Hello, Bo\
38 b and Charlie, Dianne.")
39   }
40 }

De nuevo, esto afecta a la preparación de los datos antes de montar el saludo. La solución que se nos ocurre es detectar primero la situación de que la cadena viene escapada y reemplazar la coma por un carácter arbitrario antes de hacer el split. Una vez hecho, restauramos la coma original.

En este caso, lo hacemos mediante una expresión regular, reemplazando por el símbolo # y restituyéndolo después.

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8 
 9     def concatenate(names: Seq[String]) = {
10       s"${names.length match {
11         case 1 => ""
12         case 2 => s"${names.head} and "
13         case _ => s"${names.init.mkString(", ")}, and "
14       }}${names.last}"
15     }
16 
17     if (person.isEmpty) return "Hello, my friend."
18     
19     val escaped = "^\"([^,]+),(.+)\"$".r
20     val personsList = person
21       .map(input => escaped.replaceAllIn(input, "$1#$2"))
22       .flatMap(_.split(",").map(_.trim))
23       .map(_.replace("#", ","))
24 
25     val (shout, normal) = personsList.partition(isShouting)
26 
27     s"${if (normal.nonEmpty) s"Hello, ${concatenate(normal)}." else ""}\
28 ${if (shout.nonEmpty) s"${if (normal.nonEmpty) " AND " else ""}HELLO, $\
29 {concatenate(shout)}!" else ""}"
30   }
31 }

Con esto completamos todos los requisitos. Podemos hacer un pequeño refactor:

 1 package greetings
 2 
 3 object Greetings {
 4   def greet(person: String*): String = {
 5     def isShouting(name: String): Boolean = {
 6       name.toUpperCase() == name
 7     }
 8 
 9     def concatenate(names: Seq[String]) = {
10       s"${names.length match {
11         case 1 => ""
12         case 2 => s"${names.head} and "
13         case _ => s"${names.init.mkString(", ")}, and "
14       }}${names.last}"
15     }
16 
17     if (person.isEmpty) return "Hello, my friend."
18 
19     val escaped = "^\"([^,]+),(.+)\"$".r
20     val (shout, normal) = person
21       .map(input => escaped.replaceAllIn(input, "$1#$2"))
22       .flatMap(_.split(",").map(_.trim))
23       .map(_.replace("#", ","))
24       .partition(isShouting)
25 
26     s"${if (normal.nonEmpty) s"Hello, ${concatenate(normal)}." else ""}\
27 ${if (shout.nonEmpty) s"${if (normal.nonEmpty) " AND " else ""}HELLO, $\
28 {concatenate(shout)}!" else ""}"
29   }
30 }

Una de las cosas que llama la atención en esta kata es que el enfoque funcional hace que cambios de comportamiento relativamente grandes se puedan conseguir mediante cambios comparativamente pequeños en el código de producción.

Qué hemos aprendido con esta kata

  • En esta kata hemos aprendido a posponer la generalización hasta tener más información sobre el algoritmo que estamos desarrollando
  • Hemos aplicado las técnicas aprendidas en katas anteriores
  • Hemos comprobado que un sistema de tipos estrictos nos permite ahorrarnos algunos tests