Saltar al contenido
K
Arquitectura en iOSPaso a paso40 min Intermedio

El partido sigue aunque salgas, en iOS

Al terminar: Hacer que el partido siga corriendo al salir de su pantalla y que solo haya uno en juego a la vez.

Al terminar, el partido de Marcador seguirá corriendo aunque salgas de su pantalla. Tocarás «Empezar partido» en el partido 1, volverás a la lista, abrirás el mismo partido otra vez y el minuto habrá seguido avanzando en vez de haberse parado. Y el selector de 90 o 60 minutos se pondrá gris mientras ese partido esté en juego.

Importa porque hoy el partido lo juega un .task de la pantalla, y un .task muere con su pantalla: sales y el partido se acaba. Un partido de fútbol no funciona así. Arreglarlo no es cambiar una línea: hay que decidir quién es el dueño de la tarea que juega el partido. Temas: el dueño de una Task y cuánto vive, el botón «Empezar partido» que vuelve, reutilizar el partido que ya corre, un selector que se bloquea y las pruebas. Nivel 2: usar.

Hoy deja dos cosas a medias a propósito (las verás al final, con su nombre): qué pasa si abres otro partido mientras uno se juega, y qué marca el selector en ese caso. Las resuelve la lección siguiente.

El dueño de la tarea decide cuánto vive

Una Task es un trabajo que corre por su cuenta mientras la app sigue con lo suyo. Pero ninguna tarea vive sola: alguien la lanza y alguien decide cuándo se acaba. A ese alguien se le llama su dueño.

Cómo se lanzaQuién es el dueñoCuándo acaba
.task { … } en una vistaLa vistaSwiftUI la cancela en cuanto quita la vista
Task { … } guardada en una propiedad de PartidoEnJuegoPartidoEnJuego, que vive lo que la appCuando el partido termina, o si alguien la cancela a propósito; salir de la pantalla no la toca

Un ejemplo solo para leer, para verlo sin la app: una descarga de podcasts que se lanza de dos maneras a la vez, y se «cierra la pantalla» a los 500 milisegundos. Es un programa de consola de Swift, no de Marcador, y no hace falta ejecutarlo: lo que imprime está justo debajo del código.

ejemplo mínimo: quién es el dueño de la tarea (con comentarios)
// Una clase que guarda su tarea en una propiedad: ella es la dueña.
// `@MainActor` hace que todo corra en el hilo principal, como las clases de la app.
@MainActor
final class Podcasts {
    // La tarea guardada. `private(set)`: desde fuera se puede mirar, no cambiar.
    private(set) var tarea: Task<Void, Never>?
 
    func descargar() {
        // La `Task` se guarda en la propiedad: mientras exista el objeto, la tarea sigue.
        tarea = Task {
            for episodio in 1...5 {
                try? await Task.sleep(for: .milliseconds(300))   // espera 300 ms
                print("app: episodio \(episodio)/5")
            }
        }
    }
}
 
let podcasts = Podcasts()   // vive lo que el programa, como el contenedor de la app
 
// La «pantalla»: una tarea suelta que cancelaremos al «cerrar la pantalla»,
// que es lo que hace `.task` cuando la vista se va.
let pantalla = Task {
    for episodio in 1...5 {
        do { try await Task.sleep(for: .milliseconds(200)) } catch { return }   // si la cancelan, sale
        print("pantalla: episodio \(episodio)/5")
    }
}
 
podcasts.descargar()
try await Task.sleep(for: .milliseconds(500))   // pasa medio segundo
print("-- se cierra la pantalla --")
pantalla.cancel()                               // la tarea de la pantalla se cancela
await podcasts.tarea?.value                     // espera a que la de la app termine sola

Lo que imprime:

pantalla: episodio 1/5
app: episodio 1/5
pantalla: episodio 2/5
-- se cierra la pantalla --
app: episodio 2/5
app: episodio 3/5
app: episodio 4/5
app: episodio 5/5

