Saltar al contenido
K
Jetpack y arquitecturaPaso a paso40 min Intermedio

El partido sigue aunque salgas

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 esta lección, el partido de Marcador sigue corriendo aunque salgas de su pantalla. Empiezas el partido 1, pulsas «atrás», vuelves a entrar y el minuto ha seguido avanzando. Un gol que acabas de marcar no se pierde aunque cierres la pantalla justo después. Y mientras el partido se juega, pedir otra duración (90 o 60 minutos) no cambia nada.

Importa porque es el problema de la lección anterior resuelto de verdad: el partido deja de ser de una pantalla y pasa a ser de la app, y la pantalla se limita a mirarlo. Temas: viewModelScope frente al alcance de la app; reutilizar el directo que ya está vivo; async y await para que un guardado no se quede a medias; y cómo se «cierra una pantalla» en una prueba. Queda un detalle sin pulir: si abres otro partido mientras uno se juega, la pantalla todavía no avisa de nada. De eso trata la lección siguiente, Solo un partido a la vez.

Dos alcances, dos dueños

Cada corrutina vive en un alcance (CoroutineScope) que decide hasta cuándo puede correr: cuando el alcance se cancela, todo lo que se lanzó en él se cancela (Alcance, Job y cancelación). En Marcador hay dos que importan hoy:

AlcanceQuién lo cancelaDuraPara qué sirve
viewModelScopeEl ViewModel, al irse su pantallaLo que la pantallaTrabajo que solo importa mientras se mira la pantalla
@AlcanceDeAppNadie, mientras viva el procesoLo que la appTrabajo que no es de ninguna pantalla

Hasta ahora el reloj del partido corría en viewModelScope: su dueña era la pantalla, y al irse se lo llevaba. Ahora el dueño será PartidoEnJuego, con el alcance de la app, y la pantalla pasa a ser observadora: pide que empiece, mira cómo va y se va cuando quiere, sin llevarse nada.

Con datos ajenos: un podcast de cinco episodios que se descarga en dos alcances a la vez, y la pantalla se cierra a los 250 ms. En el Kotlin Playground:

ejemplo mínimo: dos alcances, un cierre de pantalla (con comentarios)
import kotlinx.coroutines.*
 
fun main() = runBlocking {
    val alcanceDeLaApp = CoroutineScope(SupervisorJob())       // dura lo que la app
    val alcanceDeLaPantalla = CoroutineScope(SupervisorJob())  // dura lo que la pantalla (como viewModelScope)
 
    alcanceDeLaPantalla.launch {                               // descarga lanzada desde la pantalla
        repeat(5) { delay(100); println("pantalla: episodio ${it + 1}/5") }
    }
    alcanceDeLaApp.launch {                                    // otra descarga, lanzada desde la app
        repeat(5) { delay(150); println("app: episodio ${it + 1}/5") }
    }
    delay(250)                                                 // pasan 250 ms...
    println("-- se cierra la pantalla --")
    alcanceDeLaPantalla.cancel()                               // ...y la pantalla se va: todo lo suyo se para
    delay(600)                                                 // la app sigue viva y se ve qué ocurre
}
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

SupervisorJob() es el Job que hace de padre del alcance, y es el que tiene viewModelScope: un Job que no se cae si una de sus corrutinas hijas falla (Errores en corrutinas). Aquí solo sirve para tener dos alcances que se cancelan por separado.

La descarga de la pantalla se queda en el episodio 2 y no vuelve a salir. La de la app sigue hasta el 5: nadie la ha cancelado. Es exactamente la diferencia entre el reloj de hoy y el que vamos a construir.

Un gol no se queda a medias

Hay un segundo problema, más discreto. Cuando pulsas «Gol Rayo FC», el PartidoViewModel llama a RegistrarGol desde viewModelScope, y RegistrarGol guarda el gol en la base, que tarda un poco. Si cierras la pantalla justo después, viewModelScope se cancela y el guardado se corta a medias: el gol se ve en la pantalla y no llega a la base. Una prueba lo comprueba más abajo.

