P2 — Catálogo

Publicación: semana 6 · Entrega: semana 9 · En parejas · Reglas generales de entrega: projects/README.md.

No todo en la bodega se vende igual. La leche evaporada se vende por lata; el arroz a granel, por peso, y doña Rosa lo pesa en la balanza; el yogur vence, y pasada la fecha ya no se puede vender. Además, doña Rosa hace promociones: 10 % en lácteos los lunes, “lleva 3 gaseosas y paga 2”, 30 % en lo que está por vencer. Las promociones cambian cada semana.

En P2 el catálogo de la bodega se vuelve una jerarquía de productos y las promociones se vuelven objetos intercambiables detrás de una interfaz. La meta de diseño es concreta: agregar una promoción nueva debe ser escribir una clase nueva, sin modificar las que ya funcionan.

Objetivos

  • Modelar variantes de un concepto con herencia (extends, super, override) donde hay una relación “es un” de verdad.
  • Aprovechar el dynamic dispatch: el inventario calcula valores y stock bajo sin preguntar de qué tipo es cada producto.
  • Usar una clase abstracta con un Template Method y una interfaz como contrato de las promociones.
  • Probar una jerarquía, y probar con test doubles el código que depende de una interfaz.

Lo que necesitan de las semanas 5 a 8

La jerarquía

classDiagram
    class Product {
        +priceFor(int quantity) long
        +soldByWeight() boolean
        +isLowStock(int threshold) boolean
    }
    class BulkProduct
    class PerishableProduct {
        +expiresOn() LocalDate
        +isExpiredOn(LocalDate date) boolean
    }
    Product <|-- BulkProduct
    Product <|-- PerishableProduct
    class Discount {
        <<interface>>
        +discountCents(Product product, int quantity) long
    }
    class PercentageDiscount {
        <<abstract>>
        #appliesTo(Product product) boolean
    }
    Discount <|.. PercentageDiscount
    Discount <|.. MultiBuyDiscount
    PercentageDiscount <|-- CategoryDiscount
    PercentageDiscount <|-- NearExpiryDiscount
    Promotions o-- Discount

Product sigue siendo una clase concreta: representa el caso más común, el producto que se vende por unidad. No puede volverse abstracta porque las pruebas de P0 y P1 (y todo su código de P0 y P1) crean productos con new Product(...), y esas pruebas deben seguir pasando. La clase abstracta de esta entrega está donde de verdad hace falta: en los descuentos por porcentaje, que comparten todo menos la condición.

Requisitos

Todo lo de P0 y P1 sigue igual y sus pruebas deben seguir pasando. En particular, el constructor de cuatro argumentos de Product no cambia.

1. Lo que se agrega a Product

Miembro Qué hace
Product(String code, String name, long priceCents, int stock, String category) Constructor nuevo, con la categoría ("Lácteos", "Abarrotes"). Una categoría null o en blanco: IllegalArgumentException. El constructor de cuatro argumentos sigue existiendo y usa la categoría "general"; que llame al nuevo con this(...).
String category() La categoría. Se compara exactamente, como el código.
long priceFor(int quantity) El precio de quantity unidades: priceCents() * quantity. priceFor(0) es 0; una cantidad negativa: IllegalArgumentException.
boolean soldByWeight() false: el producto se vende por unidad.
boolean isLowStock(int threshold) Si el stock es menor o igual que threshold. Umbral negativo: IllegalArgumentException.

Y cambian por dentro, sin cambiar lo que devuelven para un Product:

  • stockValueCents() es priceFor(stock()). Así cada subclase calcula su valor con su propio precio, sin hacer override de stockValueCents.
  • Inventory.lowStock(threshold) usa isLowStock(threshold) de cada producto, en lugar de comparar el stock por su cuenta.
  • equals y hashCode se declaran final: dos productos son iguales si tienen el mismo código, sean del tipo que sean (un Product y un BulkProduct con el mismo código son el mismo producto de la bodega). Con final, ninguna subclase puede romper esa regla ni la simetría (el problema de Point y ColorPoint de la semana 5).
  • toString de Product no cambia; las subclases pueden hacerle override.

2. BulkProduct: a granel