La tarea de la pantalla se queda en el episodio 2: la cancelaron. La de Podcasts llega al 5. Es la misma salida que el ejemplo de Kotlin de El partido sigue aunque salgas, con otras palabras: el dueño es lo que cambia.

Volver al partido que ya corre

Si el partido sigue jugándose sin pantalla, al volver a abrirlo la pantalla no puede crear un partido nuevo: sería otro, empezando de cero. Tiene que encontrar el que ya corre y mirarlo. Eso lo hace PartidoEnJuego.directo(id:): devuelve el Directo de ese partido si es el que está en juego, o nil. El ViewModel lo pregunta al nacer: si hay uno, lo reutiliza; si no, prepara uno sin empezar.

Recuerda que PartidoEnJuego tiene también una propiedad con el mismo nombre, directo (el partido que esté en juego, sea cual sea): se distinguen por los paréntesis, enJuego.directo frente a enJuego.directo(id: 3).

La prueba de que es el mismo objeto y no uno parecido es ===, que compara identidad (Equipos y plantillas en iOS): «¿es la misma cosa en memoria?».

La pantalla pasa a observar, no a ser la dueña: muestra lo que el directo dice y le pasa los toques. Si la pantalla se cierra, el ViewModel se libera, pero el Directo no, porque lo guarda PartidoEnJuego.

Reto · Ejercicio: la descarga que sigue

Es el gemelo de Podcasts con una pantalla de verdad y con otros datos: una descarga de episodios. En la carpeta Ejercicios de tu proyecto, crea la subcarpeta DescargaQueSigue (clic derecho sobre MarcadorApp/Ejercicios → New Folder) y dentro un fichero DescargaQueSigueView.swift (clic derecho sobre DescargaQueSigue → New File from Template… → Swift File, solo con el target MarcadorApp).

Escribe:

  • Una clase observable Descargador, del hilo principal. Guarda episodios (empieza en 0, y solo ella lo cambia) y su tarea en una propiedad sin observar. Su función empezar() lanza una Task que cinco veces espera 800 milisegundos y apunta el número de episodio, del 1 al 5; si ya hay una tarea, no lanza otra.
  • Una vista PantallaDeDescargaView que lee el Descargador del entorno y enseña dos textos, «Descargador de la app: episodio N de 5» y «Contador de la pantalla: N de 5», y un botón destacado «Empezar la descarga» que llama a empezar(). El contador de la pantalla es un @State que sube de 1 a 5 con un .task de la propia vista, esperando también 800 milisegundos entre un número y el siguiente (si la cancelan, sale).
  • Una vista DescargaQueSigueView con un Toggle «Pantalla abierta» (activado al principio) y, debajo, la pantalla, solo si el Toggle está activado. El Descargador se crea aquí con @State y se reparte con .environment.
  • Un #Preview de DescargaQueSigueView.

Pruébalo en la vista previa del lienzo, en modo interactivo (Editor → Canvas; si dice que está en pausa, pulsa Resume). Lo que debe verse:

HacesDebe verse
Abres la vista previaEl contador de la pantalla sube solo de 0 a 5, uno por cada 800 ms; el del descargador sigue en 0
Pulsas «Empezar la descarga» y a los 2 segundos apagas «Pantalla abierta»La pantalla desaparece
Esperas 5 segundos y vuelves a activar «Pantalla abierta»El contador de la pantalla empieza otra vez desde 0 (la cancelaron al irse); el del descargador está en «episodio 5 de 5» (no se enteró)
Ver solución
MarcadorApp/Ejercicios/DescargaQueSigue/DescargaQueSigueView.swift
import SwiftUI
 
// El dueño de la tarea: vive lo que la app y guarda su propia `Task`.
@MainActor
@Observable
final class Descargador {
    private(set) var episodios = 0
    @ObservationIgnored private var tarea: Task<Void, Never>?
 
    func empezar() {
        guard tarea == nil else { return }   // si ya hay una descarga, no lanza otra
        tarea = Task {
            for numero in 1...5 {
                try? await Task.sleep(for: .milliseconds(800))
                episodios = numero
            }
        }
    }
}
 
