Saltar al contenido
K
Arquitectura en iOSPaso a paso38 min Intermedio

El directo, en su propia clase

Al terminar: Sacar el reloj y los goles del partido a su propia clase, sin cambiar lo que hace la app.

Al terminar, el reloj del partido, los goles del guion y el aviso de cada 15 minutos vivirán en una clase nueva, Directo, y PartidoViewModel quedará en unas 100 líneas que solo traducen. La app se verá y se comportará igual que antes: Cmd+U sigue dando 61 pruebas en verde, y siete de ellas habrán cambiado de fichero.

Importa porque hoy el partido es del ViewModel, y el ViewModel es de la pantalla: si la pantalla se cierra, el partido desaparece con ella. Dentro de dos lecciones el partido tendrá que seguir vivo aunque salgas, y eso solo es posible si el partido es un objeto aparte, que alguien más pueda guardar. Esta lección no cambia nada de lo que ves: prepara el terreno. Temas: qué es refactorizar y cómo se demuestra que no se rompió nada, una clase por trabajo, pruebas que se mudan con el código, y adónde va a parar el ciclo del cronómetro. Nivel 2: usar.

Refactorizar: cambiar cómo está escrito, no lo que hace

Refactorizar es reorganizar el código por dentro sin cambiar lo que se ve por fuera: mover cosas de sitio, repartirlas en clases, darles mejores nombres. La app hace exactamente lo mismo antes y después; lo que cambia es que el código queda más fácil de entender y de cambiar.

¿Cómo sabes que no cambiaste lo que hace? Con pruebas. Son las que ya tienes: si antes de mover nada están en verde, y después de mover siguen en verde y dicen lo mismo, la mudanza fue limpia. Por eso, en esta lección, las pruebas no se reescriben: se mudan a la clase nueva y su resultado tiene que ser el mismo.

Por qué sacar el directo

Hoy PartidoViewModel hace dos trabajos distintos:

TrabajoQué contieneQuién debería hacerlo
Jugar el partidoEl reloj, los goles del guion, los goles «a mano», el aviso de cada 15 minutos y el aviso de cada golDirecto, una clase nueva
Hablar con la pantallaTraducir el partido a lo que pinta la vista (uiState), recibir los toques y guardar el gol en el repositorioPartidoViewModel, que se queda fino

La regla es una clase por trabajo. Jugar un partido es una regla del juego, y no depende de ninguna pantalla; traducirlo a pantalla sí. Mientras vivan juntos, el partido vive y muere con el ViewModel, y el ViewModel con la pantalla. Separados, el partido es un objeto que otra pieza puede guardar y mantener vivo. En Estado de toda la app en SwiftUI montarás quién lo guarda, y en El partido sigue aunque salgas, en iOS lo mantendrás vivo; hoy solo se hace la mudanza.

Ejemplo: sacar el horno de la pantalla

Un ejemplo solo para leer, con otro tema: la pantalla de un horno que cuenta minutos y apunta un aviso cada 15. Primero, todo junto en la pantalla:

ejemplo mínimo: la pantalla del horno lo hace todo (con comentarios)
// ANTES: la pantalla del horno lleva la cuenta de los minutos Y enseña el texto.
final class PantallaDelHorno {
    let receta: String
    private(set) var minuto = 0            // cuántos minutos lleva el horno encendido
    private(set) var aviso = "ninguno"     // el último aviso (uno cada 15 minutos)
 
    init(receta: String) {
        self.receta = receta
    }
 
    // Pasa un minuto. Cada 15, apunta un aviso.
    func avanzar() {
        minuto += 1
        if minuto % 15 == 0 { aviso = "minuto \(minuto)" }
    }
 
    // Lo que se vería en pantalla.
    var texto: String { "\(receta): minuto \(minuto), aviso \(aviso)" }
}
 
// La «prueba»: enciende el horno 40 minutos y mira el texto.
let pantalla = PantallaDelHorno(receta: "Pan de molde")
for _ in 1...40 { pantalla.avanzar() }
print(pantalla.texto)

Lo que imprime:

Pan de molde: minuto 40, aviso minuto 30

Y ahora, con la cuenta sacada a su propia clase, Horno. Fíjate en las dos últimas líneas: son idénticas a las de antes.

ejemplo mínimo: el horno, en su propia clase (con comentarios)
// DESPUÉS: el horno lleva la cuenta; la pantalla solo la traduce a texto.
final class Horno {
    private(set) var minuto = 0
    private(set) var aviso = "ninguno"
 
    func avanzar() {
        minuto += 1
        if minuto % 15 == 0 { aviso = "minuto \(minuto)" }
    }
}
 
final class PantallaDelHorno {
    let receta: String
    private(set) var horno = Horno()    // la pantalla GUARDA un horno: ya no lleva la cuenta ella
 
    init(receta: String) {
        self.receta = receta
    }
 
