Saltar al contenido
K
Apéndice: más allá de la appPaso a paso60 min de lectura · 12 min de máquina Intermedio

Migrar el disco con expect/actual

Al terminar: Devolver a commonMain las funciones de disco de :torneo con dos declaraciones expect y un actual por plataforma.

En la lección anterior, :torneo pasó a ser un módulo multiplataforma, pero dejaste fuera lo que toca el disco: guardar el torneo en un fichero, cargarlo, exportar la tabla. Esas funciones usaban java.io.File, y java.io no existe en iOS. Es lo primero que la JVM no comparte.

Al terminar, guardar, cargarTorneo, exportarTabla, guardarEnSegundoPlano, cargarTorneoEnSegundoPlano y cerrarJornada habrán vuelto a commonMain, con una ruta de texto en vez de un File. Y en todo el módulo habrá dos declaraciones expect y nada más: leerTexto y escribirTexto. Tendrás 138 tests en la JVM y 117 en Android y en el simulador de iOS (con CancelacionTest; 132 y 111 si te lo saltaste).

Como en la lección anterior, sigues en la vista Project del panel (el selector de arriba a la izquierda): lo que enseña esta lección son los source sets como carpetas (commonMain, jvmMain, androidMain, iosMain), y la vista Android las agrupa y las esconde. Para crear un fichero en una carpeta concreta: clic derecho sobre la carpeta → New → Kotlin Class/File → File, y el nombre sin .kt. Si ese menú no te ofrece Kotlin, touch en la Terminal lo crea (touch ruta crea un fichero vacío en la ruta que le des). Al final de la lección vuelves a la vista Android.

Esta lección y la anterior asumen un Mac, porque el destino de iOS solo se compila en Mac; los comandos ./gradlew de JVM y Android valen en cualquier sistema.

Temas: expect/actual mínimo y errores de compilación de iOS.

Paso · El fichero Ficheros.kt: lo que el disco le pide a la plataforma

Objetivo: declarar, en el código común, las dos únicas cosas que :torneo necesita del disco.

Lo que :torneo le pide al disco es poco: leer un texto entero y escribir un texto entero. Con eso se puede guardar, cargar y exportar. Crea el fichero que las declara.

torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/
Ficheros.ktse crea: los dos expect
El fichero que creas, en la vista Project.

En la vista Project, clic derecho sobre torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo → New → Kotlin Class/File → File → Ficheros y Intro. (Si el menú no te ofrece Kotlin en esa carpeta, en la Terminal: touch torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/Ficheros.kt, y ábrelo con doble clic.) Escribe:

Ficheros.kt (con comentarios)
package com.sebastiannarvaez.marcador.torneo   // el mismo paquete que el resto de :torneo
 
// `expect` («se espera»): dice QUÉ hace falta, sin cuerpo. Cada plataforma escribirá su `actual`.
// Leer un texto entero: devuelve el contenido del fichero.
expect fun leerTexto(ruta: String): String
 
// Escribir un texto entero: crea el fichero o lo sustituye.
expect fun escribirTexto(ruta: String, texto: String)

Compila los tres destinos:

./gradlew :torneo:compileKotlinJvm :torneo:compileAndroidMain :torneo:compileKotlinIosSimulatorArm64 --continue

(--continue hace que Gradle no se detenga en el primer fallo y enseñe los de cada destino.) Falla en todos con el mismo mensaje:

e: …/Ficheros.kt:3:1 The 'expect' declaration 'leerTexto' has no 'actual' declaration in module '<commonMain> for JVM'.
e: …/Ficheros.kt:3:1 The 'expect' declaration 'leerTexto' has no 'actual' declaration in module '<commonMain> for Native'.

(Android dice «for JVM», porque Android es una JVM.) El compilador no admite una declaración expect sin su pareja en cada destino: es lo que significa «esperada».

expect y actual

Dos consecuencias que verás en el código: se pueden declarar funciones y propiedades, y el fichero de cada plataforma lleva el mismo package que el común. Un ejemplo mínimo, que no es de Marcador, con las tres plataformas:

ejemplo mínimo: expect y actual
// En commonMain: la forma, sin cuerpo.
expect fun nombreDeLaPlataforma(): String
 
// En jvmMain: la JVM la rellena a su manera.
actual fun nombreDeLaPlataforma(): String = "JVM"
 
// En androidMain: Android, a la suya (es otro destino, aunque sea una JVM).
actual fun nombreDeLaPlataforma(): String = "Android"
 
// En iosMain: iOS, a la suya.
actual fun nombreDeLaPlataforma(): String = "iOS"

Qué le toca hacer a cada plataforma:

PiezaJVM y AndroidiOS
leerTexto(ruta)File(ruta).readText()NSString.stringWithContentsOfFile
escribirTexto(ruta, texto)File(ruta).writeText(texto)writeToFile de NSString

NSString es la clase de texto de Foundation, la librería base de Apple, la misma que usarías desde Swift. Foundation está escrita en Objective-C, el lenguaje que Apple usaba antes de Swift, y sus métodos no lanzan excepciones: devuelven null o false y, si les pasas un hueco, dejan en él un NSError (el objeto que describe el fallo). Piensa en un formulario con un recuadro «motivo del error» en blanco: tú se lo entregas, y Foundation solo lo rellena si algo sale mal. (En Swift, String(contentsOfFile:) sí lanza con try; pero Kotlin no ve esa versión, sino el método de Objective-C tal cual, con ese hueco como parámetro.) Por eso el actual de iOS lanza una IllegalStateException a mano: así todas las plataformas fallan igual y runCatching lo recoge.

Paso · Los tres actual

Objetivo: que cada destino tenga su pareja de leerTexto y escribirTexto, y que los tres compilen.

torneo/src/
jvmMain/kotlin/…/torneo/Ficheros.jvm.ktse crea
androidMain/kotlin/…/torneo/Ficheros.android.ktse crea (y la carpeta androidMain)
iosMain/kotlin/…/torneo/Ficheros.ios.ktse copia (y la carpeta iosMain)
Tres ficheros nuevos, uno por plataforma. Android e iOS necesitan su carpeta de source set.

Las carpetas siguen el patrón src/<destino>Main/kotlin/com/sebastiannarvaez/marcador/torneo/. androidMain e iosMain existen como source sets en cuanto declaraste esos destinos en build.gradle.kts (Module :torneo) (que no cambia en toda la lección), pero sus carpetas hay que crearlas. Desde la Terminal, en la carpeta del proyecto:

mkdir -p torneo/src/androidMain/kotlin/com/sebastiannarvaez/marcador/torneo
mkdir -p torneo/src/iosMain/kotlin/com/sebastiannarvaez/marcador/torneo

mkdir crea una carpeta; con -p crea también todas las intermedias que falten (src/androidMain, kotlin, com…), así que basta una orden por carpeta final.

Pulsa Sync Now (Android Studio detecta las carpetas nuevas y las marca como código).

El patrón, con otra función de disco que Marcador no tiene (dos ficheros distintos, uno en cada source set):

ejemplo mínimo: un actual de la JVM que usa File
// ── Fichero de commonMain ──
// La forma, sin cuerpo.
expect fun existeFichero(ruta: String): Boolean
 
// ── Fichero de jvmMain (en androidMain sería igual) ──
import java.io.File                    // la clase de Java para rutas de disco: aquí sí existe
 
// La pareja: misma firma, `actual` delante y un cuerpo con lo que tiene esta plataforma.
actual fun existeFichero(ruta: String): Boolean = File(ruta).exists()
 
// Las dos llamadas que vas a necesitar: File(ruta).readText() lee el fichero entero
// y File(ruta).writeText(texto) lo crea o lo sustituye entero.

