Semana 15 — Archivos, E/S y persistencia
Objetivos
- Leer y escribir archivos de texto con
java.nio.file(Path,Files) y manejar sus errores. - Explicar los streams de E/S de
java.iocomo cadenas de objetos que se envuelven unos a otros: el patrón Decorator. - Guardar y recuperar el estado de un programa en CSV, separando el formato del modelo.
- Conocer la serialización de Java, cómo se usa y por qué no se recomienda para datos que vienen de fuera.
Apuntes de la semana: notes/week-15.md · Código: code/week-15
Sesión A — Archivos y E/S: java.nio.file, streams · Decorator
Lecturas:
- Eck, sec. 11.1 I/O Streams, Readers, and Writers, subsecciones 11.1.1, 11.1.2 y 11.1.4 (los conceptos de stream de bytes y de caracteres, y cómo un stream envuelve a otro).
- Eck, subsec. 11.2.1 Reading and Writing Files.
- Eck no cubre
java.nio(lo aclara en la sec. 11.1). Para eso, dev.java, The Java I/O API: Understanding the Main Java I/O Concepts, Working with Paths, Reading and Writing Small Files, Reading and Writing Text Files y Decorating I/O Streams. - Refactoring.Guru, Decorator.
Lecturas complementarias:
- dev.java, Common I/O Tasks in Modern Java.
- API de Java 25:
Path,FilesyBufferedReader.
Temas:
Pathy rutas relativas y absolutas; el directorio de trabajo dentro del contenedor (/work).- Archivos pequeños:
Files.readAllLines,Files.readString,Files.writeString,Files.write. Archivos grandes:Files.newBufferedReaderyFiles.lines(un stream de la semana 12) dentro detry-with-resources (semana 9). - Errores de E/S:
IOExceptiones checked;NoSuchFileException,AccessDeniedException. Dónde capturarlas: en el borde del programa, no en el modelo. - Codificación de caracteres: UTF-8 es la codificación por defecto desde Java 18 (JEP 400), y los métodos de texto de
Filesla usan desde antes cuando no se indica otra; por qué las tildes y la ñ se rompen al leer archivos de otros programas en Latin-1 o Windows-1252. - Streams de
java.io:InputStream/OutputStream(bytes) yReader/Writer(caracteres).new BufferedReader(new InputStreamReader(in, UTF_8)): cada capa agrega una capacidad. - Decorator: un objeto que implementa la misma interfaz que el que envuelve y le agrega comportamiento. Ejemplos propios:
CountingWriter, un decorator deWriterque cuenta las líneas escritas, yLoggingSongRepository, que registra cada operación de un repositorio. Cuándo no usarlo. Comparación con herencia: se combinan en tiempo de ejecución y sin explosión de subclases. - Error común: olvidar cerrar un archivo (o cerrarlo antes de terminar de escribir) y quedarse con un archivo vacío o incompleto.
Sesión B — Persistencia simple: CSV y serialización
Lecturas:
- Eck, subsecs. 11.3.2 Persistent Data y 11.3.3 Storing Objects in Files.
- Eck, subsec. 11.1.6 Serialized Object I/O.
Lecturas complementarias:
- API de Java 25:
Serializable. - Oracle, Secure Coding Guidelines for Java SE, sección 8 Serialization and Deserialization: por qué no deserializar datos que no son de confianza.
Temas:
- Persistir es traducir objetos a un formato y de vuelta. Dónde va ese código: una clase aparte (un repositorio o un “almacén”) y no dentro de las clases del dominio (responsabilidad única, semana 13).
- CSV a mano: una línea por objeto, campos separados por comas, encabezado. Escribir con
String.joinoString.format; leer consplity convertir cada campo. Ejemplo propio: guardar y cargar una lista de canciones (records). - Los casos difíciles del CSV: comas o saltos de línea dentro de un campo, campos vacíos, números con formato local (coma decimal). Decidir una regla, documentarla y probarla.
- Línea mal formada: lanzar una excepción del dominio con el número de línea, o saltarla y reportarla. Decidir y probar.
- Serialización de Java:
Serializable,ObjectOutputStream,ObjectInputStream,transient,serialVersionUID. Cómoda para guardar un grafo de objetos, frágil ante cambios de la clase e insegura con datos externos: los objetos se crean sin pasar por el constructor (salvo los records).ObjectInputFilter. - Prueba de ida y vuelta (round trip): guardar, cargar y comparar con
equals(otra vez la semana 5).
Laboratorio — Guardar y cargar el estado
Se trabaja sobre el propio proyecto; los ejemplos de los apuntes (repositorio CSV, decorator, menú PlaylistCli) están en code/week-15/ como referencia. Referencia: JUnit User Guide, @TempDir en Built-in Extensions.
- Escribir un repositorio que guarde y cargue el inventario en CSV con
Files, detrás de una interfaz (para poder cambiar el formato después). - Pruebas de ida y vuelta en un directorio temporal (
@TempDir), sin tocar los archivos reales. - Pruebas de errores: archivo inexistente, línea mal formada, campo numérico inválido.
- Escribir un decorator del repositorio que registre cada operación y comprobar con una prueba que delega correctamente.
- Arranque de P5: el menú de línea de comandos con
Scanneryswitch(Eck, subsecs. 2.4.6 Using Scanner for Input y 3.6.2 Menus and switch Statements; argumentos en 4.3.6 Command-line Arguments).
Entregas
- P4 entrega.
- P5 publicado: Aplicación (patrones de diseño, persistencia en archivos y una interfaz de línea de comandos). Entrega en semana 16. Ver
projects/README.md.
Preguntas de repaso
- ¿Por qué el código que lee el CSV no debería estar dentro de la clase del producto? ¿Qué principio se aplican?
- ¿Qué capas tiene
new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))y qué agrega cada una? ¿Qué patrón es? - ¿Qué pasa con una línea
"Arroz, extra",3.50,10si se lee consplit(",")? Propongan una regla para el formato del proyecto. - ¿Por qué no se debe deserializar con
ObjectInputStreamun archivo recibido de un usuario? - ¿Qué verifica una prueba de ida y vuelta y qué método de la clase necesita estar bien definido para que funcione?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (15)Pruebas (5)Compilación (1)
Ejemplos
Los programas que usan los apuntes y la clase.
import java.io.FilterWriter;
import java.io.IOException;
import java.io.Writer;
/**
* Decorator: un Writer que envuelve a otro, le pasa todo lo que recibe y,
* de paso, cuenta las líneas escritas.
*/
public class CountingWriter extends FilterWriter {
private int lines = 0;
public CountingWriter(Writer inner) {
super(inner);
}
@Override
public void write(int c) throws IOException {
if (c == '\n') {
lines++;
}
super.write(c);
}
@Override
public void write(char[] buf, int off, int len) throws IOException {
for (int i = off; i < off + len; i++) {
if (buf[i] == '\n') {
lines++;
}
}
super.write(buf, off, len);
}
@Override
public void write(String str, int off, int len) throws IOException {
for (int i = off; i < off + len; i++) {
if (str.charAt(i) == '\n') {
lines++;
}
}
super.write(str, off, len);
}
public int lines() {
return lines;
}
}import java.util.ArrayList;
import java.util.List;
/**
* La regla de nuestro CSV: campos separados por comas; un campo que tiene una
* coma o comillas va entre comillas, y sus comillas se escriben dobles.
* No se permiten saltos de línea dentro de un campo.
*/
public final class Csv {
private Csv() {
}
public static String join(List<String> fields) {
List<String> quoted = new ArrayList<>();
for (String f : fields) {
if (f.contains("\n") || f.contains("\r")) {
throw new IllegalArgumentException("salto de línea en un campo: " + f);
}
if (f.contains(",") || f.contains("\"")) {
quoted.add("\"" + f.replace("\"", "\"\"") + "\"");
} else {
quoted.add(f);
}
}
return String.join(",", quoted);
}
/** Parte una línea en campos. Lanza IllegalArgumentException si las comillas no cierran. */
public static List<String> split(String line) {
List<String> fields = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
if (inQuotes) {
if (c == '"' && i + 1 < line.length() && line.charAt(i + 1) == '"') {
current.append('"');
i++;
} else if (c == '"') {
inQuotes = false;
} else {
current.append(c);
}
} else if (c == '"') {
inQuotes = true;
} else if (c == ',') {
fields.add(current.toString());
current.setLength(0);
} else {
current.append(c);
}
}
if (inQuotes) {
throw new IllegalArgumentException("comillas sin cerrar");
}
fields.add(current.toString());
return fields;
}
}import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Guarda las canciones en un archivo CSV con encabezado, en UTF-8. */
public class CsvSongRepository implements SongRepository {
static final String HEADER = "title,artist,seconds";
private final Path file;
public CsvSongRepository(Path file) {
this.file = file;
}
@Override
public void save(List<Song> songs) throws IOException {
List<String> lines = new ArrayList<>();
lines.add(HEADER);
for (Song s : songs) {
lines.add(Csv.join(List.of(s.title(), s.artist(), String.valueOf(s.seconds()))));
}
Files.write(file, lines, StandardCharsets.UTF_8);
}
@Override
public List<Song> load() throws IOException {
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
if (lines.isEmpty() || !lines.get(0).equals(HEADER)) {
throw new MalformedCsvException(1, "se esperaba el encabezado " + HEADER);
}
List<Song> songs = new ArrayList<>();
for (int i = 1; i < lines.size(); i++) {
if (!lines.get(i).isBlank()) {
songs.add(parse(lines.get(i), i + 1));
}
}
return songs;
}
private static Song parse(String line, int number) throws MalformedCsvException {
try {
List<String> f = Csv.split(line);
if (f.size() != 3) {
throw new MalformedCsvException(number, "se esperaban 3 campos y hay " + f.size());
}
return new Song(f.get(0), f.get(1), Integer.parseInt(f.get(2).strip()));
} catch (NumberFormatException e) {
throw new MalformedCsvException(number, "la duración no es un número");
} catch (IllegalArgumentException e) {
// Lo que rechaza Csv.split (comillas) o el constructor de Song.
throw new MalformedCsvException(number, e.getMessage());
}
}
}import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class DecoratorDemo {
public static void main(String[] args) throws IOException {
Path file = Path.of("build", "demo", "informe.txt");
Files.createDirectories(file.getParent());
// Tres capas: PrintWriter (println, printf) sobre CountingWriter (cuenta)
// sobre el BufferedWriter que da Files (escribe en el archivo).
CountingWriter counter = new CountingWriter(Files.newBufferedWriter(file));
try (PrintWriter out = new PrintWriter(counter)) {
out.println("Informe del día");
out.printf("%-10s %5d%n", "Lima", 18);
out.printf("%-10s %5d%n", "Cusco", 9);
}
IO.println("líneas escritas: " + counter.lines());
// Error común: no cerrar. Lo escrito se queda en el buffer, no en el archivo.
Path other = file.resolveSibling("sin-cerrar.txt");
java.io.BufferedWriter leaky = Files.newBufferedWriter(other);
leaky.write("esto todavía no está en el disco\n");
IO.println("antes de close: " + Files.size(other) + " bytes");
leaky.close();
IO.println("después de close: " + Files.size(other) + " bytes");
// Al leer, las capas van al revés: bytes -> caracteres -> líneas.
try (InputStream bytes = Files.newInputStream(file);
InputStreamReader chars = new InputStreamReader(bytes, StandardCharsets.UTF_8);
BufferedReader lines = new BufferedReader(chars)) {
String line;
while ((line = lines.readLine()) != null) {
IO.println("> " + line);
}
}
}
}import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
public class FilesDemo {
public static void main(String[] args) throws IOException {
// Rutas: un Path es un nombre, no un archivo abierto.
Path dir = Path.of("build", "demo");
Path file = dir.resolve("notas.txt");
IO.println("ruta: " + file + " nombre: " + file.getFileName() + " padre: " + file.getParent());
IO.println("¿absoluta? " + file.isAbsolute() + " ¿existe? " + Files.exists(file));
// Archivos pequeños: todo de una vez.
Files.createDirectories(dir);
Files.write(file, List.of("Lima, 18 °C", "Cusco, 9 °C", "Iquitos, 31 °C"));
List<String> lines = Files.readAllLines(file);
IO.println("leídas " + lines.size() + " líneas; la segunda es \"" + lines.get(1) + "\"");
Files.writeString(file, "Puno, 4 °C\n", java.nio.file.StandardOpenOption.APPEND);
IO.print(Files.readString(file));
// Archivos grandes: línea por línea, con try-with-resources.
try (BufferedReader in = Files.newBufferedReader(file)) {
String line;
int n = 0;
while ((line = in.readLine()) != null) {
if (line.contains("°")) {
n++;
}
}
IO.println("con newBufferedReader: " + n + " líneas");
}
try (Stream<String> stream = Files.lines(file)) {
IO.println("con Files.lines: " + stream.filter(l -> l.startsWith("C")).toList());
}
// Un archivo que no existe.
try {
Files.readAllLines(dir.resolve("no-existe.txt"));
} catch (NoSuchFileException e) {
IO.println("NoSuchFileException: " + e.getMessage());
}
// Codificación: el mismo texto, escrito en ISO-8859-1 (Latin-1).
Path latin = dir.resolve("latin1.txt");
Files.writeString(latin, "Piña, café", StandardCharsets.ISO_8859_1);
IO.println("bytes en Latin-1: " + Files.size(latin));
IO.println("bytes en UTF-8: " + "Piña, café".getBytes(StandardCharsets.UTF_8).length);
try {
Files.readString(latin); // Files usa UTF-8 por defecto
} catch (IOException e) {
IO.println("leído como UTF-8: " + e);
}
IO.println("leído como Latin-1: " + Files.readString(latin, StandardCharsets.ISO_8859_1));
Path utf8 = dir.resolve("utf8.txt");
Files.writeString(utf8, "Piña, café");
IO.println("UTF-8 leído como Latin-1: " + Files.readString(utf8, StandardCharsets.ISO_8859_1));
}
}import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;
/** Decorator: registra cada operación y delega el trabajo en otro repositorio. */
public class LoggingSongRepository implements SongRepository {
private final SongRepository inner;
private final Consumer<String> log;
public LoggingSongRepository(SongRepository inner, Consumer<String> log) {
this.inner = inner;
this.log = log;
}
@Override
public List<Song> load() throws IOException {
try {
List<Song> songs = inner.load();
log.accept("load: " + songs.size() + " canciones");
return songs;
} catch (IOException e) {
log.accept("load falló: " + e.getMessage());
throw e;
}
}
@Override
public void save(List<Song> songs) throws IOException {
inner.save(songs);
log.accept("save: " + songs.size() + " canciones");
}
}import java.io.IOException;
/** Un archivo que se pudo leer pero no se entiende. Dice en qué línea. */
public class MalformedCsvException extends IOException {
private static final long serialVersionUID = 1L;
private final int line;
public MalformedCsvException(int line, String message) {
super("línea " + line + ": " + message);
this.line = line;
}
public int line() {
return line;
}
}import java.io.Serializable;
/** Una clase común (no un record) con la misma validación que Song. */
public class PlainSong implements Serializable {
private static final long serialVersionUID = 1L;
private final String title;
private final int seconds;
public PlainSong(String title, int seconds) {
if (seconds <= 0) {
throw new IllegalArgumentException("duración inválida: " + seconds);
}
this.title = title;
this.seconds = seconds;
}
public int seconds() {
return seconds;
}
@Override
public String toString() {
return "PlainSong[" + title + ", " + seconds + "]";
}
}import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/** Una lista de canciones que se puede serializar. */
public class Playlist implements Serializable {
private static final long serialVersionUID = 1L;
private final String name;
private final ArrayList<Song> songs = new ArrayList<>();
private transient int totalSeconds; // se recalcula; no se guarda
public Playlist(String name) {
this.name = name;
}
public void add(Song song) {
songs.add(song);
totalSeconds += song.seconds();
}
public String name() {
return name;
}
public List<Song> songs() {
return List.copyOf(songs);
}
public int totalSeconds() {
return totalSeconds;
}
}import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
/**
* Un menú de línea de comandos: lee un comando por línea y lo ejecuta.
* Recibe la entrada, la salida y el repositorio, así que se puede probar
* sin teclado ni archivos.
*/
public class PlaylistCli {
private final Scanner in;
private final PrintStream out;
private final SongRepository repo;
private final List<Song> songs = new ArrayList<>();
public PlaylistCli(Scanner in, PrintStream out, SongRepository repo) {
this.in = in;
this.out = out;
this.repo = repo;
}
public void run() {
load();
out.println("comandos: listar, agregar título;artista;segundos, guardar, salir");
while (true) {
out.print("> ");
if (!in.hasNextLine()) {
break; // se acabó la entrada
}
String line = in.nextLine().strip();
String command = line.contains(" ") ? line.substring(0, line.indexOf(' ')) : line;
String rest = line.substring(command.length()).strip();
switch (command) {
case "listar" -> list();
case "agregar" -> add(rest);
case "guardar" -> save();
case "salir" -> {
out.println("hasta luego");
return;
}
case "" -> { }
default -> out.println("comando desconocido: " + command);
}
}
}
private void load() {
try {
songs.addAll(repo.load());
out.println("cargadas " + songs.size() + " canciones");
} catch (NoSuchFileException e) {
out.println("no hay archivo todavía; se empieza con una lista vacía");
} catch (IOException e) {
out.println("no se pudo cargar: " + e.getMessage());
}
}
private void list() {
for (int i = 0; i < songs.size(); i++) {
Song s = songs.get(i);
out.printf("%d. %s - %s (%d s)%n", i + 1, s.title(), s.artist(), s.seconds());
}
}
private void add(String args) {
String[] f = args.split(";");
try {
if (f.length != 3) {
throw new IllegalArgumentException("uso: agregar título;artista;segundos");
}
songs.add(new Song(f[0].strip(), f[1].strip(), Integer.parseInt(f[2].strip())));
out.println("agregada: " + f[0].strip());
} catch (NumberFormatException e) {
out.println("no se agregó: la duración no es un número");
} catch (IllegalArgumentException e) {
out.println("no se agregó: " + e.getMessage());
}
}
private void save() {
try {
repo.save(songs);
out.println("guardadas " + songs.size() + " canciones");
} catch (IOException e) {
out.println("no se pudo guardar: " + e.getMessage());
}
}
public static void main(String[] args) {
Path file = Path.of(args.length > 0 ? args[0] : "canciones.csv");
new PlaylistCli(new Scanner(System.in), System.out, new CsvSongRepository(file)).run();
}
}import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class RepositoryDemo {
public static void main(String[] args) throws IOException {
Path file = Path.of("build", "demo", "canciones.csv");
Files.createDirectories(file.getParent());
SongRepository repo = new LoggingSongRepository(new CsvSongRepository(file),
msg -> IO.println("[log] " + msg));
List<Song> songs = List.of(
new Song("Contigo Perú", "Arturo \"Zambo\" Cavero", 254),
new Song("Cariñito", "Los Hijos del Sol", 187),
new Song("Lima, de noche", "Los Ensayos", 205));
repo.save(songs);
IO.print(Files.readString(file));
List<Song> back = repo.load();
IO.println("¿iguales? " + back.equals(songs));
// Otro archivo, con una línea mala.
Path bad = file.resolveSibling("malo.csv");
Files.writeString(bad, "title,artist,seconds\nValicha,Miguel Ángel Hurtado,192\nHuayno,Anónimo,tres\n");
try {
new LoggingSongRepository(new CsvSongRepository(bad), msg -> IO.println("[log] " + msg)).load();
} catch (MalformedCsvException e) {
IO.println("no se pudo cargar: " + e.getMessage() + " (e.line() = " + e.line() + ")");
}
}
}import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;
public class SerializationDemo {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Path file = Path.of("build", "demo", "lista.bin");
Files.createDirectories(file.getParent());
Playlist list = new Playlist("Criollas");
list.add(new Song("Contigo Perú", "Arturo Cavero", 254));
list.add(new Song("Cariñito", "Los Hijos del Sol", 187));
IO.println("antes: " + list.songs().size() + " canciones, " + list.totalSeconds() + " s");
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) {
out.writeObject(list);
}
byte[] bytes = Files.readAllBytes(file);
IO.println("archivo: " + bytes.length + " bytes, empieza con "
+ HexFormat.ofDelimiter(" ").formatHex(bytes, 0, 4));
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(file))) {
Playlist back = (Playlist) in.readObject();
IO.println("después: " + back.songs().size() + " canciones, " + back.totalSeconds()
+ " s (totalSeconds es transient)");
IO.println("¿mismas canciones? " + back.songs().equals(list.songs()));
}
// Bytes alterados: la duración 254 (00 00 00 fe) pasa a -5 (ff ff ff fb).
IO.println("clase común: " + tamper(new PlainSong("Contigo Perú", 254)));
IO.println("record: " + tamper(new Song("Contigo Perú", "Arturo Cavero", 254)));
}
/** Serializa, cambia el entero 254 por -5 en los bytes y deserializa. */
static String tamper(Object obj) throws IOException, ClassNotFoundException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(buffer)) {
out.writeObject(obj);
}
byte[] b = buffer.toByteArray();
for (int i = 0; i + 3 < b.length; i++) {
if (b[i] == 0 && b[i + 1] == 0 && b[i + 2] == 0 && b[i + 3] == (byte) 0xfe) {
b[i] = b[i + 1] = b[i + 2] = (byte) 0xff;
b[i + 3] = (byte) 0xfb;
}
}
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(b))) {
return String.valueOf(in.readObject());
} catch (IOException e) {
return e.toString();
}
}
}import java.io.Serializable;
/** Una canción. Se valida al crearla, también al deserializarla. */
public record Song(String title, String artist, int seconds) implements Serializable {
public Song {
if (title.isBlank()) {
throw new IllegalArgumentException("título vacío");
}
if (seconds <= 0) {
throw new IllegalArgumentException("duración inválida: " + seconds);
}
}
}import java.io.IOException;
import java.util.List;
/** Dónde se guardan las canciones. El modelo no sabe si es CSV, memoria u otra cosa. */
public interface SongRepository {
List<Song> load() throws IOException;
void save(List<Song> songs) throws IOException;
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CsvSongRepositoryTest {
@TempDir
Path dir;
@Test
void roundTripKeepsEverySong() throws IOException {
List<Song> songs = List.of(
new Song("Contigo Perú", "Arturo \"Zambo\" Cavero", 254),
new Song("Lima, de noche", "Los Ensayos", 205),
new Song("Ñaña", "Piña y Café", 99));
SongRepository repo = new CsvSongRepository(dir.resolve("s.csv"));
repo.save(songs);
assertEquals(songs, repo.load());
}
@Test
void emptyListRoundTrip() throws IOException {
SongRepository repo = new CsvSongRepository(dir.resolve("vacio.csv"));
repo.save(List.of());
assertEquals(List.of(), repo.load());
}
@Test
void missingFileIsNoSuchFile() {
SongRepository repo = new CsvSongRepository(dir.resolve("no-existe.csv"));
assertThrows(NoSuchFileException.class, repo::load);
}
@Test
void badNumberReportsTheLine() throws IOException {
Path file = dir.resolve("malo.csv");
Files.writeString(file, "title,artist,seconds\nA,B,100\nC,D,tres\n");
MalformedCsvException e = assertThrows(MalformedCsvException.class,
() -> new CsvSongRepository(file).load());
assertEquals(3, e.line());
}
@Test
void wrongFieldCountReportsTheLine() throws IOException {
Path file = dir.resolve("malo.csv");
Files.writeString(file, "title,artist,seconds\nLima, de noche,Los Ensayos,205\n");
MalformedCsvException e = assertThrows(MalformedCsvException.class,
() -> new CsvSongRepository(file).load());
assertEquals("línea 2: se esperaban 3 campos y hay 4", e.getMessage());
}
@Test
void invalidSongReportsTheLine() throws IOException {
Path file = dir.resolve("malo.csv");
Files.writeString(file, "title,artist,seconds\nA,B,-1\n");
MalformedCsvException e = assertThrows(MalformedCsvException.class,
() -> new CsvSongRepository(file).load());
assertEquals("línea 2: duración inválida: -1", e.getMessage());
}
@Test
void missingHeaderIsRejected() throws IOException {
Path file = dir.resolve("sin-encabezado.csv");
Files.writeString(file, "A,B,100\n");
assertThrows(MalformedCsvException.class, () -> new CsvSongRepository(file).load());
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.List;
import org.junit.jupiter.api.Test;
class CsvTest {
@Test
void plainFieldsAreJoinedWithCommas() {
assertEquals("a,b,3", Csv.join(List.of("a", "b", "3")));
}
@Test
void fieldsWithCommasOrQuotesAreQuoted() {
assertEquals("\"Lima, de noche\",\"Arturo \"\"Zambo\"\" Cavero\"",
Csv.join(List.of("Lima, de noche", "Arturo \"Zambo\" Cavero")));
}
@Test
void splitUndoesJoin() {
List<String> fields = List.of("Lima, de noche", "x \"y\" z", "", "7");
assertEquals(fields, Csv.split(Csv.join(fields)));
}
@Test
void emptyFieldsAreKept() {
assertEquals(List.of("a", "", ""), Csv.split("a,,"));
}
@Test
void newlinesInsideAFieldAreRejected() {
assertThrows(IllegalArgumentException.class, () -> Csv.join(List.of("dos\nlíneas")));
}
@Test
void unclosedQuotesAreRejected() {
assertThrows(IllegalArgumentException.class, () -> Csv.split("\"abierto,1"));
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class LoggingSongRepositoryTest {
/** Fake: un repositorio en memoria que recuerda lo que se le guardó. */
static class InMemoryRepository implements SongRepository {
List<Song> stored = List.of();
IOException failure = null;
@Override
public List<Song> load() throws IOException {
if (failure != null) {
throw failure;
}
return stored;
}
@Override
public void save(List<Song> songs) {
stored = List.copyOf(songs);
}
}
private final List<String> log = new ArrayList<>();
private final InMemoryRepository inner = new InMemoryRepository();
private final SongRepository repo = new LoggingSongRepository(inner, log::add);
@Test
void saveDelegatesAndLogs() throws IOException {
List<Song> songs = List.of(new Song("A", "B", 10));
repo.save(songs);
assertEquals(songs, inner.stored);
assertEquals(List.of("save: 1 canciones"), log);
}
@Test
void loadReturnsWhatTheInnerRepositoryReturns() throws IOException {
inner.stored = List.of(new Song("A", "B", 10), new Song("C", "D", 20));
assertSame(inner.stored, repo.load());
assertEquals(List.of("load: 2 canciones"), log);
}
@Test
void failuresAreLoggedAndRethrown() {
IOException boom = new IOException("disco lleno");
inner.failure = boom;
assertSame(boom, assertThrows(IOException.class, repo::load));
assertEquals(List.of("load falló: disco lleno"), log);
}
@Test
void decoratorsCanBeStacked() throws IOException {
List<String> outer = new ArrayList<>();
SongRepository twice = new LoggingSongRepository(repo, outer::add);
twice.save(List.of());
assertEquals(List.of("save: 0 canciones"), log);
assertEquals(List.of("save: 0 canciones"), outer);
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Scanner;
import org.junit.jupiter.api.Test;
class PlaylistCliTest {
private final LoggingSongRepositoryTest.InMemoryRepository repo = new LoggingSongRepositoryTest.InMemoryRepository();
private String run(String input) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
PrintStream out = new PrintStream(bytes, true, StandardCharsets.UTF_8);
new PlaylistCli(new Scanner(input), out, repo).run();
return bytes.toString(StandardCharsets.UTF_8);
}
@Test
void addAndSave() {
String output = run("agregar A;B;100\nguardar\nsalir\n");
assertEquals(List.of(new Song("A", "B", 100)), repo.stored);
assertTrue(output.contains("guardadas 1 canciones"));
}
@Test
void badInputDoesNotStopTheMenu() {
String output = run("agregar A;B\nfoo\nagregar A;B;x\nlistar\nsalir\n");
assertTrue(output.contains("uso: agregar título;artista;segundos"));
assertTrue(output.contains("comando desconocido: foo"));
assertTrue(output.contains("no se agregó: la duración no es un número"));
assertTrue(output.endsWith("hasta luego" + System.lineSeparator()));
}
@Test
void endOfInputEndsTheLoop() {
String output = run("listar\n");
assertTrue(output.endsWith("> "));
}
}import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SerializationTest {
@TempDir
Path dir;
private Path write(Object obj) throws IOException {
Path file = dir.resolve("obj.bin");
try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) {
out.writeObject(obj);
}
return file;
}
@Test
void playlistRoundTripLosesOnlyTheTransientField() throws Exception {
Playlist list = new Playlist("p");
list.add(new Song("A", "B", 100));
Path file = write(list);
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(file))) {
Playlist back = (Playlist) in.readObject();
assertEquals(list.songs(), back.songs());
assertEquals("p", back.name());
assertEquals(0, back.totalSeconds());
}
}
@Test
void filterRejectsClassesThatAreNotAllowed() throws Exception {
Path file = write(new PlainSong("A", 100));
try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(file))) {
in.setObjectInputFilter(ObjectInputFilter.Config.createFilter("Song;Playlist;java.util.ArrayList;!*"));
assertThrows(InvalidClassException.class, in::readObject);
}
}
@Test
void tamperedRecordIsRejected() throws Exception {
String result = SerializationDemo.tamper(new Song("A", "B", 254));
assertEquals("java.io.InvalidObjectException: duración inválida: -5", result);
}
}Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 15: archivos, E/S, Decorator, CSV, serialización y un menú con Scanner.
# make compila todo a build/
# make test corre las demostraciones y las pruebas JUnit
# make clean borra build/
JAVAC = javac
JFLAGS = -Xlint:all -Werror
SRC = $(wildcard *.java)
DEMOS = FilesDemo DecoratorDemo RepositoryDemo SerializationDemo
all: build/.stamp
build/.stamp: $(SRC)
mkdir -p build
$(JAVAC) $(JFLAGS) -cp $(JUNIT_JAR) -d build $(SRC)
touch $@
test: all
@for d in $(DEMOS); do echo "== $$d"; java -cp build $$d || exit 1; done
@echo "== PlaylistCli (entrada desde menu-input.txt)"
@cp build/demo/canciones.csv build/demo/menu.csv
java -cp build PlaylistCli build/demo/menu.csv < menu-input.txt
java -jar $(JUNIT_JAR) execute --class-path build --scan-class-path \
--disable-banner --disable-ansi-colors --details=tree
clean:
rm -rf build
.PHONY: all test clean