    // Reenvía el toque al horno.
    func avanzar() { horno.avanzar() }
 
    // Lo que se vería en pantalla: lo lee del horno.
    var texto: String { "\(receta): minuto \(horno.minuto), aviso \(horno.aviso)" }
}
 
// La «prueba»: la MISMA de antes, sin tocar ni una letra.
let pantalla = PantallaDelHorno(receta: "Pan de molde")
for _ in 1...40 { pantalla.avanzar() }
print(pantalla.texto)

Lo que imprime:

Pan de molde: minuto 40, aviso minuto 30

La salida es la misma, y esa es la prueba de que la mudanza fue limpia. Tres cosas han cambiado por dentro: minuto, aviso y la cuenta se fueron a Horno; PantallaDelHorno ahora guarda un Horno en una propiedad; y texto lee del horno en vez de leer de sí misma. Con Directo y PartidoViewModel pasa lo mismo, con más líneas.

Reto · Ejercicio: sacar el carrito de la tienda

En tu Playground, pega este punto de partida: el ViewModel de una tienda lo hace todo, y debajo hay una «prueba» que lo usa.

punto de partida del ejercicio
final class TiendaViewModel {
    private(set) var productos: [String] = []
    private(set) var total = 0
 
    func anadir(_ nombre: String, precio: Int) {
        productos.append(nombre)
        total += precio
    }
 
    var resumen: String { "\(productos.count) productos, total \(total) €" }
}
 
// La «prueba»: este trozo NO cambia durante el ejercicio.
let tienda = TiendaViewModel()
tienda.anadir("pan", precio: 3)
tienda.anadir("leche", precio: 2)
tienda.anadir("queso", precio: 7)
print(tienda.resumen)
print(tienda.productos)

Ejecútalo y comprueba que sale esto:

3 productos, total 12 €
["pan", "leche", "queso"]

Ahora refactoriza: saca los productos, el total y la forma de añadir a una clase propia, Carrito, que no sepa nada de pantallas. TiendaViewModel guarda un Carrito: cada producto que se añade se lo pasa al carrito, y su productos y su resumen los lee del carrito. Tiene que seguir ofreciendo anadir, productos y resumen, con los mismos nombres. No toques el trozo de la «prueba»: las últimas líneas tienen que seguir siendo las mismas, y la salida tiene que ser exactamente la de arriba.

Ver solución
solución del ejercicio
// El carrito lleva los productos y el total. No sabe nada de pantallas.
final class Carrito {
    private(set) var productos: [String] = []
    private(set) var total = 0
 
    func anadir(_ nombre: String, precio: Int) {
        productos.append(nombre)
        total += precio
    }
}
 
// El ViewModel guarda un carrito y lo traduce a texto.
final class TiendaViewModel {
    private(set) var carrito = Carrito()
 
    var productos: [String] { carrito.productos }
 
    func anadir(_ nombre: String, precio: Int) {
        carrito.anadir(nombre, precio: precio)
    }
 
    var resumen: String { "\(carrito.productos.count) productos, total \(carrito.total) €" }
}
 
// La «prueba»: este trozo NO cambia durante el ejercicio.
let tienda = TiendaViewModel()
tienda.anadir("pan", precio: 3)
tienda.anadir("leche", precio: 2)
tienda.anadir("queso", precio: 7)
print(tienda.resumen)
print(tienda.productos)

Sale lo mismo que antes. TiendaViewModel conserva productos y anadir (ahora reenvían al carrito) para que la «prueba» no tenga que cambiar: así el resto del programa no se entera de la mudanza. Es lo mismo que harás con PartidoViewModel, que seguirá ofreciendo alGol y jugar(duracion:) aunque el trabajo lo haga Directo.

Paso 1 · Directo.swift

Objetivo: que el reloj y los goles tengan su propia clase. Todavía nadie la usa, así que la app compila y se ve igual.

MarcadorApp (proyecto)
MarcadorApp
Marcador
Domain
Directo.swiftse crea
UI
PartidoViewModel.swiftse modifica
MarcadorAppTests
Marcador
Domain
DirectoTests.swiftse crea
UI
PartidoViewModelTests.swiftse modifica
PartidoViewModelXCTests.swiftse modifica
Los ficheros de la lección en el Project navigator.

Crea Directo.swift: clic derecho sobre MarcadorApp/Marcador/Domain (la de la app, no la de pruebas) → New File from Template… → Swift File, solo con el target MarcadorApp. Domain es la carpeta de lo que la funcionalidad sabe de sí misma, y un partido que se juega solo es eso: no dibuja nada ni sabe de pantallas.

