En la primera parte de la Coffee Machine kata desarrollé un protocolo para comunicar los pedidos a la máquina. Esta segunda parte consistirá en ir añadiendo los cambios que se nos piden, en las iteraciones de la 2 a la 5 de la kata.

El código completo de mi solución está en este repositorio de GitHub.

Segunda iteración: Hacemos negocios

El primer cambio que se nos pide es hacer que la máquina empiece a cobrar por las bebidas, y para eso nos dan unos precios muy concretos: 40 céntimos de EUR por un té, 60 céntimos de EUR por un café, y 50 céntimos de EUR por un chocolate.

Yo modifiqué el álgebra que había creado en la iteración anterior para añadir el pago por la bebida:

trait DrinkMaker[F[_]] {
  def make(payment: Money, command: Command): F[Order]
}

Aquí aproveché la librería Squants que facilita el trabajo con dinero y otras cantidates medibles junto con sus unidades (longitud, peso, energía, etc.).

También hice algunos retoques al intérprete de esta álgebra para que cada pedido pase por una validación adicional que comprueba el pago de las bebidas (los mensajes pasan sin modificación):

object DrinkMaker {
  def impl[F[_]: MonadError[*[_], Throwable]]: DrinkMaker[F](
    appContext: AppContext
  ) =
    (command: Command) => {
      for {
        tokens <- maybeTokens[F](command)
        order <- tokens.fold(
          InvalidCommand(s"Invalid command received ${command.toText.value}")
            .raiseError[F, Order]
        )(orderFrom[F])
        paidOrder = order match {
          case drinkOrder: Order =>
            val price = appContext.priceOf(drinkOrder.drink)
            val priceDiff = price - payment
            if (priceDiff <= 0.EUR) drinkOrder
            else 
              CoffeeMachineMessage(s"Not enough money, missing ${priceDiff.toString}")
          case message: CoffeeMachineMessage => message
        }
      } yield paidOrder
    }
}

Recordar que la función maybeTokens viene de la primera parte, y sirve para convertir los comandos que recibe la máquina en tokens que luego se transforman en pedidos en la función orderFrom.

Aquí tuve que decidir qué hacer en el caso de que una bebida no tenga un precio asignado en la máquina. Normalmente lo habría consultado con alguien de producto, pero es una kata, así que decidí poner un precio fijo de 1 EUR para todo aquello que no tenga precio asignado:

final case class AppContext(
  drinkPrices: DrinkPrices, priceForEveryThingElse: Money
) {
  def priceOf(drink: Drink): Money =
    drinkPrices.toMap.get(drink).fold(priceForEveryThingElse)(identity)
}

object AppContext {
  def apply(): AppContext =
    new AppContext(
      DrinkPrices(
        Map(Chocolate -> 0.5.EUR, Coffee -> 0.6.EUR, Tea -> 0.4.EUR)
      ),
      1.0.EUR
    )

  def apply(drinkPrices: DrinkPrices, priceForEveryThingElse: Money): AppContext =
    new AppContext(drinkPrices, priceForEveryThingElse)
}

Una cosa que he aprendido de Dani es a organizar en orden alfabético todo lo que pueda ser organizado.

Tercera iteración: Bebidas muy calientes

Ahora se nos pide actualizar la máquina para que además se pueda vender zumo de naranja por 60 céntimos de EUR. También se puede pedir que el café, el chocolate o el té se sirvan muy calientes.

Esta es para mí la iteración menos interesante de todas porque solamente tuve que añadir un nuevo tipo OrangeJuice al ADT Drink. Además, para soportar las bebidas muy calientes tuve que cambiar el tipo del token:

type Tokens = (Char, Boolean, Int, Boolean, Option[String])

Que codifica la información necesaria para preparar el pedido: bebida, ¿muy caliente?, azúcar, ¿removedor?, mensaje.

Cuarta iteración: Los negocios van bien

Esta vez se nos pide hacer un informe de las ventas, agrupando los beneficios por el tipo de bebida. Tuve que volver a tocar el intérprete para guardar los pedidos que generan beneficios (ventas), y que luego se pueda imprimir el informe que nos piden:

object DrinkMaker {
  def impl[F[_]: MonadError[*[_], Throwable]]: DrinkMaker[F](
    appContext: AppContext
  ) =
    (command: Command) => {
      for {
        tokens <- maybeTokens[F](command)
        order <- tokens.fold(
          InvalidCommand(s"Invalid command received ${command.toText.value}")
            .raiseError[F, Order]
        )(orderFrom[F])
        (paidOrder, sale) = order match {
          case drinkOrder: Order =>
            val price = appContext.priceOf(drinkOrder.drink)
            val priceDiff = price - payment
            if (priceDiff <= 0.EUR) (drinkOrder, Sale(drinkOrder.drink, price).some)
            else
              (CoffeeMachineMessage(s"Not enough money, missing ${priceDiff.toString}"), none[Sale])
          case message: CoffeeMachineMessage => (message, none[Sale])
        }
        _ <- sale.fold(Monad[F].unit)(sales.save)
      } yield paidOrder
    }
}