// La «pantalla»: tiene su propio contador, que cuenta con un `.task` suyo.
struct PantallaDeDescargaView: View {
    @Environment(Descargador.self) private var descargador
    @State private var contadorDeLaPantalla = 0
 
    var body: some View {
        VStack(spacing: 12) {
            Text("Descargador de la app: episodio \(descargador.episodios) de 5")
            Text("Contador de la pantalla: \(contadorDeLaPantalla) de 5")
            Button("Empezar la descarga") { descargador.empezar() }
                .buttonStyle(.borderedProminent)
        }
        .task {
            for numero in 1...5 {
                do { try await Task.sleep(for: .milliseconds(800)) } catch { return }   // si la cancelan, sale
                contadorDeLaPantalla = numero
            }
        }
    }
}
 
struct DescargaQueSigueView: View {
    @State private var descargador = Descargador()     // UNO, creado una vez en la «raíz»
    @State private var pantallaAbierta = true
 
    var body: some View {
        VStack(spacing: 24) {
            Toggle("Pantalla abierta", isOn: $pantallaAbierta)
            if pantallaAbierta {
                PantallaDeDescargaView()
            }
        }
        .padding()
        .environment(descargador)
    }
}
 
#Preview {
    DescargaQueSigueView()
}

Quitar la pantalla cancela su .task y destruye su @State: por eso el contador vuelve a 0. El Descargador no está en la pantalla, sino en la raíz, y su Task es suya: nadie se la cancela. Es lo mismo que PartidoEnJuego con el partido. El proyecto sigue compilando con este ejercicio dentro: no lo borres.

Paso · «Empezar partido» y un partido que sigue

Objetivo: que el partido empiece con un botón, siga al salir de la pantalla y el selector se bloquee mientras se juega. Al terminar, las pruebas pasan con las nuevas.

MarcadorApp
MarcadorApp
ContenedorDeLaApp.swiftse modifica
Marcador
UI
PartidoEnDirectoView.swiftse modifica
PartidoViewModel.swiftse modifica
MarcadorAppTests
Core
Domain
PartidoEnJuegoTests.swiftse modifica
Marcador
UI
PartidoViewModelTests.swiftse modifica
PartidoViewModelXCTests.swiftse modifica
MarcadorAppUITests
MarcadorAppUITests.swiftse modifica
Los ficheros de la lección en el Project navigator.

Son siete ficheros que ya existen, repartidos en cuatro apartados. En cuanto cambies el init del ViewModel, el proyecto estará en rojo hasta que termines el contenedor y la vista (los apartados 2 y 3): es normal. Si te cansas, haz una pausa al terminar el apartado 3, cuando ya ves en el simulador que el partido sigue; las pruebas son el apartado 4.

1 · El ViewModel

Piezas nuevas de este paso:

  • partidoEnJuego.directo(id:) en el init: si ya hay un directo de este partido, se reutiliza en vez de crear otro.
  • empezar(): le pide a PartidoEnJuego que empiece este directo. Ignora el Bool que devuelve empezar(_:) con _ =, porque la pantalla no tiene nada que contestar con él.
  • cambiarDuracion(_:): solo prepara un partido nuevo si este no se está jugando.
  • @ObservationIgnored sobre enJuego, como en tus otros objetos observables (@Observable): el ViewModel guarda una referencia que nunca cambia, así que no hay nada que observar.

Intenta escribirlo tú. En PartidoViewModel.swift, añade la propiedad enJuego justo debajo de notificador y sustituye el init entero. El init nuevo recibe también el PartidoEnJuego y la duración (con 90 por defecto) y los guarda; si ya hay un directo de este partido en juego, lo usa, y si no, prepara uno sin empezar. jugar(duracion:) desaparece y, en el sitio donde estaba, entran empezar(), que le pide a PartidoEnJuego que empiece este directo, y cambiarDuracion(_:), que prepara un directo nuevo con la duración elegida, salvo que este partido esté en juego.

Primero, lo que se borra (la función jugar, que la vista llamaba desde su .task):