Piezas de este paso. Casi todo lo has visto ya: es el código que hoy está dentro de PartidoViewModel, con estas diferencias:

  • let duracion: el directo nace con su duración y ya no la cambia. Si el usuario elige otra, se crea otro directo (lo hace el ViewModel, en el paso siguiente).
  • El cronómetro se crea con el directo. private let cronometro = Cronometro() lo crea en cuanto nace el directo (antes se creaba dentro de jugar), y el init le prepara su closure alTick, la que llama a alMinuto() cada vez que pasa un minuto. Eso importa para el ciclo de retención: basta con abrir un partido para que el ciclo exista.
  • Dos «minutos». cronometro.minuto es la cuenta del cronómetro, que avanza primero; minuto, a secas, es el del directo, el que lee la pantalla: alMinuto() lo copia de la cuenta del cronómetro cuando ya ha aplicado los goles de ese minuto.
  • jugar() sin parámetros. guard !corriendo, !terminado else { return } sale sin hacer nada si el partido ya corre o ya terminó. Si lo cancelaron, corriendo quedó en false y vuelve a empezar desde el minuto en que estaba, sin borrar goles ni repetir avisos.
  • deinit anota «Directo LIBERADO», igual que el del ViewModel.

Intenta escribirlo tú. Una clase Directo, final, del hilo principal y observable, que no importa SwiftUI, con:

  • Qué recibe al crearse: el id del partido, el partido y la duración; y, con valores por defecto, el notificador (nil), los milisegundos por minuto y si se reintroduce el ciclo.
  • Qué guarda, de solo lectura desde fuera: el minuto, si corre, el último aviso (empieza en «ninguno»), los goles del guion y los goles a mano. Solo cuentan los goles del guion que caben en la duración.
  • Su cronómetro: lo crea ella y, en el init, le prepara la closure con el [weak self] de siempre (Ver un ciclo de retención), o sin él si piden el ciclo.
  • Qué ofrece: marcador (la suma de los dos), terminado, golAMano(_:) y jugar(), con las anotaciones del registro (Registro.anotar("…"), de La app, sus escenas y el paquete Torneo).
  • Un método privado, alMinuto(), que aplica los goles de ese minuto, avisa al notificador de cada gol del guion (los «a mano» no) y, cada 15 minutos, guarda el aviso.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/Domain/Directo.swift
import Foundation
import Observation
import Torneo
 
// EL DIRECTO: un partido que se está jugando (gemelo del `Directo` de Android).
//
// Antes todo esto vivía dentro de `PartidoViewModel`: el reloj, los goles del guion, los «a mano» y
// el aviso de cada 15'. Se saca a su propia clase para que el partido no tenga que ser de UNA
// pantalla: un objeto que viva lo que la app puede guardarlo, y varias pantallas mirarlo a la vez.
// El ViewModel se queda fino: traduce este directo a lo que pinta la vista.
//
// No importa `SwiftUI`: se prueba sin dibujar nada. `@Observable`: cuando cambia el minuto o un gol,
// se redibujan las vistas que lo leen (aunque lo lean a través del ViewModel).
@MainActor
@Observable
final class Directo {
    let partidoId: Int
    let partido: Partido
    let duracion: Int
 
    private(set) var minuto = 0
    private(set) var corriendo = false
    private(set) var ultimoAviso = "ninguno"
    // Los goles que va marcando el guion y los que se ponen «a mano» con los botones.
    private(set) var golesEnVivo = Marcador(local: 0, visitante: 0)
    private(set) var golesAMano = Marcador(local: 0, visitante: 0)
 
    // Quien avisa de los goles (nil = nadie). Es un protocolo: en las pruebas, un espía.
    @ObservationIgnored private let notificador: (any Notificador)?
    @ObservationIgnored private let msPorMinuto: Int
    @ObservationIgnored private let eventosDelGuion: [EventoDePartido]
 
    // EL DUEÑO del cronómetro es este directo (referencia FUERTE). Ojo a la flecha inversa: el
    // cronómetro guarda una closure (`alTick`) y esa closure llama al directo. Si la closure captura
    // `self` con una referencia fuerte, hay DOS flechas fuertes que se miran: un ciclo (mira `prepararReloj`).
    @ObservationIgnored private let cronometro = Cronometro()
 
    init(
        partidoId: Int,
        partido: Partido,
        duracion: Int,
        notificador: (any Notificador)? = nil,
        msPorMinuto: Int = Configuracion.msPorMinuto,
        reintroducirElCiclo: Bool = Configuracion.cicloDelCronometro
    ) {
        self.partidoId = partidoId
        self.partido = partido
        self.duracion = duracion
        self.notificador = notificador
        self.msPorMinuto = msPorMinuto
        // Un partido de 60 minutos ignora los goles del guion posteriores al 60.
        eventosDelGuion = partido.eventos.filter { $0.minuto <= duracion }
        prepararReloj(reintroducirElCiclo: reintroducirElCiclo)
    }
 
    // `deinit` sale en el registro cuando ARC libera el directo. Si NO sale al cerrarlo, algo lo
    // retiene: es la prueba más barata de que no hay una fuga.
    deinit {
        Registro.anotar("Directo LIBERADO")
    }
 
