Semana 16 — Programación orientada a eventos y cierre

Objetivos

  • Explicar el modelo de programación orientada a eventos: un event loop que espera eventos y handlers que reaccionan a ellos.
  • Relacionar los event handlers con Observer y con las lambdas.
  • Modelar una interfaz de usuario como una máquina de estados y separar la interfaz del modelo.
  • Repasar el diseño completo de una aplicación orientada a objetos y presentar el proyecto.

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

Sesión A — Programación orientada a eventos · ✂️ Interfaces gráficas

Lecturas:

Lecturas complementarias (✂️ interfaces gráficas):

Temas:

  • Programa secuencial frente a programa orientado a eventos: el programa no decide el orden; reacciona a lo que llega (una tecla, un clic, una línea de texto, un mensaje).
  • El event loop y los handlers. Un menú de línea de comandos es un event loop sencillo: leer un comando, despacharlo, repetir.
  • Handlers como objetos: una interfaz con un método (CommandHandler en el ejemplo; ActionListener en Swing), escrita como clase, clase anónima o lambda (semana 12). Registrar un handler es registrarse como observer (semana 14).
  • Máquinas de estados: el mismo evento hace cosas distintas según el estado. Ejemplo propio: un reproductor de música (detenido, sonando, en pausa) donde play empieza, continúa o no hace nada; en el proyecto, “confirmar” en el menú de venta frente al menú principal. Estados con un enum y una expresión switch, que el compilador revisa (una sentencia switch sobre un enum no).
  • Separar el modelo de la interfaz (MVC): el modelo de la bodega no sabe si lo usa una consola o una ventana; cambiar de interfaz no toca el modelo ni sus pruebas.
  • ✂️ Interfaces gráficas con Swing, como lectura: JFrame, JPanel, JButton.addActionListener, SwingUtilities.invokeLater; el mismo modelo del reproductor con una ventana. Sin código que necesite pantalla en code/. Si se pierde la clase, este bloque pasa a lectura autónoma.
  • Error común: poner la lógica del negocio dentro del handler del botón o del case del menú; no se puede probar sin la interfaz.

Sesión B — Cierre: diseño de una aplicación completa · Repaso

Lecturas:

  • Eck, edición Swing, subsec. 13.5.2 Design of the Program: cómo organiza Eck en paquetes y clases un programa completo (lectura de estructura; no hace falta correrlo).
  • Eck, subsec. 5.3.4 Object-oriented Analysis and Design (relectura de la semana 4, ahora con todo el curso encima).

Temas:

  • Recorrido por capas de una aplicación completa (la bodega como ejemplo): modelo con invariantes, reglas como strategies, repositorio con persistencia, interfaz de línea de comandos, pruebas en cada capa.
  • Volver a las preguntas del principio: qué es un objeto, quién protege cada invariante, dónde se usa polimorfismo y dónde un tipo sealed, qué depende de qué.
  • Lo que no vimos y viene después: concurrencia (Eck, cap. 12), interfaces gráficas a fondo (caps. 6 y 13), bases de datos, herramientas de build (Maven, Gradle).
  • Repaso para el examen final: partes 3 y 4 (semanas 9 a 16), con preguntas de todo el curso.

Si el examen final se toma en esta sesión (ver la nota del calendario en el SYLLABUS), el cierre se da en versión corta durante el laboratorio y el bloque ✂️ de la sesión A pasa a lectura autónoma.

Laboratorio — Presentación de proyectos

  1. Cada equipo presenta P5 en 10 minutos: una demostración por línea de comandos, el diagrama de clases final y una decisión de diseño que cambiaría.
  2. Preguntas del docente y de otro equipo sobre el código: dónde está un invariante, qué prueba lo cubre, cómo se agregaría un tipo de producto nuevo.
  3. Revisión cruzada breve: cada equipo lee el README.md y las pruebas de otro y anota un code smell y una fortaleza.

Entregas

  • P5 entrega.