PartidoViewModel.swift: lo que se borra
    // 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()
    }

Después, lo que se añade o cambia, en tres bloques:

  1. El comentario de arriba del todo, justo antes de @MainActor, gana un párrafo nuevo.
  2. Sustituye todo lo que va desde el comentario «Quien avisa de los goles» hasta el final del init: las propiedades de antes quedan igual y se añade enJuego.
  3. empezar() y cambiarDuracion(_:) entran en el sitio donde estaba jugar.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/UI/PartidoViewModel.swift
// 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.
//
// LA PANTALLA OBSERVA, NO ES LA DUEÑA. El partido ya no lo juega un `.task` de la vista (moriría al
// cerrarla): lo empieza `PartidoEnJuego`, el estado de toda la app. Si el partido de esta pantalla ya
// está en juego (o acaba de terminar), el ViewModel REUTILIZA ese directo en vez de crear otro.
@MainActor
MarcadorApp/Marcador/UI/PartidoViewModel.swift
    // Quien avisa de los goles (nil = nadie). Se lo pasa al directo.
    @ObservationIgnored private let notificador: (any Notificador)?
    // El partido en juego de la app: lo empieza y guarda su directo, que sigue aunque se cierre la pantalla.
    @ObservationIgnored private let enJuego: PartidoEnJuego
 
    init(
        partidoId: Int,
        repositorio: any PartidosRepositorio,
        partidoEnJuego: PartidoEnJuego,
        duracion: Int = 90,
        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
        self.enJuego = partidoEnJuego
        if let vivo = partidoEnJuego.directo(id: partidoId) {
            directo = vivo
        } else {
            cargar(duracion: duracion)
        }
    }
MarcadorApp/Marcador/UI/PartidoViewModel.swift
    // «Empezar»: lo empieza el partido en juego, no la vista. Si hay otro sin terminar, no hace nada.
    func empezar() {
        guard let directo else { return }
        _ = enJuego.empezar(directo)
    }
 
    // Otra duración = un directo NUEVO sin empezar. Mientras este partido se juega, no (la vista
    // desactiva el selector y aquí se ignora).
    func cambiarDuracion(_ nueva: Int) {
        guard let directo, directo.duracion != nueva else { return }
        if let vivo = enJuego.directo(id: partidoId), !vivo.terminado { return }
        cargar(duracion: nueva)
    }

El resto del fichero (el dibujo de MVVM de arriba, alGol, cargar…) no cambia. Tres decisiones:

  • El valor por defecto de la duración es el número 90, no Ajustes.duracionPorDefecto. Mira la trampa de abajo.
  • empezar() no contesta nada. Si hubiera otro partido en juego, empezar(_:) devolvería false y no pasaría nada; la lección siguiente se ocupa de ese caso.
  • Cambiar la duración de un partido que ya terminó prepara uno nuevo, en el minuto 0, con esa duración: la duración solo se bloquea mientras se juega.

2 · El contenedor

La fábrica del ViewModel recibe ahora la duración, que le da la vista, y pone el PartidoEnJuego del propio contenedor. En ContenedorDeLaApp.swift, cambia partidoViewModel(id:):

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/ContenedorDeLaApp.swift
    // ---- interfaz: un ViewModel por pantalla ----
    // La duración llega de la vista (`@AppStorage`); el partido en juego, del propio contenedor.
    func partidoViewModel(id: Int, duracion: Int) -> PartidoViewModel {
        PartidoViewModel(partidoId: id, repositorio: partidos, partidoEnJuego: partidoEnJuego, duracion: duracion, notificador: notificador)
    }

El resto del fichero no cambia: es el cambio de esa función.

3 · La vista

Piezas nuevas de este paso:

  • Button("Empezar partido") { viewModel.empezar() } con .buttonStyle(.borderedProminent): el mismo botón relleno de color que ya usan los botones de gol (SwiftUI: primeros pasos).
  • .disabled(datos.corriendo): gris mientras el partido se juega. Es el mismo .disabled del botón «Guardar».
  • .onChange(of: duracion) { _, nueva in … }: «cuando duracion cambie, haz esto». Ya lo tienes en esta misma vista con .onChange(of: fase). Aquí avisa al ViewModel de la duración nueva.