Antes daba igual, porque el partido también moría al salir. Ahora el partido sigue vivo, y perder el gol en la base se notaría.

La solución es que el guardado lo haga el alcance de la app y quien llama solo espere el resultado. Eso son async y await:

ejemplo mínimo: guardar en el alcance de la app y solo esperar (con comentarios)
import kotlinx.coroutines.*
 
suspend fun guardarNota(texto: String) {
    delay(100)                                   // guardar tarda 100 ms
    println("guardada: $texto")
}
 
fun main() = runBlocking {
    val alcanceDeLaApp = CoroutineScope(SupervisorJob())   // dura lo que la app
 
    // Pantalla A: guarda ella misma, en su alcance
    val pantallaA = launch {
        guardarNota("nota de A")
        println("A: terminó")
    }
    // Pantalla B: le pide el guardado al alcance de la app y solo ESPERA el resultado
    val pantallaB = launch {
        alcanceDeLaApp.async { guardarNota("nota de B") }.await()   // async: el trabajo es de la app; await: yo espero
        println("B: terminó")
    }
    delay(50)                                    // a los 50 ms se cierran las dos pantallas
    pantallaA.cancel()
    pantallaB.cancel()
    delay(200)                                   // la app sigue viva y se ve qué se guardó
}
guardada: nota de B

La nota de A se perdió, porque su guardado era de su pantalla y se canceló con ella. La de B se guardó: cancelaron a quien esperaba, no al trabajo, que era de la app. Tampoco salió «B: terminó», porque esa línea era de la pantalla cancelada. En RegistrarGol será alcance.async { intentar { … } }.await().

La pantalla observa, no es la dueña

El resto del cambio es de reparto de papeles:

  • Empezar. El PartidoViewModel ya no lanza el reloj: le dice a PartidoEnJuego «empieza este directo». Si ya hay otro sin terminar, empezar devuelve false y no hace nada: es la regla de uno a la vez, y vive en el dominio, no en la pantalla. La pantalla todavía no se lo cuenta al usuario: eso llega en la lección siguiente.
  • Volver al partido. Al crear el ViewModel, antes de armar un directo nuevo se pregunta directo(id): si es el partido que está en juego (o el que acaba de terminar), se reutiliza ese directo y la pantalla sigue por donde iba. Si no, se prepara uno sin empezar.
  • La duración. Cambiarla prepara un directo nuevo con esa duración, y eso rompería un partido en marcha: habría dos relojes a la vez. Así que, mientras ese partido se juega, el ViewModel ignora el evento. Los chips de 90 y 60 minutos se siguen pudiendo pulsar, pero no cambian nada.

Cerrar una pantalla en una prueba

Para probar que el partido sigue cuando la pantalla se va, la prueba tiene que cerrar una pantalla. Un ViewModel no vive suelto: Android lo guarda en un almacén (ViewModelStore), uno por pantalla, que es como un armario con casilleros. Cuando la pantalla se cierra para siempre, Android vacía el almacén con clear(): cada ViewModel recibe su último aviso, onCleared(), y su viewModelScope se cancela. En una prueba haces lo mismo a mano. Necesitas tres cosas: el almacén, una fábrica (la receta de cómo construir el ViewModel) y un ViewModelProvider, que es quien lo pide al almacén y, si no está, lo crea con la fábrica.

Con datos ajenos (un carrito de la compra), para leer. No lo ejecutes: necesita las librerías de ViewModel de tu proyecto:

ejemplo mínimo: crear un ViewModel en un almacén y cerrarlo (con comentarios)
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
 
class Carrito : ViewModel() {                                   // un ViewModel de ejemplo
    override fun onCleared() = println("Carrito: la pantalla se cerró")   // Android lo llama al vaciar el almacén
}
 
