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.io como 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:

Lecturas complementarias:

Temas:

  • Path y 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.newBufferedReader y Files.lines (un stream de la semana 12) dentro de try-with-resources (semana 9).
  • Errores de E/S: IOException es 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 Files la 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) y Reader/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 de Writer que cuenta las líneas escritas, y LoggingSongRepository, 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:

Lecturas complementarias:

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.join o String.format; leer con split y 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.

  1. 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).
  2. Pruebas de ida y vuelta en un directorio temporal (@TempDir), sin tocar los archivos reales.
  3. Pruebas de errores: archivo inexistente, línea mal formada, campo numérico inválido.
  4. Escribir un decorator del repositorio que registre cada operación y comprobar con una prueba que delega correctamente.
  5. Arranque de P5: el menú de línea de comandos con Scanner y switch (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

  1. ¿Por qué el código que lee el CSV no debería estar dentro de la clase del producto? ¿Qué principio se aplican?
  2. ¿Qué capas tiene new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)) y qué agrega cada una? ¿Qué patrón es?
  3. ¿Qué pasa con una línea "Arroz, extra",3.50,10 si se lee con split(",")? Propongan una regla para el formato del proyecto.
  4. ¿Por qué no se debe deserializar con ObjectInputStream un archivo recibido de un usuario?
  5. ¿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.

CountingWriter.javaDescargar CountingWriter.java
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;
    }
}
Csv.javaDescargar Csv.java
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;
    }
}
CsvSongRepository.javaDescargar CsvSongRepository.java
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());
        }
    }
}
DecoratorDemo.javaDescargar DecoratorDemo.java
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);
            }
        }
    }
}
FilesDemo.javaDescargar FilesDemo.java
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));
    }
}
LoggingSongRepository.javaDescargar LoggingSongRepository.java
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");
    }
}
MalformedCsvException.javaDescargar MalformedCsvException.java
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;
    }
}
menu-input.txtDescargar menu-input.txt
listar
agregar Valicha;Miguel Ángel Hurtado;192
agregar Huayno sin duración;Anónimo;tres
borrar 1
listar
guardar
salir
PlainSong.javaDescargar PlainSong.java
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 + "]";
    }
}
Playlist.javaDescargar Playlist.java
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;
    }
}
PlaylistCli.javaDescargar PlaylistCli.java
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();
    }
}
RepositoryDemo.javaDescargar RepositoryDemo.java
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() + ")");
        }
    }
}
SerializationDemo.javaDescargar SerializationDemo.java
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();
        }
    }
}
Song.javaDescargar Song.java
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);
        }
    }
}
SongRepository.javaDescargar SongRepository.java
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.

CsvSongRepositoryTest.javaDescargar CsvSongRepositoryTest.java
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());
    }
}
CsvTest.javaDescargar CsvTest.java
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"));
    }
}
LoggingSongRepositoryTest.javaDescargar LoggingSongRepositoryTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;

import 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);
    }
}
PlaylistCliTest.javaDescargar PlaylistCliTest.java
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("> "));
    }
}
SerializationTest.javaDescargar SerializationTest.java
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.

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