Intenta escribirlo tú. En PartidoEnDirectoView.swift:

  • Borra el .task(id: duracion) que llamaba a viewModel.jugar. El otro .task (el del permiso de notificaciones) se queda.
  • Debajo del texto de estado, añade el botón «Empezar partido» (destacado), que solo se ve con el partido en el minuto 0 y sin correr, y que llama a viewModel.empezar().
  • El selector se desactiva si datos.corriendo, y al cambiar duracion avisa a viewModel.cambiarDuracion.
  • PartidoContenedor lee @AppStorage(Ajustes.claveDuracion) y se la pasa al contenedor al pedir el ViewModel.

Primero, lo que se borra, dentro del body:

PartidoEnDirectoView.swift: lo que se borra
        // `.task(id:)`: arranca al aparecer, se CANCELA al irse la vista y se relanza si cambia la duración.
        .task(id: duracion) {
            await viewModel.jugar(duracion: duracion)
        }

Después, lo que se añade o cambia:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Marcador/UI/PartidoEnDirectoView.swift
                    Text(estado(datos))
                        .foregroundStyle(.secondary)
                        .accessibilityIdentifier("estado")
                    // Solo antes de empezar: luego lo juega `PartidoEnJuego`.
                    if datos.minuto == 0 && !datos.corriendo {
                        Button("Empezar partido") { viewModel.empezar() }
                            .buttonStyle(.borderedProminent)
                    }
MarcadorApp/Marcador/UI/PartidoEnDirectoView.swift
                VStack(alignment: .leading, spacing: 8) {
                    Text("Duración del partido")
                    // El selector guarda la preferencia (`duracion`); al cambiarla, el ViewModel prepara el
                    // partido con ella. Bloqueado mientras se juega: ese partido ya no se puede reiniciar.
                    Picker("Duración", selection: $duracion) {
                        ForEach(Ajustes.duraciones, id: \.self) { minutos in
                            Text("\(minutos) min").tag(minutos)
                        }
                    }
                    .pickerStyle(.segmented)
                    .disabled(datos.corriendo)
                    .onChange(of: duracion) { _, nueva in
                        viewModel.cambiarDuracion(nueva)
                    }
                }
MarcadorApp/Marcador/UI/PartidoEnDirectoView.swift
struct PartidoContenedor: View {
    let id: Int
    @Environment(ContenedorDeLaApp.self) private var contenedor
    @State private var redibujados = 0
    // La duración guardada: con ella se prepara el partido si aún no está en juego.
    @AppStorage(Ajustes.claveDuracion) private var duracion = Ajustes.duracionPorDefecto
 
    var body: some View {
        VStack(spacing: 0) {
            HStack {
                Spacer()
                Button("Redibujar el padre (\(redibujados))") { redibujados += 1 }
                    .buttonStyle(.bordered)
            }
            .padding([.horizontal, .top], 16)
 
            PartidoEnDirectoView(viewModel: contenedor.partidoViewModel(id: id, duracion: duracion))
        }
        .navigationTitle("Partido n.º \(id)")
        .navigationBarTitleDisplayMode(.inline)
    }
}

El resto del fichero no cambia. Si quieres, actualiza también los comentarios de arriba del todo (el que decía «.task: el partido arranca solo») y el de @AppStorage: la duración es ahora el ajuste del próximo partido, y es estado de toda la app, porque todas las vistas que usan la misma clave leen el mismo valor y se redibujan juntas.