fun main() {
    val almacen = ViewModelStore()                              // el armario donde Android guarda los ViewModel
    val fabrica = viewModelFactory { initializer { Carrito() } } // la receta: «para crear un Carrito, escribe Carrito()»
    val primero = ViewModelProvider.create(almacen, fabrica)[Carrito::class]  // lo pides: no existe, se crea y se guarda
    val segundo = ViewModelProvider.create(almacen, fabrica)[Carrito::class]  // lo pides otra vez: ya existe, te da el mismo
    println("¿Es el mismo carrito? ${primero === segundo}")
    almacen.clear()                                             // cerrar la pantalla: vacía el armario y avisa a cada ViewModel
}
¿Es el mismo carrito? true
Carrito: la pantalla se cerró

Carrito::class es «la clase Carrito» como valor: con los corchetes le dices al proveedor qué ViewModel quieres. Y que la segunda petición dé el mismo es justo lo que hace Android al girar el móvil. === pregunta si dos nombres señalan el mismo objeto, no si son «iguales»: == compara el contenido, y === comprueba que es una sola cosa en memoria.

En tu prueba habrá una función de ayuda con la misma forma, crearEn(almacen) { crear(repo) }. Su segundo parámetro, crearlo: TestScope.() -> PartidoViewModel, es una lambda con receptor: una función que se escribe como si estuviera dentro de TestScope. Por eso, dentro de las llaves, puedes llamar a crear(repo) y a backgroundScope, que son cosas de TestScope, sin ponerles nada delante.

Reto · Ejercicio: un guardado que no se corta

En el Kotlin Playground. Parte de esta función, que ya está escrita: guardar tarda 100 ms, avisa por consola y devuelve un texto.

punto de partida: la función que guarda
import kotlinx.coroutines.*
 
suspend fun guardarPuntos(jugador: String, puntos: Int): String {
    delay(100)
    println("guardado: $jugador $puntos")
    return "$jugador: $puntos puntos"
}

Escribe guardarSinCortes, una función suspend que recibe el alcance de la app, el jugador y los puntos, hace el guardado en ese alcance y espera su texto, para devolverlo a quien la llame. Y un main con un alcance de la app (con SupervisorJob) que:

  1. lanza una pantalla A que guarda a «Ana» con 12 puntos y, si recibe el texto, imprime «A recibió: …»;
  2. a los 50 ms cierra la pantalla A;
  3. después, desde el propio main, guarda a «Luis» con 8 puntos e imprime «main recibió: …».

Debes ver el guardado de Ana (la pantalla A se cerró, pero el guardado era de la app) y no su «A recibió»:

guardado: Ana 12
guardado: Luis 8
main recibió: Luis: 8 puntos
Ver solución
solución: guardarSinCortes (con comentarios)
import kotlinx.coroutines.*
 
suspend fun guardarPuntos(jugador: String, puntos: Int): String {
    delay(100)
    println("guardado: $jugador $puntos")
    return "$jugador: $puntos puntos"
}
 
// El guardado es del alcance de la app (async); quien llama solo espera el texto (await).
suspend fun guardarSinCortes(alcance: CoroutineScope, jugador: String, puntos: Int): String =
    alcance.async { guardarPuntos(jugador, puntos) }.await()
 
fun main() = runBlocking {
    val alcanceDeLaApp = CoroutineScope(SupervisorJob())
    val pantallaA = launch {
        val texto = guardarSinCortes(alcanceDeLaApp, "Ana", 12)
        println("A recibió: $texto")      // no sale: la pantalla A se cancela antes de recibir
    }
    delay(50)
    pantallaA.cancel()                    // la pantalla A se cierra a mitad del guardado
    val texto = guardarSinCortes(alcanceDeLaApp, "Luis", 8)   // main espera hasta el final
    println("main recibió: $texto")
}

Cancelar a la pantalla A cancela su await(), y por eso nunca imprime «A recibió». Pero el async era del alcance de la app, y el guardado de Ana termina igualmente a los 100 ms. Es el mismo reparto que hará RegistrarGol: el trabajo es de la app y quien llama solo espera.

Paso · El partido sigue aunque salgas

