Semana 13 — Principios de diseño y refactoring

Objetivos

  • Evaluar un diseño por su cohesión (cohesion) y su acoplamiento (coupling).
  • Enunciar los cinco principios SOLID, reconocer cuándo se violan y cuándo aplicarlos sería exagerar.
  • Refactorizar en pasos pequeños con las pruebas como red de seguridad.
  • Reconocer los code smells más comunes y el refactoring que corresponde a cada uno.

Apuntes de la semana: notes/week-13.md · Código: code/week-13

Sesión A — Principios de diseño: cohesión, acoplamiento, SOLID

Lecturas (Eck no trata SOLID):

Lecturas complementarias:

Temas:

  • Cohesión: lo que está en una clase pertenece junto. Acoplamiento: cuánto tiene que saber una clase de otra. Objetivo: alta cohesión, bajo acoplamiento.
  • S, responsabilidad única: una clase tiene una sola razón para cambiar. Ejemplo propio: una clase Invoice que calcula, da formato e imprime, partida en tres (code/week-13/invoice/before y invoice/after).
  • O, abierto/cerrado: agregar comportamiento nuevo con una clase nueva y no editando un switch existente. Relación con el polimorfismo de la semana 6 y con el switch sobre un tipo sealed de la semana 8 (que cierra la otra dirección).
  • L, sustitución de Liskov: repaso de la semana 7 con throws y contratos de la semana 9.
  • I, segregación de interfaces: interfaces pequeñas y específicas; ninguna clase obligada a implementar métodos que no usa.
  • D, inversión de dependencias: las clases de alto nivel dependen de interfaces, no de clases concretas; las dependencias entran por el constructor. Es lo que ya hicimos para usar test doubles en la semana 7. Ejemplo: InvoiceService recibe un Channel y se prueba con un SpyChannel.
  • Error común: aplicar SOLID a ciegas y terminar con diez interfaces de un método para un programa de cien líneas. Un principio se aplica cuando hay un cambio concreto que lo justifica.

Sesión B — Refactoring guiado por pruebas

Lecturas (Eck no trata refactoring):

Temas:

  • Refactoring: cambiar la estructura sin cambiar el comportamiento observable. Las pruebas fijan ese comportamiento; sin esa red de seguridad, refactorizar es de alto riesgo, y antes se agregan characterization tests.
  • Pasos pequeños: un cambio, correr las pruebas, hacer commit. Si algo falla, deshacer el último paso en lugar de depurar un cambio grande.
  • Code smells frecuentes: método largo, clase grande, código duplicado, lista larga de parámetros, feature envy (un método que usa más los datos de otra clase que los suyos), cadenas de if/switch por tipo.
  • Refactorings del catálogo aplicados en vivo sobre un ejemplo propio, las multas de una biblioteca (code/week-13/library/step0 a step5, con las mismas pruebas en cada paso): renombrar, extraer método, mover método a la clase que tiene los datos, extraer clase, reemplazar condicional por polimorfismo.
  • Characterization tests: antes de refactorizar código sin pruebas, escribir pruebas que fijen lo que hace hoy, aunque parezca raro.
  • Error común: mezclar en un mismo paso refactoring y cambio de funcionalidad; si una prueba falla, ya no se sabe cuál de los dos la rompió.

Laboratorio — Refactorizar el proyecto

Código inicial en code/week-13/lab/: GradeBook, un registro de notas que funciona, tiene pruebas y está mal diseñado a propósito. Cópienlo a su repositorio para trabajar con commits.

  1. Listar los code smells del código dado y el principio SOLID que cada uno viola.
  2. Refactorizar en pasos pequeños, corriendo make test después de cada uno y haciendo un commit por paso con un mensaje que nombre el refactoring.
  3. Aplicar inversión de dependencias en una clase que crea sus colaboradores con new y probarla con un test double.
  4. Repetir el ejercicio sobre el propio P4: elegir dos code smells y corregirlos sin cambiar ninguna prueba existente.

Entregas

  • P4 en curso (Reportes). Entrega en semana 15. Esta semana cubre la segunda mitad del enunciado: refactorizar con principios SOLID lo que P1 a P3 dejaron acoplado.

Preguntas de repaso

  1. Den un ejemplo de clase con baja cohesión. ¿Cómo la partirían y qué principio aplican?
  2. ¿Qué relación hay entre el principio de inversión de dependencias y la posibilidad de usar test doubles?
  3. Un switch sobre el tipo de producto aparece en cuatro métodos distintos. ¿Qué code smell es y qué refactoring corresponde? ¿Cambiaría la respuesta si el tipo fuera sealed?
  4. ¿Por qué es arriesgado cambiar la estructura de un código que no tiene pruebas? ¿Qué se hace primero?
  5. Describan una situación en la que aplicar abierto/cerrado sería un exceso.

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (41)Pruebas (7)Laboratorio (4)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

library/LibraryDemo.javaDescargar library/LibraryDemo.java
package library;

import java.time.LocalDate;
import java.util.List;

// El mismo estado de cuenta con el código de antes (step0) y el de después
// (step5): el refactoring cambió la estructura, no lo que el programa hace.
public class LibraryDemo {

    public static void main(String[] args) {
        LocalDate due = LocalDate.parse("2026-10-01");

        var before = new library.step0.LibraryDesk();
        var alice0 = new library.step0.Member("alice", library.step0.MemberType.STUDENT);
        var loans0 = List.of(
                new library.step0.Loan("Clean Code", library.step0.ItemKind.BOOK, due, due.plusDays(5)),
                new library.step0.Loan("Matrix", library.step0.ItemKind.DVD, due, due.plusDays(30)));
        IO.println("== step0");
        IO.print(before.statement(alice0, loans0));

        var after = new library.step5.LibraryDesk();
        var alice5 = new library.step5.Member("alice", library.step5.MemberType.STUDENT);
        var loans5 = List.of(
                new library.step5.Loan("Clean Code", library.step5.ItemKind.BOOK, due, due.plusDays(5)),
                new library.step5.Loan("Matrix", library.step5.ItemKind.DVD, due, due.plusDays(30)));
        IO.println("== step5");
        IO.print(after.statement(alice5, loans5));
    }
}
invoice/after/Channel.javaDescargar invoice/after/Channel.java
package invoice.after;

/** Un canal por donde sale un documento: consola, archivo, correo... */
public interface Channel {
    void send(String recipient, String text);
}
invoice/after/ConsoleChannel.javaDescargar invoice/after/ConsoleChannel.java
package invoice.after;

/** Canal que escribe el documento en la salida estándar. */
public class ConsoleChannel implements Channel {
    @Override
    public void send(String recipient, String text) {
        IO.print("[para " + recipient + "]\n" + text);
    }
}
invoice/after/Invoice.javaDescargar invoice/after/Invoice.java
package invoice.after;

import java.util.ArrayList;
import java.util.List;

/** Una factura: sus líneas y las reglas para calcular el total. Nada de texto. */
public class Invoice {

    /** Una línea: cantidad y precio unitario en céntimos. */
    public record Line(String description, int quantity, int unitPrice) {
        public int amount() {
            return quantity * unitPrice;
        }
    }