El de la JVM. Intenta escribirlo tú: en jvmMain, un fichero Ficheros.jvm.kt con los dos actual, que usen File (la clase de Java para rutas de disco) para leer y escribir. El nombre lleva el sufijo del destino y, al crearlo, va sin .kt (Android Studio añade la extensión).

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Ficheros.jvm.kt (versión para escribir)
package com.sebastiannarvaez.marcador.torneo   // el mismo paquete que el `expect`
 
import java.io.File                            // existe en la JVM
 
// `actual`: la pareja del `expect`, con la MISMA firma. Esta lee con `File`.
actual fun leerTexto(ruta: String): String = File(ruta).readText()
 
// Y esta escribe con `File`.
actual fun escribirTexto(ruta: String, texto: String) = File(ruta).writeText(texto)

El de Android. Android es una JVM, así que su actual es idéntico. Aun así se escribe dos veces: jvm y android son dos destinos distintos, y Kotlin solo comparte código entre varios destinos si existe un source set intermedio: una carpeta de código que compilan a la vez varios destinos, pero no todos (como iosMain, que el plugin crea para los dos de iOS). Habría que crear uno para jvm y android, y para tres líneas no compensa. Crea Ficheros.android en torneo/src/androidMain/kotlin/com/sebastiannarvaez/marcador/torneo (clic derecho → New → Kotlin Class/File → File; o touch en la Terminal) y escribe lo mismo:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Ficheros.android.kt (versión para escribir)
package com.sebastiannarvaez.marcador.torneo   // el mismo paquete que el `expect`
 
import java.io.File                            // Android es una JVM: `File` existe
 
// Android es una JVM: el `actual` es el mismo que el de jvmMain. Kotlin no lo comparte solo
// (jvm y android son dos destinos distintos), así que se repite.
actual fun leerTexto(ruta: String): String = File(ruta).readText()
 
actual fun escribirTexto(ruta: String, texto: String) = File(ruta).writeText(texto)

El de iOS. Llama a Foundation desde Kotlin con la maquinaria de interoperación con Objective-C, que no has estudiado y no hace falta entender para seguir; por eso se copia con el botón Copiar fichero (el mismo gesto de Clases y objetos: crear el fichero, pulsar el botón, seleccionar todo y pegar). Crea Ficheros.ios en torneo/src/iosMain/kotlin/com/sebastiannarvaez/marcador/torneo y pega:

Ficheros.ios.kt

El fichero tiene las mismas dos actual fun. Lo único que merece una mirada es cómo se parece a las otras por fuera y cómo se diferencia por dentro:

Ficheros.ios.kt: la forma de leerTexto (un extracto)
// La misma firma que el `expect`, con `actual` delante.
actual fun leerTexto(ruta: String): String = memScoped {                  // memoria nativa temporal
    val error = alloc<ObjCObjectVar<NSError?>>()                          // el hueco donde Foundation dejará un error
    NSString.stringWithContentsOfFile(ruta, NSUTF8StringEncoding, error.ptr)   // Foundation lee el fichero
        ?: throw IllegalStateException("No se pudo leer $ruta")           // Foundation devolvió null: lo convertimos en excepción
}

Dos detalles de ese fichero: lleva @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class), un permiso explícito para usar las APIs de interoperación con C y Objective-C, que aún pueden cambiar (solo aparece en iosMain); y un único Ficheros.ios.kt sirve a iosArm64 y a iosSimulatorArm64, porque iosMain no es un destino, sino un source set intermedio que el plugin crea por defecto encima de esos dos destinos, sin que lo pidas (lo viste en Anatomía del proyecto).

Compila otra vez los tres destinos:

./gradlew :torneo:compileKotlinJvm :torneo:compileAndroidMain :torneo:compileKotlinIosSimulatorArm64

Debe terminar en BUILD SUCCESSFUL: cada expect ya tiene su actual en cada destino.

Una excepción consciente