Para guardar e imprimir las ventas tuve que desarrollar una nueva álgebra que llamé Sales, junto con su intérprete:

final case class Sale(drink: Drink, profit: Money)

trait Sales[F[_]] {
  def save(sale: Sale): F[Unit]
  def printReport: F[Unit]
}

object Sales {
  def impl[F[_]: MonadError[*[_], Throwable]](
    ref: Ref[F, List[Sale]],
    statementsPrinter: StatementsPrinter[F]
  ): Sales[F] =
    new Sales[F] {
      override def save(sale: Sale): F[Unit] = ref.get.flatMap(current => ref.set(sale :: current))

      override def printReport: F[Unit] =
        for {
          _ <- statementsPrinter.print(Statement("Drink || Total revenue"))
          currentSales <- ref.get
          statements = currentSales
            .groupBy(_.drink)
            .map {
              case (drink, sales) =>
                Statement(
                  s"${drink.toString} || ${sales.map(_.profit).fold(0.EUR)(_ + _).toString}"
                )
            }
            .toList
          _ <- statements.traverse_(statementsPrinter.print)
        } yield ()
    }
}

Es muy parecida a DrinkMaker y repite los mismos patrones con los que quería familiarizarme.

Algo que aprendí de Gastón es a no trabajar con tuplas directamente, así que en lugar de utilizar _1 y _2 para referirme a la bebida y a sus ventas, dentro del map las he renombrado con: case (drink, sales). Me parece que así queda más claro.

Algo que no he sabido hacer con el data type squants.market.Money es a usarlo como un tipo numérico y he terminado usando fold para calcular la suma de los beneficios de cada bebida.

Para facilitar las pruebas, separé las responsabilidades de guardar y generar el informe de ventas (Sales) de la responsabilidad de imprimirlo (StatementsPrinter):

trait StatementsPrinter[F[_]] {
  def print(statement: Statement): F[Unit]
}

También escribí una nueva suite de pruebas para comprobar que se imprime el informe de ventas:

object ReportPrinterSuite extends SimpleIOSuite with FakeAppContext {
  simpleTest("Save sales") {
    val (drinkMaker, _, _, salesRef, _) = testScenario
    val (sale1, sale2) = (Sale(Coffee, 0.6.EUR), Sale(Chocolate, 0.5.EUR))
    for {
      _ <- drinkMaker.make(sale1.profit, Command("C::"))
      _ <- drinkMaker.make(sale2.profit, Command("H::"))
      currentSales <- salesRef.get
    } yield expect(currentSales == List(sale2, sale1))
  }

  simpleTest("Print how many of each drink was sold and the total amount of money earned") {
    val (drinkMaker, _, sales, _, statementsPrinter) = testScenario
    for {
      _ <- drinkMaker.make(0.5.EUR, Command("H::"))
      _ <- drinkMaker.make(0.4.EUR, Command("T::"))
      _ <- drinkMaker.make(0.4.EUR, Command("T::"))
      _ <- drinkMaker.make(0.6.EUR, Command("C::"))
      _ <- drinkMaker.make(0.5.EUR, Command("H::"))
      _ <- drinkMaker.make(0.4.EUR, Command("T::"))
      _ <- sales.printReport
      printedStatements <- statementsPrinter.ref.get
    } yield expect(
      printedStatements == List(
        Statement("Chocolate || 1 EUR"),
        Statement("Tea || 1.2 EUR"),
        Statement("Coffee || 0.6 EUR"),
        Statement("Drink || Total revenue")
      )
    )
  }
}

Como tenía que generar varios escenarios de prueba, decidí escribir un generador que me ayudase a tener unos estados controlados al principio de cada test. He comentado más detalles de este generador al final del post.

Además, utilicé este doble de prueba para guardar todas las líneas del informe de venta a medida que estas se van imprimiendo, y así poder compararlas con el resultado esperado:

final class FakeStatementsPrinter(val ref: Ref[IO, List[Statement]]) extends StatementsPrinter[IO] {
  override def print(statement: Statement): IO[Unit] =
    ref.get.flatMap(current => ref.set(statement :: current))
}

Para la referencia mutable he usado cats.effect.concurrent.Ref que me ha parecido uno de los tipos más curiosos de todos los que he usado de cats y cats-effects en esta kata. Básicamente, es una referencia mutable que además es asíncrona y concurrente. Aquí lo explican muy bien.

Quinta y última iteración: Se agotan las bebidas

Por último se nos pide controlar la disponibilidad de las bebidas, y en caso de que al preparar un pedido falte alguna bebida entonces la máquina tendrá que mostrar un mensaje de error al usuario. Además, se nos pide enviar una notificación por email para que el encargado de la máquina pueda reponer la bebida faltante.