    private static final int TAX_PERCENT = 18;   // IGV

    private final String customer;
    private final List<Line> lines = new ArrayList<>();

    public Invoice(String customer) {
        this.customer = customer;
    }

    public void add(String description, int quantity, int unitPrice) {
        lines.add(new Line(description, quantity, unitPrice));
    }

    public String customer() {
        return customer;
    }

    public List<Line> lines() {
        return List.copyOf(lines);
    }

    public int subtotal() {
        int subtotal = 0;
        for (Line line : lines) {
            subtotal += line.amount();
        }
        return subtotal;
    }

    public int tax() {
        return (subtotal() * TAX_PERCENT + 50) / 100;   // redondeo al céntimo
    }

    public int total() {
        return subtotal() + tax();
    }
}
invoice/after/InvoiceDemo.javaDescargar invoice/after/InvoiceDemo.java
package invoice.after;

// Arma las piezas en un solo lugar (main) y emite una factura por consola.
public class InvoiceDemo {

    public static void main(String[] args) {
        Invoice invoice = new Invoice("alice");
        invoice.add("cuaderno", 3, 450);
        invoice.add("lapicero", 2, 150);

        InvoiceService service = new InvoiceService(new InvoiceFormatter(), new ConsoleChannel());
        service.issue(invoice);
    }
}
invoice/after/InvoiceFormatter.javaDescargar invoice/after/InvoiceFormatter.java
package invoice.after;

/** Arma el texto de una factura. Si cambia el diseño, cambia solo esta clase. */
public class InvoiceFormatter {

    public String format(Invoice invoice) {
        StringBuilder text = new StringBuilder("Factura para " + invoice.customer() + "\n");
        for (Invoice.Line line : invoice.lines()) {
            text.append(String.format("  %2d x %-12s %s\n",
                    line.quantity(), line.description(), money(line.amount())));
        }
        text.append("Total con IGV: ").append(money(invoice.total())).append("\n");
        return text.toString();
    }

    private static String money(int cents) {
        return String.format("S/ %d.%02d", cents / 100, cents % 100);
    }
}
invoice/after/InvoiceService.javaDescargar invoice/after/InvoiceService.java
package invoice.after;

/**
 * Emite facturas: les da formato y las envía. Depende de la interfaz Channel,
 * no de un canal concreto; el canal entra por el constructor.
 */
public class InvoiceService {

    private final InvoiceFormatter formatter;
    private final Channel channel;

    public InvoiceService(InvoiceFormatter formatter, Channel channel) {
        this.formatter = formatter;
        this.channel = channel;
    }

    public void issue(Invoice invoice) {
        channel.send(invoice.customer(), formatter.format(invoice));
    }
}
invoice/after/SpyChannel.javaDescargar invoice/after/SpyChannel.java
package invoice.after;

import java.util.ArrayList;
import java.util.List;

/** Test double (un spy): no envía nada, recuerda lo que le pidieron enviar. */
class SpyChannel implements Channel {

    final List<String> recipients = new ArrayList<>();
    final List<String> texts = new ArrayList<>();

    @Override
    public void send(String recipient, String text) {
        recipients.add(recipient);
        texts.add(text);
    }
}
invoice/before/Invoice.javaDescargar invoice/before/Invoice.java
package invoice.before;

import java.util.ArrayList;
import java.util.List;

// Una factura que hace de todo: guarda las líneas, calcula el IGV, arma el
// texto y lo imprime. Tiene tres razones para cambiar (ver la semana 13).
public class Invoice {

    private record Line(String description, int quantity, int unitPrice) {
    }

    private final String customer;
    private final List<Line> lines = new ArrayList<>();

    public Invoice(String customer) {
        this.customer = customer;
    }

    public void add(String description, int quantity, int unitPrice) {
        lines.add(new Line(description, quantity, unitPrice));
    }

    // Razón 1: las reglas del negocio (el IGV es 18 %).
    public int total() {
        int subtotal = 0;
        for (Line line : lines) {
            subtotal += line.quantity() * line.unitPrice();
        }
        return subtotal + (subtotal * 18 + 50) / 100;
    }

    // Razón 2: el diseño del documento.
    public String format() {
        StringBuilder text = new StringBuilder("Factura para " + customer + "\n");
        for (Line line : lines) {
            int amount = line.quantity() * line.unitPrice();
            text.append(String.format("  %2d x %-12s S/ %d.%02d\n",
                    line.quantity(), line.description(), amount / 100, amount % 100));
        }
        int total = total();
        text.append(String.format("Total con IGV: S/ %d.%02d\n", total / 100, total % 100));
        return text.toString();
    }

    // Razón 3: a dónde va la factura.
    public void print() {
        System.out.print(format());
    }
}
library/step0/ItemKind.javaDescargar library/step0/ItemKind.java
package library.step0;

/** Qué se prestó. */
public enum ItemKind {
    BOOK, DVD
}
library/step0/LibraryDesk.javaDescargar library/step0/LibraryDesk.java
package library.step0;

import java.time.temporal.ChronoUnit;
import java.util.List;

// Mostrador de la biblioteca: calcula las multas de un socio y arma su estado
// de cuenta. Funciona y tiene pruebas, pero está mal diseñado a propósito:
// es el punto de partida del refactoring de la semana 13.
public class LibraryDesk {

    public String statement(Member m, List<Loan> l) {
        String s = "Multas de " + m.name() + " (" + m.type() + ")\n";
        int t = 0;
        for (Loan x : l) {
            int d = (int) ChronoUnit.DAYS.between(x.due(), x.returned());
            if (d < 0) {
                d = 0;
            }
            int a = 0;
            switch (m.type()) {
                case STUDENT:
                    if (d > 2) {
                        a = (d - 2) * (x.kind() == ItemKind.DVD ? 100 : 50);
                    }
                    if (a > 1000) {
                        a = 1000;
                    }
                    break;
                case TEACHER:
                    if (x.kind() == ItemKind.DVD) {
                        a = d * 100;
                    }
                    break;
                case VISITOR:
                    a = d * (x.kind() == ItemKind.DVD ? 100 : 50) * 2;
                    break;
            }
            t += a;
            s += String.format("  %-18s %3d días  S/ %d.%02d\n", x.title(), d, a / 100, a % 100);
        }
        s += String.format("Total: S/ %d.%02d\n", t / 100, t % 100);
        return s;
    }

    public int totalFine(Member m, List<Loan> l) {
        int t = 0;
        for (Loan x : l) {
            int d = (int) ChronoUnit.DAYS.between(x.due(), x.returned());
            if (d < 0) {
                d = 0;
            }
            int a = 0;
            switch (m.type()) {
                case STUDENT:
                    if (d > 2) {
                        a = (d - 2) * (x.kind() == ItemKind.DVD ? 100 : 50);
                    }
                    if (a > 1000) {
                        a = 1000;
                    }
                    break;
                case TEACHER:
                    if (x.kind() == ItemKind.DVD) {
                        a = d * 100;
                    }
                    break;
                case VISITOR:
                    a = d * (x.kind() == ItemKind.DVD ? 100 : 50) * 2;
                    break;
            }
            t += a;
        }
        return t;
    }
}
library/step0/Loan.javaDescargar library/step0/Loan.java
package library.step0;