    var marcador: Marcador {
        Marcador(local: golesEnVivo.local + golesAMano.local, visitante: golesEnVivo.visitante + golesAMano.visitante)
    }
 
    var terminado: Bool { minuto >= duracion }
 
    func golAMano(_ lado: Lado) {
        switch lado {
        case .local: golesAMano = Marcador(local: golesAMano.local + 1, visitante: golesAMano.visitante)
        case .visitante: golesAMano = Marcador(local: golesAMano.local, visitante: golesAMano.visitante + 1)
        }
    }
 
    // UN SOLO BUCLE marca el tiempo y el cronómetro solo cuenta lo que le mandan. Si lo cancelan,
    // se para donde iba; si se vuelve a llamar, SIGUE desde ese minuto (no borra goles ni repite
    // avisos). Si ya terminó, no hace nada.
    func jugar() async {
        guard !corriendo, !terminado else { return }
        corriendo = true
        while cronometro.minuto < duracion {
            do {
                try await Task.sleep(for: .milliseconds(msPorMinuto))
            } catch {
                Registro.anotar("partido CANCELADO en el minuto \(minuto)")
                corriendo = false
                return
            }
            cronometro.avanzar()   // llama a `alTick`, que llama a `alMinuto()`
        }
        corriendo = false
        Registro.anotar("partido TERMINADO en el minuto \(minuto)")
    }
 
    // EL CICLO DE RETENCIÓN EN UNA APP DE VERDAD.
    //
    //   ASÍ NO VALE:        reloj.alTick = { self.alMinuto() }
    //   BIEN:               reloj.alTick = { [weak self] in self?.alMinuto() }
    //
    // `alTick` es una propiedad del cronómetro: la closure se GUARDA. Si captura `self` fuerte, el
    // directo retiene al cronómetro y el cronómetro retiene al directo (dentro de la closure): ninguno
    // llega a contador cero, `deinit` no sale nunca y el directo de cada partido visitado se queda en
    // memoria para siempre. Se prepara en el `init`: basta con abrir un partido para que el ciclo exista.
    //
    // (El parámetro `reintroducirElCiclo` y el argumento de lanzamiento `-cicloDelCronometro YES`
    // existen SOLO para la lección del Memory Graph: vuelven a meter el bug para verlo. En una app real
    // no existiría ese `if`.)
    private func prepararReloj(reintroducirElCiclo: Bool) {
        if reintroducirElCiclo {
            cronometro.alTick = { self.alMinuto() }
        } else {
            cronometro.alTick = { [weak self] in self?.alMinuto() }
        }
    }
 
    private func alMinuto() {
        let nuevo = cronometro.minuto
        for evento in eventosDelGuion where evento.minuto == nuevo {
            golesEnVivo = golesEnVivo.despuesDe(evento, en: partido)
            // Cada gol del guion avisa (los «a mano» no: los puso el usuario, que ya lo sabe).
            if case let .gol(minutoDelGol, jugador, equipo) = evento {
                notificador?.notificarGol(minuto: minutoDelGol, jugador: jugador.nombre, equipo: equipo.nombre)
            }
        }
        minuto = nuevo
        if nuevo % 15 == 0 { ultimoAviso = "minuto \(nuevo) con \(marcador)" }
    }
}

Compila con Cmd+B: tiene que terminar en Build Succeeded.

Registro, Cronometro y Configuracion son de lecciones anteriores y siguen en tu proyecto. Si Xcode dice «Cannot find Registro in scope» (o lo mismo con alguno de los otros dos), lo más probable es que Directo.swift no esté en el target de la app. Selecciónalo en el Project navigator, abre el File inspector (View → Inspectors → File) y, en Target Membership, marca MarcadorApp. Vuelve a compilar.

Paso 2 · PartidoViewModel.swift, más fino

Objetivo: que el ViewModel guarde un Directo y se limite a traducirlo. La vista no cambia: sigue llamando a viewModel.jugar(duracion:) desde su .task(id:).

Sustituye todo el contenido de PartidoViewModel.swift. Lo que desaparece: el reloj, los goles, el aviso y el cronómetro. Lo que se queda: uiState, alGol y jugar(duracion:).

Intenta escribirlo tú. El ViewModel guarda su directo (opcional) y un mensaje de error. Sus dependencias llegan por el init: el repositorio, el notificador y los parámetros que pasará al directo. uiState lee el directo: error si hay mensaje, «cargando» si aún no hay directo, y éxito con los datos del directo si lo hay. alGol suma el gol en el directo y lo guarda con el caso de uso. jugar(duracion:) crea un directo nuevo si la duración cambió, y después espera a directo.jugar().

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/UI/PartidoViewModel.swift
import Foundation
import Observation
import Torneo
 