BulkProduct extends Product es un producto que se vende por peso.

  • Constructor: BulkProduct(String code, String name, long pricePerKgCents, int stockGrams, String category), con las mismas validaciones que Product.
  • El stock está en gramos y priceCents() es el precio por kilogramo. addStock, removeStock y stock() se heredan sin cambios y trabajan en gramos.
  • priceFor(grams) es el precio de esa cantidad de gramos, redondeado al céntimo más cercano, con las mitades hacia arriba: con S/ 4.20 el kilo, 1500 g cuestan 630 céntimos y 250 g, 105. Con aritmética entera: (priceCents() * grams + 500) / 1000. Una cantidad negativa: IllegalArgumentException.
  • soldByWeight() es true.
  • isLowStock(threshold): para un producto a granel el umbral está en kilogramos: el stock es bajo si es menor o igual que threshold * 1000 gramos. Así un solo umbral, “5”, significa 5 latas de leche o 5 kilos de arroz.

3. PerishableProduct: perecible

PerishableProduct extends Product es un producto por unidad que vence.

  • Constructor: PerishableProduct(String code, String name, long priceCents, int stock, String category, LocalDate expiresOn). Una fecha null: NullPointerException (Objects.requireNonNull).
  • LocalDate expiresOn(): la fecha de vencimiento.
  • boolean isExpiredOn(LocalDate date): si en date el producto ya está vencido, es decir, si date es posterior a expiresOn(). El mismo día del vencimiento todavía se puede vender.

Pregunta para su README: ¿qué harían con un producto que es a granel y vence (queso fresco por peso)? Java no tiene herencia múltiple de clases. Piénsenlo con lo que vieron de composición; no hace falta implementarlo.

4. Discount: la interfaz de las promociones

public interface Discount {
    long discountCents(Product product, int quantity);
}

discountCents dice cuántos céntimos se descuentan al vender quantity (unidades, o gramos si el producto es a granel) de product. El resultado está entre 0 y product.priceFor(quantity), y es 0 si la promoción no se aplica a ese producto. La interfaz tiene un solo método; no le agreguen otros (en la semana 12 van a ver por qué conviene).

5. PercentageDiscount: la clase abstracta

public abstract class PercentageDiscount implements Discount es un descuento de un porcentaje del precio, para los productos que cumplen una condición. Es un Template Method (semana 6): la clase abstracta hace la cuenta y las subclases solo deciden a qué se aplica.

Miembro Qué hace
protected PercentageDiscount(int percent) percent entre 1 y 100; si no, IllegalArgumentException.
int percent() El porcentaje.
protected abstract boolean appliesTo(Product product) Si el descuento se aplica a ese producto. Lo implementa cada subclase.
final long discountCents(Product product, int quantity) Si appliesTo(product), el porcentaje de product.priceFor(quantity) redondeado al céntimo más cercano con las mitades hacia arriba: (product.priceFor(quantity) * percent + 50) / 100. Si no, 0. Es final.

Dos subclases:

  • CategoryDiscount(String category, int percent): se aplica a los productos de esa categoría, de cualquier tipo. Categoría null o en blanco: IllegalArgumentException.
  • NearExpiryDiscount(LocalDate today, int days, int percent): se aplica a los PerishableProduct que no están vencidos en today y vencen a más tardar days días después de today (days = 0: solo los que vencen hoy). days negativo: IllegalArgumentException. A los que no son perecibles no se aplica. Aquí sí hace falta preguntar el tipo con instanceof (semana 8); expliquen en el README por qué no hay otra forma razonable, o propongan una.

La fecha de hoy entra por el constructor, en lugar de que la clase llame a LocalDate.now(): así se puede probar qué pasa “el 1 de noviembre” cualquier día del año (es la idea del Greeter de la semana 7).

6. MultiBuyDiscount: lleva N, paga M

MultiBuyDiscount implements Discount directamente (no es un porcentaje).

  • Constructor: MultiBuyDiscount(String code, int take, int pay): “lleva take, paga pay” para el producto con ese código. Se exige take > pay >= 1 y un código no vacío; si no, IllegalArgumentException.
  • Por cada grupo completo de take unidades, take - pay son gratis: con “lleva 3, paga 2” y 7 gaseosas, hay 2 grupos y 2 gaseosas gratis. El descuento es el precio de las unidades gratis: product.priceFor((quantity / take) * (take - pay)).
  • Solo se aplica al producto con ese código y no a los productos a granel (soldByWeight()): “lleva 3 gramos” no tiene sentido. En esos casos, 0.