Objetivo. Hacer que el PartidoViewModel use PartidoEnJuego, que el gol se guarde en el alcance de la app y que el partido sobreviva al cierre de su pantalla. Al terminar, ./gradlew :app:testGratisDebugUnitTest cuenta 35 tests (38 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine) y 0 fallos, y en el emulador el partido sigue al salir y volver.

app → kotlin+java
com.sebastiannarvaez.marcador
feature
marcador
domain
RegistrarGol.ktse modifica
ui
PartidoViewModel.ktse modifica
com.sebastiannarvaez.marcador (test)
core
Falsos.ktse modifica
feature
marcador
ui
PartidoViewModelTest.ktse modifica
Cuatro ficheros se modifican, en la vista Android.

Piezas nuevas de este paso:

  • alcance.async { … }.await(): lo que acabas de ver. RegistrarGol recibe el alcance con @AlcanceDeApp, igual que lo recibió PartidoEnJuego. RegistrarGol ya llevaba @Inject desde Casos de uso: RegistrarGol: Hilt le da el repositorio y ahora también el alcance. PartidoEnJuego, en cambio, no lleva nada de Hilt: su receta está en DatosModule. Las dos formas valen y las dos se prueban escribiendo el constructor (RegistrarGol(repo, backgroundScope), PartidoEnJuego(backgroundScope)); RegistrarGol se quedó con @Inject porque ya lo traía.
  • (carga.value as? Carga.Lista): as? intenta ver el valor como una Carga.Lista; si lo es, lo da, y si no, da null, sin fallar. Después, ?.let { … } solo se ejecuta si no es null.
  • Unit al final de las ramas del when: el when es lo que devuelve alEvento. Ahora enJuego.empezar(…) devuelve un Boolean y launch devuelve un Job: sin Unit, cada rama daría una cosa distinta y alEvento devolvería un valor mezclado que a nadie le interesa. La pantalla recibe viewModel::alEvento como «una función que recibe un evento y no devuelve nada» (Unit), así que cada rama acaba en Unit («nada»); y ?: Unit cubre el caso null de la rama del gol.
  • directo?.instante?.value?.corriendo != true: la cadena de ?. da true, false o null (si no hay directo cargado). Compararla con != true es «el reloj no corre, o no hay directo»: solo entonces se deja cambiar la duración.
  • ViewModelStore y ViewModelProvider.create(almacen, …): lo de la sección anterior, ahora en la función crearEn de tu prueba.
  • advanceTimeBy(…) + runCurrent() en lugar de advanceUntilIdle() cuando algo tarda en backgroundScope (el aviso de abajo).

Ya vistas: backgroundScope, advanceTimeBy, runCurrent y Turbine (Tests con backgroundScope y Turbine y Tests de un ViewModel: fakes, setMain y Turbine); combine (Flow: scan y combine).

Intenta escribirlo tú, en este orden:

  1. RegistrarGol: recibe también el alcance de la app (con su etiqueta) y hace su trabajo dentro de un async de ese alcance, esperando el resultado con await. Actualiza su comentario sobre la cancelación: ya no es la pantalla la que corta el guardado.
  2. PartidoViewModel: recibe PartidoEnJuego; «Empezar» se lo pide a PartidoEnJuego; cambiar la duración se ignora si este partido se juega; y cargar reutiliza el directo vivo. Ya no necesita la lista de trabajos que guardaba para cancelarlos. Su uiState no cambia.
  3. Las pruebas: el repositorio falso tarda en guardar; el crear de la prueba usa backgroundScope como alcance de la app para PartidoEnJuego y para RegistrarGol; y tres pruebas nuevas: el gol se guarda aunque se cierre la pantalla, el partido sigue al cerrarla (y otra pantalla lo encuentra donde iba) y la duración no cambia mientras se juega.

Lo que debes ver: en el emulador, partido 1 → «Empezar partido» → atrás → volver, y el minuto ha seguido; y los 35 tests en verde.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código