// MVVM EN SWIFTUI (gemelo del `PartidoViewModel` de Android)
//
//   Vista (SwiftUI)  ──eventos──▶  ViewModel  ──llama──▶  caso de uso / repositorio  ──▶  datos
//        ▲                            │
//        └──── estado (`uiState`) ────┘
//
// La VISTA solo pinta `uiState` y traduce toques en llamadas al ViewModel. El VIEWMODEL sabe de
// pantalla (qué se ve, en qué orden) pero NO de SwiftUI: no importa `SwiftUI`. Por eso se puede
// probar sin dibujar nada.
//
// `@MainActor`: el ViewModel es estado de INTERFAZ y SwiftUI lo lee desde el hilo principal. Con
// esta anotación el compilador impide tocarlo desde otro hilo sin `await`. En un proyecto de
// Xcode 26 el aislamiento a MainActor ya es el valor por defecto de TODO el módulo, así que aquí es
// redundante; se escribe igualmente porque dice la intención y porque en el paquete `Torneo` (que no
// lo tiene por defecto) sí haría falta. Comparado con Android: `Dispatchers.Main` + `viewModelScope`.
//
// EL VIEWMODEL, FINO. El reloj, los goles y el aviso viven en `Directo`; este ViewModel lo crea, lo
// traduce a `uiState` y le pasa los toques. Las vistas siguen sin saber que existe el directo.
@MainActor
@Observable
final class PartidoViewModel {
    let partidoId: Int
 
    // ---- estado (lo lee la vista; solo este ViewModel lo escribe) ----
    private(set) var directo: Directo?
    private(set) var mensajeDeError: String?
 
    // ---- dependencias (llegan por el constructor: nadie las crea a escondidas) ----
    @ObservationIgnored private let repositorio: any PartidosRepositorio
    @ObservationIgnored private let registrarGol: RegistrarGol
    @ObservationIgnored private let msPorMinuto: Int
    @ObservationIgnored private let reintroducirElCiclo: Bool
    // Quien avisa de los goles (nil = nadie). Se lo pasa al directo.
    @ObservationIgnored private let notificador: (any Notificador)?
 
    init(
        partidoId: Int,
        repositorio: any PartidosRepositorio,
        notificador: (any Notificador)? = nil,
        msPorMinuto: Int = Configuracion.msPorMinuto,
        reintroducirElCiclo: Bool = Configuracion.cicloDelCronometro
    ) {
        self.partidoId = partidoId
        self.repositorio = repositorio
        self.registrarGol = RegistrarGol(repositorio: repositorio)
        self.notificador = notificador
        self.msPorMinuto = msPorMinuto
        self.reintroducirElCiclo = reintroducirElCiclo
        cargar(duracion: Ajustes.duracionPorDefecto)
    }
 
    // `deinit` sale en el registro cuando ARC libera el ViewModel.
    deinit {
        Registro.anotar("PartidoViewModel LIBERADO")
    }
 
    // ---- lo que ve la pantalla ----
    var uiState: MarcadorUiState {
        if let mensajeDeError { return .error(mensajeDeError) }
        guard let directo else { return .cargando }
        return .exito(.init(
            partidoId: partidoId, partido: directo.partido, marcador: directo.marcador, minuto: directo.minuto,
            corriendo: directo.corriendo, ultimoAviso: directo.ultimoAviso, duracion: directo.duracion
        ))
    }
 
    // ---- eventos de la pantalla ----
    func alGol(_ lado: Lado) {
        guard let directo else { return }
        // Se ve al instante en el directo Y se guarda con el caso de uso.
        directo.golAMano(lado)
        if case let .failure(fallo) = registrarGol(partidoId: partidoId, lado: lado, minuto: directo.minuto) {
            Registro.anotar("no se pudo registrar el gol: \(fallo)")
        }
    }
 
    // La vista la llama desde `.task(id: duración)`: se cancela sola al irse la pantalla.
    // `.task` se CANCELA cuando otra pantalla se apila encima (Goleadores) y se RELANZA al volver, pero el
    // ViewModel (y su directo) siguen vivos: si la duración no cambió, `jugar` SIGUE donde iba. Solo hace
    // un directo nuevo si cambia la duración.
    func jugar(duracion nueva: Int) async {
        if directo?.duracion != nueva { cargar(duracion: nueva) }
        await directo?.jugar()
    }
 
    // Leer el partido del repositorio y preparar un directo sin empezar.
    private func cargar(duracion: Int) {
        guard let partido = repositorio.partido(id: partidoId) else {
            directo = nil
            mensajeDeError = "No existe el partido n.º \(partidoId)"
            return
        }
        mensajeDeError = nil
        directo = Directo(
            partidoId: partidoId, partido: partido, duracion: duracion,
            notificador: notificador, msPorMinuto: msPorMinuto, reintroducirElCiclo: reintroducirElCiclo
        )
    }
}