7. Promotions: las promociones vigentes

Miembro Qué hace
Promotions() Sin promociones.
void add(Discount discount) Agrega una promoción. Con null: NullPointerException.
List<Discount> discounts() Las promociones en el orden en que se agregaron (copia no modificable).
long discountFor(Product product, int quantity) El descuento para esa venta: el mayor de los que dan las promociones (no se suman), acotado a [0, product.priceFor(quantity)]. Sin promociones, 0.
long priceFor(Product product, int quantity) product.priceFor(quantity) - discountFor(product, quantity).

Promotions no pregunta de qué clase es cada descuento: solo llama a discountCents. Si mañana doña Rosa inventa “S/ 0.50 menos en cada paquete de galletas”, es una clase nueva que implementa Discount, y Promotions no se toca.

8. Pruebas propias

Además de las pruebas de cada clase nueva (normales, bordes y errores):

  • una prueba que recorra una List<Product> con los tres tipos de producto y verifique priceFor y stockValueCents de cada uno;
  • pruebas de Promotions con al menos un test double propio (un stub que implemente Discount), sin usar las clases reales de descuento;
  • los bordes de las fechas (vence hoy, venció ayer, vence justo en days días) y del redondeo (una mitad exacta).

Archivos provistos

Archivo Para qué
Makefile El mismo de siempre.
P2ProductsPublicTest.java, P2DiscountsPublicTest.java Pruebas públicas de P2. No las modifiquen.
BulkProduct.java, PerishableProduct.java, Discount.java, PercentageDiscount.java, CategoryDiscount.java, NearExpiryDiscount.java, MultiBuyDiscount.java, Promotions.java Esqueletos de las clases nuevas (Discount.java ya está completa).

Copien a su proyecto las pruebas y los esqueletos sin borrar nada de P0 y P1. Para que todo compile, agreguen primero a su Product los miembros nuevos, aunque sea así:

public Product(String code, String name, long priceCents, int stock, String category) {
    this(code, name, priceCents, stock);   // por hacer: guardar la categoría
}

public String category() {
    throw new UnsupportedOperationException("por hacer");
}

public long priceFor(int quantity) {
    throw new UnsupportedOperationException("por hacer");
}

public boolean soldByWeight() {
    throw new UnsupportedOperationException("por hacer");
}

public boolean isLowStock(int threshold) {
    throw new UnsupportedOperationException("por hacer");
}

(Al terminar, el constructor de cuatro argumentos llama al de cinco, y no al revés.) Con eso make test compila, lo de P0 y P1 sigue pasando y las pruebas de P2 fallan hasta que las completen.

Cómo se prueba

Pruebas públicas

make test corre lo de P0 y P1 (8 casos de entrada y salida y 39 pruebas JUnit) y las 29 pruebas de P2 (15 de productos y 14 de descuentos).

Pruebas privadas y diseño

Las pruebas privadas usan otros productos, precios, porcentajes y fechas, y otras combinaciones de promociones. El diseño, además, tiene que cumplir esto:

  • que no haya cadenas de instanceof donde basta un override (por ejemplo, para calcular un precio o un stock bajo);
  • que las subclases no repitan código de Product (validaciones, campos);
  • que Promotions funcione con cualquier Discount, también uno que no existía cuando la escribieron;
  • que ningún constructor llame a un método que admite override;
  • el diagrama de clases y las respuestas del README.

Qué entregar

Para la entrega de la semana 9 (si usan git, el tag p2):

  1. Todo el código, las pruebas públicas de P0 a P2 sin cambios y sus pruebas propias.
  2. El README.md del equipo, que desde esta entrega incluye un diagrama de clases en Mermaid con todas sus clases, sus relaciones (herencia, implementación, composición) y los métodos públicos principales. Respondan también:
    • ¿Qué cambiaría si Product fuera abstracta desde P0? ¿Qué ganaríamos y qué se rompería ahora?
    • El queso fresco a granel que vence (requisito 3).
    • ¿Por qué Discount es una interfaz y PercentageDiscount una clase abstracta, y no al revés?
    • ¿Convendría que Discount fuera sealed (semana 8)? Piensen en la meta de esta entrega: agregar promociones sin tocar el código existente.