import java.time.LocalDate;

/** Un préstamo ya devuelto: qué se prestó, cuándo vencía y cuándo volvió. */
public record Loan(String title, ItemKind kind, LocalDate due, LocalDate returned) {
}
library/step0/Member.javaDescargar library/step0/Member.java
package library.step0;

/** Un socio: nombre y tipo. */
public record Member(String name, MemberType type) {
}
library/step0/MemberType.javaDescargar library/step0/MemberType.java
package library.step0;

/** Tipo de socio de la biblioteca. */
public enum MemberType {
    STUDENT, TEACHER, VISITOR
}
library/step1/ItemKind.javaDescargar library/step1/ItemKind.java
package library.step1;

/** Qué se prestó. */
public enum ItemKind {
    BOOK, DVD
}
library/step1/LibraryDesk.javaDescargar library/step1/LibraryDesk.java
package library.step1;

import java.time.temporal.ChronoUnit;
import java.util.List;

// Paso 1 · Rename: nombres que dicen qué guardan. Nada más cambió.
public class LibraryDesk {

    public String statement(Member member, List<Loan> loans) {
        String text = "Multas de " + member.name() + " (" + member.type() + ")\n";
        int total = 0;
        for (Loan loan : loans) {
            int daysLate = (int) ChronoUnit.DAYS.between(loan.due(), loan.returned());
            if (daysLate < 0) {
                daysLate = 0;
            }
            int fine = 0;
            switch (member.type()) {
                case STUDENT:
                    if (daysLate > 2) {
                        fine = (daysLate - 2) * (loan.kind() == ItemKind.DVD ? 100 : 50);
                    }
                    if (fine > 1000) {
                        fine = 1000;
                    }
                    break;
                case TEACHER:
                    if (loan.kind() == ItemKind.DVD) {
                        fine = daysLate * 100;
                    }
                    break;
                case VISITOR:
                    fine = daysLate * (loan.kind() == ItemKind.DVD ? 100 : 50) * 2;
                    break;
            }
            total += fine;
            text += String.format("  %-18s %3d días  S/ %d.%02d\n", loan.title(), daysLate, fine / 100, fine % 100);
        }
        text += String.format("Total: S/ %d.%02d\n", total / 100, total % 100);
        return text;
    }

    public int totalFine(Member member, List<Loan> loans) {
        int total = 0;
        for (Loan loan : loans) {
            int daysLate = (int) ChronoUnit.DAYS.between(loan.due(), loan.returned());
            if (daysLate < 0) {
                daysLate = 0;
            }
            int fine = 0;
            switch (member.type()) {
                case STUDENT:
                    if (daysLate > 2) {
                        fine = (daysLate - 2) * (loan.kind() == ItemKind.DVD ? 100 : 50);
                    }
                    if (fine > 1000) {
                        fine = 1000;
                    }
                    break;
                case TEACHER:
                    if (loan.kind() == ItemKind.DVD) {
                        fine = daysLate * 100;
                    }
                    break;
                case VISITOR:
                    fine = daysLate * (loan.kind() == ItemKind.DVD ? 100 : 50) * 2;
                    break;
            }
            total += fine;
        }
        return total;
    }
}
library/step1/Loan.javaDescargar library/step1/Loan.java
package library.step1;

import java.time.LocalDate;

/** Un préstamo ya devuelto: qué se prestó, cuándo vencía y cuándo volvió. */
public record Loan(String title, ItemKind kind, LocalDate due, LocalDate returned) {
}
library/step1/Member.javaDescargar library/step1/Member.java
package library.step1;

/** Un socio: nombre y tipo. */
public record Member(String name, MemberType type) {
}
library/step1/MemberType.javaDescargar library/step1/MemberType.java
package library.step1;

/** Tipo de socio de la biblioteca. */
public enum MemberType {
    STUDENT, TEACHER, VISITOR
}
library/step2/ItemKind.javaDescargar library/step2/ItemKind.java
package library.step2;

/** Qué se prestó. */
public enum ItemKind {
    BOOK, DVD
}
library/step2/LibraryDesk.javaDescargar library/step2/LibraryDesk.java
package library.step2;

import java.time.temporal.ChronoUnit;
import java.util.List;

// Paso 2 · Extract Function: el cálculo de una multa y el formato del dinero
// tienen nombre y viven en un solo lugar. Se acabó el código duplicado.
public class LibraryDesk {

    public String statement(Member member, List<Loan> loans) {
        String text = "Multas de " + member.name() + " (" + member.type() + ")\n";
        for (Loan loan : loans) {
            text += String.format("  %-18s %3d días  %s\n",
                    loan.title(), daysLate(loan), money(fineFor(member, loan)));
        }
        return text + "Total: " + money(totalFine(member, loans)) + "\n";
    }

    public int totalFine(Member member, List<Loan> loans) {
        int total = 0;
        for (Loan loan : loans) {
            total += fineFor(member, loan);
        }
        return total;
    }

    private int fineFor(Member member, Loan loan) {
        int daysLate = daysLate(loan);
        int fine = 0;
        switch (member.type()) {
            case STUDENT:
                if (daysLate > 2) {
                    fine = (daysLate - 2) * (loan.kind() == ItemKind.DVD ? 100 : 50);
                }
                if (fine > 1000) {
                    fine = 1000;
                }
                break;
            case TEACHER:
                if (loan.kind() == ItemKind.DVD) {
                    fine = daysLate * 100;
                }
                break;
            case VISITOR:
                fine = daysLate * (loan.kind() == ItemKind.DVD ? 100 : 50) * 2;
                break;
        }
        return fine;
    }

    private int daysLate(Loan loan) {
        int days = (int) ChronoUnit.DAYS.between(loan.due(), loan.returned());
        return Math.max(days, 0);
    }

    private static String money(int cents) {
        return String.format("S/ %d.%02d", cents / 100, cents % 100);
    }
}
library/step2/Loan.javaDescargar library/step2/Loan.java
package library.step2;

import java.time.LocalDate;

/** Un préstamo ya devuelto: qué se prestó, cuándo vencía y cuándo volvió. */
public record Loan(String title, ItemKind kind, LocalDate due, LocalDate returned) {
}
library/step2/Member.javaDescargar library/step2/Member.java
package library.step2;

/** Un socio: nombre y tipo. */
public record Member(String name, MemberType type) {
}
library/step2/MemberType.javaDescargar library/step2/MemberType.java
package library.step2;

/** Tipo de socio de la biblioteca. */
public enum MemberType {
    STUDENT, TEACHER, VISITOR
}
library/step3/ItemKind.javaDescargar library/step3/ItemKind.java
package library.step3;

/** Qué se prestó, con su multa por día de atraso en céntimos. */
public enum ItemKind {
    BOOK(50), DVD(100);

    private final int dailyRate;

    ItemKind(int dailyRate) {
        this.dailyRate = dailyRate;
    }