Hasta que acabes el paso 3, las pruebas viejas no compilan (siguen hablando de viewModel.minuto o de viewModel.jugar con el reloj dentro): si pulsas Cmd+U antes, Xcode marcará errores en ellas. Es lo esperado.

Paso 3 · Las pruebas se mudan

Objetivo: que las pruebas del reloj y de los goles hablen con Directo, y que el resultado de cada una sea el de antes.

De las diez pruebas de PartidoViewModelTests, siete son del reloj y de los goles. Esas siete se mudan a un fichero nuevo, DirectoTests.swift; las otras tres se quedan donde estaban:

Antes, en PartidoViewModelTestsAhora
jugarAplicaLosGolesDelGuionDirectoTests, hablando con el directo
unPartidoDeSesentaMinutosIgnoraElGolDelMinutoOchentaDirectoTests
cancelarLaTareaDetieneElPartidoDirectoTests
elViewModelSeLiberaAlSoltarloDirectoTests, como elDirectoSeLiberaAlSoltarlo
conElCicloReintroducidoElViewModelSeQuedaEnMemoriaDirectoTests, como conElCicloReintroducidoElDirectoSeQuedaEnMemoria
cadaGolDelGuionAvisaAlNotificadorDirectoTests
losGolesAManoNoAvisanYLosPosterioresALaDuracionTampocoDirectoTests
unPartidoQueNoExisteDaUnEstadoDeError, unGolAManoSeVeYSeGuardaEnElRepositorio y elViewModelPideAlRepositorioSuPartidoSe quedan en PartidoViewModelTests

Hay un cambio pequeño en el sentido de dos pruebas: «se libera al soltarlo» y «con el ciclo se queda en memoria» ya no miden al ViewModel, sino al Directo, porque el cronómetro y su closure viven ahora en el directo.

Crea DirectoTests.swift en MarcadorAppTests/Marcador/Domain, porque prueba lo que hay en Marcador/Domain. Esa carpeta de pruebas ya existe, junto a RegistrarGolTests (Tests en iOS: falsos y espías): clic derecho sobre MarcadorAppTests/Marcador/Domain → New File from Template… → Swift File, solo con el target MarcadorAppTests. Este fichero puedes copiarlo (abre el código de abajo y pégalo): son las pruebas que ya tenías, mudadas, y tu intento de escribir va en Directo y en el ViewModel. Si aun así quieres practicar: un struct de pruebas del hilo principal con una función de ayuda que crea un directo del partido de ejemplo (con minutos de 0 ms por defecto), y las siete pruebas de la tabla. En cada prueba verás let directo = directo(): una constante con el mismo nombre que la función de ayuda. Swift lo permite: a la derecha del =, con paréntesis, directo() llama a la función; a la izquierda se estrena la constante, y desde esa línea directo sin paréntesis es la constante.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Marcador/Domain/DirectoTests.swift
import Testing
import Torneo
@testable import MarcadorApp
 
// Pruebas del directo: el reloj, los goles del guion, los avisos y la memoria. Antes eran del
// ViewModel; se mudaron con el código. `@MainActor`: el directo lo es.
@MainActor
struct DirectoTests {
 
    private func directo(
        duracion: Int = 90, notificador: (any Notificador)? = nil, msPorMinuto: Int = 0, ciclo: Bool = false
    ) -> Directo {
        Directo(
            partidoId: 1, partido: Ejemplo.rayoContraToros, duracion: duracion,
            notificador: notificador, msPorMinuto: msPorMinuto, reintroducirElCiclo: ciclo
        )
    }
 
    @Test func jugarAplicaLosGolesDelGuion() async {
        let directo = directo()
        await directo.jugar()
        #expect(directo.marcador == Marcador(local: 2, visitante: 1))
        #expect(directo.minuto == 90)
        #expect(!directo.corriendo)
        #expect(directo.terminado)
        #expect(directo.ultimoAviso == "minuto 90 con 2-1")
    }
 
    @Test func unPartidoDeSesentaMinutosIgnoraElGolDelMinutoOchenta() async {
        let directo = directo(duracion: 60)
        await directo.jugar()
        #expect(directo.marcador == Marcador(local: 1, visitante: 1))
    }
 
    @Test func cancelarLaTareaDetieneElPartido() async {
        let directo = directo(msPorMinuto: 20)
        let tarea = Task { await directo.jugar() }
        try? await Task.sleep(for: .milliseconds(150))
        tarea.cancel()
        await tarea.value
        #expect(directo.minuto < 90)
        #expect(!directo.corriendo)
    }
 
    // LA PRUEBA DEL CICLO. Con `[weak self]` el directo se libera en cuanto se suelta...
    @Test func elDirectoSeLiberaAlSoltarlo() async {
        weak var referencia: Directo?
        do {
            let directo = directo(duracion: 3)
            referencia = directo
            await directo.jugar()
        }
        #expect(referencia == nil)
    }
 