Compila con Cmd+B y ejecuta con Cmd+R en un iPhone del simulador. Las pruebas todavía no compilan, porque siguen llamando al init de antes: es el apartado siguiente.

  • Entra en «Partidos» → el partido 1. El minuto está en 0 y hay un botón «Empezar partido»: el partido ya no arranca solo.
  • Pulsa «Empezar partido»: el estado pasa a «En juego», el minuto avanza y el selector se pone gris.
  • Vuelve atrás a la lista y espera unos segundos. Entra otra vez en el partido 1: el minuto ha seguido avanzando, y no hay botón. Antes se paraba al salir.
  • Cuando el partido llega al final, se queda en «Partido terminado», sin botón, hasta que empiece otro partido (más adelante, la barra de La barra del partido en juego, en iOS lo quitará con una X).

Las dos cosas que quedan a medias. Con el partido 1 en juego, entra en el partido 2:

  • «Empezar partido» sale, pero al pulsarlo no pasa nada: empezar(_:) devuelve false porque ya hay otro partido sin terminar, y la pantalla no lo cuenta. Debería avisar.
  • El selector del partido 2 sí se puede tocar. Si eliges 60 minutos allí y vuelves al partido 1, el selector marca 60 aunque ese partido se juegue a 90: lee del ajuste guardado, no del partido.

No es un fallo tuyo: es lo que queda cuando solo se ha movido la tarea. Solo un partido a la vez, en iOS arregla las dos.

4 · Las pruebas

Objetivo: que las pruebas de antes vuelvan a compilar y tres pruebas nuevas, dos del ViewModel y una del partido en juego, pasen.

Las pruebas del ViewModel (PartidoViewModelTests.swift). Las tres que ya tienes crean el ViewModel con PartidoViewModel(partidoId:repositorio:msPorMinuto:): cada una necesita ahora un PartidoEnJuego() nuevo entre el repositorio y los milisegundos. Las tres líneas quedan así (cada una en su prueba, sustituyendo a la que ya tenías):

PartidoViewModelTests.swift: las tres llamadas, ahora con partidoEnJuego
let viewModel = PartidoViewModel(partidoId: 99, repositorio: repositorio(), partidoEnJuego: PartidoEnJuego(), msPorMinuto: 0)
let viewModel = PartidoViewModel(partidoId: 1, repositorio: repo, partidoEnJuego: PartidoEnJuego(), msPorMinuto: 0)
_ = PartidoViewModel(partidoId: 1, repositorio: repo, partidoEnJuego: PartidoEnJuego(), msPorMinuto: 0)

Y se añaden dos pruebas, al final del struct:

  1. otraPantallaDelMismoPartidoReutilizaElDirecto: se crea un ViewModel, se empieza el partido y se crea un segundo ViewModel del mismo partido con el mismo PartidoEnJuego; los dos tienen el mismo directo (===).
  2. laDuracionSoloCambiaSiNoSeJuega: antes de empezar, cambiarDuracion(60) deja el directo en 60; empezado, cambiarDuracion(90) no cambia nada.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Marcador/UI/PartidoViewModelTests.swift
    // Volver a abrir el partido que se está jugando REUTILIZA su directo: no empieza de cero.
    @Test func otraPantallaDelMismoPartidoReutilizaElDirecto() {
        let enJuego = PartidoEnJuego()
        let primera = PartidoViewModel(partidoId: 1, repositorio: repositorio(), partidoEnJuego: enJuego, msPorMinuto: 60_000)
        primera.empezar()
        let segunda = PartidoViewModel(partidoId: 1, repositorio: repositorio(), partidoEnJuego: enJuego, msPorMinuto: 60_000)
        #expect(segunda.directo === primera.directo)
    }
 
    // Mientras se juega, la duración no cambia; sin empezar, sí (un directo nuevo).
    @Test func laDuracionSoloCambiaSiNoSeJuega() {
        let enJuego = PartidoEnJuego()
        let viewModel = PartidoViewModel(partidoId: 1, repositorio: repositorio(), partidoEnJuego: enJuego, msPorMinuto: 60_000)
        viewModel.cambiarDuracion(60)
        #expect(viewModel.directo?.duracion == 60)
        viewModel.empezar()
        viewModel.cambiarDuracion(90)
        #expect(viewModel.directo?.duracion == 60)
    }

msPorMinuto: 60_000 es el mismo truco de la lección anterior: el partido empieza pero no avanza mientras dura la prueba.