RegistrarGol.kt entero (es corto):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/domain/RegistrarGol.kt
package com.sebastiannarvaez.marcador.feature.marcador.domain
 
import com.sebastiannarvaez.marcador.di.AlcanceDeApp
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.intentar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import javax.inject.Inject
 
// CASO DE USO: una accion del usuario con sentido para el negocio.
//
// Un caso de uso es una clase con UNA operacion (`invoke`, asi se llama como una funcion:
// `registrarGol(1, Lado.LOCAL, 30)`). Vale la pena cuando hay REGLA: aqui, «quien marca».
// Si solo reenviara la llamada al repositorio, sobraria: el ViewModel lo llamaria directo.
//
// Regla: el gol a mano no dice quien lo marco, asi que lo marca el jugador de la
// plantilla que toca por turno (los goles que ya lleva el equipo, modulo el tamano).
// Los errores esperables (no existe el partido, el equipo no tiene jugadores) salen como
// `Result`, no como excepcion. Se atrapan con `intentar` (Cronometro.kt): como
// `runCatching`, pero RELANZA la CancellationException.
//
// UN GOL NO SE QUEDA A MEDIAS. Quien llama es la pantalla del partido, desde viewModelScope; si
// se cierra justo despues del gol, ese alcance se cancela y el gol se perderia a mitad de
// guardar. Por eso el trabajo se lanza en el ALCANCE DE LA APP (`alcance.async`) y aqui solo
// se ESPERA su resultado (`await`): si cancelan al que espera, el guardado sigue.
//
// @Inject constructor: le dice a Hilt «asi se construye esta clase; dame lo que pide entre
// parentesis». Hilt busca un PartidosRepository y el alcance con la etiqueta @AlcanceDeApp.
class RegistrarGol @Inject constructor(
    private val repositorio: PartidosRepository,
    @AlcanceDeApp private val alcance: CoroutineScope,
) {
 
    suspend operator fun invoke(partidoId: Int, lado: Lado, minuto: Int): Result<Gol> = alcance.async {
        intentar {
            val partido = repositorio.partido(partidoId) ?: error("No existe el partido $partidoId")
            val equipo = if (lado == Lado.LOCAL) partido.local else partido.visitante
            check(equipo.plantilla.isNotEmpty()) { "${equipo.nombre} no tiene jugadores" }
            val jugador = equipo.plantilla[partido.golesDe(equipo) % equipo.plantilla.size]
            Gol(minuto, jugador, equipo).also { repositorio.registrarGol(partidoId, it) }
        }
    }.await()
}

En PartidoViewModel.kt hay cuatro cambios. Primero los import: añade el de PartidoEnJuego junto a los demás de domain y borra el de kotlinx.coroutines.Job:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
import com.sebastiannarvaez.marcador.feature.marcador.domain.PartidoEnJuego