    // ...y con el ciclo reintroducido, NO: `referencia` sigue viva aunque nadie tenga el directo.
    // (Este objeto se queda en memoria hasta que termina el proceso de pruebas: es la fuga.)
    @Test func conElCicloReintroducidoElDirectoSeQuedaEnMemoria() async {
        weak var referencia: Directo?
        do {
            let directo = directo(duracion: 3, ciclo: true)
            referencia = directo
            await directo.jugar()
        }
        #expect(referencia != nil)
    }
 
    // Cada gol del guion avisa: goles de Ana (12), Iván (55) y Ana (80).
    @Test func cadaGolDelGuionAvisaAlNotificador() async {
        let espia = NotificadorEspia()
        let directo = directo(notificador: espia)
        await directo.jugar()
        #expect(espia.avisos.map(\.minuto) == [12, 55, 80])
        #expect(espia.avisos.map(\.jugador) == ["Ana", "Iván", "Ana"])
        #expect(espia.avisos.first?.equipo == "Rayo FC")
    }
 
    // Los goles «a mano» no avisan, y un partido de 60 minutos no avisa del gol del minuto 80.
    @Test func losGolesAManoNoAvisanYLosPosterioresALaDuracionTampoco() async {
        let espia = NotificadorEspia()
        let directo = directo(duracion: 60, notificador: espia)
        directo.golAMano(.local)
        #expect(espia.avisos.isEmpty)
        await directo.jugar()
        #expect(espia.avisos.map(\.minuto) == [12, 55])
    }
}

Después, sustituye todo el contenido de PartidoViewModelTests.swift (también se puede copiar): quedan tres pruebas.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Marcador/UI/PartidoViewModelTests.swift
import Testing
import Torneo
@testable import MarcadorApp
 
// Pruebas del ViewModel con el repositorio en memoria (en las otras pruebas, un falso).
// `@MainActor`: el ViewModel lo es, y el target de pruebas no tiene el aislamiento por defecto.
@MainActor
struct PartidoViewModelTests {
 
    // El repositorio es un FALSO (ver `Dobles.swift`): sin disco y con los goles anotados.
    private func repositorio() -> RepositorioFalso { RepositorioFalso() }
 
    @Test func unPartidoQueNoExisteDaUnEstadoDeError() {
        let viewModel = PartidoViewModel(partidoId: 99, repositorio: repositorio(), msPorMinuto: 0)
        #expect(viewModel.uiState == .error("No existe el partido n.º 99"))
    }
 
    @Test func unGolAManoSeVeYSeGuardaEnElRepositorio() {
        let repo = repositorio()
        let viewModel = PartidoViewModel(partidoId: 1, repositorio: repo, msPorMinuto: 0)
        viewModel.alGol(.visitante)
        #expect(viewModel.directo?.marcador == Marcador(local: 0, visitante: 1))
        // El falso anotó UN gol, del equipo visitante, con el jugador que tocaba por turno
        // (Toros lleva 1 gol de 2 jugadores: le toca el índice 1, Sofía).
        #expect(repo.golesRegistrados.count == 1)
        #expect(repo.golesRegistrados.first?.partidoId == 1)
        guard case let .gol(minuto, jugador, equipo)? = repo.golesRegistrados.first?.gol else {
            Issue.record("Se esperaba un gol")
            return
        }
        #expect(minuto == 0)
        #expect(jugador.nombre == "Sofía")
        #expect(equipo == Ejemplo.toros)
    }
 
    // El ViewModel pregunta por SU partido, no por otro.
    @Test func elViewModelPideAlRepositorioSuPartido() {
        let repo = repositorio()
        _ = PartidoViewModel(partidoId: 1, repositorio: repo, msPorMinuto: 0)
        #expect(repo.consultas == [1])
    }
 
}

Los dos comentarios de arriba hablan de lo mismo con palabras distintas: «repositorio en memoria» y «falso» son el mismo RepositorioFalso, el doble que creaste en Tests en iOS: falsos y espías. Guarda todo en memoria, no toca el disco, y lo usan las tres pruebas.

Y sustituye todo el contenido de PartidoViewModelXCTests.swift (otra vez, se puede copiar). Siguen siendo cuatro pruebas, pero dos de ellas (testJugarAplicaLosGolesDelGuion y la de rendimiento) juegan el partido con un Directo; y testElViewModelSeLiberaAlSoltarlo ya no juega nada, porque el ViewModel no tiene reloj: solo se crea y se suelta.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Marcador/UI/PartidoViewModelXCTests.swift
import XCTest
import Torneo
@testable import MarcadorApp
 