La lección de expect/actual frente a interfaces desaconseja que el dominio llame a un expect directamente. En el paso siguiente el dominio sí lo hace: guardar, cargarTorneo y exportarTabla llamarán a leerTexto y escribirTexto. Es una excepción consciente a esa regla, y se hace por una razón práctica: que la migración cruce hasta iOS con dos funciones mínimas de E/S y cambiando solo el tipo de un parámetro (de File a String) en las firmas que ya existen.

El diseño preferible al crecer, o en cuanto quieras probar con un fake, es el que enseñó aquella lección: una interfaz Disco y un solo expect fun discoDeLaPlataforma() que la devuelve, igual que currentPlatform(). Con él, el disco llega como parámetro con valor por defecto y a quien llama las firmas le siguen valiendo:

el diseño preferible (no lo escribes)
interface Disco {
    fun leer(ruta: String): String
    fun escribir(ruta: String, texto: String)
}
 
// La única declaración expect, como currentPlatform().
expect fun discoDeLaPlataforma(): Disco
 
fun Torneo.guardar(ruta: String, disco: Disco = discoDeLaPlataforma()): Result<Unit> =
    runCatching { disco.escribir(ruta, aJson()) }

Con eso, un test pasa un DiscoEnMemoria y no toca el disco. Marcador no da ese paso porque sus tests de disco escriben en una carpeta temporal de verdad y no necesitan un disco falso; el Reto de esta lección es justo ese paso.

Paso · Las funciones de disco vuelven a commonMain

Objetivo: que :torneo no tenga ningún java.io fuera de los actual, que las funciones de disco vuelvan a commonMain con una ruta de texto en lugar de un File, y que todos los destinos compilen y pasen sus tests.

torneo/src/
commonMain/…/Guardado.ktse modifica: guardar y cargarTorneo vuelven
commonMain/…/Exportar.ktsube de jvmMain y se reescribe
commonMain/…/SegundoPlanoEnFichero.ktsube de jvmMain y se modifica
jvmMain/…/GuardadoEnFichero.ktse borra
jvmMain/…/Main.kt y DemoKotlin.ktse modifican: pasan rutas
commonTest/…/GuardadoTest.ktgana un test
jvmTest/…/tres ficheros pasan rutas
Qué se toca en este paso.

Piezas nuevas de este paso:

  • ruta: String: la API pública pasa de recibir un File a recibir el camino del fichero como texto, porque String existe en todas partes. :app nunca llamaba a estas funciones, así que no cambia.
  • Una propiedad de extensión, que es como una función de extensión (la viste en Extensiones) pero para una propiedad: una propiedad añadida a un tipo desde fuera. Aparecerá en el error de Dispatchers.IO más abajo.

1 · guardar y cargarTorneo, otra vez en Guardado.kt

Abre Guardado.kt (en commonMain) y añade al final las dos funciones, ahora con ruta y usando leerTexto y escribirTexto. Borra el fichero GuardadoEnFichero.kt de jvmMain: ya no hace falta (clic derecho → Delete).

Intenta escribirlo tú: guardar, una extensión de Torneo que recibe la ruta como texto y escribe en ella el torneo como JSON; y cargarTorneo, que recibe la ruta, lee el texto y lo convierte en un torneo. Las dos devuelven un Result: si el disco o el JSON fallan, el fallo llega como valor, no como caída.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Guardado.kt (versión para escribir)
// Guardar: convierte el torneo a JSON (aJson) y lo escribe con el `expect` de escritura.
// El resultado es un Result: si el disco falla, llega como valor, no como caída.
fun Torneo.guardar(ruta: String): Result<Unit> = runCatching { escribirTexto(ruta, aJson()) }
 
// Cargar: lee el texto con el `expect` de lectura y lo convierte de JSON a Torneo.
fun cargarTorneo(ruta: String): Result<Torneo> = runCatching { leerTexto(ruta) }.mapCatching {
    torneoDesdeJson(it).getOrThrow()
}

2 · Exportar.kt sube a commonMain