    public int dailyRate() {
        return dailyRate;
    }
}
library/step3/LibraryDesk.javaDescargar library/step3/LibraryDesk.java
package library.step3;

import java.util.List;

// Paso 3 · Move Function: cada cálculo se mudó a la clase que tiene los datos.
// Los días de atraso, a Loan; la tarifa diaria, a ItemKind; la multa, a
// MemberType (y Member la ofrece). En LibraryDesk ya no queda feature envy.
public class LibraryDesk {

    public String statement(Member member, List<Loan> loans) {
        String text = "Multas de " + member.name() + " (" + member.type() + ")\n";
        for (Loan loan : loans) {
            text += String.format("  %-18s %3d días  %s\n",
                    loan.title(), loan.daysLate(), money(member.fineFor(loan)));
        }
        return text + "Total: " + money(totalFine(member, loans)) + "\n";
    }

    public int totalFine(Member member, List<Loan> loans) {
        int total = 0;
        for (Loan loan : loans) {
            total += member.fineFor(loan);
        }
        return total;
    }

    private static String money(int cents) {
        return String.format("S/ %d.%02d", cents / 100, cents % 100);
    }
}
library/step3/Loan.javaDescargar library/step3/Loan.java
package library.step3;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

/** Un préstamo ya devuelto: qué se prestó, cuándo vencía y cuándo volvió. */
public record Loan(String title, ItemKind kind, LocalDate due, LocalDate returned) {

    /** Días de atraso; 0 si se devolvió a tiempo o antes. */
    public int daysLate() {
        int days = (int) ChronoUnit.DAYS.between(due, returned);
        return Math.max(days, 0);
    }
}
library/step3/Member.javaDescargar library/step3/Member.java
package library.step3;

/** Un socio: nombre y tipo. */
public record Member(String name, MemberType type) {

    /** Multa de uno de sus préstamos, en céntimos. */
    public int fineFor(Loan loan) {
        return type.fineFor(loan);
    }
}
library/step3/MemberType.javaDescargar library/step3/MemberType.java
package library.step3;

/** Tipo de socio de la biblioteca. */
public enum MemberType {
    STUDENT, TEACHER, VISITOR;

    /** Multa de un préstamo para este tipo de socio, en céntimos. */
    int fineFor(Loan loan) {
        int daysLate = loan.daysLate();
        int rate = loan.kind().dailyRate();
        return switch (this) {
            case STUDENT -> Math.min(Math.max(daysLate - 2, 0) * rate, 1000);
            case TEACHER -> loan.kind() == ItemKind.DVD ? daysLate * rate : 0;
            case VISITOR -> daysLate * rate * 2;
        };
    }
}
library/step4/ItemKind.javaDescargar library/step4/ItemKind.java
package library.step4;

/** Qué se prestó, con su multa por día de atraso en céntimos. */
public enum ItemKind {
    BOOK(50), DVD(100);

    private final int dailyRate;

    ItemKind(int dailyRate) {
        this.dailyRate = dailyRate;
    }

    public int dailyRate() {
        return dailyRate;
    }
}
library/step4/LibraryDesk.javaDescargar library/step4/LibraryDesk.java
package library.step4;

import java.util.List;

// Paso 4 · Extract Class: el formato del estado de cuenta se fue a
// StatementFormatter. LibraryDesk solo coordina: pide multas y las junta.
public class LibraryDesk {

    private final StatementFormatter formatter = new StatementFormatter();

    public String statement(Member member, List<Loan> loans) {
        StringBuilder text = new StringBuilder(formatter.header(member));
        for (Loan loan : loans) {
            text.append(formatter.line(loan, member.fineFor(loan)));
        }
        text.append(formatter.footer(totalFine(member, loans)));
        return text.toString();
    }

    public int totalFine(Member member, List<Loan> loans) {
        int total = 0;
        for (Loan loan : loans) {
            total += member.fineFor(loan);
        }
        return total;
    }
}
library/step4/Loan.javaDescargar library/step4/Loan.java
package library.step4;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

/** Un préstamo ya devuelto: qué se prestó, cuándo vencía y cuándo volvió. */
public record Loan(String title, ItemKind kind, LocalDate due, LocalDate returned) {

    /** Días de atraso; 0 si se devolvió a tiempo o antes. */
    public int daysLate() {
        int days = (int) ChronoUnit.DAYS.between(due, returned);
        return Math.max(days, 0);
    }
}
library/step4/Member.javaDescargar library/step4/Member.java
package library.step4;

/** Un socio: nombre y tipo. */
public record Member(String name, MemberType type) {

    /** Multa de uno de sus préstamos, en céntimos. */
    public int fineFor(Loan loan) {
        return type.fineFor(loan);
    }
}
library/step4/MemberType.javaDescargar library/step4/MemberType.java
package library.step4;

/** Tipo de socio de la biblioteca. */
public enum MemberType {
    STUDENT, TEACHER, VISITOR;

    /** Multa de un préstamo para este tipo de socio, en céntimos. */
    int fineFor(Loan loan) {
        int daysLate = loan.daysLate();
        int rate = loan.kind().dailyRate();
        return switch (this) {
            case STUDENT -> Math.min(Math.max(daysLate - 2, 0) * rate, 1000);
            case TEACHER -> loan.kind() == ItemKind.DVD ? daysLate * rate : 0;
            case VISITOR -> daysLate * rate * 2;
        };
    }
}
library/step4/StatementFormatter.javaDescargar library/step4/StatementFormatter.java
package library.step4;

/** Da formato al estado de cuenta: solo sabe de texto, no de multas. */
class StatementFormatter {

    String header(Member member) {
        return "Multas de " + member.name() + " (" + member.type() + ")\n";
    }

    String line(Loan loan, int fine) {
        return String.format("  %-18s %3d días  %s\n", loan.title(), loan.daysLate(), money(fine));
    }

    String footer(int total) {
        return "Total: " + money(total) + "\n";
    }

    static String money(int cents) {
        return String.format("S/ %d.%02d", cents / 100, cents % 100);
    }
}
library/step5/ItemKind.javaDescargar library/step5/ItemKind.java
package library.step5;

/** Qué se prestó, con su multa por día de atraso en céntimos. */
public enum ItemKind {
    BOOK(50), DVD(100);

    private final int dailyRate;

    ItemKind(int dailyRate) {
        this.dailyRate = dailyRate;
    }

    public int dailyRate() {
        return dailyRate;
    }
}
library/step5/LibraryDesk.javaDescargar library/step5/LibraryDesk.java
package library.step5;

import java.util.List;

// Paso 5 (final): igual que el paso 4; lo que cambió está en MemberType.
public class LibraryDesk {

    private final StatementFormatter formatter = new StatementFormatter();

    public String statement(Member member, List<Loan> loans) {
        StringBuilder text = new StringBuilder(formatter.header(member));
        for (Loan loan : loans) {
            text.append(formatter.line(loan, member.fineFor(loan)));
        }
        text.append(formatter.footer(totalFine(member, loans)));
        return text.toString();
    }

