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: Comparator como strategy de orden, y PropertyChangeSupport como 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 switch que 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. Observable y Observer de java.util está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:

Lecturas complementarias:

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 deja create(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, Playlist con records; la duración como un método en cada caso (semana 6) y operaciones nuevas (contar canciones, aplanar el árbol) con un switch recursivo (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 switch gigante 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.

  1. 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.
  2. Implementar un observer para alertas de stock bajo: el inventario notifica y una alerta registra lo recibido; probarlo con un spy (semana 7).
  3. Implementar paquetes de productos con Composite y probar el precio de un paquete anidado.
  4. 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

  1. ¿Qué diferencia hay entre Strategy y el polimorfismo de una jerarquía de herencia? ¿Cuándo prefieren uno u otro?
  2. En Observer, ¿qué sabe el subject de sus observers? ¿Qué gana el diseño con eso?
  3. Den dos razones para preferir Money.of(cents, currency) a new Money(cents, currency).
  4. Dibujen el diagrama de clases de un Composite para paquetes de productos. ¿Dónde está la recursión?
  5. 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.

ByWeight.javaDescargar ByWeight.java
/** 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));
    }
}
Checkout.javaDescargar Checkout.java
/** 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));
    }
}
ColdChainAlarm.javaDescargar ColdChainAlarm.java
/** 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;
    }
}
CompositeDemo.javaDescargar CompositeDemo.java
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()));
    }
}
Display.javaDescargar Display.java
/** 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()));
    }
}
EmailNotification.javaDescargar EmailNotification.java
public record EmailNotification(String address, String text) implements Notification {
    @Override
    public String send() {
        return "email a " + address + ": " + text;
    }
}
FactoryDemo.javaDescargar FactoryDemo.java
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());
        }
    }
}
FirstAlert.javaDescargar FirstAlert.java
/** 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);
        }
    }
}
FlatRate.javaDescargar FlatRate.java
/** Tarifa plana: el mismo costo para cualquier paquete. */
public record FlatRate(Money amount) implements ShippingRate {
    @Override
    public Money cost(Parcel parcel) {
        return amount;
    }
}
FreeAbove.javaDescargar FreeAbove.java
/** 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();
    }
}
LineImporter.javaDescargar LineImporter.java
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);
}
Money.javaDescargar Money.java
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;
    }
}
NaiveThermometer.javaDescargar NaiveThermometer.java
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);
        }
    }
}
Notification.javaDescargar Notification.java
/** 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);
        };
    }
}
NotificationImporter.javaDescargar NotificationImporter.java
/** 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());
    }
}
ObserverDemo.javaDescargar ObserverDemo.java
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);
        }
    }
}
Parcel.javaDescargar Parcel.java
/** 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);
        }
    }
}
Playable.javaDescargar Playable.java
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));
    }
}
Playables.javaDescargar Playables.java
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);
            }
        }
    }
}
Playlist.javaDescargar Playlist.java
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;
    }
}
Reading.javaDescargar Reading.java
/** Lo que viaja en cada notificación: dónde se midió y cuántos grados. */
public record Reading(String place, double celsius) {
}
ShippingDemo.javaDescargar ShippingDemo.java
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));
    }
}
ShippingRate.javaDescargar ShippingRate.java
/** Strategy: una forma de calcular el costo de envío de un paquete. */
public interface ShippingRate {
    Money cost(Parcel parcel);
}
ShippingSwitch.javaDescargar ShippingSwitch.java
/** 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);
        };
    }
}
SmsNotification.javaDescargar SmsNotification.java
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;
    }
}
Song.javaDescargar Song.java
/** 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);
        }
    }
}
SongImporter.javaDescargar SongImporter.java
/** 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()));
    }
}
TemperatureListener.javaDescargar TemperatureListener.java
/** Observer: lo único que el termómetro sabe de quien lo escucha. */
public interface TemperatureListener {
    void onReading(Reading reading);
}
TemperatureSource.javaDescargar TemperatureSource.java
/** 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);
}
Thermometer.javaDescargar Thermometer.java
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.

FactoryTest.javaDescargar FactoryTest.java
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")));
    }
}
PlayableTest.javaDescargar PlayableTest.java
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));
    }
}
ShippingRateTest.javaDescargar ShippingRateTest.java
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));
    }
}
ThermometerTest.javaDescargar ThermometerTest.java
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.

MakefileDescargar Makefile
# 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