Exportar.kt usa un escritor de Java (bufferedWriter). Pasa por escribirTexto, que cierra el fichero por dentro. Primero súbelo de carpeta, desde la Terminal (mv origen destino mueve un fichero; si el destino es una carpeta, lo deja dentro con el mismo nombre):

mv torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo/Exportar.kt torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/

Y reescríbelo entero. Intenta escribirlo tú: exportarTabla, una extensión de Torneo que recibe la ruta como texto y escribe en ella la tabla como texto, con un salto de línea al final, devolviendo el fallo (carpeta inexistente, disco lleno…) como un Result.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Exportar.kt (versión para escribir)
package com.sebastiannarvaez.marcador.torneo
 
// Escribe la tabla en un fichero de texto, con el `expect` de escritura.
// Todo va dentro de `runCatching` para que el fallo (carpeta inexistente, disco lleno…)
// llegue como Result.
fun Torneo.exportarTabla(ruta: String): Result<Unit> = runCatching {
    escribirTexto(ruta, tablaComoTexto() + "\n")   // la tabla, más un salto de línea final
}

3 · SegundoPlanoEnFichero.kt sube a commonMain

Súbelo igual:

mv torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo/SegundoPlanoEnFichero.kt torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/

En ese fichero cambia fichero: File por ruta: String en las tres funciones (y fichero por ruta al llamar a guardar, cargarTorneo, guardarEnSegundoPlano), y borra import java.io.File. Compila ahora los destinos de iOS:

./gradlew :torneo:compileKotlinIosSimulatorArm64

Añade el import y deja el fichero así. Intenta escribirlo tú: las tres funciones de antes, ahora con la ruta como texto en lugar del fichero y con el import de la extensión de IO. Lo que debes ver: ./gradlew :torneo:compileKotlinIosSimulatorArm64 termina sin el error de internal.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
SegundoPlanoEnFichero.kt (versión para escribir)
package com.sebastiannarvaez.marcador.torneo
 
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO                   // la propiedad de extensión: sin ella, `Dispatchers.IO` da error en iOS
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
 
// Escribir el JSON es esperar al disco: IO. Ahora recibe una ruta de texto.
suspend fun Torneo.guardarEnSegundoPlano(ruta: String, dispatcher: CoroutineDispatcher = Dispatchers.IO): Result<Unit> =
    withContext(dispatcher) { guardar(ruta) }
 
suspend fun cargarTorneoEnSegundoPlano(ruta: String): Result<Torneo> =
    withContext(Dispatchers.IO) { cargarTorneo(ruta) }
 
// Las dos cosas a la vez, cada una en su dispatcher. Si el guardado falla, se
// lanza el error (`getOrThrow`) al esperar el resultado.
suspend fun Torneo.cerrarJornada(ruta: String): List<FilaDePosicion> = coroutineScope {
    val guardado = async { guardarEnSegundoPlano(ruta) }
    val tabla = async { tablaEnSegundoPlano() }
    guardado.await().getOrThrow()
    tabla.await()
}

4 · La consola pasa rutas

Compila la JVM. Las demos de consola (Main.kt y DemoKotlin.kt, en jvmMain) todavía llaman a las funciones con un File:

./gradlew :torneo:compileKotlinJvm
e: …/DemoKotlin.kt:124:26 Argument type mismatch: actual type is 'File!', but 'String' was expected.
e: …/DemoKotlin.kt:137:20 Argument type mismatch: actual type is 'File', but 'String' was expected.
e: …/DemoKotlin.kt:140:18 Argument type mismatch: actual type is 'File', but 'String' was expected.
e: …/Main.kt:169:38 Argument type mismatch: actual type is 'File', but 'String' was expected.

Los números de línea de tu Main.kt serán otros, porque el tuyo no es idéntico al del autor: guíate por el fichero y el mensaje. Son cuatro llamadas. Cada una pasa la ruta como texto: en lugar del File, su path (la ruta con la que lo creaste) o su absolutePath (la ruta completa desde la raíz del disco). En DemoKotlin.kt, la exportación:

DemoKotlin.kt: exportar
// antes
torneo.exportarTabla(destino)
// después: `.path` es la ruta del fichero temporal, como texto
torneo.exportarTabla(destino.path)

En DemoKotlin.kt, guardar y cargar (la ruta se calcula una vez y se pasa a las dos):

DemoKotlin.kt: guardar y cargar
// después
val ruta = File("torneo.json").absolutePath      // la ruta completa, como texto
torneo.guardar(ruta)
    .onSuccess { println("Guardado en $ruta") }
    .onFailure { println("No se pudo guardar: ${it.message}") }
cargarTorneo(ruta)
    .onSuccess { println("Cargado: ${it.nombre}, ${it.partidos.size} partidos, líder ${it.tablaDePosiciones().first().equipo.nombre}") }
    .onFailure { println("No se pudo cargar: ${it.message}") }

Y en Main.kt, dentro de demoDispatchers: val ruta = "torneo.json" en lugar de val fichero = File("torneo.json"), torneo.cerrarJornada(ruta), y "Guardado en $ruta; líder: …" en el println. Después de ese cambio, Main.kt ya no usa File: borra su import java.io.File (Android Studio lo pinta en gris).

5 · Los tests

Un test con la misma forma, sobre otra cosa que puede fallar:

ejemplo mínimo: comprobar que un Result es failure
import kotlin.test.Test
import kotlin.test.assertTrue
 
class DivisionTest {
    @Test
    fun dividirPorCeroEsFailure() {
        val divisor = 0
        // runCatching convierte la excepción en un Result.failure en vez de dejarla escapar.
        val resultado = runCatching { 10 / divisor }
        assertTrue(resultado.isFailure)               // isFailure: true si el Result guarda un error
        assertTrue(runCatching { 10 / 2 }.isSuccess)  // e isSuccess, si guarda un valor
    }
}

Un test nuevo, escrito por ti. En GuardadoTest.kt (commonTest) añade un test, unaRutaQueNoExisteEsFailure, que compruebe, con tres comprobaciones, que cargar un torneo de una ruta dentro de una carpeta que no existe (por ejemplo, /no/existe/torneo.json), guardar el torneo de ejemplo en ella y exportar su tabla a /no/existe/tabla.txt devuelven un fallo y no una excepción. Como ya no necesita un File, corre en los tres destinos. torneoDeEjemplo() ya existe en ese fichero (viene del GuardadoTest.kt que copiaste en JSON y serialización). El test equivalente que había en jvmTest desaparecerá: al pegar el nuevo GuardadoEnFicheroTest (justo debajo), ese test ya no está. Intenta escribirlo tú.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
GuardadoTest.kt: el test nuevo (versión para escribir)
    @Test
    fun unaRutaQueNoExisteEsFailure() {
        // Cargar una ruta que no existe: el Result es failure, no una excepción.
        assertTrue(cargarTorneo("/no/existe/torneo.json").isFailure)
        // Guardar en una carpeta que no existe.
        assertTrue(torneoDeEjemplo().guardar("/no/existe/torneo.json").isFailure)
        // Exportar la tabla a una carpeta que no existe.
        assertTrue(torneoDeEjemplo().exportarTabla("/no/existe/tabla.txt").isFailure)
    }

Tres tests de jvmTest, por botón. ExportarTest, GuardadoEnFicheroTest y SegundoPlanoTest solo cambian File por .path al llamar a las funciones, y no enseñan nada nuevo: se actualizan con el botón Copiar fichero. Para cada uno: abre el fichero existente en torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo, pulsa su botón, haz clic dentro del fichero, selecciona todo con Cmd+A y pega encima con Cmd+V. El texto copiado trae su propia línea package.

ExportarTest.kt

GuardadoEnFicheroTest.kt

SegundoPlanoTest.kt

Los tests que crean una carpeta temporal siguen en jvmTest porque la crean con java.io. Así, en iOS solo se prueba el camino de fallo (la ruta que no existe); que guardar y cargar funcionan de verdad en iOS no lo comprueba ningún test de Marcador.