    public int totalFine(Member member, List<Loan> loans) {
        int total = 0;
        for (Loan loan : loans) {
            total += member.fineFor(loan);
        }
        return total;
    }
}
library/step5/Loan.javaDescargar library/step5/Loan.java
package library.step5;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

/** Un préstamo ya devuelto: qué se prestó, cuándo vencía y cuándo volvió. */
public record Loan(String title, ItemKind kind, LocalDate due, LocalDate returned) {

    /** Días de atraso; 0 si se devolvió a tiempo o antes. */
    public int daysLate() {
        int days = (int) ChronoUnit.DAYS.between(due, returned);
        return Math.max(days, 0);
    }
}
library/step5/Member.javaDescargar library/step5/Member.java
package library.step5;

/** Un socio: nombre y tipo. */
public record Member(String name, MemberType type) {

    /** Multa de uno de sus préstamos, en céntimos. */
    public int fineFor(Loan loan) {
        return type.fineFor(loan);
    }
}
library/step5/MemberType.javaDescargar library/step5/MemberType.java
package library.step5;

/**
 * Tipo de socio de la biblioteca. Paso 5 · Replace Conditional with
 * Polymorphism: cada constante hace override de fineFor con su propia regla,
 * así que ya no hay switch. Un tipo de socio nuevo es una constante nueva.
 */
public enum MemberType {
    STUDENT {
        @Override
        int fineFor(Loan loan) {
            int charged = Math.max(loan.daysLate() - GRACE_DAYS, 0);
            return Math.min(charged * loan.kind().dailyRate(), CAP);
        }
    },
    TEACHER {
        @Override
        int fineFor(Loan loan) {
            return loan.kind() == ItemKind.DVD ? loan.daysLate() * loan.kind().dailyRate() : 0;
        }
    },
    VISITOR {
        @Override
        int fineFor(Loan loan) {
            return loan.daysLate() * loan.kind().dailyRate() * 2;
        }
    };

    private static final int GRACE_DAYS = 2;
    private static final int CAP = 1000;     // tope por préstamo, en céntimos

    /** Multa de un préstamo para este tipo de socio, en céntimos. */
    abstract int fineFor(Loan loan);
}
library/step5/StatementFormatter.javaDescargar library/step5/StatementFormatter.java
package library.step5;

/** Da formato al estado de cuenta: solo sabe de texto, no de multas. */
class StatementFormatter {

    String header(Member member) {
        return "Multas de " + member.name() + " (" + member.type() + ")\n";
    }

    String line(Loan loan, int fine) {
        return String.format("  %-18s %3d días  %s\n", loan.title(), loan.daysLate(), money(fine));
    }

    String footer(int total) {
        return "Total: " + money(total) + "\n";
    }

    static String money(int cents) {
        return String.format("S/ %d.%02d", cents / 100, cents % 100);
    }
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

invoice/after/InvoiceServiceTest.javaDescargar invoice/after/InvoiceServiceTest.java
package invoice.after;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;

import org.junit.jupiter.api.Test;

class InvoiceServiceTest {

    private static Invoice sample() {
        Invoice invoice = new Invoice("alice");
        invoice.add("cuaderno", 3, 450);
        invoice.add("lapicero", 2, 150);
        return invoice;
    }

    @Test
    void totalIncludesTax() {
        Invoice invoice = sample();
        assertEquals(1650, invoice.subtotal());
        assertEquals(297, invoice.tax());
        assertEquals(1947, invoice.total());
    }

    @Test
    void formatterBuildsTheText() {
        String expected = """
                Factura para alice
                   3 x cuaderno     S/ 13.50
                   2 x lapicero     S/ 3.00
                Total con IGV: S/ 19.47
                """;
        assertEquals(expected, new InvoiceFormatter().format(sample()));
    }

    @Test
    void issueSendsOneDocumentToTheCustomer() {
        SpyChannel spy = new SpyChannel();
        InvoiceService service = new InvoiceService(new InvoiceFormatter(), spy);

        service.issue(sample());

        assertEquals(List.of("alice"), spy.recipients);
        assertEquals(new InvoiceFormatter().format(sample()), spy.texts.get(0));
    }
}
library/step0/LibraryDeskTest.javaDescargar library/step0/LibraryDeskTest.java
package library.step0;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.LocalDate;
import java.util.List;

import org.junit.jupiter.api.Test;

// Characterization tests: fijan lo que LibraryDesk hace HOY, antes de
// refactorizar. Los valores esperados se copiaron de la salida real del
// programa, aunque algo parezca raro. Son los mismos en todos los pasos.
class LibraryDeskTest {

    private final LibraryDesk desk = new LibraryDesk();

    private static Loan loan(String title, ItemKind kind, String due, String returned) {
        return new Loan(title, kind, LocalDate.parse(due), LocalDate.parse(returned));
    }

    private static final List<Loan> ALICE_LOANS = List.of(
            loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-06"),
            loan("Matrix", ItemKind.DVD, "2026-10-03", "2026-10-04"),
            loan("SICP", ItemKind.BOOK, "2026-10-10", "2026-10-08"));

    @Test
    void studentStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        String expected = """
                Multas de alice (STUDENT)
                  Clean Code           5 días  S/ 1.50
                  Matrix               1 días  S/ 0.00
                  SICP                 0 días  S/ 0.00
                Total: S/ 1.50
                """;
        assertEquals(expected, desk.statement(alice, ALICE_LOANS));
    }

    @Test
    void totalFineMatchesStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        assertEquals(150, desk.totalFine(alice, ALICE_LOANS));
    }

    @Test
    void studentFineIsCappedPerLoan() {
        Member alice = new Member("alice", MemberType.STUDENT);
        List<Loan> loans = List.of(
                loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"),
                loan("Clean Code", ItemKind.BOOK, "2026-09-01", "2026-09-05"));
        // 28 días cobrables de DVD serían S/ 28.00, pero el tope es S/ 10.00 por préstamo.
        assertEquals(1000 + 100, desk.totalFine(alice, loans));
    }

    @Test
    void teacherPaysOnlyForDvds() {
        Member carol = new Member("carol", MemberType.TEACHER);
        List<Loan> loans = List.of(
                loan("SICP", ItemKind.BOOK, "2026-10-01", "2026-10-11"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-04"));
        assertEquals(300, desk.totalFine(carol, loans));
    }

    @Test
    void visitorPaysDoubleWithoutGrace() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(
                loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-05"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-02"));
        assertEquals(400 + 200, desk.totalFine(bob, loans));
    }

    @Test
    void visitorStatementWithoutCap() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"));
        String expected = """
                Multas de bob (VISITOR)
                  Matrix              30 días  S/ 60.00
                Total: S/ 60.00
                """;
        assertEquals(expected, desk.statement(bob, loans));
    }

    @Test
    void emptyStatement() {
        Member bob = new Member("bob", MemberType.VISITOR);
        assertEquals("Multas de bob (VISITOR)\nTotal: S/ 0.00\n", desk.statement(bob, List.of()));
    }
}
library/step1/LibraryDeskTest.javaDescargar library/step1/LibraryDeskTest.java
package library.step1;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.LocalDate;
import java.util.List;