Preguntas de repaso

  1. ¿En qué se parece registrar un event handler a registrar un observer? ¿Quién es el subject?
  2. Dibujen la máquina de estados del menú de su proyecto. ¿Qué pasa con un comando que no corresponde al estado actual?
  3. ¿Por qué la lógica del negocio no debe estar dentro de un event handler? ¿Qué se pierde en las pruebas?
  4. Si mañana la bodega necesitara una interfaz gráfica, ¿qué clases de su proyecto cambiarían y cuáles no?
  5. Elijan una decisión de diseño de su proyecto y expliquen qué principio o patrón la justifica y qué costo tiene.

Código de la semana

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

Ejemplos (10)Pruebas (3)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

CommandHandler.javaDescargar CommandHandler.java
import java.util.List;

/** Handler de un comando: recibe las palabras que siguen al nombre. */
@FunctionalInterface
public interface CommandHandler {
    void handle(List<String> args);
}
ConsoleView.javaDescargar ConsoleView.java
import java.io.PrintStream;

/** La vista de consola: observa al reproductor y muestra lo que pasa. */
public class ConsoleView implements PlayerListener {
    private final PrintStream out;

    public ConsoleView(PrintStream out) {
        this.out = out;
    }

    @Override
    public void onEvent(PlayerEvent e) {
        out.printf("[%s] %s: %s%n", e.state(), e.message(), e.song().title());
    }
}
EventLoop.javaDescargar EventLoop.java
import java.io.PrintStream;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Scanner;

/**
 * Un event loop de consola: espera una línea, busca el handler
 * registrado para su primera palabra y lo ejecuta. No sabe qué hace cada
 * comando.
 */
public class EventLoop {
    private final Map<String, CommandHandler> handlers = new LinkedHashMap<>();
    private final PrintStream out;
    private boolean running;

    public EventLoop(PrintStream out) {
        this.out = out;
    }

    /** Registrar un handler es suscribirse a un tipo de evento. */
    public void on(String command, CommandHandler handler) {
        handlers.put(command, handler);
    }

    public void stop() {
        running = false;
    }

    public void run(Scanner in) {
        running = true;
        while (running && in.hasNextLine()) {
            String line = in.nextLine().strip();
            if (line.isEmpty()) {
                continue;
            }
            List<String> words = Arrays.asList(line.split("\\s+"));
            CommandHandler handler = handlers.get(words.get(0));
            if (handler == null) {
                out.println("comando desconocido: " + words.get(0) + "; comandos: " + handlers.keySet());
                continue;
            }
            try {
                handler.handle(words.subList(1, words.size()));
            } catch (RuntimeException e) {
                out.println("error: " + e.getMessage());     // un handler que falla no detiene el ciclo
            }
        }
    }
}
player-input.txtDescargar player-input.txt
pause
play
play
tick 200
pause
tick 100
play
tick 60
next
rewind
tick
next
salir
Player.javaDescargar Player.java
import java.util.ArrayList;
import java.util.List;

/**
 * El modelo: un reproductor que es una máquina de estados. No sabe nada de
 * consolas ni de ventanas; avisa a sus observers de lo que pasa.
 */
public class Player {
    private final List<Song> queue;
    private final List<PlayerListener> listeners = new ArrayList<>();
    private State state = State.STOPPED;
    private int index = 0;
    private int elapsed = 0;            // segundos de la canción actual

    public Player(List<Song> queue) {
        if (queue.isEmpty()) {
            throw new IllegalArgumentException("lista vacía");
        }
        this.queue = List.copyOf(queue);
    }

    public void addListener(PlayerListener listener) {
        listeners.add(listener);
    }

    public State state() {
        return state;
    }

    public Song current() {
        return queue.get(index);
    }

    /** El mismo evento hace cosas distintas según el estado. */
    public void play() {
        String message = switch (state) {
            case STOPPED -> {
                index = 0;
                elapsed = 0;
                yield "suena";
            }
            case PAUSED -> "continúa";
            case PLAYING -> "ya está sonando";
        };
        change(State.PLAYING, message);
    }

    public void pause() {
        switch (state) {
            case PLAYING -> change(State.PAUSED, "en pausa");
            case PAUSED, STOPPED -> change(state, "no hay nada que pausar");
        }
    }

    public void stop() {
        elapsed = 0;
        change(State.STOPPED, "detenido");
    }