// LAS MISMAS PRUEBAS, EN XCTEST (el marco anterior a Swift Testing, de 2014).
//
// Es lo que vas a encontrar en casi todo el código que ya existe, y sigue siendo OBLIGATORIO para
// las pruebas de UI y de rendimiento. Diferencias con Swift Testing (`PartidoViewModelTests` y `DirectoTests`):
//
//   Swift Testing                     XCTest
//   struct + @Test                    clase que hereda de XCTestCase; el método debe EMPEZAR por `test`
//   #expect(a == b)                   XCTAssertEqual(a, b)   (una función por tipo de comprobación)
//   Issue.record("…")                 XCTFail("…")
//   init() / deinit                   setUp() / tearDown()   (una instancia NUEVA por prueba, en los dos)
//   @Test(arguments: […])             no hay: se escribe un bucle o varias pruebas
//   se ejecutan en paralelo           en serie por defecto
//
// `@MainActor` en la clase: el ViewModel lo es (en XCTest, además, `setUp` y los `test…` heredan el aislamiento de la clase).
@MainActor
final class PartidoViewModelXCTests: XCTestCase {
    var repositorio: RepositorioFalso!
 
    override func setUp() async throws {
        repositorio = RepositorioFalso()
    }
 
    func testJugarAplicaLosGolesDelGuion() async {
        let directo = Directo(partidoId: 1, partido: Ejemplo.rayoContraToros, duracion: 90, msPorMinuto: 0)
        await directo.jugar()
        XCTAssertEqual(directo.marcador, Marcador(local: 2, visitante: 1))
        XCTAssertEqual(directo.minuto, 90)
        XCTAssertFalse(directo.corriendo)
    }
 
    func testUnPartidoQueNoExisteDaUnEstadoDeError() {
        let viewModel = PartidoViewModel(partidoId: 99, repositorio: repositorio, msPorMinuto: 0)
        XCTAssertEqual(viewModel.uiState, .error("No existe el partido n.º 99"))
    }
 
    func testElViewModelSeLiberaAlSoltarlo() async {
        weak var referencia: PartidoViewModel?
        do {
            let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio, msPorMinuto: 0)
            referencia = viewModel
        }
        XCTAssertNil(referencia)
    }
 
    // XCTest trae MEDICIÓN de rendimiento (`measure`), que Swift Testing no tiene: ejecuta el bloque
    // varias veces y compara con una línea base. Aquí, jugar 90 minutos sin esperas.
    func testRendimientoDeJugarUnPartidoEntero() {
        measure {
            let directo = Directo(partidoId: 1, partido: Ejemplo.rayoContraToros, duracion: 90, msPorMinuto: 0)
            let terminado = expectation(description: "el partido termina")
            Task { @MainActor in
                await directo.jugar()
                terminado.fulfill()
            }
            wait(for: [terminado], timeout: 10)
        }
    }
}

Lanza Cmd+U. Las cifras no cambian: se mudó código, no se añadió ninguno.

Qué pasa ahora con el ciclo del cronómetro

En Ver un ciclo de retención: deinit y Memory Graph lanzabas la app con -cicloDelCronometro YES y el objeto que se quedaba en memoria era PartidoViewModel. Ahora el ciclo (cronómetro ↔ closure) está dentro de Directo. Si repites esa medida, al cerrar el partido el registro dirá PartidoViewModel LIBERADO pero no Directo LIBERADO, y en el Memory Graph el filtro que enseña el ciclo es Directo. La prueba conElCicloReintroducidoElDirectoSeQuedaEnMemoria ya lo comprueba por ti, así que hoy no hace falta que lo repitas.

Punto de control

  • Cmd+B termina en Build Succeeded y Cmd+U da 61 pruebas en verde, las mismas que antes: 45 unitarias y 16 de interfaz (con tus ejercicios de pruebas, 64). DirectoTests tiene siete, PartidoViewModelTests tres y PartidoViewModelXCTests cuatro.
  • Ejecuta la app (Cmd+R) y abre un partido: arranca solo, el minuto avanza, «Gol» sube el marcador y el aviso sale cada 15 minutos. Se ve exactamente igual que antes de la lección.
  • PartidoViewModel.swift ya no tiene ni Cronometro ni los goles: solo el estado de la pantalla.
  • Sabes decir qué es refactorizar y cómo sabes que no rompiste nada (las mismas pruebas, el mismo resultado), y por qué el partido sale del ViewModel: para que dentro de dos lecciones pueda vivir más que la pantalla.
  • Tu Playground tiene el ejercicio del carrito con la misma salida que el punto de partida.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué es refactorizar?

  2. 2.¿Por qué se saca el reloj del partido de PartidoViewModel a una clase Directo?

  3. 3.Siete pruebas pasan de PartidoViewModelTests a DirectoTests. ¿Qué tiene que pasar con su resultado?

  4. 4.Un Directo ya terminó y alguien llama otra vez a jugar(). ¿Qué ocurre?

  5. 5.¿Qué comprueba la prueba conElCicloReintroducidoElDirectoSeQuedaEnMemoria?

Faltan 5 de 5