Para esta última iteración tuve que añadir una validación al intérprete del álgebra DrinkMaker. La he puesto antes de comprobar el pago para que sólo se registren las ventas de bebidas que no estén en falta a la hora de preparar el pedido:

object DrinkMaker {
  def impl[F[+_]: MonadError[*[_], Throwable]]: DrinkMaker[F](
    appContext: AppContext
  ) =
    (command: Command) => {
      val F = Monad[F]
      for {
        tokens <- maybeTokens[F](command)
        order <- tokens.fold(
          InvalidCommand(s"Invalid command received ${command.toText.value}")
            .raiseError[F, Order]
        )(orderFrom[F])
        validatedOrder <- order match {
          case drinkOrder: DrinkOrder =>
            F.ifA(beverageQuantityChecker.isEmpty(drinkOrder.drink))(
              ifTrue = emailNotifier.notifyMissingDrink(drinkOrder.drink) *> F.pure(
                CoffeeMachineMessage(
                  s"I ran out of ${drinkOrder.drink.toString}. My human was notified already"
                )
              ),
              ifFalse = F.pure(order)
            )
          case _ => F.pure(order)
        }
        (paidOrder, sale) = validatedOrder match {
          case drinkOrder: Order =>
            val price = appContext.priceOf(drinkOrder.drink)
            val priceDiff = price - payment
            if (priceDiff <= 0.EUR) (drinkOrder, Sale(drinkOrder.drink, price).some)
            else
              (CoffeeMachineMessage(s"Not enough money, missing ${priceDiff.toString}"), none[Sale])
          case message: CoffeeMachineMessage => (message, none[Sale])
        }
        _ <- sale.fold(F.unit)(sales.save)
      } yield paidOrder
    }
}

Un detalle que aún no sé explicar del todo es que hasta este cambio había estado utilizando el tipo F[_] que es inmutable para el parámetro _. Sin embargo, para usar la función Monad[F].ifA(...) he tenido que cambiar este tipo a F[+_], que además incluye a los subtipos de _.

Para probar esta parte escribí esta nueva test suite, aprovechando el generador de escenarios de prueba que había escrito antes:

object BeverageQuantityCheckerSuite extends SimpleIOSuite with FakeAppContext {
  simpleTest("The coffee machine informs me the shortage and that a notification has been sent") {
    val (drinkMaker, emailNotifier, _, _, _) = testScenario(List(Coffee))
    for {
      order <- drinkMaker.make(appContext.priceOf(Coffee), Command("C::"))
      notifications <- emailNotifier.ref.get
    } yield expect(
      order == CoffeeMachineMessage(s"I ran out of Coffee. My human was notified already") && notifications == List(
        Coffee
      )
    )
  }
}

Finalmente, este es el código del generador de escenarios de prueba:

trait FakeAppContext {
  val appContext: AppContext = AppContext()

  def testScenario
    : (DrinkMaker[IO], FakeEmailNotifier, Sales[IO], Ref[IO, List[Sale]], FakeStatementsPrinter) =
    testScenario(List.empty)

  def testScenario(
    missingDrinks: List[Drink]
  ): (DrinkMaker[IO], FakeEmailNotifier, Sales[IO], Ref[IO, List[Sale]], FakeStatementsPrinter) = {
    val statementsPrinter = new FakeStatementsPrinter(Ref.unsafe[IO, List[Statement]](List.empty))
    val salesRef = Ref.unsafe[IO, List[Sale]](List.empty)
    val sales = Sales.impl[IO](salesRef, statementsPrinter)
    val emailNotifier = new FakeEmailNotifier(Ref.unsafe[IO, List[Drink]](List.empty))
    val drinkMaker = DrinkMaker.impl[IO](
      appContext,
      new FakeBeverageQuantityChecker(missingDrinks),
      emailNotifier,
      sales
    )
    (drinkMaker, emailNotifier, sales, salesRef, statementsPrinter)
  }
}

Básicamente, hay dos versiones: una en la que puedo generar escenarios en los que falta alguna bebida, y la otra en la que hay disponibilidad de todo.

Conclusiones finales

He disfrutado mucho con esta kata y también creo que he aprendido algunas cosas sobre programación funcional y Scala. Sienta bien llegar al final, comprender cada una de las líneas de código y tener una explicación para cada una de las decisiones que he tomado.

Creo que repetiré la experiencia de escribir un post después de cada kata que haga porque veo que me sirve como notas que más adelante podré volver a consultar.

Agradecimientos

Una vez más, a Dani y a Victor (ambos de Agilogy), por descubrirme el mundo de la programación funcional, a Gastón por compartir su pasión por Scala, y a todos ellos por todas las charlas y por la motivación.

cats