import org.junit.jupiter.api.Test;

// Characterization tests: fijan lo que LibraryDesk hace HOY, antes de
// refactorizar. Los valores esperados se copiaron de la salida real del
// programa, aunque algo parezca raro. Son los mismos en todos los pasos.
class LibraryDeskTest {

    private final LibraryDesk desk = new LibraryDesk();

    private static Loan loan(String title, ItemKind kind, String due, String returned) {
        return new Loan(title, kind, LocalDate.parse(due), LocalDate.parse(returned));
    }

    private static final List<Loan> ALICE_LOANS = List.of(
            loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-06"),
            loan("Matrix", ItemKind.DVD, "2026-10-03", "2026-10-04"),
            loan("SICP", ItemKind.BOOK, "2026-10-10", "2026-10-08"));

    @Test
    void studentStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        String expected = """
                Multas de alice (STUDENT)
                  Clean Code           5 días  S/ 1.50
                  Matrix               1 días  S/ 0.00
                  SICP                 0 días  S/ 0.00
                Total: S/ 1.50
                """;
        assertEquals(expected, desk.statement(alice, ALICE_LOANS));
    }

    @Test
    void totalFineMatchesStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        assertEquals(150, desk.totalFine(alice, ALICE_LOANS));
    }

    @Test
    void studentFineIsCappedPerLoan() {
        Member alice = new Member("alice", MemberType.STUDENT);
        List<Loan> loans = List.of(
                loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"),
                loan("Clean Code", ItemKind.BOOK, "2026-09-01", "2026-09-05"));
        // 28 días cobrables de DVD serían S/ 28.00, pero el tope es S/ 10.00 por préstamo.
        assertEquals(1000 + 100, desk.totalFine(alice, loans));
    }

    @Test
    void teacherPaysOnlyForDvds() {
        Member carol = new Member("carol", MemberType.TEACHER);
        List<Loan> loans = List.of(
                loan("SICP", ItemKind.BOOK, "2026-10-01", "2026-10-11"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-04"));
        assertEquals(300, desk.totalFine(carol, loans));
    }

    @Test
    void visitorPaysDoubleWithoutGrace() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(
                loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-05"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-02"));
        assertEquals(400 + 200, desk.totalFine(bob, loans));
    }

    @Test
    void visitorStatementWithoutCap() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"));
        String expected = """
                Multas de bob (VISITOR)
                  Matrix              30 días  S/ 60.00
                Total: S/ 60.00
                """;
        assertEquals(expected, desk.statement(bob, loans));
    }

    @Test
    void emptyStatement() {
        Member bob = new Member("bob", MemberType.VISITOR);
        assertEquals("Multas de bob (VISITOR)\nTotal: S/ 0.00\n", desk.statement(bob, List.of()));
    }
}
library/step2/LibraryDeskTest.javaDescargar library/step2/LibraryDeskTest.java
package library.step2;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.LocalDate;
import java.util.List;

import org.junit.jupiter.api.Test;

// Characterization tests: fijan lo que LibraryDesk hace HOY, antes de
// refactorizar. Los valores esperados se copiaron de la salida real del
// programa, aunque algo parezca raro. Son los mismos en todos los pasos.
class LibraryDeskTest {

    private final LibraryDesk desk = new LibraryDesk();

    private static Loan loan(String title, ItemKind kind, String due, String returned) {
        return new Loan(title, kind, LocalDate.parse(due), LocalDate.parse(returned));
    }

    private static final List<Loan> ALICE_LOANS = List.of(
            loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-06"),
            loan("Matrix", ItemKind.DVD, "2026-10-03", "2026-10-04"),
            loan("SICP", ItemKind.BOOK, "2026-10-10", "2026-10-08"));

    @Test
    void studentStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        String expected = """
                Multas de alice (STUDENT)
                  Clean Code           5 días  S/ 1.50
                  Matrix               1 días  S/ 0.00
                  SICP                 0 días  S/ 0.00
                Total: S/ 1.50
                """;
        assertEquals(expected, desk.statement(alice, ALICE_LOANS));
    }

    @Test
    void totalFineMatchesStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        assertEquals(150, desk.totalFine(alice, ALICE_LOANS));
    }

    @Test
    void studentFineIsCappedPerLoan() {
        Member alice = new Member("alice", MemberType.STUDENT);
        List<Loan> loans = List.of(
                loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"),
                loan("Clean Code", ItemKind.BOOK, "2026-09-01", "2026-09-05"));
        // 28 días cobrables de DVD serían S/ 28.00, pero el tope es S/ 10.00 por préstamo.
        assertEquals(1000 + 100, desk.totalFine(alice, loans));
    }

    @Test
    void teacherPaysOnlyForDvds() {
        Member carol = new Member("carol", MemberType.TEACHER);
        List<Loan> loans = List.of(
                loan("SICP", ItemKind.BOOK, "2026-10-01", "2026-10-11"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-04"));
        assertEquals(300, desk.totalFine(carol, loans));
    }

    @Test
    void visitorPaysDoubleWithoutGrace() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(
                loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-05"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-02"));
        assertEquals(400 + 200, desk.totalFine(bob, loans));
    }

    @Test
    void visitorStatementWithoutCap() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"));
        String expected = """
                Multas de bob (VISITOR)
                  Matrix              30 días  S/ 60.00
                Total: S/ 60.00
                """;
        assertEquals(expected, desk.statement(bob, loans));
    }

    @Test
    void emptyStatement() {
        Member bob = new Member("bob", MemberType.VISITOR);
        assertEquals("Multas de bob (VISITOR)\nTotal: S/ 0.00\n", desk.statement(bob, List.of()));
    }
}
library/step3/LibraryDeskTest.javaDescargar library/step3/LibraryDeskTest.java
package library.step3;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.LocalDate;
import java.util.List;

import org.junit.jupiter.api.Test;

// Characterization tests: fijan lo que LibraryDesk hace HOY, antes de
// refactorizar. Los valores esperados se copiaron de la salida real del
// programa, aunque algo parezca raro. Son los mismos en todos los pasos.
class LibraryDeskTest {

    private final LibraryDesk desk = new LibraryDesk();

    private static Loan loan(String title, ItemKind kind, String due, String returned) {
        return new Loan(title, kind, LocalDate.parse(due), LocalDate.parse(returned));
    }

    private static final List<Loan> ALICE_LOANS = List.of(
            loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-06"),
            loan("Matrix", ItemKind.DVD, "2026-10-03", "2026-10-04"),
            loan("SICP", ItemKind.BOOK, "2026-10-10", "2026-10-08"));