La prueba del partido en juego (PartidoEnJuegoTests.swift): una prueba más, que enseña lo que cambió: el partido sigue con el ViewModel liberado. Usa dos piezas que ya viste en las pruebas de El directo, en su propia clase y de Estado de toda la app en SwiftUI: weak var referencia (una referencia que no mantiene vivo al objeto: en cuanto nadie más lo tiene, vale nil) y do { … } (lo que se crea dentro deja de existir al cerrar la llave). Así, tras el bloque, referencia debe ser nil: el ViewModel se liberó, como cuando se cierra la pantalla.

Aun así, al esperar la tarea (await enJuego.tarea?.value), el partido llega al minuto 90 con 2-1: lo jugaba PartidoEnJuego, no el ViewModel.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Core/Domain/PartidoEnJuegoTests.swift
    // EL PARTIDO SIGUE CON EL VIEWMODEL LIBERADO (salir de la pantalla ya no lo para).
    @Test func elPartidoSigueConElViewModelLiberado() async {
        let enJuego = PartidoEnJuego()
        weak var referencia: PartidoViewModel?
        do {
            let viewModel = PartidoViewModel(partidoId: 1, repositorio: RepositorioFalso(), partidoEnJuego: enJuego, msPorMinuto: 0)
            referencia = viewModel
            viewModel.empezar()
        }
        #expect(referencia == nil)
        await enJuego.tarea?.value
        #expect(enJuego.directo?.minuto == 90)
        #expect(enJuego.directo?.marcador == Marcador(local: 2, visitante: 1))
    }

El resto del fichero no cambia: es una prueba más, al final del struct.

Las pruebas en XCTest (PartidoViewModelXCTests.swift): dos líneas necesitan el mismo argumento, partidoEnJuego: PartidoEnJuego(), en testUnPartidoQueNoExisteDaUnEstadoDeError y en testElViewModelSeLiberaAlSoltarlo.

Las pruebas de interfaz (MarcadorAppUITests.swift). Tres cambios, porque ya no hay partido que arranque solo:

  1. En testElPartidoSobreviveAlRedibujadoDelPadre y en testVolverDeGoleadoresNoReiniciaElPartido (dos pruebas), justo después de app.buttons["partido-1"].tap(), se pulsa el botón nuevo. Y testVolverDeGoleadoresNoReiniciaElPartido gana un argumento más al lanzar la app (más abajo se explica).
  2. En testUnGolSacaUnaNotificacion, tras elegir 60 minutos, se pulsa «Empezar partido» (cambiar la duración ya no relanza el partido).
  3. testAlSalirDelPartidoSeCancelaElTask comprobaba lo contrario de lo que pasa ahora: se renombra a testAlSalirDelPartidoSigueEnJuego y comprueba que, tras salir y volver, el minuto ha seguido.
MarcadorAppUITests.swift: la línea que se añade en tres pruebas
        app.buttons["Empezar partido"].tap()
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppUITests.swift: la prueba que cambia (reemplaza a la de «se cancela el Task»)
    // El partido SIGUE al volver atrás: lo juega el partido en juego, no la pantalla. Al volver a
    // abrirlo, el minuto ha seguido avanzando.
    @MainActor
    func testAlSalirDelPartidoSigueEnJuego() throws {
        let app = XCUIApplication()
        app.launchArguments = ["-msPorMinuto", "100"] + sinAvisos
        app.launch()
        app.buttons["partido-2"].tap()
        app.buttons["Empezar partido"].tap()
        XCTAssertTrue(app.staticTexts["En juego"].waitForExistence(timeout: 5))
        capturar(app, "en-juego")
        app.navigationBars.buttons.firstMatch.tap()   // «atrás»
        XCTAssertTrue(app.buttons["partido-2"].waitForExistence(timeout: 5))
        Thread.sleep(forTimeInterval: 2)
        app.buttons["partido-2"].tap()
        let minuto = app.staticTexts["minuto"]
        XCTAssertTrue(minuto.waitForExistence(timeout: 5))
        XCTAssertGreaterThanOrEqual(Int(minuto.label.filter(\.isNumber)) ?? 0, 15, "El partido no siguió: \(minuto.label)")
    }

