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 lanza | Quién es el dueño | Cuándo acaba |
|---|---|---|
.task { … } en una vista | La vista | SwiftUI la cancela en cuanto quita la vista |
Task { … } guardada en una propiedad de PartidoEnJuego | PartidoEnJuego, que vive lo que la app | Cuando 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.
// 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 solaLo 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/5La 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. Guardaepisodios(empieza en 0, y solo ella lo cambia) y su tarea en una propiedad sin observar. Su funciónempezar()lanza unaTaskque 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
PantallaDeDescargaViewque lee elDescargadordel 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 aempezar(). El contador de la pantalla es un@Stateque sube de 1 a 5 con un.taskde la propia vista, esperando también 800 milisegundos entre un número y el siguiente (si la cancelan, sale). - Una vista
DescargaQueSigueViewcon unToggle«Pantalla abierta» (activado al principio) y, debajo, la pantalla, solo si elToggleestá activado. ElDescargadorse crea aquí con@Statey se reparte con.environment. - Un
#PreviewdeDescargaQueSigueView.
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:
| Haces | Debe verse |
|---|---|
| Abres la vista previa | El 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
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.
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 elinit: si ya hay un directo de este partido, se reutiliza en vez de crear otro.empezar(): le pide aPartidoEnJuegoque empiece este directo. Ignora elBoolque devuelveempezar(_:)con_ =, porque la pantalla no tiene nada que contestar con él.cambiarDuracion(_:): solo prepara un partido nuevo si este no se está jugando.@ObservationIgnoredsobreenJuego, como en tus otros objetos observables (@Observable): elViewModelguarda 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):
// 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:
- El comentario de arriba del todo, justo antes de
@MainActor, gana un párrafo nuevo. - 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ñadeenJuego. empezar()ycambiarDuracion(_:)entran en el sitio donde estabajugar.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// 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 // 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)
}
} // «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, noAjustes.duracionPorDefecto. Mira la trampa de abajo. empezar()no contesta nada. Si hubiera otro partido en juego,empezar(_:)devolveríafalsey 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
// ---- 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.disableddel botón «Guardar»..onChange(of: duracion) { _, nueva in … }: «cuandoduracioncambie, haz esto». Ya lo tienes en esta misma vista con.onChange(of: fase). Aquí avisa alViewModelde la duración nueva.
Intenta escribirlo tú. En PartidoEnDirectoView.swift:
- Borra el
.task(id: duracion)que llamaba aviewModel.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 cambiarduracionavisa aviewModel.cambiarDuracion. PartidoContenedorlee@AppStorage(Ajustes.claveDuracion)y se la pasa al contenedor al pedir elViewModel.
Primero, lo que se borra, dentro del body:
// `.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
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)
} 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)
}
}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(_:)devuelvefalseporque 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):
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:
otraPantallaDelMismoPartidoReutilizaElDirecto: se crea unViewModel, se empieza el partido y se crea un segundoViewModeldel mismo partido con el mismoPartidoEnJuego; los dos tienen el mismo directo (===).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
// 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
// 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:
- En
testElPartidoSobreviveAlRedibujadoDelPadrey entestVolverDeGoleadoresNoReiniciaElPartido(dos pruebas), justo después deapp.buttons["partido-1"].tap(), se pulsa el botón nuevo. YtestVolverDeGoleadoresNoReiniciaElPartidogana un argumento más al lanzar la app (más abajo se explica). - En
testUnGolSacaUnaNotificacion, tras elegir 60 minutos, se pulsa «Empezar partido» (cambiar la duración ya no relanza el partido). testAlSalirDelPartidoSeCancelaElTaskcomprobaba lo contrario de lo que pasa ahora: se renombra atestAlSalirDelPartidoSigueEnJuegoy comprueba que, tras salir y volver, el minuto ha seguido.
app.buttons["Empezar partido"].tap()Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// 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":
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 Succeededy Cmd+U da 69 pruebas en verde: 53 unitarias y 16 de interfaz (con tus ejercicios de pruebas, 72). Las nuevas son dos dePartidoViewModelTestsy una dePartidoEnJuegoTests. - 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
EjerciciostieneDescargaQueSigue, 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.Antes de esta lección, el partido se paraba al salir de su pantalla. ¿Por qué?
2.¿Quién es ahora el dueño de la tarea que juega el partido?
3.Sales del partido 1 mientras se juega y vuelves a abrirlo. ¿Cómo evita el
ViewModelnuevo empezar de cero?4.En la prueba
elPartidoSigueConElViewModelLiberado,referencia == nily, aun así, el partido llega al minuto 90. ¿Qué demuestra?5.
cambiarDuracion(_:)empieza conif let vivo = enJuego.directo(id: partidoId), !vivo.terminado { return }. ¿Cuándo no hace nada?