    public void next() {
        switch (state) {
            case STOPPED -> change(State.STOPPED, "detenido: use play");
            case PLAYING, PAUSED -> advance();
        }
    }

    /** Evento del reloj: pasaron unos segundos. Solo cuenta si está sonando. */
    public void tick(int seconds) {
        if (state != State.PLAYING) {
            return;
        }
        elapsed += seconds;
        if (elapsed >= current().seconds()) {
            advance();
        }
    }

    private void advance() {
        elapsed = 0;
        if (index + 1 < queue.size()) {
            index++;
            change(state, "siguiente");
        } else {
            change(State.STOPPED, "fin de la lista");
            index = 0;
        }
    }

    private void change(State newState, String message) {
        state = newState;
        PlayerEvent event = new PlayerEvent(state, current(), message);
        for (PlayerListener l : List.copyOf(listeners)) {
            l.onEvent(event);
        }
    }
}
PlayerApp.javaDescargar PlayerApp.java
import java.io.PrintStream;
import java.util.List;
import java.util.Scanner;

/**
 * Arma la aplicación: crea el modelo, conecta la vista y registra los
 * handlers. Es el único lugar que conoce a todas las piezas.
 */
public class PlayerApp {

    public static EventLoop build(Player player, PrintStream out) {
        player.addListener(new ConsoleView(out));
        EventLoop loop = new EventLoop(out);
        loop.on("play", args -> player.play());
        loop.on("pause", args -> player.pause());
        loop.on("next", args -> player.next());
        loop.on("stop", args -> player.stop());
        loop.on("tick", args -> {
            if (args.size() != 1) {
                throw new IllegalArgumentException("uso: tick <segundos>");
            }
            player.tick(Integer.parseInt(args.get(0)));
        });
        loop.on("salir", args -> loop.stop());
        return loop;
    }

    public static void main(String[] args) {
        Player player = new Player(List.of(
                new Song("Contigo Perú", "Arturo Cavero", 254),
                new Song("Cariñito", "Los Hijos del Sol", 187),
                new Song("Valicha", "Miguel Ángel Hurtado", 192)));
        build(player, System.out).run(new Scanner(System.in));
    }
}
PlayerEvent.javaDescargar PlayerEvent.java
/** Lo que el reproductor avisa a sus observers después de cada evento. */
public record PlayerEvent(State state, Song song, String message) {
}
PlayerListener.javaDescargar PlayerListener.java
/** Observer del reproductor. */
public interface PlayerListener {
    void onEvent(PlayerEvent event);
}
Song.javaDescargar Song.java
/** Una canción (la misma de la semana 15, sin serialización). */
public record Song(String title, String artist, int seconds) {
    public Song {
        if (seconds <= 0) {
            throw new IllegalArgumentException("duración inválida: " + seconds);
        }
    }
}
State.javaDescargar State.java
/** Los estados del reproductor. */
public enum State {
    STOPPED, PLAYING, PAUSED
}

Pruebas

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

EventLoopTest.javaDescargar EventLoopTest.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.ArrayList;
import java.util.List;
import java.util.Scanner;
import org.junit.jupiter.api.Test;

class EventLoopTest {
    private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    private final PrintStream out = new PrintStream(bytes, true, StandardCharsets.UTF_8);
    private final EventLoop loop = new EventLoop(out);

    private String output() {
        return bytes.toString(StandardCharsets.UTF_8);
    }

    @Test
    void dispatchesToTheRegisteredHandlerWithItsArguments() {
        List<List<String>> received = new ArrayList<>();
        loop.on("add", received::add);
        loop.run(new Scanner("add 1 2\n\nadd\n"));
        assertEquals(List.of(List.of("1", "2"), List.of()), received);
    }

    @Test
    void unknownCommandIsReportedAndTheLoopGoesOn() {
        List<String> seen = new ArrayList<>();
        loop.on("ok", args -> seen.add("ok"));
        loop.run(new Scanner("foo\nok\n"));
        assertTrue(output().startsWith("comando desconocido: foo"));
        assertEquals(List.of("ok"), seen);
    }