6 · Todo, junto

Ejecuta las tres suites:

./gradlew :torneo:allTests :torneo:testAndroidHostTest

Debe terminar en BUILD SUCCESSFUL. Los tests de iOS los ejecuta un simulador de iPhone que elige el plugin de Kotlin entre los que tienes instalados en Xcode (en un Mac con un iPhone 17 Pro instalado, usa ese). Si en su lugar falla con Invalid device: … (el nombre elegido no existe en tu Mac), mira los nombres que tienes con xcrun simctl list devices available y fija uno al final de build.gradle.kts (Module :torneo):

build.gradle.kts (Module :torneo): fijar el simulador de los tests (solo si falla)
// Cada tarea de tests de iOS (simulador) usa este iPhone; cambia el nombre por uno de tu lista.
tasks.withType<org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest>().configureEach {
    device.set("iPhone 17")
}

Cuánto se comparte ahora

Las líneas de Kotlin de :torneo. Cuéntalas con find torneo/src/commonMain -name '*.kt' | xargs wc -l y lo mismo con las otras carpetas: find busca ficheros (aquí, los .kt dentro de esa carpeta), | pasa su lista a la orden siguiente, y xargs wc -l hace que wc -l (contar líneas) los cuente todos y dé un total.

Source setFicherosLíneas
commonMain241 168
jvmMain (consola y demos)3371
androidMain19
iosMain133
commonTest191 246
jvmTest5274

(Tus cifras pueden variar unas líneas según tus comentarios.)

  • Producción de :torneo: 1 168 de 1 581 líneas están en commonMain: el 74 %. Solo 42 líneas (androidMain más iosMain) son específicas de una plataforma de producto; las 371 de jvmMain son consola de la JVM, que no es la app y no se publica.
  • Tests de :torneo: 1 246 de 1 520 líneas, el 82 %, corren en la JVM, en Android y en el simulador de iOS.
  • Contando también la interfaz de Android (unas 3 060 líneas de app/src/main, sin contar la carpeta ejercicios): 1 168 de unas 4 640, el 25 %. Es bajo porque la interfaz es nativa y no se comparte, y es la parte más grande.

Los tests, por destino: 138 en la JVM (117 de commonTest y 21 de jvmTest), 117 en Android y 117 en el simulador de iOS (132, 111 y 111 sin CancelacionTest). En la lección anterior eran 116 en Android y en iOS y 22 en jvmTest, con los mismos 138 en la JVM. Ese test de más no se «gana»: se mueve. unFicheroQueNoExisteEsFailure sale de GuardadoEnFicheroTest (de jvmTest) y se reescribe en GuardadoTest (de commonTest) con tres comprobaciones; como ya no necesita un File, corre en los tres destinos.

Vuelve a la vista Android

Ya no necesitas ver los source sets como carpetas. Vuelve el selector del panel (arriba a la izquierda) a Android, como estaba antes de Migrar un proyecto Android existente. Desde aquí, las lecciones usan esa vista. Para crear un fichero con ruta completa en un módulo como :torneo, el método es: clic derecho sobre el módulo torneo → New → File y escribe la ruta desde la carpeta del módulo, por ejemplo src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/MiTest.kt. Si ese menú no te lo ofrece, touch en la Terminal crea el fichero (después de un mkdir -p de su carpeta si no existe).

