P1 — Inventario
Publicación: semana 3 · Entrega: semana 6 · En parejas ·
Reglas generales de entrega: projects/README.md.
En P0, Product aceptaba cualquier cosa: un precio de cero, un stock de −5,
un código vacío. Funcionaba porque nadie le pasaba datos malos. En P1 la clase
se defiende sola: un producto inválido no llega a existir y ninguna operación
lo deja en un estado imposible. Además aparecen dos clases nuevas: Soles,
para leer y escribir montos sin repetir código, e Inventory, el cuaderno de
doña Rosa con todos los productos de la bodega.
Desde esta entrega, cada clase llega con sus propias pruebas JUnit, escritas por ustedes.
Objetivos
- Proteger el estado de una clase con encapsulamiento (encapsulation) e invariantes (invariants), y rechazar los datos inválidos con excepciones.
- Escribir una clase de utilidades con métodos
static. - Guardar objetos en un
ArrayListencapsulado, sin entregar la lista interna. - Implementar
equals,hashCodeytoStringrespetando sus contratos. - Escribir unit tests que cubran los casos límite y los errores, no solo el caso feliz.
Lo que necesitan de las semanas 3 a 5
- Semana 3: invariantes de clase,
el constructor valida,
getters y setters,
staticy pruebas con JUnit, en especial probar que se rechaza lo inválido y qué probar. Modelo de código:BankAccounty sus pruebas. - Semana 4:
ArrayList,containscompara conequals, errores comunes al quitar elementos y responsabilidades. - Semana 5:
toString,equals, el contrato deequalsyhashCode. - Eck: subsec. 5.1.3 Getters and Setters, subsec. 8.2.3 Invariants, sec. 7.3 ArrayList.
Requisitos
La firma de P0 no cambia: el constructor y los métodos de Product siguen
siendo exactamente los mismos, y las pruebas de P0 deben seguir pasando. Lo
que cambia es lo que la clase acepta.
1. Product con invariantes
Después del constructor y después de cada método público se cumple siempre:
code()yname()no sonnullni están en blanco (isBlank()).priceCents()es mayor que cero.stock()es mayor o igual que cero.
Para lograrlo:
| Operación | Si los datos son inválidos |
|---|---|
new Product(code, name, priceCents, stock) |
IllegalArgumentException si el código o el nombre es null o está en blanco, si priceCents <= 0 o si stock < 0. Un stock inicial de 0 es válido. |
addStock(quantity) |
IllegalArgumentException si quantity <= 0. |
removeStock(quantity) |
IllegalArgumentException si quantity <= 0; IllegalStateException si quantity es mayor que el stock. Descontar exactamente el stock lo deja en 0. |
Una operación que falla no cambia nada: el stock queda como estaba. El
código se guarda tal como llega y se compara exactamente ("lec-01" y
"LEC-01" son códigos distintos).
¿Por qué IllegalStateException para el stock insuficiente? La cantidad en sí
no es inválida (pedir 6 es razonable); lo que no permite la operación es el
estado actual del producto. En la semana 9 veremos excepciones propias del
dominio; las de P3 serán subclases de estas, así que lo que escriban ahora
seguirá siendo correcto.
2. changePrice
Un método nuevo, void changePrice(long newPriceCents), cambia el precio.
Lanza IllegalArgumentException si el precio nuevo no es positivo, y en ese
caso el precio no cambia. No agreguen setCode, setName ni setStock: el
código identifica al producto y no cambia, y el stock solo cambia con
addStock y removeStock.
3. equals, hashCode y toString
- Dos productos son iguales si y solo si tienen el mismo código, aunque
tengan nombre, precio o stock distintos: en la bodega, el código es el
producto.
equalscumple su contrato (reflexivo, simétrico, transitivo, consistente yfalseconnullo con un objeto que no es unProduct). hashCodees consistente conequals.toStringdevuelve exactamenteCÓDIGO NOMBRE (S/ PRECIO, stock STOCK), por ejemploARR-02 Arroz superior 5 kg (S/ 21.90, stock 8). Así una prueba que falla muestra algo legible en lugar deProduct@1b6d3586.
4. Soles: leer y escribir montos
En P0 los tres programas repetían los mismos dos métodos. Llévenlos a una
clase de utilidades, public final class Soles, con constructor private y
dos métodos static:
| Método | Qué hace |
|---|---|
static String format(long cents) |
450 → "S/ 4.50", 5 → "S/ 0.05", 123456 → "S/ 1234.56" (sin separador de miles). Un monto negativo: IllegalArgumentException. |
static long parse(String text) |
De texto en soles a céntimos. Ignora los espacios de los extremos y acepta uno o más dígitos, seguidos opcionalmente de un punto y uno o dos dígitos: "4.50" → 450, "4.5" → 450, "4" → 400, "0.05" → 5. Cualquier otro texto ("", "4,50", "-1", "4.505", "4.", ".5", "cuatro", null): IllegalArgumentException. |
parse no usa double en ningún momento. Para "cuatro", ojo:
Long.parseLong lanza NumberFormatException, que es subclase de
IllegalArgumentException, así que ya cumple; pero "-1" sí lo acepta
Long.parseLong, y parse debe rechazarlo.
Los programas de P0 pueden usar Soles desde ahora (es una buena forma de
probar que se comporta igual), pero no es obligatorio. Sus casos de
tests/ deben seguir pasando.
5. Inventory: los productos de la bodega
Una clase nueva que guarda los productos en un ArrayList<Product> privado.
| Método | Qué hace |
|---|---|
Inventory() |
Crea un inventario vacío. |
void add(Product product) |
Agrega el producto al final. Si ya hay uno con el mismo código: IllegalArgumentException y el inventario no cambia. Con null: NullPointerException (Objects.requireNonNull). |
boolean contains(String code) |
Si hay un producto con ese código. |
Product find(String code) |
El producto con ese código. Si no existe: NoSuchElementException (de java.util). |
Product remove(String code) |
Quita el producto y lo devuelve. Si no existe: NoSuchElementException. Los demás conservan su orden. |
void addStock(String code, int quantity) |
Repone stock del producto con ese código. Código inexistente: NoSuchElementException; cantidad no positiva: IllegalArgumentException. |
void removeStock(String code, int quantity) |
Descuenta stock. Las mismas excepciones, y además IllegalStateException si no alcanza; en ese caso nada cambia. |
List<Product> products() |
Todos los productos, en el orden en que se agregaron, en una lista que no se puede modificar (UnsupportedOperationException si alguien lo intenta) y que es una copia: no cambia si después cambia el inventario. List.copyOf hace las dos cosas. |
int size() |
Cuántos productos hay (no cuántas unidades). |
long totalValueCents() |
La suma de stockValueCents() de todos los productos; 0 si está vacío. |
List<Product> lowStock(int threshold) |
Los productos con stock menor o igual que threshold, en orden de inserción (una lista nueva, no modificable). threshold negativo: IllegalArgumentException. |
- La clase no expone su lista interna (semana 3: el getter que entrega el arreglo interno).
- El orden de inserción es parte del contrato desde ahora y se mantiene en todas las entregas siguientes, aunque la representación interna cambie.
finddevuelve el mismo objeto que se agregó, así queinventory.find("LEC-01").removeStock(2)también cambia el stock. Es mejor usarinventory.removeStock("LEC-01", 2): así el inventario sabe que algo cambió (en P5 lo va a necesitar).
6. Pruebas propias
Escriban sus pruebas en archivos propios (ProductTest.java,
SolesTest.java, InventoryTest.java; el nombre debe terminar en Test y no
puede coincidir con el de una prueba pública). Para cada requisito, al menos
una prueba del caso normal y una de cada caso límite o de error: cantidad 0,
cantidad exacta, código repetido, código inexistente, inventario vacío,
umbral 0. Prueben también la simetría y la transitividad de equals.
Un ejercicio útil antes de entregar (semana 3: romper el invariante a propósito): quiten una validación y comprueben que alguna prueba suya falla.
Archivos provistos
| Archivo | Para qué |
|---|---|
Makefile |
El mismo de P0. |
P1ProductPublicTest.java, P1SolesPublicTest.java, P1InventoryPublicTest.java |
Pruebas públicas de P1. No las modifiquen. |
Soles.java, Inventory.java |
Esqueletos de las clases nuevas. |
Copien a la carpeta de su proyecto las tres pruebas y los dos esqueletos,
sin borrar nada de P0: P0ProductPublicTest.java y los casos de tests/
se quedan y se siguen corriendo. Para que las pruebas compilen, agreguen
primero a su Product el método nuevo, aunque sea así:
public void changePrice(long newPriceCents) {
throw new UnsupportedOperationException("por hacer");
}
Con eso make test compila, las pruebas de P0 siguen pasando y las de P1
fallan hasta que las completen.
Cómo se prueba
Pruebas públicas
make test corre los 8 casos de entrada y salida de P0, las 7 pruebas JUnit de
P0 y las 32 de P1 (13 de Product, 5 de Soles y 14 de Inventory). Todas
deben pasar.
Pruebas privadas y diseño
Las pruebas privadas verifican los mismos requisitos con otros datos (otros
códigos, umbrales, cantidades límite, montos para Soles.parse) y vuelven a
correr las de P0. El diseño, además, tiene que cumplir esto:
- que ninguna operación deje un producto inválido, ni siquiera a medias;
- que
Inventoryno exponga su lista ni repita código (por ejemplo, la búsqueda por código escrita una sola vez y usada porfind,remove,addStock, …); - que las pruebas propias cubran los errores y no solo el caso feliz.
Qué entregar
Para la entrega de la semana 6 (si usan git, el tag
p1):
- Todo el código, las pruebas públicas de P0 y P1 sin cambios, y sus pruebas propias.
- El
README.mddel equipo: integrantes, qué funciona y qué no, cómo se ejecuta, y las decisiones de diseño. Respondan en especial: ¿por quéproducts()devuelve una copia? ¿Qué podría pasar si devolviera la lista interna? Muestren una prueba que lo demuestre (puede ser una de las suyas). Opcional, y útil para P2: un primer diagrama de clases en Mermaid (semana 4).