    @Test
    void failingHandlerDoesNotStopTheLoop() {
        List<String> seen = new ArrayList<>();
        loop.on("boom", args -> {
            throw new IllegalStateException("se rompió");
        });
        loop.on("ok", args -> seen.add("ok"));
        loop.run(new Scanner("boom\nok\n"));
        assertTrue(output().contains("error: se rompió"));
        assertEquals(List.of("ok"), seen);
    }

    @Test
    void stopEndsTheLoopBeforeTheInputEnds() {
        List<String> seen = new ArrayList<>();
        loop.on("salir", args -> loop.stop());
        loop.on("ok", args -> seen.add("ok"));
        loop.run(new Scanner("ok\nsalir\nok\n"));
        assertEquals(List.of("ok"), seen);
    }
}
PlayerAppTest.javaDescargar PlayerAppTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

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;

/** Prueba de toda la aplicación: entrada de texto, salida de texto, sin teclado. */
class PlayerAppTest {

    @Test
    void wholeSessionFromText() {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        PrintStream out = new PrintStream(bytes, true, StandardCharsets.UTF_8);
        Player player = new Player(List.of(new Song("A", "x", 10), new Song("B", "y", 10)));
        PlayerApp.build(player, out).run(new Scanner("play\ntick 10\ntick\nstop\n"));
        String nl = System.lineSeparator();
        assertEquals("[PLAYING] suena: A" + nl
                + "[PLAYING] siguiente: B" + nl
                + "error: uso: tick <segundos>" + nl
                + "[STOPPED] detenido: B" + nl, bytes.toString(StandardCharsets.UTF_8));
    }
}
PlayerTest.javaDescargar PlayerTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class PlayerTest {
    private final Song a = new Song("A", "x", 100);
    private final Song b = new Song("B", "y", 50);
    private final List<PlayerEvent> events = new ArrayList<>();
    private Player player;

    @BeforeEach
    void setUp() {
        player = new Player(List.of(a, b));
        player.addListener(events::add);          // un spy hecho con una referencia a método
    }

    private String lastMessage() {
        return events.get(events.size() - 1).message();
    }

    @Test
    void startsStopped() {
        assertEquals(State.STOPPED, player.state());
    }

    @Test
    void playMeansDifferentThingsInEachState() {
        player.play();
        assertEquals("suena", lastMessage());
        player.play();
        assertEquals("ya está sonando", lastMessage());
        player.pause();
        player.play();
        assertEquals("continúa", lastMessage());
        assertEquals(State.PLAYING, player.state());
    }

    @Test
    void pauseOnlyWorksWhilePlaying() {
        player.pause();
        assertEquals(State.STOPPED, player.state());
        assertEquals("no hay nada que pausar", lastMessage());
        player.play();
        player.pause();
        assertEquals(State.PAUSED, player.state());
    }

    @Test
    void tickAdvancesWhenTheSongEnds() {
        player.play();
        player.tick(99);
        assertEquals(a, player.current());
        player.tick(1);
        assertEquals(b, player.current());
        assertEquals(State.PLAYING, player.state());
    }

    @Test
    void tickIsIgnoredWhilePaused() {
        player.play();
        player.pause();
        player.tick(500);
        assertEquals(a, player.current());
        assertEquals(State.PAUSED, player.state());
    }

    @Test
    void nextAfterTheLastSongStops() {
        player.play();
        player.next();
        player.next();
        assertEquals(State.STOPPED, player.state());
        assertEquals("fin de la lista", lastMessage());
        assertEquals(b, events.get(events.size() - 1).song());
        assertEquals(a, player.current());
    }

    @Test
    void emptyQueueIsRejected() {
        assertThrows(IllegalArgumentException.class, () -> new Player(List.of()));
    }
}

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 16: programación orientada a eventos sin interfaz gráfica.
#   make         compila todo a build/
#   make test    corre el reproductor con player-input.txt y las pruebas JUnit
#   make clean   borra build/
JAVAC  = javac
JFLAGS = -Xlint:all -Werror
SRC    = $(wildcard *.java)

all: build/.stamp

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

test: all
	@echo "== PlayerApp (entrada desde player-input.txt)"
	java -cp build PlayerApp < player-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