Semana 14 — Patrones de diseño
Objetivos
- Explicar qué es un patrón de diseño (design pattern): un problema que se repite, una solución con nombre y sus costos.
- Aplicar Strategy y Observer, y reconocerlos en la API de Java.
- Aplicar Factory (static factory method y Factory Method) y Composite.
- Decidir cuándo un patrón simplifica el código y cuándo solo agrega clases.
Apuntes de la semana: notes/week-14.md · Código: code/week-14
Sesión A — Patrones de diseño I: Strategy, Observer
Lecturas (Eck no trata patrones de diseño como tema):
- Refactoring.Guru, catálogo de patrones (libre en línea): Strategy y Observer. Leer la intención, el problema, la estructura y la sección de aplicabilidad; el código de ejemplo está en varios lenguajes, incluido Java.
- Eck, edición Swing, subsec. 13.4.1 Model-View-Controller (opcional): modelo, vista y controlador, donde la vista observa al modelo. Basta con la idea; las interfaces gráficas se retoman como lectura en la semana 16.
Lecturas complementarias:
- API de Java 25:
Comparatorcomo strategy de orden, yPropertyChangeSupportcomo implementación de observer en la biblioteca estándar. - Opcional: E. Gamma, R. Helm, R. Johnson y J. Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, 1994 (el libro original de los patrones; no es libre).
Temas:
- Qué es un patrón y qué no: un vocabulario compartido, no una receta para copiar. Cada patrón tiene un costo (más clases, más indirección).
- Strategy: encapsular una familia de algoritmos detrás de una interfaz y elegir uno en tiempo de ejecución. Ya lo usamos sin nombrarlo:
Comparator(semanas 11 y 12) y las reglas de precio de P2. Con interfaces funcionales, una strategy simple puede ser una lambda. - Ejemplo propio: tarifas de envío (por peso, tarifa plana, gratis desde cierto monto) como strategies intercambiables, y el
switchque reemplazan. - Observer: un subject mantiene una lista de observers y los notifica cuando cambia. Ejemplo propio: el termómetro de la refrigeradora de vacunas de una posta (cadena de frío, 2 a 8 °C), con una pantalla y una alarma como observers. El subject conoce solo la interfaz del observer: bajo acoplamiento.
ObservableyObserverdejava.utilestán marcadas como deprecated desde Java 9. - Detalles que importan en Observer: quién se registra y quién se da de baja, qué información viaja en la notificación, qué pasa si un observer lanza una excepción, y un observer que se da de baja mientras se le notifica.
- Error común: introducir Strategy para un único algoritmo que nunca va a cambiar. De cada patrón se ve también cuándo no usarlo.
Sesión B — Patrones de diseño II: Factory, Composite
Lecturas:
- Refactoring.Guru: Factory Method y Composite.
- Eck, subsec. 9.4.3 Expression Trees: un árbol de nodos con una operación común, que es la estructura de Composite.
Lecturas complementarias:
- API de Java 25: los static factory methods
List.of,Integer.valueOfyPath.of.
Temas:
- Static factory method:
Money.of(...),List.of(...). Ventajas frente al constructor: tiene nombre, puede devolver un objeto existente o un subtipo, puede validar antes de crear. - Una factory: la decisión de qué clase concreta crear según un dato, en un solo lugar. Ejemplo:
Notification.of(canal, destinatario, texto)con los avisos de la semana 6. - Factory Method: una superclase con un algoritmo que delega la decisión de qué objeto crear en un método al que cada subclase le hace override. Ejemplo:
LineImporter<T>, que lee líneas de texto (salta comentarios, parte campos, informa el número de línea) y dejacreate(campos)a cada subclase. - Composite: tratar igual a un objeto simple y a un grupo de objetos. Ejemplo propio: una lista de reproducción que contiene canciones y otras listas, con la duración total calculada recorriendo el árbol.
- Composite con tipos sealed:
sealed interface Playable permits Song, Playlistcon records; la duración como un método en cada caso (semana 6) y operaciones nuevas (contar canciones, aplanar el árbol) con unswitchrecursivo (semana 8). - Patrones juntos: un importador (Factory Method) que crea las leaves de un Composite, y un
Comparator(Strategy) que ordena las leaves. - Error común: una “factory” que es solo un
switchgigante copiado en varios lugares; el sentido de la factory es que la decisión esté en un solo lugar.
Laboratorio — Patrones en el proyecto
Se trabaja sobre el propio proyecto; los ejemplos de los apuntes están en code/week-14/ como referencia.
- Identificar en el propio proyecto dónde ya hay una strategy sin nombre (precios, descuentos, orden de reportes) y hacerla explícita con una interfaz y pruebas.
- Implementar un observer para alertas de stock bajo: el inventario notifica y una alerta registra lo recibido; probarlo con un spy (semana 7).
- Implementar paquetes de productos con Composite y probar el precio de un paquete anidado.
- Centralizar en una factory la creación de productos desde texto; dejar listas las pruebas que se usarán al leer archivos en la semana 15.
Entregas
- P4 en curso (Reportes). Entrega en semana 15. Los patrones de esta semana son parte de P5 (Aplicación), que se publica la semana siguiente; adelantarlos en el laboratorio ahorra trabajo.
Preguntas de repaso
- ¿Qué diferencia hay entre Strategy y el polimorfismo de una jerarquía de herencia? ¿Cuándo prefieren uno u otro?
- En Observer, ¿qué sabe el subject de sus observers? ¿Qué gana el diseño con eso?
- Den dos razones para preferir
Money.of(cents, currency)anew Money(cents, currency). - Dibujen el diagrama de clases de un Composite para paquetes de productos. ¿Dónde está la recursión?
- Nombren un caso de su proyecto en que agregar un patrón sería un exceso y expliquen por qué.
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (30)Pruebas (4)Compilación (1)
Ejemplos
Los programas que usan los apuntes y la clase.
/** Por peso: un monto base más un monto por cada kilo empezado. */
public record ByWeight(Money base, Money perKilo) implements ShippingRate {
@Override
public Money cost(Parcel parcel) {
long kilos = (parcel.grams() + 999) / 1000; // 1 g a 1000 g cuenta como 1 kilo
return base.plus(perKilo.times(kilos));
}
}/** Context: calcula el total de una compra con la tarifa que se le dé. */
public class Checkout {
private ShippingRate rate;
public Checkout(ShippingRate rate) {
this.rate = rate;
}
/** La strategy se puede cambiar mientras el programa corre. */
public void setRate(ShippingRate rate) {
this.rate = rate;
}
public Money total(Parcel parcel) {
return parcel.subtotal().plus(rate.cost(parcel));
}
}/** Observer: avisa si una vacuna sale del rango de 2 a 8 °C. */
public class ColdChainAlarm implements TemperatureListener {
private static final double MIN = 2.0;
private static final double MAX = 8.0;
private int alerts = 0;
@Override
public void onReading(Reading reading) {
if (reading.celsius() < MIN || reading.celsius() > MAX) {
alerts++;
IO.println("ALARMA en " + reading.place() + ": " + reading.celsius() + " °C fuera de 2-8 °C");
}
}
public int alerts() {
return alerts;
}
}import java.util.Comparator;
import java.util.List;
public class CompositeDemo {
public static void main(String[] args) {
List<Song> imported = new SongImporter().importAll(List.of(
"Contigo Perú;Arturo Cavero;254",
"El cóndor pasa;Daniel Alomía Robles;198",
"Cariñito;Los Hijos del Sol;187",
"Valicha;Miguel Ángel Hurtado;192"));
Song contigoPeru = imported.get(0);
Song condorPasa = imported.get(1);
Song carinito = imported.get(2);
Song valicha = imported.get(3);
Playlist criollas = Playable.playlist("Criollas", contigoPeru);
Playlist cumbias = Playable.playlist("Cumbias", carinito);
Playlist party = Playable.playlist("Fiesta", criollas, condorPasa, cumbias, valicha);
IO.print(Playables.tree(party));
IO.println("canciones: " + Playables.countSongs(party) + ", duración: " + party.length());
// Una strategy de orden (Comparator) sobre las leaves del árbol.
List<Song> shortestFirst = Playables.songs(party);
shortestFirst.sort(Comparator.comparingInt(Song::seconds));
shortestFirst.forEach(s -> IO.println(" " + s.length() + " " + s.title()));
}
}/** Observer: muestra cada lectura. */
public class Display implements TemperatureListener {
@Override
public void onReading(Reading reading) {
IO.println(String.format("[%s] %.1f °C", reading.place(), reading.celsius()));
}
}public record EmailNotification(String address, String text) implements Notification {
@Override
public String send() {
return "email a " + address + ": " + text;
}
}import java.util.List;
public class FactoryDemo {
public static void main(String[] args) {
// Static factory methods.
Money a = Money.of(0, "pen");
Money b = Money.zero();
IO.println(a + " mismo objeto que zero(): " + (a == b));
IO.println(Money.parse("12.5") + " + " + Money.of(250, "USD"));
// Una factory de avisos y un importador que la usa (Factory Method).
List<String> lines = List.of(
"# canal;destinatario;texto",
"email;alice@uni.pe;Su pedido salió hoy",
"sms;987654321;Su pedido llega mañana entre 9 y 12",
"",
"email;bob@uni.pe;Gracias por su compra");
for (Notification n : new NotificationImporter().importAll(lines)) {
IO.println(n.send());
}
try {
new NotificationImporter().importAll(List.of("fax;014567890;Hola"));
} catch (IllegalArgumentException e) {
IO.println("error: " + e.getMessage());
}
}
}/** Observer que avisa una sola vez y se da de baja él mismo. */
public class FirstAlert implements TemperatureListener {
private final TemperatureSource subject;
private final double limit;
public FirstAlert(TemperatureSource subject, double limit) {
this.subject = subject;
this.limit = limit;
}
@Override
public void onReading(Reading reading) {
if (reading.celsius() > limit) {
IO.println("primer aviso: se pasó de " + limit + " °C; ya no aviso más");
subject.removeListener(this);
}
}
}/** Tarifa plana: el mismo costo para cualquier paquete. */
public record FlatRate(Money amount) implements ShippingRate {
@Override
public Money cost(Parcel parcel) {
return amount;
}
}/** Gratis desde cierto subtotal; por debajo, usa otra tarifa. */
public record FreeAbove(Money threshold, ShippingRate otherwise) implements ShippingRate {
@Override
public Money cost(Parcel parcel) {
if (parcel.subtotal().isLessThan(threshold)) {
return otherwise.cost(parcel);
}
return Money.zero();
}
}import java.util.ArrayList;
import java.util.List;
/**
* Lee líneas con campos separados por ';'. El recorrido es siempre el mismo;
* qué objeto se crea con cada línea lo decide la subclase en create
* (Factory Method).
*/
public abstract class LineImporter<T> {
public final List<T> importAll(List<String> lines) {
List<T> result = new ArrayList<>();
int number = 0;
for (String line : lines) {
number++;
if (line.isBlank() || line.startsWith("#")) {
continue;
}
String[] fields = line.split(";");
if (fields.length != fieldCount()) {
throw new IllegalArgumentException("línea " + number + ": se esperaban "
+ fieldCount() + " campos y hay " + fields.length);
}
result.add(create(fields));
}
return result;
}
/** Cuántos campos tiene cada línea. */
protected abstract int fieldCount();
/** El factory method: crea el objeto de una línea. */
protected abstract T create(String[] fields);
}import java.util.Objects;
/**
* Un monto de dinero en céntimos. No tiene constructor público: se crea con
* los static factory methods of, parse y zero.
*/
public final class Money {
private static final Money ZERO_PEN = new Money(0, "PEN");
private final long cents;
private final String currency;
private Money(long cents, String currency) {
this.cents = cents;
this.currency = currency;
}
/** Valida y normaliza antes de crear; el cero en soles es siempre el mismo objeto. */
public static Money of(long cents, String currency) {
if (cents < 0) {
throw new IllegalArgumentException("monto negativo: " + cents);
}
String code = currency.strip().toUpperCase();
if (code.length() != 3) {
throw new IllegalArgumentException("moneda inválida: " + currency);
}
if (cents == 0 && code.equals("PEN")) {
return ZERO_PEN;
}
return new Money(cents, code);
}
/** Lee un monto en soles escrito como "12.50". */
public static Money parse(String soles) {
String[] parts = soles.strip().split("\\.");
long whole = Long.parseLong(parts[0]);
long fraction = parts.length > 1 ? Long.parseLong((parts[1] + "0").substring(0, 2)) : 0;
return of(whole * 100 + fraction, "PEN");
}
public static Money zero() {
return ZERO_PEN;
}
public long cents() {
return cents;
}
public String currency() {
return currency;
}
public Money plus(Money other) {
requireSameCurrency(other);
return of(cents + other.cents, currency);
}
public Money times(long factor) {
return of(cents * factor, currency);
}
public boolean isLessThan(Money other) {
requireSameCurrency(other);
return cents < other.cents;
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(currency + " y " + other.currency);
}
}
@Override
public boolean equals(Object o) {
return o instanceof Money m && cents == m.cents && currency.equals(m.currency);
}
@Override
public int hashCode() {
return Objects.hash(cents, currency);
}
@Override
public String toString() {
String amount = String.format("%d.%02d", cents / 100, cents % 100);
return currency.equals("PEN") ? "S/ " + amount : amount + " " + currency;
}
}import java.util.ArrayList;
import java.util.List;
/** Versión ingenua, solo para mostrar el error: notifica recorriendo la lista original. */
public class NaiveThermometer implements TemperatureSource {
private final String place;
private final List<TemperatureListener> listeners = new ArrayList<>();
public NaiveThermometer(String place) {
this.place = place;
}
@Override
public void addListener(TemperatureListener listener) {
listeners.add(listener);
}
@Override
public void removeListener(TemperatureListener listener) {
listeners.remove(listener);
}
public void record(double celsius) {
Reading reading = new Reading(place, celsius);
for (TemperatureListener listener : listeners) {
listener.onReading(reading);
}
}
}/** Un aviso a un destinatario. Se crea con el static factory method of. */
public interface Notification {
String send();
/** Factory: la decisión de qué clase concreta crear está en este único lugar. */
static Notification of(String channel, String to, String text) {
return switch (channel) {
case "email" -> new EmailNotification(to, text);
case "sms" -> new SmsNotification(to, text);
default -> throw new IllegalArgumentException("canal desconocido: " + channel);
};
}
}/** Una línea "canal;destinatario;texto" se vuelve el aviso que corresponde. */
public class NotificationImporter extends LineImporter<Notification> {
@Override
protected int fieldCount() {
return 3;
}
@Override
protected Notification create(String[] fields) {
return Notification.of(fields[0].strip(), fields[1].strip(), fields[2].strip());
}
}public class ObserverDemo {
public static void main(String[] args) {
Thermometer fridge = new Thermometer("refri 1");
ColdChainAlarm alarm = new ColdChainAlarm();
fridge.addListener(new Display());
fridge.addListener(alarm);
fridge.addListener(reading -> { // un observer también puede ser una lambda
if (reading.celsius() > 7.5) {
IO.println(" (aviso: cerca del límite)");
}
});
fridge.record(4.5);
fridge.record(7.8);
fridge.record(9.1);
IO.println("alarmas: " + alarm.alerts());
// Darse de baja durante la notificación.
Thermometer safe = new Thermometer("refri 2");
safe.addListener(new FirstAlert(safe, 8.0));
safe.addListener(new Display());
safe.record(8.5);
safe.record(9.0);
NaiveThermometer naive = new NaiveThermometer("refri 3");
naive.addListener(new FirstAlert(naive, 8.0));
naive.addListener(new Display());
naive.addListener(new ColdChainAlarm());
try {
naive.record(8.5);
} catch (RuntimeException e) {
IO.println("ingenuo: " + e);
}
}
}/** Un envío: lo que pesa (en gramos) y cuánto suma lo que lleva adentro. */
public record Parcel(int grams, Money subtotal) {
public Parcel {
if (grams <= 0) {
throw new IllegalArgumentException("peso inválido: " + grams);
}
}
}import java.util.List;
/** Component: algo que se puede reproducir, sea una canción o una lista. */
public sealed interface Playable permits Song, Playlist {
String title();
int seconds();
/** Duración total en minutos y segundos, igual para leaves y composites. */
default String length() {
return String.format("%d:%02d", seconds() / 60, seconds() % 60);
}
static Playlist playlist(String title, Playable... entries) {
return new Playlist(title, List.of(entries));
}
}import java.util.ArrayList;
import java.util.List;
/** Operaciones nuevas sobre el árbol, escritas con un switch sobre el tipo sealed. */
public final class Playables {
private Playables() {
}
public static int countSongs(Playable p) {
return switch (p) {
case Song s -> 1;
case Playlist list -> {
int n = 0;
for (Playable entry : list.entries()) {
n += countSongs(entry);
}
yield n;
}
};
}
/** Todas las canciones del árbol, en orden, sin las listas. */
public static List<Song> songs(Playable p) {
List<Song> result = new ArrayList<>();
collect(p, result);
return result;
}
private static void collect(Playable p, List<Song> into) {
switch (p) {
case Song s -> into.add(s);
case Playlist list -> list.entries().forEach(entry -> collect(entry, into));
}
}
/** El árbol con sangría, una línea por elemento. */
public static String tree(Playable p) {
StringBuilder sb = new StringBuilder();
tree(p, "", sb);
return sb.toString();
}
private static void tree(Playable p, String indent, StringBuilder sb) {
sb.append(indent).append(p.title()).append(" ").append(p.length()).append('\n');
if (p instanceof Playlist list) {
for (Playable entry : list.entries()) {
tree(entry, indent + " ", sb);
}
}
}
}import java.util.List;
/** Composite: una lista que contiene canciones y otras listas. */
public record Playlist(String title, List<Playable> entries) implements Playable {
public Playlist {
entries = List.copyOf(entries);
}
/** La duración se calcula pidiéndosela a cada elemento: aquí está la recursión. */
@Override
public int seconds() {
int total = 0;
for (Playable entry : entries) {
total += entry.seconds();
}
return total;
}
}/** Lo que viaja en cada notificación: dónde se midió y cuántos grados. */
public record Reading(String place, double celsius) {
}import java.util.List;
public class ShippingDemo {
public static void main(String[] args) {
Money soles5 = Money.of(500, "PEN");
List<ShippingRate> rates = List.of(
new FlatRate(Money.of(1000, "PEN")),
new ByWeight(soles5, Money.of(300, "PEN")),
new FreeAbove(Money.of(15000, "PEN"), new ByWeight(soles5, Money.of(300, "PEN"))));
Parcel small = new Parcel(800, Money.of(4590, "PEN"));
Parcel heavy = new Parcel(3200, Money.of(18000, "PEN"));
for (ShippingRate rate : rates) {
IO.println(rate);
IO.println(" pequeño: " + rate.cost(small) + " pesado: " + rate.cost(heavy));
}
// Una strategy simple también puede ser una lambda.
ShippingRate pickup = parcel -> Money.zero();
Checkout checkout = new Checkout(rates.get(1));
IO.println("total por peso: " + checkout.total(heavy));
checkout.setRate(pickup);
IO.println("total en tienda: " + checkout.total(heavy));
// La versión con switch da lo mismo, pero cada tarifa nueva la obliga a cambiar.
IO.println("switch, pesado por peso: " + ShippingSwitch.cost("weight", heavy));
}
}/** Strategy: una forma de calcular el costo de envío de un paquete. */
public interface ShippingRate {
Money cost(Parcel parcel);
}/** El problema: todas las tarifas en un solo método, elegidas con un switch. */
public class ShippingSwitch {
static Money cost(String kind, Parcel parcel) {
return switch (kind) {
case "flat" -> Money.of(1000, "PEN");
case "weight" -> {
long kilos = (parcel.grams() + 999) / 1000;
yield Money.of(500 + 300 * kilos, "PEN");
}
case "free-above" -> parcel.subtotal().isLessThan(Money.of(15000, "PEN"))
? cost("weight", parcel)
: Money.zero();
default -> throw new IllegalArgumentException("tarifa desconocida: " + kind);
};
}
}public record SmsNotification(String phone, String text) implements Notification {
@Override
public String send() {
String shortText = text.length() > 20 ? text.substring(0, 20) + "..." : text;
return "SMS a " + phone + ": " + shortText;
}
}/** Leaf: una canción. Su duración es la suya. */
public record Song(String title, String artist, int seconds) implements Playable {
public Song {
if (seconds <= 0) {
throw new IllegalArgumentException("duración inválida: " + seconds);
}
}
}/** Una línea "título;artista;segundos" se vuelve una canción. */
public class SongImporter extends LineImporter<Song> {
@Override
protected int fieldCount() {
return 3;
}
@Override
protected Song create(String[] fields) {
return new Song(fields[0].strip(), fields[1].strip(), Integer.parseInt(fields[2].strip()));
}
}/** Observer: lo único que el termómetro sabe de quien lo escucha. */
public interface TemperatureListener {
void onReading(Reading reading);
}/** Lo que un observer puede hacer con el subject: suscribirse y darse de baja. */
public interface TemperatureSource {
void addListener(TemperatureListener listener);
void removeListener(TemperatureListener listener);
}import java.util.ArrayList;
import java.util.List;
/** Subject: un termómetro que avisa a sus observers de cada lectura. */
public class Thermometer implements TemperatureSource {
private final String place;
private final List<TemperatureListener> listeners = new ArrayList<>();
public Thermometer(String place) {
this.place = place;
}
@Override
public void addListener(TemperatureListener listener) {
listeners.add(listener);
}
@Override
public void removeListener(TemperatureListener listener) {
listeners.remove(listener);
}
/** Registra una lectura y notifica a todos, aunque alguno falle. */
public void record(double celsius) {
Reading reading = new Reading(place, celsius);
RuntimeException failure = null;
// Se recorre una copia: un observer puede darse de baja mientras se le notifica.
for (TemperatureListener listener : List.copyOf(listeners)) {
try {
listener.onReading(reading);
} catch (RuntimeException e) {
if (failure == null) {
failure = e;
} else {
failure.addSuppressed(e);
}
}
}
if (failure != null) {
throw failure;
}
}
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.List;
import org.junit.jupiter.api.Test;
class FactoryTest {
@Test
void moneyOfValidatesAndNormalizes() {
assertEquals("USD", Money.of(100, " usd ").currency());
assertThrows(IllegalArgumentException.class, () -> Money.of(-1, "PEN"));
assertThrows(IllegalArgumentException.class, () -> Money.of(1, "SOLES"));
}
@Test
void zeroInSolesIsAlwaysTheSameObject() {
assertSame(Money.zero(), Money.of(0, "PEN"));
assertSame(Money.zero(), Money.parse("0.00"));
}
@Test
void parseReadsSoles() {
assertEquals(Money.of(1250, "PEN"), Money.parse("12.5"));
assertEquals(Money.of(1205, "PEN"), Money.parse("12.05"));
assertEquals(Money.of(1200, "PEN"), Money.parse("12"));
}
@Test
void notificationFactoryChoosesTheClass() {
assertInstanceOf(EmailNotification.class, Notification.of("email", "a@b.pe", "hola"));
assertInstanceOf(SmsNotification.class, Notification.of("sms", "999", "hola"));
assertThrows(IllegalArgumentException.class, () -> Notification.of("fax", "1", "hola"));
}
@Test
void importerSkipsCommentsAndBlankLines() {
List<Notification> all = new NotificationImporter().importAll(List.of(
"# comentario", "", "email;alice@uni.pe;Hola", "sms;999;Chau"));
assertEquals(List.of(new EmailNotification("alice@uni.pe", "Hola"), new SmsNotification("999", "Chau")), all);
}
@Test
void importerReportsTheLineNumber() {
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> new SongImporter().importAll(List.of("A;B;100", "solo un campo")));
assertEquals("línea 2: se esperaban 3 campos y hay 1", e.getMessage());
}
@Test
void songImporterCreatesSongs() {
assertEquals(List.of(new Song("Valicha", "Miguel Ángel Hurtado", 192)),
new SongImporter().importAll(List.of("Valicha ; Miguel Ángel Hurtado ; 192")));
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class PlayableTest {
private final Song a = new Song("A", "x", 100);
private final Song b = new Song("B", "y", 200);
private final Song c = new Song("C", "z", 30);
@Test
void songIsALeaf() {
assertEquals(100, a.seconds());
assertEquals(1, Playables.countSongs(a));
assertEquals("1:40", a.length());
}
@Test
void nestedPlaylistAddsEverything() {
Playlist inner = Playable.playlist("dentro", b, c);
Playlist outer = Playable.playlist("fuera", a, inner);
assertEquals(330, outer.seconds());
assertEquals(3, Playables.countSongs(outer));
assertEquals(List.of(a, b, c), Playables.songs(outer));
}
@Test
void emptyPlaylistLastsZero() {
assertEquals(0, Playable.playlist("vacía").seconds());
}
@Test
void playlistCannotBeChangedFromOutside() {
List<Playable> source = new ArrayList<>(List.of(a));
Playlist p = new Playlist("p", source);
source.add(b);
assertEquals(100, p.seconds());
assertThrows(UnsupportedOperationException.class, () -> p.entries().add(c));
}
@Test
void treeShowsIndentation() {
Playlist p = Playable.playlist("fuera", a, Playable.playlist("dentro", c));
assertEquals("fuera 2:10\n A 1:40\n dentro 0:30\n C 0:30\n", Playables.tree(p));
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class ShippingRateTest {
private static final Money FIVE = Money.of(500, "PEN");
private static final Money THREE = Money.of(300, "PEN");
@Test
void flatRateIgnoresTheParcel() {
ShippingRate rate = new FlatRate(Money.of(1000, "PEN"));
assertEquals(Money.of(1000, "PEN"), rate.cost(new Parcel(100, FIVE)));
assertEquals(Money.of(1000, "PEN"), rate.cost(new Parcel(9000, FIVE)));
}
@Test
void byWeightCountsEveryStartedKilo() {
ShippingRate rate = new ByWeight(FIVE, THREE);
assertEquals(Money.of(800, "PEN"), rate.cost(new Parcel(1000, FIVE)));
assertEquals(Money.of(1100, "PEN"), rate.cost(new Parcel(1001, FIVE)));
}
@Test
void freeAboveDelegatesBelowTheThreshold() {
ShippingRate rate = new FreeAbove(Money.of(15000, "PEN"), new FlatRate(FIVE));
assertEquals(FIVE, rate.cost(new Parcel(500, Money.of(14999, "PEN"))));
assertEquals(Money.zero(), rate.cost(new Parcel(500, Money.of(15000, "PEN"))));
}
@Test
void checkoutUsesTheCurrentStrategy() {
Parcel parcel = new Parcel(2500, Money.of(4000, "PEN"));
Checkout checkout = new Checkout(new ByWeight(FIVE, THREE));
assertEquals(Money.of(5400, "PEN"), checkout.total(parcel));
checkout.setRate(p -> Money.zero());
assertEquals(Money.of(4000, "PEN"), checkout.total(parcel));
}
@Test
void strategiesAgreeWithTheOldSwitch() {
Parcel parcel = new Parcel(3200, Money.of(9000, "PEN"));
ShippingRate byWeight = new ByWeight(FIVE, THREE);
assertEquals(ShippingSwitch.cost("weight", parcel), byWeight.cost(parcel));
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class ThermometerTest {
/** Spy: recuerda cada lectura que recibió. */
static class RecordingListener implements TemperatureListener {
final List<Reading> received = new ArrayList<>();
@Override
public void onReading(Reading reading) {
received.add(reading);
}
}
@Test
void everyListenerReceivesEveryReading() {
Thermometer t = new Thermometer("refri");
RecordingListener a = new RecordingListener();
RecordingListener b = new RecordingListener();
t.addListener(a);
t.addListener(b);
t.record(4.0);
t.record(5.5);
assertEquals(List.of(new Reading("refri", 4.0), new Reading("refri", 5.5)), a.received);
assertEquals(a.received, b.received);
}
@Test
void removedListenerHearsNothingMore() {
Thermometer t = new Thermometer("refri");
RecordingListener spy = new RecordingListener();
t.addListener(spy);
t.record(4.0);
t.removeListener(spy);
t.record(9.0);
assertEquals(1, spy.received.size());
}
@Test
void listenerCanUnsubscribeWhileBeingNotified() {
Thermometer t = new Thermometer("refri");
RecordingListener spy = new RecordingListener();
t.addListener(new FirstAlert(t, 8.0));
t.addListener(spy);
t.addListener(new ColdChainAlarm());
t.record(9.0);
t.record(9.5);
assertEquals(2, spy.received.size());
}
@Test
void aFailingListenerDoesNotSilenceTheOthers() {
Thermometer t = new Thermometer("refri");
RuntimeException boom = new IllegalStateException("pantalla rota");
RecordingListener spy = new RecordingListener();
t.addListener(r -> {
throw boom;
});
t.addListener(spy);
RuntimeException thrown = assertThrows(RuntimeException.class, () -> t.record(5.0));
assertSame(boom, thrown);
assertEquals(1, spy.received.size());
}
@Test
void alarmCountsOnlyReadingsOutOfRange() {
Thermometer t = new Thermometer("refri");
ColdChainAlarm alarm = new ColdChainAlarm();
t.addListener(alarm);
for (double c : new double[] {1.5, 2.0, 5.0, 8.0, 8.1}) {
t.record(c);
}
assertEquals(2, alarm.alerts());
assertTrue(alarm.alerts() > 0);
}
}Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 14: patrones de diseño (Strategy, Observer, Factory, Composite).
# make compila todo a build/
# make test corre las demostraciones y las pruebas JUnit
# make clean borra build/
JAVAC = javac
JFLAGS = -Xlint:all -Werror
SRC = $(wildcard *.java)
DEMOS = ShippingDemo ObserverDemo FactoryDemo CompositeDemo
all: build/.stamp
build/.stamp: $(SRC)
mkdir -p build
$(JAVAC) $(JFLAGS) -cp $(JUNIT_JAR) -d build $(SRC)
touch $@
test: all
@for d in $(DEMOS); do echo "== $$d"; java -cp build $$d || exit 1; done
java -jar $(JUNIT_JAR) execute --class-path build --scan-class-path \
--disable-banner --disable-ansi-colors --details=tree
clean:
rm -rf build
.PHONY: all test clean