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 ArrayList encapsulado, sin entregar la lista interna.
  • Implementar equals, hashCode y toString respetando 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

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() y name() no son null ni 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. equals cumple su contrato (reflexivo, simétrico, transitivo, consistente y false con null o con un objeto que no es un Product).
  • hashCode es consistente con equals.
  • toString devuelve exactamente CÓDIGO NOMBRE (S/ PRECIO, stock STOCK), por ejemplo ARR-02 Arroz superior 5 kg (S/ 21.90, stock 8). Así una prueba que falla muestra algo legible en lugar de Product@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.
  • find devuelve el mismo objeto que se agregó, así que inventory.find("LEC-01").removeStock(2) también cambia el stock. Es mejor usar inventory.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 Inventory no exponga su lista ni repita código (por ejemplo, la búsqueda por código escrita una sola vez y usada por find, 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):

  1. Todo el código, las pruebas públicas de P0 y P1 sin cambios, y sus pruebas propias.
  2. El README.md del 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).