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
- Semana 5:
extends, constructores ysuper(...), override de un método,finaly herencia yequals. - Semana 6: dynamic dispatch, clases y métodos abstractos, Template Method, probar una jerarquía y el error de llamar desde un constructor a un método que admite override.
- Semana 7: interfaces, ¿interfaz o clase abstracta?, composición frente a herencia, Liskov y stub y spy.
- Semana 8: pattern matching con
instanceofy jerarquías cerradas consealed. - Eck: sec. 5.5 Inheritance, Polymorphism, and Abstract Classes y sec. 5.7 Interfaces.
- Fechas:
java.time.LocalDate(LocalDate.of(2026, 11, 1),isAfter,plusDays), como enLoande la semana 4.
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()espriceFor(stock()). Así cada subclase calcula su valor con su propio precio, sin hacer override destockValueCents.Inventory.lowStock(threshold)usaisLowStock(threshold)de cada producto, en lugar de comparar el stock por su cuenta.equalsyhashCodese declaranfinal: dos productos son iguales si tienen el mismo código, sean del tipo que sean (unProducty unBulkProductcon el mismo código son el mismo producto de la bodega). Confinal, ninguna subclase puede romper esa regla ni la simetría (el problema dePointyColorPointde la semana 5).toStringdeProductno 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 queProduct. - El stock está en gramos y
priceCents()es el precio por kilogramo.addStock,removeStockystock()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()estrue.isLowStock(threshold): para un producto a granel el umbral está en kilogramos: el stock es bajo si es menor o igual quethreshold * 1000gramos. 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 fechanull:NullPointerException(Objects.requireNonNull). LocalDate expiresOn(): la fecha de vencimiento.boolean isExpiredOn(LocalDate date): si endateel producto ya está vencido, es decir, sidatees posterior aexpiresOn(). 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íanullo en blanco:IllegalArgumentException.NearExpiryDiscount(LocalDate today, int days, int percent): se aplica a losPerishableProductque no están vencidos entodayy vencen a más tardardaysdías después detoday(days= 0: solo los que vencen hoy).daysnegativo:IllegalArgumentException. A los que no son perecibles no se aplica. Aquí sí hace falta preguntar el tipo coninstanceof(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): “llevatake, pagapay” para el producto con ese código. Se exigetake > pay >= 1y un código no vacío; si no,IllegalArgumentException. - Por cada grupo completo de
takeunidades,take - payson 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 verifiquepriceForystockValueCentsde cada uno; - pruebas de
Promotionscon al menos un test double propio (un stub que implementeDiscount), sin usar las clases reales de descuento; - los bordes de las fechas (vence hoy, venció ayer, vence justo en
daysdí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
instanceofdonde 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
Promotionsfuncione con cualquierDiscount, 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):
- Todo el código, las pruebas públicas de P0 a P2 sin cambios y sus pruebas propias.
- El
README.mddel 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
Productfuera abstracta desde P0? ¿Qué ganaríamos y qué se rompería ahora? - El queso fresco a granel que vence (requisito 3).
- ¿Por qué
Discountes una interfaz yPercentageDiscountuna clase abstracta, y no al revés? - ¿Convendría que
Discountfuerasealed(semana 8)? Piensen en la meta de esta entrega: agregar promociones sin tocar el código existente.
- ¿Qué cambiaría si