    @Test
    void studentStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        String expected = """
                Multas de alice (STUDENT)
                  Clean Code           5 días  S/ 1.50
                  Matrix               1 días  S/ 0.00
                  SICP                 0 días  S/ 0.00
                Total: S/ 1.50
                """;
        assertEquals(expected, desk.statement(alice, ALICE_LOANS));
    }

    @Test
    void totalFineMatchesStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        assertEquals(150, desk.totalFine(alice, ALICE_LOANS));
    }

    @Test
    void studentFineIsCappedPerLoan() {
        Member alice = new Member("alice", MemberType.STUDENT);
        List<Loan> loans = List.of(
                loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"),
                loan("Clean Code", ItemKind.BOOK, "2026-09-01", "2026-09-05"));
        // 28 días cobrables de DVD serían S/ 28.00, pero el tope es S/ 10.00 por préstamo.
        assertEquals(1000 + 100, desk.totalFine(alice, loans));
    }

    @Test
    void teacherPaysOnlyForDvds() {
        Member carol = new Member("carol", MemberType.TEACHER);
        List<Loan> loans = List.of(
                loan("SICP", ItemKind.BOOK, "2026-10-01", "2026-10-11"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-04"));
        assertEquals(300, desk.totalFine(carol, loans));
    }

    @Test
    void visitorPaysDoubleWithoutGrace() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(
                loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-05"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-02"));
        assertEquals(400 + 200, desk.totalFine(bob, loans));
    }

    @Test
    void visitorStatementWithoutCap() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"));
        String expected = """
                Multas de bob (VISITOR)
                  Matrix              30 días  S/ 60.00
                Total: S/ 60.00
                """;
        assertEquals(expected, desk.statement(bob, loans));
    }

    @Test
    void emptyStatement() {
        Member bob = new Member("bob", MemberType.VISITOR);
        assertEquals("Multas de bob (VISITOR)\nTotal: S/ 0.00\n", desk.statement(bob, List.of()));
    }
}
library/step4/LibraryDeskTest.javaDescargar library/step4/LibraryDeskTest.java
package library.step4;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.LocalDate;
import java.util.List;

import org.junit.jupiter.api.Test;

// Characterization tests: fijan lo que LibraryDesk hace HOY, antes de
// refactorizar. Los valores esperados se copiaron de la salida real del
// programa, aunque algo parezca raro. Son los mismos en todos los pasos.
class LibraryDeskTest {

    private final LibraryDesk desk = new LibraryDesk();

    private static Loan loan(String title, ItemKind kind, String due, String returned) {
        return new Loan(title, kind, LocalDate.parse(due), LocalDate.parse(returned));
    }

    private static final List<Loan> ALICE_LOANS = List.of(
            loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-06"),
            loan("Matrix", ItemKind.DVD, "2026-10-03", "2026-10-04"),
            loan("SICP", ItemKind.BOOK, "2026-10-10", "2026-10-08"));

    @Test
    void studentStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        String expected = """
                Multas de alice (STUDENT)
                  Clean Code           5 días  S/ 1.50
                  Matrix               1 días  S/ 0.00
                  SICP                 0 días  S/ 0.00
                Total: S/ 1.50
                """;
        assertEquals(expected, desk.statement(alice, ALICE_LOANS));
    }

    @Test
    void totalFineMatchesStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        assertEquals(150, desk.totalFine(alice, ALICE_LOANS));
    }

    @Test
    void studentFineIsCappedPerLoan() {
        Member alice = new Member("alice", MemberType.STUDENT);
        List<Loan> loans = List.of(
                loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"),
                loan("Clean Code", ItemKind.BOOK, "2026-09-01", "2026-09-05"));
        // 28 días cobrables de DVD serían S/ 28.00, pero el tope es S/ 10.00 por préstamo.
        assertEquals(1000 + 100, desk.totalFine(alice, loans));
    }

    @Test
    void teacherPaysOnlyForDvds() {
        Member carol = new Member("carol", MemberType.TEACHER);
        List<Loan> loans = List.of(
                loan("SICP", ItemKind.BOOK, "2026-10-01", "2026-10-11"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-04"));
        assertEquals(300, desk.totalFine(carol, loans));
    }

    @Test
    void visitorPaysDoubleWithoutGrace() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(
                loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-05"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-02"));
        assertEquals(400 + 200, desk.totalFine(bob, loans));
    }

    @Test
    void visitorStatementWithoutCap() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"));
        String expected = """
                Multas de bob (VISITOR)
                  Matrix              30 días  S/ 60.00
                Total: S/ 60.00
                """;
        assertEquals(expected, desk.statement(bob, loans));
    }

    @Test
    void emptyStatement() {
        Member bob = new Member("bob", MemberType.VISITOR);
        assertEquals("Multas de bob (VISITOR)\nTotal: S/ 0.00\n", desk.statement(bob, List.of()));
    }
}
library/step5/LibraryDeskTest.javaDescargar library/step5/LibraryDeskTest.java
package library.step5;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.LocalDate;
import java.util.List;

import org.junit.jupiter.api.Test;

// Characterization tests: fijan lo que LibraryDesk hace HOY, antes de
// refactorizar. Los valores esperados se copiaron de la salida real del
// programa, aunque algo parezca raro. Son los mismos en todos los pasos.
class LibraryDeskTest {

    private final LibraryDesk desk = new LibraryDesk();

    private static Loan loan(String title, ItemKind kind, String due, String returned) {
        return new Loan(title, kind, LocalDate.parse(due), LocalDate.parse(returned));
    }

    private static final List<Loan> ALICE_LOANS = List.of(
            loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-06"),
            loan("Matrix", ItemKind.DVD, "2026-10-03", "2026-10-04"),
            loan("SICP", ItemKind.BOOK, "2026-10-10", "2026-10-08"));

    @Test
    void studentStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        String expected = """
                Multas de alice (STUDENT)
                  Clean Code           5 días  S/ 1.50
                  Matrix               1 días  S/ 0.00
                  SICP                 0 días  S/ 0.00
                Total: S/ 1.50
                """;
        assertEquals(expected, desk.statement(alice, ALICE_LOANS));
    }

    @Test
    void totalFineMatchesStatement() {
        Member alice = new Member("alice", MemberType.STUDENT);
        assertEquals(150, desk.totalFine(alice, ALICE_LOANS));
    }

    @Test
    void studentFineIsCappedPerLoan() {
        Member alice = new Member("alice", MemberType.STUDENT);
        List<Loan> loans = List.of(
                loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"),
                loan("Clean Code", ItemKind.BOOK, "2026-09-01", "2026-09-05"));
        // 28 días cobrables de DVD serían S/ 28.00, pero el tope es S/ 10.00 por préstamo.
        assertEquals(1000 + 100, desk.totalFine(alice, loans));
    }

    @Test
    void teacherPaysOnlyForDvds() {
        Member carol = new Member("carol", MemberType.TEACHER);
        List<Loan> loans = List.of(
                loan("SICP", ItemKind.BOOK, "2026-10-01", "2026-10-11"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-04"));
        assertEquals(300, desk.totalFine(carol, loans));
    }

    @Test
    void visitorPaysDoubleWithoutGrace() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(
                loan("Clean Code", ItemKind.BOOK, "2026-10-01", "2026-10-05"),
                loan("Matrix", ItemKind.DVD, "2026-10-01", "2026-10-02"));
        assertEquals(400 + 200, desk.totalFine(bob, loans));
    }

    @Test
    void visitorStatementWithoutCap() {
        Member bob = new Member("bob", MemberType.VISITOR);
        List<Loan> loans = List.of(loan("Matrix", ItemKind.DVD, "2026-09-01", "2026-10-01"));
        String expected = """
                Multas de bob (VISITOR)
                  Matrix              30 días  S/ 60.00
                Total: S/ 60.00
                """;
        assertEquals(expected, desk.statement(bob, loans));
    }

    @Test
    void emptyStatement() {
        Member bob = new Member("bob", MemberType.VISITOR);
        assertEquals("Multas de bob (VISITOR)\nTotal: S/ 0.00\n", desk.statement(bob, List.of()));
    }
}