Punto de control

  • En la Terminal, ./gradlew :torneo:allTests termina con BUILD SUCCESSFUL: 138 tests en la JVM y 117 en el simulador de iOS, ninguno fallido (132 y 111 sin CancelacionTest). Los de iOS corren de verdad, en un simulador. allTests cubre la JVM y el simulador de iOS; no incluye Android.
  • ./gradlew :torneo:testAndroidHostTest (Android en tu ordenador) termina en verde con 117 tests.
  • ./gradlew :torneo:compileKotlinIosSimulatorArm64 :torneo:compileKotlinIosArm64 y ./gradlew :torneo:linkDebugFrameworkIosSimulatorArm64 :torneo:linkDebugFrameworkIosArm64 terminan en verde.
  • ./gradlew :app:assembleGratisDebug :app:testGratisDebugUnitTest sigue en verde, sin haber tocado app/.
  • ./gradlew :torneo:jvmRun --args=kotlin escribe y vuelve a leer torneo.json y, entre la salida, verás una línea que empieza por «Cargado:».
  • ls torneo/src en la Terminal (ls lista el contenido de una carpeta) muestra seis carpetas de código: androidMain, commonMain, commonTest, iosMain, jvmMain y jvmTest, y en todo :torneo no queda un java.io fuera de jvmMain, jvmTest y Ficheros.android.kt.
  • El selector del panel pone Android.

Reto · Probar el disco sin tocar el disco

Es el paso hacia el diseño preferible. En el Kotlin Playground (play.kotlinlang.org), fuera de tu proyecto, escribe:

  • Una interfaz, Disco, con dos funciones: leer, que recibe una ruta y devuelve su texto, y escribir, que recibe una ruta y un texto.
  • Una clase, DiscoEnMemoria, que la implemente guardando los textos en un mapa y que, al leer una ruta que no existe, lance una excepción.
  • Una función, copiar, que reciba un disco, una ruta de origen y una de destino, y copie el texto de una a otra.
  1. Escribe el texto Copa Barrio en a.txt, cópialo a b.txt y comprueba que b.txt tiene el mismo texto.
  2. Comprueba que copiar no-existe.txt a c.txt falla. Pista: Kotlin puede ejecutar un bloque y decirte si falló, sin que la excepción pare el programa.

Si las dos comprobaciones pasan, imprime un mensaje. Lo que debe salir:

Las dos comprobaciones pasan
Ver solución
solución del reto
interface Disco {
    fun leer(ruta: String): String
    fun escribir(ruta: String, texto: String)
}
 
class DiscoEnMemoria : Disco {
    private val ficheros = mutableMapOf<String, String>()
    override fun leer(ruta: String): String =
        ficheros[ruta] ?: throw IllegalStateException("No existe $ruta")
    override fun escribir(ruta: String, texto: String) { ficheros[ruta] = texto }
}
 
fun copiar(disco: Disco, origen: String, destino: String) =
    disco.escribir(destino, disco.leer(origen))
 
fun main() {
    val disco = DiscoEnMemoria()
    disco.escribir("a.txt", "Copa Barrio")
    copiar(disco, "a.txt", "b.txt")
    check(disco.leer("b.txt") == "Copa Barrio") { "la copia no coincide" }
    check(runCatching { copiar(disco, "no-existe.txt", "c.txt") }.isFailure) { "debía fallar" }
    println("Las dos comprobaciones pasan")
}

Con una interfaz, el disco se sustituye en un test por uno de memoria y no se escribe ningún fichero. Con los expect directos de Marcador no se puede: se prueba el disco de verdad, en una carpeta temporal. Tu solución es el primer paso del diseño preferible de esta lección; el segundo es el expect fun discoDeLaPlataforma(): Disco que devuelve el disco real de cada plataforma, y disco: Disco = discoDeLaPlataforma() como parámetro por defecto de guardar.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.leerTexto se declara con expect en commonMain en vez de usar java.io.File directamente. ¿Por qué?

  2. 2.Borras Ficheros.ios.kt y compilas todos los destinos. ¿Qué pasa?

  3. 3.Al compilar para iOS ves Cannot access 'val IO': it is internal in 'kotlinx.coroutines.Dispatchers'. ¿Cuál es el arreglo que usa Marcador?

  4. 4.Ficheros.android.kt repite las mismas líneas que Ficheros.jvm.kt. ¿Por qué no se comparten?

  5. 5.El test unaRutaQueNoExisteEsFailure está en commonTest y no en jvmTest. ¿Qué lo permite?

Faltan 5 de 5