Tres líneas de esa prueba, explicadas: Thread.sleep(forTimeInterval: 2) hace esperar a la prueba 2 segundos (a 100 ms por minuto, unos 20 minutos de partido); minuto.label es el texto que se ve en pantalla (por ejemplo «Minuto 21'»); y filter(\.isNumber) se queda solo con los dígitos («21») para que Int(…) lo convierta en número y se pueda comparar con 15.

El argumento nuevo de testVolverDeGoleadoresNoReiniciaElPartido. Esa prueba lanza la app con una línea de argumentos; ahora lleva dos más, "-reiniciarDatos", "YES":

MarcadorAppUITests.swift: la línea de lanzamiento de testVolverDeGoleadoresNoReiniciaElPartido
        app.launchArguments = ["-msPorMinuto", "150", "-reiniciarDatos", "YES"] + sinAvisos

¿Por qué? La prueba anterior, testUnGolSacaUnaNotificacion, elige 60 minutos, y esa duración se queda guardada en el simulador. Como ahora el partido sigue mientras miras Goleadores, al volver ya va por el minuto 54 o más de un partido de 60, y llega al final antes de que la prueba compruebe que «el minuto sigue avanzando»: a veces falla, a veces no. -reiniciarDatos YES borra lo guardado antes de empezar, y la duración vuelve a 90.

Dos comentarios que se quedaron viejos, por si los quieres poner al día (un comentario no cambia el resultado de las pruebas):

  • El de testVolverDeGoleadoresNoReiniciaElPartido, si aún habla de que SwiftUI cancela el .task: cámbialo por «Volver de «Goleadores» NO reinicia el partido: el partido en juego sigue mientras se mira otra pantalla».
  • El de testUnGolSacaUnaNotificacion, si aún dice que cambiar la duración relanza el partido: ahora lo que lo arranca es «Empezar partido».

testAbreYCierraUnPartidoParaMedirLaMemoria no cambia: el Directo prepara el reloj en cuanto se crea, así que abrir un partido ya crea el ciclo de la demo de memoria, sin pulsar «Empezar».

Lanza Cmd+U. Las pruebas de interfaz tardan.

Punto de control

  • Cmd+B termina en Build Succeeded y Cmd+U da 69 pruebas en verde: 53 unitarias y 16 de interfaz (con tus ejercicios de pruebas, 72). Las nuevas son dos de PartidoViewModelTests y una de PartidoEnJuegoTests.
  • En el simulador, el partido 1 no arranca solo: «Empezar partido» lo pone en juego, el selector se pone gris y, al salir y volver, el minuto ha seguido.
  • Con el partido 1 en juego, el partido 2 enseña «Empezar partido» y, al pulsarlo, no pasa nada: es lo que arregla la lección siguiente.
  • La carpeta Ejercicios tiene DescargaQueSigue, y su vista previa deja la descarga del descargador en «episodio 5 de 5» mientras el contador de la pantalla vuelve a 0 al reabrirla.
  • Sabes decir quién es el dueño de la tarea que juega el partido y por qué ya no es .task, y cómo encuentra la pantalla el partido que ya corre.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Antes de esta lección, el partido se paraba al salir de su pantalla. ¿Por qué?

  2. 2.¿Quién es ahora el dueño de la tarea que juega el partido?

  3. 3.Sales del partido 1 mientras se juega y vuelves a abrirlo. ¿Cómo evita el ViewModel nuevo empezar de cero?

  4. 4.En la prueba elPartidoSigueConElViewModelLiberado, referencia == nil y, aun así, el partido llega al minuto 90. ¿Qué demuestra?

  5. 5.cambiarDuracion(_:) empieza con if let vivo = enJuego.directo(id: partidoId), !vivo.terminado { return }. ¿Cuándo no hace nada?

Faltan 5 de 5