Laboratorio

Archivos de partida del laboratorio de la semana.

lab/BulletinBoard.javaDescargar lab/BulletinBoard.java
package lab;

// El "periódico mural" del curso: por ahora, la consola.
public class BulletinBoard {

    public void post(String text) {
        IO.println("==================== MURAL ====================");
        IO.print(text);
        IO.println("===============================================");
    }
}
lab/GradeBook.javaDescargar lab/GradeBook.java
package lab;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

// Registro de notas de un curso. Funciona y tiene pruebas, pero está mal
// diseñado a propósito: es el punto de partida del laboratorio de la semana
// 13. No lo arreglen aquí; cópienlo a su repositorio y refactoricen allá.
//
// Cada fila de datos: código;nombre;sistema;nota1;nota2;nota3
//   sistema A: promedio simple de las tres notas
//   sistema B: la tercera nota (examen final) pesa doble
//   sistema C: la mejor de las dos primeras, promediada con la tercera
public class GradeBook {

    public List<String[]> rows = new ArrayList<>();

    public void load(String text) {
        for (String line : text.split("\n")) {
            if (!line.isBlank()) {
                rows.add(line.split(";"));
            }
        }
    }

    public String report() {
        String r = "";
        int ok = 0;
        int ko = 0;
        int sum = 0;
        for (String[] x : rows) {
            int a = Integer.parseInt(x[3]);
            int b = Integer.parseInt(x[4]);
            int c = Integer.parseInt(x[5]);
            double p;
            if (x[2].equals("A")) {
                p = (a + b + c) / 3.0;
            } else if (x[2].equals("B")) {
                p = (a + b + 2 * c) / 4.0;
            } else if (x[2].equals("C")) {
                p = (Math.max(a, b) + c) / 2.0;
            } else {
                p = 0;
            }
            int f = (int) Math.round(p);
            String st;
            if (f >= 11) {
                st = "aprobado";
                ok++;
            } else {
                st = "desaprobado";
                ko++;
            }
            r += x[0] + " " + x[1] + ": " + f + " (" + st + ")\n";
            sum += f;
        }
        int tenths = (int) Math.round(sum * 10.0 / rows.size());
        r += "aprobados: " + ok + ", desaprobados: " + ko + "\n";
        r += "promedio del curso: " + tenths / 10 + "." + tenths % 10 + "\n";
        return r;
    }

    public int finalGrade(String system, int a, int b, int c) {
        double p;
        if (system.equals("A")) {
            p = (a + b + c) / 3.0;
        } else if (system.equals("B")) {
            p = (a + b + 2 * c) / 4.0;
        } else if (system.equals("C")) {
            p = (Math.max(a, b) + c) / 2.0;
        } else {
            p = 0;
        }
        return (int) Math.round(p);
    }

    public void publish() {
        BulletinBoard board = new BulletinBoard();
        board.post("Notas al " + LocalDate.now() + "\n" + report());
    }
}
lab/GradeBookDemo.javaDescargar lab/GradeBookDemo.java
package lab;

public class GradeBookDemo {

    public static void main(String[] args) {
        GradeBook book = new GradeBook();
        book.load("""
                20261001;alice;A;12;14;10
                20261002;bob;B;8;10;11
                20261003;carol;C;6;15;9
                """);
        IO.print(book.report());
    }
}
lab/GradeBookTest.javaDescargar lab/GradeBookTest.java
package lab;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

// Pruebas del registro de notas tal como funciona hoy. Al refactorizar, no
// las cambien: si alguna falla, el último paso cambió el comportamiento.
class GradeBookTest {

    private static final String DATA = """
            20261001;alice;A;12;14;10
            20261002;bob;B;8;10;11
            20261003;carol;C;6;15;9
            """;

    @Test
    void finalGradeBySystem() {
        GradeBook book = new GradeBook();
        assertEquals(12, book.finalGrade("A", 12, 14, 10));
        assertEquals(10, book.finalGrade("B", 8, 10, 11));
        assertEquals(12, book.finalGrade("C", 6, 15, 9));
    }

    @Test
    void halfRoundsUp() {
        assertEquals(11, new GradeBook().finalGrade("C", 10, 9, 11));
    }

    @Test
    void report() {
        GradeBook book = new GradeBook();
        book.load(DATA);
        String expected = """
                20261001 alice: 12 (aprobado)
                20261002 bob: 10 (desaprobado)
                20261003 carol: 12 (aprobado)
                aprobados: 2, desaprobados: 1
                promedio del curso: 11.3
                """;
        assertEquals(expected, book.report());
    }
}

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 13: principios de diseño y refactoring guiado por pruebas.
#   make         compila todo a build/
#   make test    comprueba que los pasos del refactoring tienen las mismas
#                pruebas, corre las demostraciones y todas las pruebas JUnit
#   make clean   borra build/
JAVAC  = javac
JFLAGS = -Xlint:all -Werror
SRC    = $(shell find invoice library lab -name '*.java')
DEMOS  = invoice.after.InvoiceDemo library.LibraryDemo lab.GradeBookDemo
STEPS  = step1 step2 step3 step4 step5

all: build/.stamp

build/.stamp: $(SRC)
	mkdir -p build
	$(JAVAC) $(JFLAGS) -cp $(JUNIT_JAR) -d build $(SRC)
	touch $@

# Las pruebas de cada paso deben ser las de step0, salvo la línea del paquete.
same-tests: all
	@sed 1d library/step0/LibraryDeskTest.java > build/step0-tests.txt
	@for s in $(STEPS); do \
	  sed 1d library/$$s/LibraryDeskTest.java | cmp -s - build/step0-tests.txt \
	    || { echo "library/$$s: sus pruebas no son las de step0"; exit 1; }; \
	done
	@echo "== las pruebas de step1..step5 son idénticas a las de step0"

JUNIT = java -jar $(JUNIT_JAR) execute --class-path build --disable-banner --disable-ansi-colors

# Cada paso del refactoring se prueba por separado, con un resumen de una línea.
test: same-tests
	@for d in $(DEMOS); do echo "== $$d"; java -cp build $$d || exit 1; done
	@for s in step0 $(STEPS); do \
	  out=$$($(JUNIT) --select-package library.$$s --details=summary) || { echo "$$out"; exit 1; }; \
	  echo "== pruebas de library.$$s: $$(echo "$$out" | grep -o '[0-9]* tests successful')"; \
	done
	$(JUNIT) --select-package invoice --select-package lab --details=tree

clean:
	rm -rf build

.PHONY: all same-tests test clean