Después, el comentario de la clase y su constructor. Al final del comentario que ya tienes añade las cuatro últimas líneas (desde // hasta «le pasa los eventos»), justo encima de @HiltViewModel; y en el constructor, entre preferencias y @Assisted, la línea de enJuego. Aquí tienes el final del comentario y el constructor tal como quedan (lo de encima no cambia):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
//
// LA PANTALLA OBSERVA, NO ES LA DUENA. El partido ya no corre en viewModelScope (moriria al
// cerrar la pantalla): lo empieza `PartidoEnJuego`, en el alcance de la app. Este ViewModel
// solo lo busca (si ya esta en juego, lo reutiliza), lo traduce a un UiState y le pasa los eventos.
@HiltViewModel(assistedFactory = PartidoViewModel.Fabrica::class)
class PartidoViewModel @AssistedInject constructor(
    private val estadoGuardado: SavedStateHandle,
    private val repositorio: PartidosRepository,
    private val registrarGol: RegistrarGol,
    private val preferencias: PreferenciasRepository,
    private val enJuego: PartidoEnJuego,
    @Assisted partidoInicial: Int,
) : ViewModel() {

Debajo de private val carga = … borra la línea private var trabajos: List<Job> = emptyList(). Si en otro sitio del fichero usabas trabajos, bórralo también: el cargar de más abajo ya no lo necesita. El uiState no cambia.

alEvento y cargar completos (sustituyen a los que tenías):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
    // UNICA puerta de entrada de los eventos de la pantalla.
    fun alEvento(evento: MarcadorEvento) = when (evento) {
        // Lo empieza el partido en juego, no este ViewModel. Si hay otro sin terminar, no hace
        // nada.
        MarcadorEvento.Empezar -> {
            (carga.value as? Carga.Lista)?.let { enJuego.empezar(it.directo) }
            Unit
        }
        is MarcadorEvento.Gol -> (carga.value as? Carga.Lista)?.directo?.let { directo ->
            // Se ve al instante en el directo Y se guarda con el caso de uso (que termina de
            // guardarlo aunque esta pantalla se cierre: mira RegistrarGol).
            directo.golAMano(evento.lado)
            // El `Result` con el fallo (no existe el partido, plantilla vacia) se descarta:
            // por ahora no hay donde mostrarlo, y el gol ya se ve en el directo.
            viewModelScope.launch {
                registrarGol(directo.partidoId, evento.lado, directo.instante.value.minuto)
            }
            Unit
        } ?: Unit
        // Se guarda en DataStore y se prepara un directo NUEVO con esa duracion. Mientras este
        // partido se juega, se ignora: lo juega `PartidoEnJuego` y esta pantalla no puede reiniciarlo.
        is MarcadorEvento.CambiarDuracion -> {
            val directo = (carga.value as? Carga.Lista)?.directo
            if (directo?.instante?.value?.corriendo != true) {
                viewModelScope.launch {
                    preferencias.cambiarDuracion(evento.minutos)
                    cargar(partidoId.value, reutilizar = false)
                }
            }
            Unit
        }
    }
 
    // Cargar el partido `id`. Si es el que esta en juego (o el que acaba de terminar), se REUTILIZA
    // su directo: volver a la pantalla no lo reinicia. Si no, uno nuevo, sin empezar. Ahora el id llega en la
    // NavKey de la pantalla y solo se pide aqui (al crear y al cambiar la duracion): nadie lo cambia
    // desde dentro, asi que el handle y la clave no pueden discrepar.
    // La lectura va al repositorio: es `suspend`, asi que ya puede tardar (con Room).
    private fun cargar(id: Int, reutilizar: Boolean = true) {
        val vivo = enJuego.directo(id)
        if (reutilizar && vivo != null) {
            estadoGuardado[CLAVE_PARTIDO] = id
            carga.value = Carga.Lista(vivo)
            return
        }
        carga.value = Carga.EnCurso
        viewModelScope.launch {
            val elegido = repositorio.partido(id)
            if (elegido == null) {
                carga.value = Carga.Fallo("No existe el partido n.º $id")
            } else {
                // Solo se guarda el id si es valido: un id malo en el handle
                // rompería la app tambien tras un am kill.
                estadoGuardado[CLAVE_PARTIDO] = id
                carga.value = Carga.Lista(Directo(id, elegido, MS_POR_MINUTO, preferencias.duracionDelPartido.first()))
            }
        }
    }

En Falsos.kt: el import de delay junto a los demás, y después el bloque que sigue, que sustituye lo que tienes desde class FakePartidosRepository( hasta el final de registrarGol. Trae el parámetro nuevo retrasoAlGuardar (con su comentario) y la línea delay(retrasoAlGuardar) al principio de registrarGol; lo que viene después (desde observarGoleadores) no cambia:

app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
import kotlinx.coroutines.delay
app/src/test/java/com/sebastiannarvaez/marcador/core/Falsos.kt
class FakePartidosRepository(
    partidos: List<PartidoDeLista> = listOf(PartidoDeLista(1, Partido(Ejemplo.rayo, Ejemplo.toros))),
    // Lo que tarda en guardar un gol (Room tambien tarda): para probar que no se pierde a medias.
    private val retrasoAlGuardar: Long = 0,
) : PartidosRepository {
    private val lista = MutableStateFlow(partidos)
 
    // Las plantillas, por nombre de equipo: hacen de tabla `jugador`.
    private val plantillas = MutableStateFlow(listOf(Ejemplo.rayo, Ejemplo.toros).associate { it.nombre to it.plantilla })
 
    override fun observarPartidos(): Flow<List<PartidoDeLista>> = lista
 
    override suspend fun partido(id: Int): Partido? = lista.value.firstOrNull { it.id == id }?.partido
 
    override suspend fun registrarGol(partidoId: Int, gol: Gol) {
        delay(retrasoAlGuardar)
        lista.value = lista.value.map { if (it.id == partidoId) it.copy(partido = it.partido.registrar(gol)) else it }
    }

En PartidoViewModelTest.kt, los import nuevos (con los demás):

app/src/test/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModelTest.kt
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.sebastiannarvaez.marcador.feature.marcador.domain.PartidoEnJuego

Sustituye tu crear por este, y añade crearEn justo debajo:

app/src/test/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModelTest.kt
    private fun TestScope.crear(
        repo: FakePartidosRepository = FakePartidosRepository(),
        preferencias: FakePreferencias = FakePreferencias(),
        partidoInicial: Int = 1,
        // `backgroundScope` hace de alcance de la app: el partido en juego vive ahi, no en el ViewModel.
        enJuego: PartidoEnJuego = PartidoEnJuego(backgroundScope),
    ) = PartidoViewModel(SavedStateHandle(), repo, RegistrarGol(repo, backgroundScope), preferencias, enJuego, partidoInicial)
 
    // Crea el ViewModel DENTRO de un almacen, como hace Android: `almacen.clear()` es cerrar la
    // pantalla (llama a onCleared y cancela viewModelScope).
    private fun TestScope.crearEn(almacen: ViewModelStore, crearlo: TestScope.() -> PartidoViewModel) =
        ViewModelProvider.create(almacen, viewModelFactory { initializer { crearlo() } })[PartidoViewModel::class]

En elGolDelCasoDeUsoLoMarcaElJugadorPorTurno, la línea del caso de uso recibe el alcance:

app/src/test/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModelTest.kt
        val registrar = RegistrarGol(repo, backgroundScope)

Y las tres pruebas nuevas. Pégalas debajo de la última prueba que ya tienes, antes de la } que cierra la clase:

app/src/test/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModelTest.kt
    // Cerrar la pantalla justo despues de un gol no lo pierde, aunque guardar tarde (como Room).
    @Test
    fun unGolSeGuardaAunqueSeCierreLaPantalla() = runTest {
        val repo = FakePartidosRepository(retrasoAlGuardar = 100)
        val almacen = ViewModelStore()
        val viewModel = crearEn(almacen) { crear(repo) }
        runCurrent()
        viewModel.alEvento(MarcadorEvento.Gol(Lado.LOCAL))
        runCurrent()          // el guardado empieza...
        almacen.clear()       // ...y la pantalla se cierra antes de que termine
        advanceTimeBy(1_000)  // (advanceUntilIdle no espera a lo que corre en backgroundScope)
        runCurrent()
        assertEquals(1, repo.partido(1)!!.eventos.filterIsInstance<Gol>().size)
    }
 
    // EL PARTIDO SIGUE AUNQUE SE CIERRE SU PANTALLA, y al volver se ve por donde va.
    @Test
    fun elPartidoSigueAunqueSeCierreLaPantalla() = runTest {
        val enJuego = PartidoEnJuego(backgroundScope)
        val almacen = ViewModelStore()
        val viewModel = crearEn(almacen) { crear(enJuego = enJuego) }
        runCurrent()
        viewModel.alEvento(MarcadorEvento.Empezar)
        advanceTimeBy(10 * PartidoViewModel.MS_POR_MINUTO + 1)
        almacen.clear()
        advanceTimeBy(20 * PartidoViewModel.MS_POR_MINUTO)
        runCurrent()
        assertEquals(30, enJuego.actual.value?.minuto)
        // Otra pantalla del MISMO partido reutiliza el directo: no empieza de cero.
        crear(enJuego = enJuego).uiState.test {
            skipItems(1) // Cargando, el valor inicial de stateIn
            assertEquals(30, (awaitItem() as MarcadorUiState.Exito).minuto)
            cancelAndIgnoreRemainingEvents()
        }
    }
 
    // Mientras se juega, la duracion no cambia (el ViewModel lo ignora).
    @Test
    fun laDuracionNoCambiaMientrasSeJuega() = runTest {
        val preferencias = FakePreferencias()
        val viewModel = crear(preferencias = preferencias)
        runCurrent()
        viewModel.alEvento(MarcadorEvento.Empezar)
        viewModel.alEvento(MarcadorEvento.CambiarDuracion(60))
        runCurrent()
        assertEquals(90, preferencias.duracion.value)
    }

Qué pasa ahora cuando sales y vuelves:

PasoQuiénQué hace
1PartidoViewModel«Empezar» llama a enJuego.empezar(directo)
2PartidoEnJuegoArranca el directo en el alcance de la app y publica cada cambio en actual
3PantallaSales: viewModelScope se cancela, pero el reloj no era suyo
4PartidoViewModel nuevoAl volver, enJuego.directo(id) da el directo vivo y se reutiliza

unGolSeGuardaAunqueSeCierreLaPantalla es la prueba del gol: el repositorio falso tarda 100 ms en guardar, se pulsa el gol, se hace almacen.clear() en mitad del guardado y se comprueba que el gol está. Con el async en el alcance de la app, hay 1 gol guardado; si lo quitaras de RegistrarGol, la prueba fallaría.

Probarlo. Ejecuta la app (Run, variante gratisDebug). Abre el partido 1 y pulsa «Empezar partido». Pulsa «atrás» y vuelve a entrar: el minuto ha seguido avanzando y el botón dice «En juego». Mientras se juega, pulsa el chip «60 min»: no pasa nada, el partido sigue con sus 90.

Ahora mira lo que todavía no está bien: pulsa «atrás» y abre el partido 2. Sigue saliendo el botón «Empezar partido», y si lo pulsas no ocurre nada, porque PartidoEnJuego no deja empezar otro partido. Es correcto para el dominio, pero la pantalla no se lo explica al usuario. Eso lo arregla Solo un partido a la vez.

Punto de control

  • ./gradlew :app:testGratisDebugUnitTest termina en BUILD SUCCESSFUL y cuenta 35 tests (38 si hiciste el ejercicio del temporizador), 0 fallos; PartidoViewModelTest tiene tres pruebas nuevas.
  • En el emulador: partido 1 → «Empezar partido» → atrás → volver, y el minuto ha seguido; el botón dice «En juego».
  • Con el partido 1 en juego, el chip «60 min» no cambia nada.
  • El partido 2, abierto con el 1 en juego, todavía enseña «Empezar partido» y no hace nada al pulsarlo: es lo que arregla la lección siguiente.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Empiezas el partido, sales de su pantalla y el reloj sigue corriendo. ¿Por qué?

  2. 2.RegistrarGol hace alcance.async { … }.await(). Cierras la pantalla justo después del gol. ¿Qué pasa con el guardado?

  3. 3.Sales de la pantalla del partido 1, que sigue en juego, y vuelves a entrar. ¿Cómo hace el nuevo PartidoViewModel para no empezar de cero?

  4. 4.En una prueba, el repositorio falso tarda 100 ms en guardar y el guardado corre en backgroundScope. Comprobar justo después de advanceUntilIdle() falla. ¿Qué usas?

  5. 5.En una prueba llamas a almacen.clear() sobre el ViewModelStore donde está el PartidoViewModel. ¿Qué estás simulando?

Faltan 5 de 5