Módulo 21: Proyecto final
Objetivos del módulo
- Desarrollar de principio a fin una aplicación de consola completa: gestión de una biblioteca
- Pasar de un enunciado a una lista de requisitos y de ahí a un diagrama de clases UML
- Organizar el código en paquetes por capas: modelo, excepciones, servicio, persistencia e interfaz de consola
- Aplicar en un mismo proyecto herencia, clases selladas, interfaces, enumeraciones, records y pattern matching
- Usar colecciones (
ListyMap) y excepciones propias con una jerarquía coherente - Guardar y cargar los datos en ficheros CSV
- Construir un menú de consola robusto que valide toda la entrada del usuario
- Probar la lógica con JUnit y gestionar el proyecto con Maven
- Revisar la calidad del resultado con una lista de comprobación profesional
1. Enunciado y requisitos
1.1. Enunciado
La biblioteca municipal quiere sustituir sus fichas de papel por un pequeño programa. En el módulo 18 ya diseñaste un modelo de biblioteca en UML; ahora vas a construir la aplicación real, simplificando algunos aspectos (no distinguimos ejemplares) y añadiendo lo que necesita un programa de verdad: guardar los datos, validar la entrada y tener pruebas.
La biblioteca presta libros (código, título, año, autor y número de páginas) y revistas (código, título, año y número). Los libros se prestan 15 días y las revistas 7. Los socios se identifican por su DNI, tienen un nombre y una modalidad: estándar (hasta 2 préstamos a la vez) o premium (hasta 5). Un recurso solo puede estar prestado a un socio a la vez. Al devolver con retraso se cobra una multa de 0,50 € por día. El programa debe permitir dar de alta recursos y socios, buscar por título, prestar, devolver y consultar los préstamos activos y los vencidos. Los datos deben conservarse entre ejecuciones.
1.2. Requisitos
| Id | Requisito funcional |
|---|---|
| RF1 | Listar el catálogo indicando qué recursos están prestados |
| RF2 | Buscar recursos por una parte del título, sin distinguir mayúsculas |
| RF3 | Dar de alta libros y revistas con código único |
| RF4 | Dar de alta socios con DNI válido (formato y letra) y único |
| RF5 | Prestar un recurso disponible a un socio que no haya alcanzado su límite |
| RF6 | Devolver un recurso y calcular la multa por retraso |
| RF7 | Listar los préstamos activos marcando los vencidos y su multa |
| RF8 | Guardar los datos al salir y cargarlos al arrancar |
| Id | Requisito no funcional |
|---|---|
| RNF1 | Aplicación de consola en Java 25, proyecto Maven |
| RNF2 | Persistencia en ficheros CSV legibles (UTF-8, separador ;) |
| RNF3 | Ninguna entrada incorrecta del usuario debe cerrar el programa |
| RNF4 | La lógica de negocio debe estar cubierta por pruebas JUnit |
| RNF5 | Código organizado en paquetes por responsabilidad, documentado y con nombres significativos |
💡 Consejo: Numerar los requisitos te permite comprobar al final que todos están implementados y probados (sección 9). Es lo mismo que hace un equipo profesional con una matriz de trazabilidad.
2. Análisis: del enunciado a las clases
Una técnica clásica para descubrir las clases es subrayar en el enunciado los sustantivos (candidatos a clases o atributos) y los verbos (candidatos a métodos):
| En el enunciado | Decisión de diseño |
|---|---|
| libros, revistas | Clases Libro y Revista con una superclase común Recurso (generalización) |
| código, título, año | Atributos comunes en Recurso |
| «se prestan 15 días / 7 días» | Cada subclase responde a diasPrestamo(); lo define la interfaz Prestable |
| «solo libros y revistas» | Recurso es una clase sellada (sealed ... permits Libro, Revista) |
| socios, DNI, nombre | Socio como record: es un dato inmutable con validación |
| estándar, premium, «hasta 2 / 5» | Enumeración TipoSocio con el atributo maxPrestamos |
| préstamo, fechas, multa | Clase Prestamo (mutable: se marca la devolución) |
| prestar, devolver, buscar | Métodos de un servicio Biblioteca, que guarda las colecciones |
| «conservarse entre ejecuciones» | Clase RepositorioCsv en la capa de persistencia |
| «no puede prestarse», «límite alcanzado» | Excepciones propias del negocio |
Figura 1. Diagrama de clases del proyecto (capas de modelo y servicio).
Dos decisiones importantes:
Bibliotecaguarda las colecciones en mapas (Map<String, Recurso>por código yMap<String, Socio>por DNI) para buscar en tiempo constante, y en unaList<Prestamo>para los préstamos, que no tienen clave natural. Se usaLinkedHashMappara que el catálogo se liste en el orden en que se dio de alta.Prestamoguarda el código del recurso y el DNI del socio, no referencias a los objetos. Así cada fila del CSV es independiente y, al cargar,Bibliotecacomprueba que los códigos existen. Es la misma idea que una clave ajena en una base de datos.
3. Diseño: paquetes y capas
Figura 2. Arquitectura en capas. Las flechas discontinuas son dependencias: la interfaz usa el servicio, el servicio usa el modelo.
| Paquete | Responsabilidad | Clases |
|---|---|---|
modelo | Las entidades del dominio y sus reglas internas (validaciones, cálculo de la multa) | Prestable, Recurso, Libro, Revista, TipoSocio, Socio, Prestamo |
excepciones | Errores de negocio, todos subclases de BibliotecaException | 6 clases |
servicio | Casos de uso: prestar, devolver, buscar… Coordina el modelo | Biblioteca |
persistencia | Leer y escribir los ficheros CSV | RepositorioCsv |
ui | Hablar con el usuario: menú, lectura validada, mensajes | MenuConsola, Consola |
| (raíz) | Arrancar la aplicación y conectar las piezas | App |
📘 Concepto: Separar la aplicación en capas permite cambiar una sin tocar las demás. Si mañana la biblioteca quiere una interfaz web o guardar en una base de datos, solo cambian
uiopersistencia: el modelo y el servicio, que contienen las reglas de negocio y están cubiertos por las pruebas, siguen igual. Por eso ni el modelo ni el servicio contienen un soloSystem.out.printlnni unScanner.
4. Estructura del proyecto Maven
Figura 3. Estructura del proyecto. Las pruebas reproducen la estructura de paquetes de src/main/java.
El pom.xml añade a lo visto en los módulos 19 y 20 la configuración de maven-jar-plugin para indicar la clase principal, de modo que el .jar sea ejecutable con java -jar:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>es.curso</groupId>
<artifactId>biblioteca</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.16.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
<plugin>
<!-- Indica la clase principal para poder ejecutar: java -jar target/biblioteca-1.0.jar -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<archive>
<manifest>
<mainClass>es.curso.biblioteca.App</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
5. El modelo
5.1. Prestable, Recurso, Libro y Revista
La interfaz Prestable define el contrato «se presta durante N días» y un método por defecto que calcula la fecha límite:
package es.curso.biblioteca.modelo;
import java.time.LocalDate;
/** Algo que se puede prestar durante un número de días. */
public interface Prestable {
/**
* Días que dura el préstamo.
*
* @return número de días de préstamo
*/
int diasPrestamo();
/**
* Calcula la fecha en la que hay que devolver el elemento.
*
* @param desde fecha del préstamo
* @return {@code desde} más {@link #diasPrestamo()} días
*/
default LocalDate fechaLimite(LocalDate desde) {
return desde.plusDays(diasPrestamo());
}
}
Recurso es abstracta y sellada: no se pueden crear recursos «genéricos» y solo Libro y Revista pueden heredar de ella. Implementa Prestable (sin definir diasPrestamo(), que queda para las subclases) y Comparable para ordenar por código:
package es.curso.biblioteca.modelo;
/** Elemento del catálogo de la biblioteca. Solo puede ser un libro o una revista. */
public abstract sealed class Recurso implements Prestable, Comparable<Recurso> permits Libro, Revista {
private final String codigo;
private final String titulo;
private final int anio;
protected Recurso(String codigo, String titulo, int anio) {
this.codigo = Validacion.texto(codigo, "código").toUpperCase();
this.titulo = Validacion.texto(titulo, "título");
this.anio = Validacion.rango(anio, 1450, 2100, "año");
}
public String getCodigo() { return codigo; }
public String getTitulo() { return titulo; }
public int getAnio() { return anio; }
/** Nombre del tipo de recurso, en mayúsculas ("LIBRO", "REVISTA"). */
public abstract String tipo();
/** Datos propios de cada tipo de recurso, para mostrarlos al usuario. */
public abstract String detalle();
@Override
public boolean equals(Object o) {
return o instanceof Recurso r && codigo.equals(r.codigo);
}
@Override
public int hashCode() {
return codigo.hashCode();
}
@Override
public int compareTo(Recurso otro) {
return codigo.compareTo(otro.codigo);
}
@Override
public String toString() {
return String.format("%-5s %-8s %-28s %4d %s", codigo, tipo(), titulo, anio, detalle());
}
}
package es.curso.biblioteca.modelo;
public final class Libro extends Recurso {
public static final int DIAS_PRESTAMO = 15;
private final String autor;
private final int paginas;
public Libro(String codigo, String titulo, int anio, String autor, int paginas) {
super(codigo, titulo, anio);
this.autor = Validacion.texto(autor, "autor");
this.paginas = Validacion.rango(paginas, 1, 10_000, "páginas");
}
public String getAutor() { return autor; }
public int getPaginas() { return paginas; }
@Override public int diasPrestamo() { return DIAS_PRESTAMO; }
@Override public String tipo() { return "LIBRO"; }
@Override public String detalle() { return autor + ", " + paginas + " págs."; }
}
Revista es análoga, con el atributo numero y DIAS_PRESTAMO = 7. Las validaciones comunes están en una clase de utilidad con visibilidad de paquete (sin public), porque solo la usa el modelo:
package es.curso.biblioteca.modelo;
/** Comprobaciones comunes de los datos del modelo (visibilidad de paquete). */
final class Validacion {
private Validacion() { }
/** Exige un texto no vacío y sin ';' (el separador de los ficheros CSV). */
static String texto(String valor, String campo) {
if (valor == null || valor.isBlank()) {
throw new IllegalArgumentException("El campo " + campo + " es obligatorio");
}
if (valor.contains(";")) {
throw new IllegalArgumentException("El campo " + campo + " no puede contener ';'");
}
return valor.strip();
}
static int rango(int valor, int min, int max, String campo) {
if (valor < min || valor > max) {
throw new IllegalArgumentException("El campo " + campo + " debe estar entre " + min + " y " + max);
}
return valor;
}
}
⚠️ Importante: Los textos no pueden contener
;porque es el separador de los CSV. Validarlo en el constructor garantiza que nunca existirá un objeto que luego no se pueda guardar: es mejor rechazar el dato al principio que corromper el fichero al final.
5.2. TipoSocio y Socio
package es.curso.biblioteca.modelo;
/** Modalidades de socio y número máximo de préstamos simultáneos de cada una. */
public enum TipoSocio {
ESTANDAR(2),
PREMIUM(5);
private final int maxPrestamos;
TipoSocio(int maxPrestamos) {
this.maxPrestamos = maxPrestamos;
}
public int getMaxPrestamos() {
return maxPrestamos;
}
}
Socio es un record con constructor compacto: valida y normaliza los datos (el DNI se pasa a mayúsculas) antes de asignar los componentes. Reutiliza el algoritmo de la letra del DNI que desarrollaste con TDD en el módulo 19:
package es.curso.biblioteca.modelo;
/** Socio de la biblioteca. Es inmutable: un record con validación en el constructor compacto. */
public record Socio(String dni, String nombre, TipoSocio tipo) {
private static final String LETRAS_DNI = "TRWAGMYFPDXBNJZSQVHLCKE";
public Socio {
dni = Validacion.texto(dni, "DNI").toUpperCase();
if (!esDniValido(dni)) {
throw new IllegalArgumentException("DNI no válido: " + dni);
}
nombre = Validacion.texto(nombre, "nombre");
if (tipo == null) {
throw new IllegalArgumentException("El tipo de socio es obligatorio");
}
}
/** Comprueba el formato (8 cifras y letra) y la letra de control del DNI. */
public static boolean esDniValido(String dni) {
if (dni == null || !dni.matches("\\d{8}[A-Z]")) {
return false;
}
int numero = Integer.parseInt(dni.substring(0, 8));
return dni.charAt(8) == LETRAS_DNI.charAt(numero % LETRAS_DNI.length());
}
@Override
public String toString() {
return String.format("%-10s %-22s %s", dni, nombre, tipo);
}
}
5.3. Prestamo
Es la única clase del modelo con estado mutable: se crea activo y se marca como devuelto.
package es.curso.biblioteca.modelo;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
/** Préstamo de un recurso a un socio. Guarda los códigos, no los objetos, para facilitar el CSV. */
public class Prestamo {
public static final double MULTA_POR_DIA = 0.50;
private final String codigoRecurso;
private final String dniSocio;
private final LocalDate fechaPrestamo;
private final LocalDate fechaLimite;
private LocalDate fechaDevolucion; // null mientras el préstamo está activo
public Prestamo(String codigoRecurso, String dniSocio, LocalDate fechaPrestamo,
LocalDate fechaLimite, LocalDate fechaDevolucion) {
if (fechaLimite.isBefore(fechaPrestamo)) {
throw new IllegalArgumentException("La fecha límite es anterior al préstamo");
}
this.codigoRecurso = codigoRecurso;
this.dniSocio = dniSocio;
this.fechaPrestamo = fechaPrestamo;
this.fechaLimite = fechaLimite;
this.fechaDevolucion = fechaDevolucion;
}
public boolean estaActivo() {
return fechaDevolucion == null;
}
public boolean estaVencido(LocalDate hoy) {
return estaActivo() && hoy.isAfter(fechaLimite);
}
/** Días de retraso hasta hoy (si sigue activo) o hasta la devolución. Nunca negativo. */
public long diasRetraso(LocalDate hoy) {
LocalDate fin = estaActivo() ? hoy : fechaDevolucion;
return Math.max(0, ChronoUnit.DAYS.between(fechaLimite, fin));
}
public double multa(LocalDate hoy) {
return diasRetraso(hoy) * MULTA_POR_DIA;
}
public void registrarDevolucion(LocalDate fecha) {
if (!estaActivo()) {
throw new IllegalStateException("El préstamo ya estaba devuelto");
}
if (fecha.isBefore(fechaPrestamo)) {
throw new IllegalArgumentException("La devolución no puede ser anterior al préstamo");
}
fechaDevolucion = fecha;
}
public String getCodigoRecurso() { return codigoRecurso; }
public String getDniSocio() { return dniSocio; }
public LocalDate getFechaPrestamo() { return fechaPrestamo; }
public LocalDate getFechaLimite() { return fechaLimite; }
public LocalDate getFechaDevolucion() { return fechaDevolucion; }
@Override
public String toString() {
return String.format("%-5s -> %s del %s al %s%s", codigoRecurso, dniSocio, fechaPrestamo, fechaLimite,
estaActivo() ? "" : " (devuelto el " + fechaDevolucion + ")");
}
}
💡 Consejo:
diasRetrasoymultareciben la fechahoycomo parámetro en lugar de llamar aLocalDate.now(). Así las pruebas son repetibles (la F-I-R-S-T del módulo 19): un test que pase hoy seguirá pasando dentro de un año.
6. Excepciones propias
Todas heredan de BibliotecaException, que es comprobada (extends Exception): el compilador obliga a quien llama a prestar() a tratar los errores de negocio, que no son fallos de programación sino situaciones previstas.
package es.curso.biblioteca.excepciones;
/** Excepción base (comprobada) de todos los errores de negocio de la biblioteca. */
public class BibliotecaException extends Exception {
public BibliotecaException(String mensaje) {
super(mensaje);
}
public BibliotecaException(String mensaje, Throwable causa) {
super(mensaje, causa);
}
}
package es.curso.biblioteca.excepciones;
public class RecursoNoDisponibleException extends BibliotecaException {
public RecursoNoDisponibleException(String mensaje) {
super(mensaje);
}
}
| Excepción | Cuándo se lanza |
|---|---|
ElementoNoEncontradoException | Código de recurso o DNI que no existen; devolver algo que no está prestado |
ElementoDuplicadoException | Alta con un código o DNI ya registrados |
RecursoNoDisponibleException | Prestar un recurso que ya está prestado |
LimitePrestamosException | El socio ya tiene el máximo de préstamos de su modalidad |
PersistenciaException | Error al leer o escribir los CSV (guarda la causa original) |
Los datos mal formados (un año fuera de rango, un DNI con la letra incorrecta) lanzan IllegalArgumentException, la excepción estándar de Java para argumentos no válidos. La interfaz captura ambas familias.
7. El servicio: la lógica de negocio
Biblioteca concentra las reglas del enunciado. Fíjate en que prestar comprueba todas las condiciones antes de modificar nada: si algo falla, el estado queda intacto.
Figura 4. Secuencia de la opción «Prestar»: cuatro comprobaciones, cada una con su excepción, antes de crear el préstamo.
package es.curso.biblioteca.servicio;
import es.curso.biblioteca.excepciones.BibliotecaException;
import es.curso.biblioteca.excepciones.ElementoDuplicadoException;
import es.curso.biblioteca.excepciones.ElementoNoEncontradoException;
import es.curso.biblioteca.excepciones.LimitePrestamosException;
import es.curso.biblioteca.excepciones.RecursoNoDisponibleException;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.modelo.Recurso;
import es.curso.biblioteca.modelo.Socio;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Lógica de negocio: catálogo, socios y préstamos. No sabe nada de consola ni de ficheros. */
public class Biblioteca {
private final Map<String, Recurso> catalogo = new LinkedHashMap<>();
private final Map<String, Socio> socios = new LinkedHashMap<>();
private final List<Prestamo> prestamos = new ArrayList<>();
// ---------- Catálogo ----------
public void anadirRecurso(Recurso recurso) throws ElementoDuplicadoException {
if (catalogo.containsKey(recurso.getCodigo())) {
throw new ElementoDuplicadoException("Ya existe un recurso con el código " + recurso.getCodigo());
}
catalogo.put(recurso.getCodigo(), recurso);
}
public Recurso buscarRecurso(String codigo) throws ElementoNoEncontradoException {
Recurso r = catalogo.get(codigo.strip().toUpperCase());
if (r == null) {
throw new ElementoNoEncontradoException("No existe el recurso " + codigo);
}
return r;
}
public List<Recurso> buscarPorTitulo(String texto) {
String buscado = texto.strip().toLowerCase();
List<Recurso> resultado = new ArrayList<>();
for (Recurso r : catalogo.values()) {
if (r.getTitulo().toLowerCase().contains(buscado)) {
resultado.add(r);
}
}
resultado.sort(Comparator.comparing(Recurso::getTitulo));
return resultado;
}
public boolean estaDisponible(String codigo) {
return prestamoActivoDe(codigo) == null;
}
// ---------- Socios ----------
public void anadirSocio(Socio socio) throws ElementoDuplicadoException {
if (socios.containsKey(socio.dni())) {
throw new ElementoDuplicadoException("Ya existe un socio con el DNI " + socio.dni());
}
socios.put(socio.dni(), socio);
}
public Socio buscarSocio(String dni) throws ElementoNoEncontradoException {
Socio s = socios.get(dni.strip().toUpperCase());
if (s == null) {
throw new ElementoNoEncontradoException("No existe el socio con DNI " + dni);
}
return s;
}
// ---------- Préstamos ----------
public Prestamo prestar(String codigo, String dni, LocalDate hoy) throws BibliotecaException {
Recurso recurso = buscarRecurso(codigo);
Socio socio = buscarSocio(dni);
if (!estaDisponible(recurso.getCodigo())) {
throw new RecursoNoDisponibleException("\"" + recurso.getTitulo() + "\" ya está prestado");
}
int activos = prestamosActivosDe(socio.dni()).size();
if (activos >= socio.tipo().getMaxPrestamos()) {
throw new LimitePrestamosException(socio.nombre() + " ya tiene " + activos
+ " préstamos (máximo " + socio.tipo().getMaxPrestamos() + ")");
}
Prestamo p = new Prestamo(recurso.getCodigo(), socio.dni(), hoy, recurso.fechaLimite(hoy), null);
prestamos.add(p);
return p;
}
/** Registra la devolución y devuelve la multa por retraso (0 si se devuelve a tiempo). */
public double devolver(String codigo, LocalDate hoy) throws ElementoNoEncontradoException {
Prestamo p = prestamoActivoDe(codigo.strip().toUpperCase());
if (p == null) {
throw new ElementoNoEncontradoException("El recurso " + codigo + " no está prestado");
}
p.registrarDevolucion(hoy);
return p.multa(hoy);
}
/** Añade un préstamo leído de fichero, comprobando que recurso y socio existen. */
public void registrarPrestamo(Prestamo p) throws BibliotecaException {
buscarRecurso(p.getCodigoRecurso());
buscarSocio(p.getDniSocio());
if (p.estaActivo() && !estaDisponible(p.getCodigoRecurso())) {
throw new RecursoNoDisponibleException("Dos préstamos activos del recurso " + p.getCodigoRecurso());
}
prestamos.add(p);
}
public List<Prestamo> prestamosActivos() {
List<Prestamo> activos = new ArrayList<>();
for (Prestamo p : prestamos) {
if (p.estaActivo()) activos.add(p);
}
return activos;
}
public List<Prestamo> prestamosVencidos(LocalDate hoy) {
List<Prestamo> vencidos = new ArrayList<>();
for (Prestamo p : prestamos) {
if (p.estaVencido(hoy)) vencidos.add(p);
}
return vencidos;
}
public List<Prestamo> prestamosActivosDe(String dni) {
List<Prestamo> resultado = new ArrayList<>();
for (Prestamo p : prestamos) {
if (p.estaActivo() && p.getDniSocio().equals(dni)) resultado.add(p);
}
return resultado;
}
private Prestamo prestamoActivoDe(String codigo) {
for (Prestamo p : prestamos) {
if (p.estaActivo() && p.getCodigoRecurso().equals(codigo)) return p;
}
return null;
}
// ---------- Consultas para la interfaz y la persistencia ----------
public List<Recurso> getRecursos() { return List.copyOf(catalogo.values()); }
public List<Socio> getSocios() { return List.copyOf(socios.values()); }
public List<Prestamo> getPrestamos() { return List.copyOf(prestamos); }
}
💡 Consejo: Los métodos
getRecursos(),getSocios()ygetPrestamos()devuelven copias inmutables (List.copyOf). Si devolvieran las colecciones internas, cualquier clase podría añadir un préstamo saltándose las reglas deprestar(). Es la encapsulación del módulo 11 aplicada a las colecciones.
8. Persistencia en CSV
Cada colección se guarda en su propio fichero con una línea de cabecera. Estos son los ficheros de datos con los que arranca la sesión de la sección 10:
--- datos/recursos.csv
tipo;codigo;titulo;anio;autor_o_numero;paginas
LIBRO;L001;El Quijote;1605;Miguel de Cervantes;1376
LIBRO;L002;Cien años de soledad;1967;Gabriel García Márquez;471
LIBRO;L003;El nombre del viento;2007;Patrick Rothfuss;880
REVISTA;R001;Muy Interesante;2026;540;
REVISTA;R002;National Geographic;2026;312;
--- datos/socios.csv
dni;nombre;tipo
12345678Z;Marta López;PREMIUM
87654321X;Jorge Sanz;ESTANDAR
--- datos/prestamos.csv
codigo;dni;fecha_prestamo;fecha_limite;fecha_devolucion
L003;12345678Z;2026-09-01;2026-09-16;2026-09-14
L001;12345678Z;2026-09-28;2026-10-13;
R001;87654321X;2026-09-15;2026-09-22;
Un campo vacío al final de un préstamo significa que sigue activo. El repositorio usa java.nio.file (módulo 17), un switch con pattern matching sobre la jerarquía sellada para escribir cada tipo de recurso y convierte cualquier error de lectura en una PersistenciaException que indica qué fichero y qué fila fallan:
package es.curso.biblioteca.persistencia;
import es.curso.biblioteca.excepciones.BibliotecaException;
import es.curso.biblioteca.excepciones.PersistenciaException;
import es.curso.biblioteca.modelo.Libro;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.modelo.Recurso;
import es.curso.biblioteca.modelo.Revista;
import es.curso.biblioteca.modelo.Socio;
import es.curso.biblioteca.modelo.TipoSocio;
import es.curso.biblioteca.servicio.Biblioteca;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/** Guarda y carga la biblioteca en tres ficheros CSV separados por ';'. */
public class RepositorioCsv {
private static final String SEP = ";";
private static final String RECURSOS = "recursos.csv";
private static final String SOCIOS = "socios.csv";
private static final String PRESTAMOS = "prestamos.csv";
private final Path carpeta;
public RepositorioCsv(Path carpeta) {
this.carpeta = carpeta;
}
// ---------- Guardar ----------
public void guardar(Biblioteca biblioteca) throws PersistenciaException {
List<String> recursos = new ArrayList<>(List.of("tipo;codigo;titulo;anio;autor_o_numero;paginas"));
for (Recurso r : biblioteca.getRecursos()) {
recursos.add(switch (r) { // pattern matching (Java 21)
case Libro l -> String.join(SEP, "LIBRO", l.getCodigo(), l.getTitulo(),
String.valueOf(l.getAnio()), l.getAutor(), String.valueOf(l.getPaginas()));
case Revista v -> String.join(SEP, "REVISTA", v.getCodigo(), v.getTitulo(),
String.valueOf(v.getAnio()), String.valueOf(v.getNumero()), "");
});
}
List<String> socios = new ArrayList<>(List.of("dni;nombre;tipo"));
for (Socio s : biblioteca.getSocios()) {
socios.add(String.join(SEP, s.dni(), s.nombre(), s.tipo().name()));
}
List<String> prestamos = new ArrayList<>(List.of("codigo;dni;fecha_prestamo;fecha_limite;fecha_devolucion"));
for (Prestamo p : biblioteca.getPrestamos()) {
prestamos.add(String.join(SEP, p.getCodigoRecurso(), p.getDniSocio(), p.getFechaPrestamo().toString(),
p.getFechaLimite().toString(), p.estaActivo() ? "" : p.getFechaDevolucion().toString()));
}
try {
Files.createDirectories(carpeta);
Files.write(carpeta.resolve(RECURSOS), recursos, StandardCharsets.UTF_8);
Files.write(carpeta.resolve(SOCIOS), socios, StandardCharsets.UTF_8);
Files.write(carpeta.resolve(PRESTAMOS), prestamos, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new PersistenciaException("No se pudo guardar en " + carpeta + ": " + e.getMessage(), e);
}
}
// ---------- Cargar ----------
/** Carga los datos; si la carpeta no existe devuelve una biblioteca vacía. */
public Biblioteca cargar() throws PersistenciaException {
Biblioteca biblioteca = new Biblioteca();
if (!Files.isDirectory(carpeta)) {
return biblioteca;
}
for (String[] c : leer(RECURSOS)) {
procesar(RECURSOS, c, () -> biblioteca.anadirRecurso(switch (c[0]) {
case "LIBRO" -> new Libro(c[1], c[2], Integer.parseInt(c[3]), c[4], Integer.parseInt(c[5]));
case "REVISTA" -> new Revista(c[1], c[2], Integer.parseInt(c[3]), Integer.parseInt(c[4]));
default -> throw new IllegalArgumentException("tipo desconocido " + c[0]);
}));
}
for (String[] c : leer(SOCIOS)) {
procesar(SOCIOS, c, () -> biblioteca.anadirSocio(new Socio(c[0], c[1], TipoSocio.valueOf(c[2]))));
}
for (String[] c : leer(PRESTAMOS)) {
procesar(PRESTAMOS, c, () -> biblioteca.registrarPrestamo(new Prestamo(c[0], c[1],
LocalDate.parse(c[2]), LocalDate.parse(c[3]), c[4].isEmpty() ? null : LocalDate.parse(c[4]))));
}
return biblioteca;
}
/** Lee un fichero (si existe) y devuelve sus filas ya separadas, sin la cabecera. */
private List<String[]> leer(String fichero) throws PersistenciaException {
Path ruta = carpeta.resolve(fichero);
List<String[]> filas = new ArrayList<>();
if (!Files.exists(ruta)) {
return filas;
}
try {
List<String> lineas = Files.readAllLines(ruta, StandardCharsets.UTF_8);
for (String linea : lineas.subList(Math.min(1, lineas.size()), lineas.size())) { // sin cabecera
if (!linea.isBlank()) {
filas.add(linea.split(SEP, -1)); // -1: conserva campos vacíos
}
}
} catch (IOException e) {
throw new PersistenciaException("No se pudo leer " + ruta + ": " + e.getMessage(), e);
}
return filas;
}
/** Operación sobre una fila que puede fallar. */
@FunctionalInterface
private interface Operacion {
void ejecutar() throws BibliotecaException;
}
/** Ejecuta la operación y convierte cualquier error en un PersistenciaException con contexto. */
private void procesar(String fichero, String[] campos, Operacion op) throws PersistenciaException {
try {
op.ejecutar();
} catch (BibliotecaException | RuntimeException e) {
throw new PersistenciaException(fichero + ", fila [" + String.join(SEP, campos) + "]: " + e.getMessage(), e);
}
}
}
Dos detalles avanzados que merece la pena entender:
linea.split(SEP, -1): el-1hace quesplitconserve los campos vacíos del final; sin él,"R001;87654321X;2026-09-15;2026-09-22;"daría 4 campos en lugar de 5.procesarrecibe la operación como una lambda (() -> biblioteca.anadirSocio(...)) gracias a la interfaz funcionalOperacion. Así el tratamiento de errores, que es igual para los tres ficheros, se escribe una sola vez (DRY). Las lambdas se estudian a fondo en el curso avanzado.
⚠️ Importante: El
switchdeguardarno tienedefault. ComoRecursoes sellada, el compilador sabe que solo hay libros y revistas. Si añades una subclaseDvdapermitsy olvidas actualizar esteswitch, el proyecto no compila (the switch expression does not cover all possible input values): el compilador te avisa del sitio exacto que debes cambiar. Lo comprobarás en el ejercicio 1.
9. La interfaz de consola
9.1. Lectura validada
Consola encapsula el Scanner y repite la pregunta hasta obtener un dato válido. Así ninguna entrada incorrecta (letras donde se espera un número, valores fuera de rango, líneas vacías) cierra el programa (RNF3):
package es.curso.biblioteca.ui;
import java.io.Console;
import java.util.Scanner;
/** Lectura validada de datos por teclado. Repite la pregunta hasta obtener un valor correcto. */
public class Consola {
/** Se lanza cuando la entrada se termina (por ejemplo, al leer de una tubería). */
public static class FinDeEntradaException extends RuntimeException { }
private final Scanner teclado = new Scanner(System.in);
// Si la entrada no es un terminal (tubería o fichero), mostramos lo leído para que la sesión se entienda
private final boolean eco;
public Consola() {
Console consola = System.console();
this.eco = consola == null || !consola.isTerminal(); // isTerminal(): Java 22
}
public String leerTexto(String pregunta) {
while (true) {
String linea = leerLinea(pregunta);
if (!linea.isEmpty()) {
return linea;
}
System.out.println(" [!] El valor no puede estar vacío.");
}
}
public int leerEntero(String pregunta, int min, int max) {
while (true) {
String linea = leerLinea(pregunta);
try {
int valor = Integer.parseInt(linea);
if (valor >= min && valor <= max) {
return valor;
}
System.out.println(" [!] Debe estar entre " + min + " y " + max + ".");
} catch (NumberFormatException e) {
System.out.println(" [!] \"" + linea + "\" no es un número entero.");
}
}
}
private String leerLinea(String pregunta) {
System.out.print(pregunta);
if (!teclado.hasNextLine()) {
System.out.println();
throw new FinDeEntradaException();
}
String linea = teclado.nextLine().strip();
if (eco) {
System.out.println(linea);
}
return linea;
}
}
Cuando la entrada no viene de un terminal sino de una tubería o un fichero (como en la sesión de la sección 10), Consola muestra lo leído para que la transcripción se entienda; Console.isTerminal() existe desde Java 22. Y si la entrada se acaba, lanza FinDeEntradaException para que el menú guarde y termine limpiamente en lugar de fallar con NoSuchElementException.
9.2. El menú
MenuConsola traduce cada opción en una llamada al servicio y es el único lugar donde se capturan las excepciones de negocio para mostrárselas al usuario:
package es.curso.biblioteca.ui;
import es.curso.biblioteca.excepciones.BibliotecaException;
import es.curso.biblioteca.modelo.Libro;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.modelo.Recurso;
import es.curso.biblioteca.modelo.Revista;
import es.curso.biblioteca.modelo.Socio;
import es.curso.biblioteca.modelo.TipoSocio;
import es.curso.biblioteca.persistencia.RepositorioCsv;
import es.curso.biblioteca.servicio.Biblioteca;
import java.time.LocalDate;
import java.util.List;
/** Interfaz de usuario de consola: muestra el menú y traduce cada opción en llamadas al servicio. */
public class MenuConsola {
private final Biblioteca biblioteca;
private final RepositorioCsv repositorio;
private final Consola consola;
private final LocalDate hoy;
public MenuConsola(Biblioteca biblioteca, RepositorioCsv repositorio, Consola consola, LocalDate hoy) {
this.biblioteca = biblioteca;
this.repositorio = repositorio;
this.consola = consola;
this.hoy = hoy;
}
public void ejecutar() {
boolean salir = false;
try {
while (!salir) {
mostrarMenu();
int opcion = consola.leerEntero("Opción: ", 0, 9);
try {
switch (opcion) {
case 1 -> listarCatalogo();
case 2 -> buscarPorTitulo();
case 3 -> altaLibro();
case 4 -> altaRevista();
case 5 -> altaSocio();
case 6 -> listarSocios();
case 7 -> prestar();
case 8 -> devolver();
case 9 -> listarPrestamos();
case 0 -> salir = true;
default -> throw new IllegalStateException("Opción imposible: " + opcion);
}
} catch (BibliotecaException | IllegalArgumentException e) {
System.out.println(" [ERROR] " + e.getMessage());
}
}
} catch (Consola.FinDeEntradaException e) {
System.out.println("Fin de la entrada.");
}
guardar();
}
private void mostrarMenu() {
System.out.println();
System.out.println("===== BIBLIOTECA (" + hoy + ") =====");
System.out.println("1. Listar catálogo 6. Listar socios");
System.out.println("2. Buscar por título 7. Prestar");
System.out.println("3. Alta de libro 8. Devolver");
System.out.println("4. Alta de revista 9. Préstamos activos");
System.out.println("5. Alta de socio 0. Guardar y salir");
}
private void listarCatalogo() {
for (Recurso r : biblioteca.getRecursos()) {
System.out.println(" " + r + (biblioteca.estaDisponible(r.getCodigo()) ? "" : " [PRESTADO]"));
}
}
private void buscarPorTitulo() {
String texto = consola.leerTexto("Texto a buscar: ");
List<Recurso> encontrados = biblioteca.buscarPorTitulo(texto);
if (encontrados.isEmpty()) {
System.out.println(" No hay resultados.");
}
for (Recurso r : encontrados) {
System.out.println(" " + r);
}
}
private void altaLibro() throws BibliotecaException {
String codigo = consola.leerTexto("Código: ");
String titulo = consola.leerTexto("Título: ");
int anio = consola.leerEntero("Año: ", 1450, hoy.getYear());
String autor = consola.leerTexto("Autor: ");
int paginas = consola.leerEntero("Páginas: ", 1, 10_000);
biblioteca.anadirRecurso(new Libro(codigo, titulo, anio, autor, paginas));
System.out.println(" [OK] Libro dado de alta.");
}
private void altaRevista() throws BibliotecaException {
String codigo = consola.leerTexto("Código: ");
String titulo = consola.leerTexto("Título: ");
int anio = consola.leerEntero("Año: ", 1450, hoy.getYear());
int numero = consola.leerEntero("Número: ", 1, 100_000);
biblioteca.anadirRecurso(new Revista(codigo, titulo, anio, numero));
System.out.println(" [OK] Revista dada de alta.");
}
private void altaSocio() throws BibliotecaException {
String dni = consola.leerTexto("DNI: ");
String nombre = consola.leerTexto("Nombre: ");
TipoSocio[] tipos = TipoSocio.values();
for (int i = 0; i < tipos.length; i++) {
System.out.println(" " + (i + 1) + ". " + tipos[i] + " (hasta " + tipos[i].getMaxPrestamos() + " préstamos)");
}
TipoSocio tipo = tipos[consola.leerEntero("Tipo: ", 1, tipos.length) - 1];
biblioteca.anadirSocio(new Socio(dni, nombre, tipo));
System.out.println(" [OK] Socio dado de alta.");
}
private void listarSocios() {
for (Socio s : biblioteca.getSocios()) {
System.out.println(" " + s + " préstamos activos: " + biblioteca.prestamosActivosDe(s.dni()).size());
}
}
private void prestar() throws BibliotecaException {
String codigo = consola.leerTexto("Código del recurso: ");
String dni = consola.leerTexto("DNI del socio: ");
Prestamo p = biblioteca.prestar(codigo, dni, hoy);
System.out.println(" [OK] Prestado hasta el " + p.getFechaLimite() + ".");
}
private void devolver() throws BibliotecaException {
String codigo = consola.leerTexto("Código del recurso: ");
double multa = biblioteca.devolver(codigo, hoy);
System.out.println(multa == 0 ? " [OK] Devuelto a tiempo."
: String.format(" [OK] Devuelto con retraso. Multa: %.2f €", multa));
}
private void listarPrestamos() {
List<Prestamo> activos = biblioteca.prestamosActivos();
System.out.println(" Activos: " + activos.size());
for (Prestamo p : activos) {
String aviso = p.estaVencido(hoy)
? String.format(" VENCIDO hace %d días (%.2f €)", p.diasRetraso(hoy), p.multa(hoy)) : "";
System.out.println(" " + p + aviso);
}
}
private void guardar() {
try {
repositorio.guardar(biblioteca);
System.out.println("Datos guardados. ¡Hasta pronto!");
} catch (BibliotecaException e) {
System.out.println("[ERROR] " + e.getMessage());
}
}
}
9.3. El punto de entrada
package es.curso.biblioteca;
import es.curso.biblioteca.excepciones.PersistenciaException;
import es.curso.biblioteca.persistencia.RepositorioCsv;
import es.curso.biblioteca.servicio.Biblioteca;
import es.curso.biblioteca.ui.Consola;
import es.curso.biblioteca.ui.MenuConsola;
import java.nio.file.Path;
import java.time.LocalDate;
/**
* Punto de entrada. Argumentos opcionales: carpeta de datos (por defecto "datos")
* y fecha de hoy en formato AAAA-MM-DD (útil para pruebas y demostraciones).
*/
public class App {
public static void main(String[] args) {
Path carpeta = Path.of(args.length > 0 ? args[0] : "datos");
LocalDate hoy = args.length > 1 ? LocalDate.parse(args[1]) : LocalDate.now();
RepositorioCsv repositorio = new RepositorioCsv(carpeta);
try {
Biblioteca biblioteca = repositorio.cargar();
new MenuConsola(biblioteca, repositorio, new Consola(), hoy).ejecutar();
} catch (PersistenciaException e) {
System.err.println("No se pudieron cargar los datos: " + e.getMessage());
System.exit(1);
}
}
}
App es la única clase que conoce todas las piezas: crea el repositorio, carga la biblioteca, construye el menú y lo ejecuta. El segundo argumento opcional permite fijar la fecha «de hoy», lo que hace las demostraciones reproducibles.
10. Compilar, probar y ejecutar
10.1. Pruebas JUnit de la lógica
Las pruebas cubren las reglas de negocio (servicio), los cálculos y validaciones del modelo y la persistencia. Las del servicio:
package es.curso.biblioteca.servicio;
import es.curso.biblioteca.excepciones.ElementoDuplicadoException;
import es.curso.biblioteca.excepciones.ElementoNoEncontradoException;
import es.curso.biblioteca.excepciones.LimitePrestamosException;
import es.curso.biblioteca.excepciones.RecursoNoDisponibleException;
import es.curso.biblioteca.modelo.Libro;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.modelo.Revista;
import es.curso.biblioteca.modelo.Socio;
import es.curso.biblioteca.modelo.TipoSocio;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Servicio Biblioteca")
class BibliotecaTest {
private static final LocalDate HOY = LocalDate.of(2026, 10, 5);
private Biblioteca biblioteca;
@BeforeEach
void setUp() throws Exception {
biblioteca = new Biblioteca();
biblioteca.anadirRecurso(new Libro("L001", "El Quijote", 1605, "Miguel de Cervantes", 1376));
biblioteca.anadirRecurso(new Libro("L002", "Cien años de soledad", 1967, "Gabriel García Márquez", 471));
biblioteca.anadirRecurso(new Revista("R001", "Muy Interesante", 2026, 540));
biblioteca.anadirSocio(new Socio("12345678Z", "Marta López", TipoSocio.ESTANDAR));
}
@Test
@DisplayName("Prestar un libro fija la fecha límite a 15 días y lo marca como no disponible")
void prestarLibro() throws Exception {
Prestamo p = biblioteca.prestar("L001", "12345678Z", HOY);
assertAll(
() -> assertEquals(LocalDate.of(2026, 10, 20), p.getFechaLimite()),
() -> assertFalse(biblioteca.estaDisponible("L001")),
() -> assertEquals(1, biblioteca.prestamosActivos().size()));
}
@Test
@DisplayName("Una revista se presta 7 días")
void prestarRevista() throws Exception {
assertEquals(HOY.plusDays(7), biblioteca.prestar("r001", "12345678z", HOY).getFechaLimite());
}
@Test
void noSePuedePrestarUnRecursoYaPrestado() throws Exception {
biblioteca.prestar("L001", "12345678Z", HOY);
assertThrows(RecursoNoDisponibleException.class, () -> biblioteca.prestar("L001", "12345678Z", HOY));
}
@Test
void unSocioEstandarNoPuedeSuperarSuLimite() throws Exception {
biblioteca.prestar("L001", "12345678Z", HOY);
biblioteca.prestar("L002", "12345678Z", HOY);
LimitePrestamosException e = assertThrows(LimitePrestamosException.class,
() -> biblioteca.prestar("R001", "12345678Z", HOY));
assertTrue(e.getMessage().contains("máximo 2"));
}
@Test
void recursoOSocioInexistentes() {
assertAll(
() -> assertThrows(ElementoNoEncontradoException.class, () -> biblioteca.prestar("X999", "12345678Z", HOY)),
() -> assertThrows(ElementoNoEncontradoException.class, () -> biblioteca.prestar("L001", "87654321X", HOY)));
}
@Test
void noSeAdmitenCodigosDuplicados() {
assertThrows(ElementoDuplicadoException.class,
() -> biblioteca.anadirRecurso(new Revista("L001", "Otra", 2020, 1)));
}
@Test
@DisplayName("Devolver a tiempo no tiene multa y deja el recurso disponible")
void devolverATiempo() throws Exception {
biblioteca.prestar("L001", "12345678Z", HOY);
assertEquals(0.0, biblioteca.devolver("L001", HOY.plusDays(15)));
assertTrue(biblioteca.estaDisponible("L001"));
}
@Test
@DisplayName("Devolver 3 días tarde cuesta 1,50 €")
void devolverConRetraso() throws Exception {
biblioteca.prestar("L001", "12345678Z", HOY);
assertEquals(1.50, biblioteca.devolver("L001", HOY.plusDays(18)), 0.001);
}
@Test
void devolverAlgoNoPrestadoLanzaExcepcion() {
assertThrows(ElementoNoEncontradoException.class, () -> biblioteca.devolver("L002", HOY));
}
@Test
void buscarPorTituloNoDistingueMayusculas() {
assertEquals("El Quijote", biblioteca.buscarPorTitulo("QUIJ").getFirst().getTitulo());
assertTrue(biblioteca.buscarPorTitulo("inexistente").isEmpty());
}
}
Las del modelo usan pruebas parametrizadas con valores límite para la multa (el último día de plazo y el primero de retraso) y para los DNI incorrectos:
package es.curso.biblioteca.modelo;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
class ModeloTest {
private static final LocalDate LIMITE = LocalDate.of(2026, 10, 20);
@ParameterizedTest(name = "devuelto el {0}: {1} días de retraso, multa {2} €")
@CsvSource({
"2026-10-15, 0, 0.0", // antes de la fecha límite
"2026-10-20, 0, 0.0", // valor límite: el último día
"2026-10-21, 1, 0.5", // valor límite: un día tarde
"2026-11-03, 14, 7.0"
})
void multaPorRetraso(LocalDate devolucion, long dias, double multa) {
Prestamo p = new Prestamo("L001", "12345678Z", LocalDate.of(2026, 10, 5), LIMITE, null);
p.registrarDevolucion(devolucion);
assertEquals(dias, p.diasRetraso(devolucion));
assertEquals(multa, p.multa(devolucion), 0.001);
}
@Test
void noSePuedeDevolverDosVeces() {
Prestamo p = new Prestamo("L001", "12345678Z", LocalDate.of(2026, 10, 5), LIMITE, null);
p.registrarDevolucion(LIMITE);
assertThrows(IllegalStateException.class, () -> p.registrarDevolucion(LIMITE));
}
@ParameterizedTest
@ValueSource(strings = {"12345678A", "1234567Z", "ABCDEFGHZ", ""})
void dniIncorrectosSeRechazan(String dni) {
assertThrows(IllegalArgumentException.class, () -> new Socio(dni, "Ana", TipoSocio.PREMIUM));
}
@Test
void elDniSeNormalizaAMayusculas() {
assertEquals("12345678Z", new Socio("12345678z", "Ana", TipoSocio.PREMIUM).dni());
}
@Test
void losTextosNoPuedenContenerElSeparadorCsv() {
assertThrows(IllegalArgumentException.class, () -> new Libro("L9", "Título;roto", 2000, "Autor", 10));
}
}
Las de persistencia usan @TempDir: JUnit inyecta una carpeta temporal vacía en cada prueba y la borra al terminar, de modo que las pruebas nunca tocan los datos reales:
package es.curso.biblioteca.persistencia;
import es.curso.biblioteca.excepciones.PersistenciaException;
import es.curso.biblioteca.modelo.Libro;
import es.curso.biblioteca.modelo.Revista;
import es.curso.biblioteca.modelo.Socio;
import es.curso.biblioteca.modelo.TipoSocio;
import es.curso.biblioteca.servicio.Biblioteca;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class RepositorioCsvTest {
@TempDir
Path carpeta; // JUnit crea una carpeta temporal nueva para cada prueba y la borra después
@Test
void loQueSeGuardaSeVuelveACargarIgual() throws Exception {
Biblioteca original = new Biblioteca();
original.anadirRecurso(new Libro("L001", "El Quijote", 1605, "Miguel de Cervantes", 1376));
original.anadirRecurso(new Revista("R001", "Muy Interesante", 2026, 540));
original.anadirSocio(new Socio("12345678Z", "Marta López", TipoSocio.PREMIUM));
original.prestar("R001", "12345678Z", LocalDate.of(2026, 10, 1));
RepositorioCsv repo = new RepositorioCsv(carpeta);
repo.guardar(original);
Biblioteca cargada = repo.cargar();
assertAll(
() -> assertEquals(2, cargada.getRecursos().size()),
() -> assertEquals("Miguel de Cervantes", ((Libro) cargada.buscarRecurso("L001")).getAutor()),
() -> assertEquals("Marta López", cargada.buscarSocio("12345678Z").nombre()),
() -> assertFalse(cargada.estaDisponible("R001")),
() -> assertEquals(LocalDate.of(2026, 10, 8), cargada.prestamosActivos().getFirst().getFechaLimite()));
}
@Test
void siNoHayCarpetaSeEmpiezaConUnaBibliotecaVacia() throws Exception {
Biblioteca b = new RepositorioCsv(carpeta.resolve("no-existe")).cargar();
assertTrue(b.getRecursos().isEmpty());
}
@Test
void unFicheroCorruptoIndicaLaFilaQueFalla() throws Exception {
Files.write(carpeta.resolve("recursos.csv"), List.of("cabecera", "LIBRO;L001;El Quijote;MIL;Cervantes;100"));
PersistenciaException e = assertThrows(PersistenciaException.class, () -> new RepositorioCsv(carpeta).cargar());
assertTrue(e.getMessage().startsWith("recursos.csv, fila [LIBRO;L001"));
}
}
Con mvn -q test no aparece ninguna salida (todo correcto). Este es el resumen de mvn test (JDK 25, JUnit 6.1.3):
[INFO] Running es.curso.biblioteca.persistencia.RepositorioCsvTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.864 s -- in es.curso.biblioteca.persistencia.RepositorioCsvTest
[INFO] Running Servicio Biblioteca
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.280 s -- in Servicio Biblioteca
[INFO] Running es.curso.biblioteca.modelo.ModeloTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.995 s -- in es.curso.biblioteca.modelo.ModeloTest
[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
10.2. Empaquetar y ejecutar
mvn -q package
java -jar target/biblioteca-1.0.jar datos 2026-10-05
10.3. Una sesión real
Esta es una sesión real del programa con la entrada pasada por tubería (cat entrada.txt | java -jar target/biblioteca-1.0.jar datos 2026-10-05). Para no repetirlo, el menú se muestra completo solo la primera vez; las demás apariciones se han sustituido por [... menú ...]:
===== BIBLIOTECA (2026-10-05) =====
1. Listar catálogo 6. Listar socios
2. Buscar por título 7. Prestar
3. Alta de libro 8. Devolver
4. Alta de revista 9. Préstamos activos
5. Alta de socio 0. Guardar y salir
Opción: 1
L001 LIBRO El Quijote 1605 Miguel de Cervantes, 1376 págs. [PRESTADO]
L002 LIBRO Cien años de soledad 1967 Gabriel García Márquez, 471 págs.
L003 LIBRO El nombre del viento 2007 Patrick Rothfuss, 880 págs.
R001 REVISTA Muy Interesante 2026 nº 540 [PRESTADO]
R002 REVISTA National Geographic 2026 nº 312
[... menú ...]
Opción: 2
Texto a buscar: nombre
L003 LIBRO El nombre del viento 2007 Patrick Rothfuss, 880 págs.
[... menú ...]
Opción: siete
[!] "siete" no es un número entero.
Opción: 12
[!] Debe estar entre 0 y 9.
Opción: 5
DNI: 33333333P
Nombre: Iván Gómez
1. ESTANDAR (hasta 2 préstamos)
2. PREMIUM (hasta 5 préstamos)
Tipo: 1
[OK] Socio dado de alta.
[... menú ...]
Opción: 7
Código del recurso: L002
DNI del socio: 33333333P
[OK] Prestado hasta el 2026-10-20.
[... menú ...]
Opción: 7
Código del recurso: L001
DNI del socio: 87654321X
[ERROR] "El Quijote" ya está prestado
[... menú ...]
Opción: 7
Código del recurso: R002
DNI del socio: 87654321X
[OK] Prestado hasta el 2026-10-12.
[... menú ...]
Opción: 7
Código del recurso: L003
DNI del socio: 87654321X
[ERROR] Jorge Sanz ya tiene 2 préstamos (máximo 2)
[... menú ...]
Opción: 9
Activos: 4
L001 -> 12345678Z del 2026-09-28 al 2026-10-13
R001 -> 87654321X del 2026-09-15 al 2026-09-22 VENCIDO hace 13 días (6.50 €)
L002 -> 33333333P del 2026-10-05 al 2026-10-20
R002 -> 87654321X del 2026-10-05 al 2026-10-12
[... menú ...]
Opción: 8
Código del recurso: R001
[OK] Devuelto con retraso. Multa: 6.50 €
[... menú ...]
Opción: 0
Datos guardados. ¡Hasta pronto!
La sesión recorre los requisitos: listado con recursos prestados (RF1), búsqueda (RF2), entradas no válidas que no rompen nada (RNF3), alta de socio (RF4), un préstamo correcto, uno rechazado porque el recurso no está disponible y otro porque el socio ha llegado a su límite (RF5), préstamos activos con un vencido (RF7) y una devolución con multa: del 22 de septiembre al 5 de octubre son 13 días, 6,50 € (RF6).
Al salir, los ficheros quedan así (RF8): el préstamo de R001 tiene fecha de devolución, hay dos préstamos nuevos y un socio nuevo.
--- datos/prestamos.csv
codigo;dni;fecha_prestamo;fecha_limite;fecha_devolucion
L003;12345678Z;2026-09-01;2026-09-16;2026-09-14
L001;12345678Z;2026-09-28;2026-10-13;
R001;87654321X;2026-09-15;2026-09-22;2026-10-05
L002;33333333P;2026-10-05;2026-10-20;
R002;87654321X;2026-10-05;2026-10-12;
--- datos/recursos.csv
tipo;codigo;titulo;anio;autor_o_numero;paginas
LIBRO;L001;El Quijote;1605;Miguel de Cervantes;1376
LIBRO;L002;Cien años de soledad;1967;Gabriel García Márquez;471
LIBRO;L003;El nombre del viento;2007;Patrick Rothfuss;880
REVISTA;R001;Muy Interesante;2026;540;
REVISTA;R002;National Geographic;2026;312;
--- datos/socios.csv
dni;nombre;tipo
12345678Z;Marta López;PREMIUM
87654321X;Jorge Sanz;ESTANDAR
33333333P;Iván Gómez;ESTANDAR
11. Lista de comprobación de calidad
Antes de entregar un proyecto, revísalo con esta lista (el proyecto de este módulo la cumple):
| Aspecto | Comprobación | Módulo |
|---|---|---|
| Requisitos | Cada requisito funcional está implementado y se ha probado | 21 |
| Diseño | Diagrama de clases actualizado; paquetes por responsabilidad; el modelo no imprime ni lee | 18 |
| Encapsulación | Atributos privados; colecciones devueltas como copias inmutables | 11, 16 |
| Herencia y polimorfismo | Jerarquía con sentido «es un»; sin switch sobre tipos que se podría evitar | 12, 13 |
| Tipos modernos | enum para conjuntos cerrados, records para datos inmutables, sealed cuando los subtipos son conocidos | 14 |
| Excepciones | Jerarquía propia; mensajes claros; nunca un catch vacío; recursos cerrados | 15 |
| Colecciones | Map para búsquedas por clave, List para secuencias; genéricos siempre | 16 |
| Ficheros | UTF-8 explícito; errores con contexto; datos de prueba separados de los reales | 17 |
| Pruebas | Pruebas de la lógica en verde; valores límite; independientes y repetibles | 19 |
| Entrada | Ninguna entrada del usuario provoca una excepción no controlada | 4, 15 |
| Estilo | Convenciones de nombres; sin números mágicos; métodos cortos; formato del IDE (Ctrl+Alt+L) | 20 |
| Documentación | Javadoc en las clases y métodos públicos; README con instrucciones de uso | 20 |
| Versiones | Proyecto en Git con commits pequeños y .gitignore; mvn clean package funciona desde cero | 20 |
12. Ejercicios: amplía el proyecto
Las soluciones de los seis primeros ejercicios se han implementado sobre una copia del proyecto y comprobado con 5 pruebas JUnit nuevas: en total, 29 pruebas en verde.
Ejercicio 1: Un nuevo tipo de recurso
Añade DVD (código, título, año y duración en minutos) que se prestan 3 días. Comprueba qué ocurre al compilar tras añadirlo a permits y corrige lo necesario para que se guarden y carguen en el CSV.
Ver solución
package es.curso.biblioteca.modelo;
public final class Dvd extends Recurso {
public static final int DIAS_PRESTAMO = 3;
private final int minutos;
public Dvd(String codigo, String titulo, int anio, int minutos) {
super(codigo, titulo, anio);
this.minutos = Validacion.rango(minutos, 1, 1_000, "minutos");
}
public int getMinutos() { return minutos; }
@Override public int diasPrestamo() { return DIAS_PRESTAMO; }
@Override public String tipo() { return "DVD"; }
@Override public String detalle() { return minutos + " min"; }
}
Al cambiar Recurso a permits Libro, Revista, Dvd, mvn compile falla exactamente donde hay que actuar:
[ERROR] /tmp/p/src/main/java/es/curso/biblioteca/persistencia/RepositorioCsv.java:[40,26] the switch expression does not cover all possible input values
Se añade un caso al switch de guardar y otro al de cargar (más el import de Dvd):
case Dvd d -> String.join(SEP, "DVD", d.getCodigo(), d.getTitulo(),
String.valueOf(d.getAnio()), String.valueOf(d.getMinutos()), "");
case "DVD" -> new Dvd(c[1], c[2], Integer.parseInt(c[3]), Integer.parseInt(c[4]));
La prueba que lo comprueba (las del resto de ejercicios están en la misma clase AmpliacionesTest, con el mismo setUp que da de alta un libro, una revista, el DVD D001 y dos socios):
@Test
void ejercicio1UnDvdSePresta3DiasYSeGuardaEnCsv(@TempDir Path carpeta) throws Exception {
assertEquals(HOY.plusDays(3), b.prestar("D001", "12345678Z", HOY).getFechaLimite());
new RepositorioCsv(carpeta).guardar(b);
Recurso cargado = new RepositorioCsv(carpeta).cargar().buscarRecurso("D001");
assertInstanceOf(Dvd.class, cargado);
assertEquals(125, ((Dvd) cargado).getMinutos());
}
Para completar la ampliación habría que añadir la opción «Alta de DVD» al menú, igual que las de libro y revista.
Ejercicio 2: Socios con deudas
Añade a Biblioteca un método Map<String, Double> deudaPorSocio(LocalDate hoy) que devuelva, para cada socio con préstamos vencidos, la suma de sus multas pendientes, ordenado por DNI.
Ver solución
/** Ejercicio 2: deuda acumulada por cada socio con préstamos vencidos (DNI -> euros). */
public Map<String, Double> deudaPorSocio(LocalDate hoy) {
Map<String, Double> deudas = new TreeMap<>();
for (Prestamo p : prestamosVencidos(hoy)) {
deudas.merge(p.getDniSocio(), p.multa(hoy), Double::sum);
}
return deudas;
}
TreeMap mantiene las claves ordenadas. merge(clave, valor, Double::sum) inserta el valor si la clave no existe o lo suma al que había: equivale a deudas.put(dni, deudas.getOrDefault(dni, 0.0) + multa). La prueba:
@Test
void ejercicio2DeudaPorSocio() throws Exception {
b.registrarPrestamo(new Prestamo("L001", "87654321X", HOY.minusDays(30), HOY.minusDays(15), null));
b.registrarPrestamo(new Prestamo("R001", "87654321X", HOY.minusDays(10), HOY.minusDays(3), null));
assertEquals(Map.of("87654321X", 9.0), b.deudaPorSocio(HOY)); // 15 días + 3 días = 18 × 0,50
}
Ejercicio 3: Ordenar el catálogo
Añade un método que devuelva el catálogo ordenado según un Comparator recibido como parámetro y úsalo para ordenar por año (y, en el menú, por título).
Ver solución
/** Ejercicio 3: catálogo ordenado según el criterio que se reciba. */
public List<Recurso> catalogoOrdenado(Comparator<Recurso> criterio) {
List<Recurso> lista = new ArrayList<>(catalogo.values());
lista.sort(criterio);
return lista;
}
Uso: biblioteca.catalogoOrdenado(Comparator.comparingInt(Recurso::getAnio)) o catalogoOrdenado(Comparator.comparing(Recurso::getTitulo)). Como Recurso es Comparable por código, catalogoOrdenado(Comparator.naturalOrder()) lo ordena por código. La prueba comprueba el orden por año (1605, 2004, 2026):
@Test
void ejercicio3CatalogoOrdenadoPorAnio() {
List<Recurso> porAnio = b.catalogoOrdenado(Comparator.comparingInt(Recurso::getAnio));
assertEquals(List.of("L001", "D001", "R001"), porAnio.stream().map(Recurso::getCodigo).toList());
}
(La última línea usa un stream para extraer los códigos; los verás en el curso avanzado. Equivale a recorrer la lista con un for y añadir cada código a otra lista.)
Ejercicio 4: El recurso más prestado
Añade un método String recursoMasPrestado() que devuelva el código del recurso con más préstamos en total (activos o devueltos), o null si no hay ninguno.
Ver solución
/** Ejercicio 4: código del recurso con más préstamos (activos o devueltos), o null si no hay. */
public String recursoMasPrestado() {
Map<String, Integer> veces = new HashMap<>();
for (Prestamo p : prestamos) {
veces.put(p.getCodigoRecurso(), veces.getOrDefault(p.getCodigoRecurso(), 0) + 1);
}
String mejor = null;
for (Map.Entry<String, Integer> e : veces.entrySet()) {
if (mejor == null || e.getValue() > veces.get(mejor)) {
mejor = e.getKey();
}
}
return mejor;
}
Es el patrón clásico de contar con un mapa (módulo 16): la clave es lo que se cuenta y el valor, el contador. La prueba presta dos veces el DVD y una el libro:
@Test
void ejercicio4RecursoMasPrestado() throws Exception {
assertNull(b.recursoMasPrestado());
b.prestar("D001", "12345678Z", HOY);
b.devolver("D001", HOY.plusDays(1));
b.prestar("D001", "87654321X", HOY.plusDays(2));
b.prestar("L001", "12345678Z", HOY);
assertEquals("D001", b.recursoMasPrestado());
}
Ejercicio 5: Pedir el DNI hasta que sea válido
Ahora, si el usuario escribe un DNI con la letra mal al dar de alta un socio, el alta falla y vuelve al menú. Añade a Consola un método leerDni que repita la pregunta hasta obtener un DNI válido, y úsalo en altaSocio.
Ver solución
/** Ejercicio 5: repite la pregunta hasta que se escriba un DNI con formato y letra correctos. */
public String leerDni(String pregunta) {
while (true) {
String dni = leerTexto(pregunta).toUpperCase();
if (Socio.esDniValido(dni)) {
return dni;
}
System.out.println(" [!] DNI no válido: deben ser 8 cifras y la letra correcta.");
}
}
En MenuConsola.altaSocio basta cambiar consola.leerTexto("DNI: ") por consola.leerDni("DNI: "). Fíjate en que la regla de validación sigue estando en un solo sitio (Socio.esDniValido): la consola solo la reutiliza para dar una experiencia mejor al usuario.
Ejercicio 6: Informe de préstamos en un fichero de texto
Crea en el paquete persistencia una clase InformeTexto con un método estático que escriba en un fichero un informe legible de los préstamos activos: título del recurso, nombre del socio, fecha límite y la marca ¡VENCIDO! si procede.
Ver solución
package es.curso.biblioteca.persistencia;
import es.curso.biblioteca.excepciones.BibliotecaException;
import es.curso.biblioteca.excepciones.PersistenciaException;
import es.curso.biblioteca.modelo.Prestamo;
import es.curso.biblioteca.servicio.Biblioteca;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
/** Ejercicio 6: informe legible de los préstamos activos, con título, socio y aviso de retraso. */
public class InformeTexto {
public static void exportar(Biblioteca biblioteca, LocalDate hoy, Path destino) throws BibliotecaException {
try (BufferedWriter out = Files.newBufferedWriter(destino, StandardCharsets.UTF_8)) {
out.write("INFORME DE PRÉSTAMOS ACTIVOS - " + hoy);
out.newLine();
for (Prestamo p : biblioteca.prestamosActivos()) {
String titulo = biblioteca.buscarRecurso(p.getCodigoRecurso()).getTitulo();
String socio = biblioteca.buscarSocio(p.getDniSocio()).nombre();
out.write(String.format("%-25s %-15s hasta %s%s", titulo, socio, p.getFechaLimite(),
p.estaVencido(hoy) ? " ¡VENCIDO!" : ""));
out.newLine();
}
} catch (IOException e) {
throw new PersistenciaException("No se pudo escribir el informe: " + e.getMessage(), e);
}
}
}
try-with-resources (módulo 15) cierra el fichero aunque se produzca una excepción a mitad. La prueba:
@Test
void ejercicio6InformeDeTexto(@TempDir Path carpeta) throws Exception {
b.registrarPrestamo(new Prestamo("L001", "87654321X", HOY.minusDays(30), HOY.minusDays(15), null));
Path informe = carpeta.resolve("informe.txt");
InformeTexto.exportar(b, HOY, informe);
List<String> lineas = Files.readAllLines(informe);
assertEquals(2, lineas.size());
assertTrue(lineas.get(1).startsWith("El Quijote"));
assertTrue(lineas.get(1).endsWith("¡VENCIDO!"));
}
Resultado de ejecutar todas las pruebas del proyecto ampliado:
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.257 s -- in es.curso.biblioteca.persistencia.RepositorioCsvTest
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.570 s -- in Servicio Biblioteca
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.383 s -- in es.curso.biblioteca.AmpliacionesTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.317 s -- in es.curso.biblioteca.modelo.ModeloTest
[INFO] Tests run: 29, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Ejercicio 7 (propuesto): Renovar un préstamo
Permite renovar un préstamo una sola vez, ampliando la fecha límite otros diasPrestamo() días, siempre que no esté vencido. Tendrás que hacer fechaLimite modificable en Prestamo, añadir un campo renovado (y su columna en el CSV, manteniendo la compatibilidad con los ficheros antiguos) y una opción nueva en el menú. Escribe primero las pruebas (TDD).
Ejercicio 8 (propuesto): Reservas
Si un recurso está prestado, permite que un socio lo reserve. Al devolverlo, el programa debe avisar de que hay una reserva y prestárselo automáticamente al primer socio de la cola. Pista: un Map<String, Deque<String>> (código del recurso → cola de DNI).
Ejercicio 9 (propuesto): Bajas y modificaciones
Añade opciones para dar de baja recursos y socios (solo si no tienen préstamos activos, lanzando una excepción propia en caso contrario) y para modificar el nombre de un socio (recuerda que Socio es un record inmutable: tendrás que sustituirlo por uno nuevo en el mapa).
Ejercicio 10 (propuesto): Historial del socio
Añade la opción «Historial de un socio», que pida un DNI y muestre todos sus préstamos (activos y devueltos) ordenados por fecha, el total de multas pagadas y el número de devoluciones con retraso.
Ejercicio 11 (propuesto): Copia de seguridad
Antes de guardar, copia los tres CSV a una carpeta datos/copia-AAAA-MM-DD/ con Files.copy (módulo 17). Si la carga falla al arrancar por un fichero corrupto, ofrece al usuario restaurar la última copia.
Ejercicio 12 (propuesto): Publica tu proyecto
Sube el proyecto a GitHub con un README.md que explique qué hace, cómo compilarlo (mvn package) y cómo ejecutarlo, el diagrama de clases (puedes escribirlo en PlantUML, módulo 18) y un .gitignore adecuado. Genera la documentación con javadoc y comprueba con JaCoCo (módulo 19) que la cobertura del paquete servicio supera el 90 %.
13. ¿Y ahora qué?
¡Enhorabuena! Has completado el curso de fundamentos: sabes programar en Java con orientación a objetos, diseñar con UML, probar, documentar y versionar tu código, y construir una aplicación completa. Este proyecto tiene limitaciones que son, precisamente, el temario del siguiente nivel:
| Limitación del proyecto | Se resuelve con… |
|---|---|
| Bucles para filtrar, contar y ordenar colecciones | Programación funcional: lambdas, streams y collectors |
| Ficheros CSV escritos a mano | Bases de datos relacionales con JDBC, y JPA/Hibernate |
| Interfaz de consola | Aplicaciones web y API REST con Spring Boot |
| Un solo usuario a la vez | Concurrencia e hilos |
| Pruebas solo de la lógica en memoria | Pruebas de integración, mocks con Mockito, Testcontainers |
💡 Consejo: Continúa con el Curso de Java Avanzado: retomarás este mismo tipo de aplicación para guardarla en MySQL con JDBC e Hibernate y convertirla en un servicio web con Spring Boot. Guarda tu proyecto de la biblioteca en GitHub: será un buen punto de partida.
Resumen
| Fase | Qué hiciste | Herramientas |
|---|---|---|
| Requisitos | Enunciado → requisitos funcionales y no funcionales numerados | Tablas de requisitos |
| Análisis | Sustantivos → clases, verbos → métodos; relaciones | Diagrama de clases UML |
| Diseño | Paquetes por capas: modelo, excepciones, servicio, persistencia, ui | Arquitectura en capas |
| Modelo | Clase abstracta sellada, interfaz con método por defecto, record con validación, enum con atributos | Módulos 12-14 |
| Errores | Jerarquía de excepciones comprobadas del negocio | Módulo 15 |
| Servicio | Reglas de negocio con Map y List; copias inmutables | Módulo 16 |
| Persistencia | CSV con java.nio.file, UTF-8 y errores con contexto | Módulo 17 |
| Interfaz | Menú con validación de la entrada y captura centralizada de errores | Módulos 4 y 15 |
| Calidad | 24 pruebas JUnit, Maven, .jar ejecutable, lista de comprobación | Módulos 19 y 20 |