Saltar al contenido
K
Arquitectura en iOSPaso a paso40 min de lectura · 15 min de máquina Básico

Guardar el torneo en un fichero

Al terminar: Guardar y cargar el torneo en ficheros.

Al terminar, Marcador guardará el torneo entero en un fichero JSON dentro de la carpeta Documents de la app. Marcas un gol a mano, cierras la app del todo, la abres otra vez, y la lista conserva el gol. Y sabrás mirar el sandbox de la app por dentro, desde el Mac, para ver el fichero con tus ojos.

Importa porque un ajuste cabe en UserDefaults, pero un documento entero (el torneo, con sus equipos y sus partidos) necesita un fichero. Y porque el sitio donde se guarda y la forma de escribir importan: una ruta fija o una escritura a medias son errores que no dan ningún aviso.

Temas: el sandbox y la carpeta Documents, FileManager, escritura atómica, defer, un repositorio que guarda y carga, y las pruebas con carpetas temporales.

El sandbox y la carpeta Documents

Antes de escribir un fichero, una idea: dónde se guarda. Cada app de iOS vive en su sandbox (una caja de arena: una carpeta propia que ninguna otra app puede ver). Dentro hay sitios con reglas distintas:

CarpetaQué guarda
DocumentsLo que crea el usuario; aquí, torneo.json. Se incluye en las copias de seguridad
Library/PreferencesEl fichero .plist (una lista de claves y valores) donde UserDefaults guarda los ajustes
Library/CachesDatos que puedes volver a calcular
tmpTemporales; el sistema los puede borrar en cualquier momento

En Android, Documents hace de filesDir. La ruta del sandbox cambia en cada instalación: nunca guardes una ruta absoluta; pregunta al sistema cada vez con URL.documentsDirectory.

Piezas nuevas:

  • URL.documentsDirectory y .appending(path:): la carpeta Documents del sandbox, y un nombre de fichero colgando de ella.
  • Data.write(to:options: .atomic): escribe primero a un fichero temporal y luego lo renombra; si la app muere a mitad, queda el fichero viejo entero en vez de uno a medias.
  • FileManager.default.fileExists(atPath:) y removeItem(at:): preguntar si un fichero existe y borrarlo.
  • Data(texto.utf8) y String(decoding: datos, as: UTF8.self): pasan un texto a bytes (Data, en la codificación estándar UTF-8) y de bytes a texto. joined(separator: "\n") pega los textos de una lista con un salto de línea entre ellos, y components(separatedBy: "\n") hace lo contrario: parte un texto por cada salto de línea y devuelve la lista.
  • try?: ejecuta algo que puede fallar y, si falla, devuelve nil en vez de lanzar el error (Errores: throws, try y do/catch).

Las piezas en un almacén más sencillo, que guarda una lista de notas como texto. Cuidado: en tu Mac, .documentsDirectory es tu carpeta Documentos de verdad, por eso este ejemplo no lo ejecuta: solo define el almacén, y el tercer ejemplo lo prueba en una carpeta temporal.

ejemplo mínimo: un almacén con documentsDirectory, write atómico y FileManager (con comentarios)
import Foundation
 
// Lleva una lista de notas al disco y la trae de vuelta.
struct AlmacenDeNotas {
    let url: URL                                   // dónde está el fichero; las pruebas pasan otro
 
    // El de verdad: notas.txt dentro de Documents. documentsDirectory pregunta la ruta al sistema.
    static let predeterminado = AlmacenDeNotas(url: .documentsDirectory.appending(path: "notas.txt"))
 
    var existe: Bool { FileManager.default.fileExists(atPath: url.path) }   // ¿hay fichero?
 
    func guardar(_ notas: [String]) throws {
        let texto = notas.joined(separator: "\n")            // una nota por línea
        // .atomic: escribe en un temporal y lo renombra; si la app muere a mitad, queda el viejo entero.
        try Data(texto.utf8).write(to: url, options: .atomic)
    }
 
    func cargar() throws -> [String] {
        let datos = try Data(contentsOf: url)                // lee el fichero entero
        return String(decoding: datos, as: UTF8.self).components(separatedBy: "\n")
    }
 
    func borrar() {
        try? FileManager.default.removeItem(at: url)         // si no existe, se ignora el error
    }
}

Este ejemplo no imprime nada: es la definición que usan los dos siguientes.

El repositorio de Marcador es la única puerta a los datos, así que será el único que hable con el almacén. Necesita dos piezas más:

  • if let almacen, almacen.existe: «si hay almacén (no es nil) y tiene fichero». Es la forma corta de if let almacen = almacen.
  • guardado.partidos.enumerated().map { … }: enumerated() recorre los partidos con su posición; el identificador de cada uno es su posición más uno.

Con las notas:

ejemplo mínimo: if let con condición, enumerated y un init que puede salir antes (con comentarios)
struct Nota: Identifiable { let id: Int; let texto: String }
 
final class Notas {
    private let almacen: AlmacenDeNotas?
    private(set) var lista: [Nota]
 
    init(almacen: AlmacenDeNotas? = nil) {
        self.almacen = almacen
        // if let x, condición: entra si almacen no es nil Y además existe el fichero.
        if let almacen, almacen.existe {
            do {
                let textos = try almacen.cargar()
                // enumerated() da (posición, elemento); el id es la posición más uno.
                self.lista = textos.enumerated().map { Nota(id: $0 + 1, texto: $1) }
                return                                       // ya está todo: sale del init
            } catch {
                print("fichero ilegible: \(error)")         // no impide arrancar
            }
        }
        self.lista = [Nota(id: 1, texto: "pan")]             // si no, los datos de ejemplo
    }
}

Este tampoco imprime nada: sin almacén (o sin fichero), Notas().lista tiene una sola nota, «pan».

Y por último, cómo se prueba un almacén sin ensuciar nada. Tres piezas:

  • UUID().uuidString: un texto único al azar, para que cada ejecución use una carpeta distinta.
  • FileManager.default.createDirectory(at:withIntermediateDirectories:): crea la carpeta, y las que falten por el camino.
  • defer { … }: un bloque que se ejecuta al salir de la función, pase lo que pase; aquí, borra lo que se creó.
ejemplo mínimo: una carpeta temporal única, createDirectory y defer (con comentarios)
func probarElAlmacen() throws {
    // UUID().uuidString: un texto único al azar; cada ejecución usa su propia carpeta.
    let carpeta = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
    // Crea la carpeta, y las que falten por el camino.
    try FileManager.default.createDirectory(at: carpeta, withIntermediateDirectories: true)
    // defer: se ejecuta al salir de la función, pase lo que pase (también si algo lanza).
    defer { try? FileManager.default.removeItem(at: carpeta) }
 
    let almacen = AlmacenDeNotas(url: carpeta.appending(path: "notas.txt"))
    try almacen.guardar(["pan", "leche"])
    print(Notas(almacen: almacen).lista.map(\.texto))       // ["pan", "leche"]: leído del fichero
}
 
try probarElAlmacen()

Con los tres ejemplos juntos en tu Playground, la consola dice:

["pan", "leche"]

Reto · Ejercicio: la lista de la compra en un fichero

En tu Playground (reutiliza el mismo y borra lo anterior), escribe un struct, AlmacenDeCompra, que guarde la dirección de su fichero (se la dan al crearlo) y sepa decir si el fichero existe, guardar una lista de productos como JSON sin que una escritura a medias estropee el fichero anterior, cargarla y borrar el fichero sin quejarse si no existe. Para el JSON, JSONEncoder y JSONDecoder los viste en Codable: guardar y cargar el torneo. Crea una carpeta temporal propia, con un nombre único, y un almacén con un fichero «compra.json» dentro de ella. Pregunta si existe, guarda pan, leche y huevos, vuelve a preguntar, carga e imprime la lista, bórrala y pregunta otra vez. Lo que debe salir:

¿existe? false
¿existe? true
["pan", "leche", "huevos"]
¿existe? false
Ver solución
solución: la lista de la compra en un fichero
import Foundation
 
// Lleva la lista de la compra al disco como JSON y la trae de vuelta.
struct AlmacenDeCompra {
    let url: URL                                            // dónde está el fichero
 
    var existe: Bool { FileManager.default.fileExists(atPath: url.path) }
 
    func guardar(_ productos: [String]) throws {
        let datos = try JSONEncoder().encode(productos)     // [String] → bytes JSON
        try datos.write(to: url, options: .atomic)          // escritura atómica
    }
 
    func cargar() throws -> [String] {
        let datos = try Data(contentsOf: url)
        return try JSONDecoder().decode([String].self, from: datos)
    }
 
    func borrar() {
        try? FileManager.default.removeItem(at: url)        // si no existe, no pasa nada
    }
}
 
// Una carpeta temporal propia, para no tocar nada tuyo.
let carpeta = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: carpeta, withIntermediateDirectories: true)
let almacen = AlmacenDeCompra(url: carpeta.appending(path: "compra.json"))
 
print("¿existe? \(almacen.existe)")
try almacen.guardar(["pan", "leche", "huevos"])
print("¿existe? \(almacen.existe)")
print(try almacen.cargar())
almacen.borrar()
print("¿existe? \(almacen.existe)")

La salida son las cuatro líneas del enunciado. borrar usa try? porque, si el fichero no existe, no hay nada que lamentar. Un detalle: este ejemplo no usa la carpeta Documentos del Mac a propósito. Con URL.documentsDirectory, en el Mac habría escrito un compra.json en tu carpeta Documentos de verdad (y habría sobrescrito uno que ya tuvieras).

Paso 1 · El torneo en un fichero de Documents

Objetivo: marcar un gol a mano, parar y volver a ejecutar, y ver el gol en la lista. Cambian varias cosas: se crea el almacén, una clase se renombra y se le añade el guardado.

MarcadorApp (proyecto)
MarcadorApp
Partidos
Data
AlmacenDelTorneo.swiftse crea
PartidosRepositorio.swiftpasa a llamarse RepositorioDePartidos.swift
UI
ListaDePartidosView.swiftse modifica
Configuracion.swiftse modifica
MarcadorApp.swiftse modifica
Goleadores
UI
GoleadoresView.swiftcambia de nombre una clase
MarcadorAppTests
MarcadorAppTests.swiftse modifica
MarcadorAppUITests
MarcadorAppUITests.swiftse modifica
En el Project navigator: un fichero nuevo, uno que cambia de nombre y varios que cambian.

1.1 Crea AlmacenDelTorneo.swift

AlmacenDelTorneo.swift es el mensajero que lleva el torneo al disco y lo trae de vuelta. No sabe nada de pantallas ni de partidos en curso: recibe un Torneo, lo escribe; o lee el fichero y devuelve un Torneo. Es el almacén de notas de arriba, con el JSON del paquete en vez de texto.

Crea el fichero igual que en la lección anterior (clic derecho sobre la carpeta Partidos/Data, porque guarda los datos de los partidos → New File from Template… → Swift File → nombre AlmacenDelTorneo; o touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Partidos/Data/AlmacenDelTorneo.swift).

Intenta escribirlo tú: AlmacenDelTorneo, un struct que guarda la dirección del fichero, url (que le dan de fuera, para que las pruebas usen otro sitio), y tiene predeterminado, el de la app de verdad, que apunta a torneo.json dentro de Documents; existe, que dice si ya hay fichero; guardar, que recibe un torneo sin etiqueta y lo escribe como JSON de forma que una escritura a medias no estropee el fichero anterior; cargar, que lo lee y devuelve el torneo; y borrar, que lo borra sin quejarse si no existe. guardar y cargar pueden fallar (son throws).

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Partidos/Data/AlmacenDelTorneo.swift
import Foundation
import Torneo   // el paquete: de aquí salen `Torneo`, `aJson()` y `torneoDesdeJson`
 
// Lleva el torneo al disco y lo trae de vuelta. No sabe de pantallas.
struct AlmacenDelTorneo {
    // Dónde está el fichero. Se recibe de fuera para que las pruebas usen otro sitio.
    let url: URL
 
    // El de la app de verdad: `torneo.json` dentro de `Documents`.
    // `documentsDirectory` pregunta la ruta al sistema cada vez (cambia en cada instalación).
    static let predeterminado = AlmacenDelTorneo(url: .documentsDirectory.appending(path: "torneo.json"))
 
    // ¿Hay ya un fichero guardado?
    var existe: Bool { FileManager.default.fileExists(atPath: url.path) }
 
    func guardar(_ torneo: Torneo) throws {
        // `aJson()` es del paquete: devuelve los bytes (`Data`) del JSON.
        // `.atomic`: si la app muere a mitad de escribir, queda el fichero viejo entero.
        try torneo.aJson().write(to: url, options: .atomic)
    }
 
    func cargar() throws -> Torneo {
        // `Data(contentsOf:)` lee el fichero entero; `torneoDesdeJson` lo convierte en un `Torneo`.
        try torneoDesdeJson(Data(contentsOf: url))
    }
 
    func borrar() {
        // `try?`: si el fichero no existe, el error se ignora.
        try? FileManager.default.removeItem(at: url)
    }
}

Compila con Cmd+B. Todavía no lo usa nadie.

1.2 Renombra el repositorio

RepositorioEnMemoria deja de ser un buen nombre: ahora guardará en disco. Pasa a llamarse RepositorioDePartidos, en dos cambios.

El fichero: en el Project navigator, selecciona PartidosRepositorio.swift, pulsa Return y escribe RepositorioDePartidos.swift. El protocolo que vive dentro de él, PartidosRepositorio, conserva su nombre: PartidoViewModel y el resto siguen recibiendo un any PartidosRepositorio.

La clase: en ese fichero, clic derecho sobre RepositorioEnMemoria en la línea final class RepositorioEnMemoria → Refactor → Rename…, escribe RepositorioDePartidos y confirma. Xcode lo cambia en todos los ficheros que lo usan, pruebas incluidas.

Comprueba con Cmd+B que sigue compilando.

1.3 Que el repositorio guarde

El repositorio sigue siendo la única puerta a los datos. Tres cambios, todos en RepositorioDePartidos.swift:

  1. Recibe un almacen opcional (nil en las pruebas: vive solo en memoria) y, al arrancar, si ya hay fichero, parte de él.
  2. Sabe armar un Torneo con sus partidos y guardarlo.
  3. Guarda tras cada gol.

Dos piezas más, con las que ya puedes escribirlo:

  • @ObservationIgnored: el almacén no cambia nunca, así que la pantalla no necesita observarlo (@Observable).
  • Torneo(nombre:equipos:) y registrar(_:): el Torneo del paquete valida cada partido que le registras; un gol de alguien ajeno al partido lanza un error.

Las tablas y los goleadores no se guardan: se calculan siempre a partir de los partidos. Esa es la única fuente de la verdad.

Intenta escribirlo tú, en tres bloques y en este orden:

  1. La propiedad del almacén, sin observar, y el init, que sustituye al que tienes y gana un parámetro, almacen (si no se da, nada: solo memoria). Si hay almacén y existe el fichero, toma de él los equipos y los partidos (el id de cada partido es su posición más uno) y anota «torneo CARGADO de torneo.json: 18 partidos»; si el fichero está roto, anota «torneo.json ILEGIBLE» con el error y sigue con los datos de ejemplo. Si no hay fichero, los datos que le pasen. El resto de la clase no cambia.
  2. Debajo del init, dos funciones: torneo, que arma la «Copa Barrio» con los equipos y registra en ella todos los partidos (puede fallar), y guardar, privada, que guarda ese torneo en el almacén si hay almacén y, si falla, lo anota sin romper nada («no se pudo guardar el torneo:» y el error).
  3. registrarGol termina guardando.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
RepositorioDePartidos.swift: propiedad e init, después
    // La pantalla no observa el almacén: no cambia nunca.
    @ObservationIgnored private let almacen: AlmacenDelTorneo?
 
    init(
        equipos: [Equipo] = DatosDeEjemplo.torneo.equipos,
        partidos: [PartidoDeLista] = DatosDeEjemplo.partidos,
        almacen: AlmacenDelTorneo? = nil   // nil = solo en memoria (las pruebas)
    ) {
        self.almacen = almacen
        // Si hay almacén y ya existe el fichero, se parte de él.
        if let almacen, almacen.existe {
            do {
                let guardado = try almacen.cargar()
                self.equipos = guardado.equipos
                // El id de cada partido es su posición más uno.
                self.partidos = guardado.partidos.enumerated().map { PartidoDeLista(id: $0 + 1, partido: $1) }
                Registro.anotar("torneo CARGADO de \(almacen.url.lastPathComponent): \(guardado.partidos.count) partidos")
                return
            } catch {
                // Un JSON roto no debe impedir arrancar: se avisa y se parte de los datos de ejemplo.
                Registro.anotar("torneo.json ILEGIBLE (\(error)); se usan los datos de ejemplo")
            }
        }
        self.equipos = equipos
        self.partidos = partidos
    }
RepositorioDePartidos.swift: torneo() y guardar(), debajo del init
    // Arma un `Torneo` con los partidos actuales. `registrar` valida cada partido.
    func torneo() throws -> Torneo {
        let torneo = Torneo(nombre: "Copa Barrio", equipos: equipos)
        for item in partidos { try torneo.registrar(item.partido) }
        return torneo
    }
 
    private func guardar() {
        // Sin almacén (pruebas), no hay nada que guardar.
        guard let almacen else { return }
        do {
            try almacen.guardar(try torneo())
        } catch {
            // Que falle el guardado no debe romper la app: se anota y se sigue.
            Registro.anotar("no se pudo guardar el torneo: \(error)")
        }
    }
RepositorioDePartidos.swift: registrarGol termina con una línea nueva, guardar()
    func registrarGol(partidoId: Int, gol: EventoDePartido) {
        guard let indice = partidos.firstIndex(where: { $0.id == partidoId }) else { return }
        partidos[indice] = PartidoDeLista(id: partidoId, partido: partidos[indice].partido.registrando(gol))
        guardar()   // cada gol se vuelve a guardar
    }

1.4 Arranca desde el fichero

Configuracion.swift es el fichero donde la app lee sus argumentos de lanzamiento (viste -msPorMinuto). Añade uno nuevo, -reiniciarDatos YES, que sirve para partir de cero sin borrar la app: dentro de enum Configuracion, después de la última propiedad:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Configuracion.swift: reiniciarDatos
    // `-reiniciarDatos YES`: al arrancar, la app borra el torneo y la duración guardados.
    static var reiniciarDatos: Bool {
        UserDefaults.standard.bool(forKey: "reiniciarDatos")
    }

MarcadorApp.swift crea el repositorio una sola vez. Sustituye la línea @State private var repositorio = RepositorioEnMemoria() (que el renombrado ya dejó como RepositorioDePartidos()) por este bloque. El { … }() del final es una closure que se ejecuta en el acto: calcula el valor inicial con varios pasos en vez de una sola expresión:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp.swift: el repositorio con guardado
    @State private var repositorio: RepositorioDePartidos = {
        // Con `-reiniciarDatos YES` se borra todo lo guardado antes de crear el repositorio.
        if Configuracion.reiniciarDatos {
            AlmacenDelTorneo.predeterminado.borrar()
            UserDefaults.standard.removeObject(forKey: Ajustes.claveDuracion)
        }
        // Y el repositorio arranca leyendo el fichero, si existe.
        return RepositorioDePartidos(almacen: .predeterminado)
    }()

1.5 Pruébalo y mira el sandbox por dentro

Ejecuta la app (Cmd+R). Toca el partido 1, pulsa Gol Rayo FC y elige 60 min. Vuelve a la lista con el botón de atrás: la fila del partido 1 muestra un gol más. Para la app (Cmd+.) y ejecútala otra vez: la fila conserva el gol y, al abrir el partido, el selector sigue en 60. En la consola de Xcode (el panel de abajo; se muestra con Cmd+Shift+Y) verás torneo CARGADO de torneo.json: 18 partidos.

Con el simulador arrancado, pregunta dónde está el contenedor de datos de tu app. Si tienes varios simuladores encendidos, booted elige uno de ellos: apaga los demás, o pon su identificador en lugar de booted.

xcrun simctl get_app_container booted com.sebastiannarvaez.marcador data

Imprime una ruta con un identificador largo que cambia en cada instalación. Entra en ella y lista su contenido ($(…) ejecuta la orden de dentro y pega lo que imprime, así que cd entra en esa ruta; las comillas cubren una ruta con espacios):

cd "$(xcrun simctl get_app_container booted com.sebastiannarvaez.marcador data)"
ls

Entre otras, verás Documents, Library, SystemData y tmp, las carpetas de la tabla de antes. Lee el ajuste guardado con plutil (desde esa carpeta) y abre el torneo:

plutil -p Library/Preferences/com.sebastiannarvaez.marcador.plist
open Documents/torneo.json

plutil -p imprime { "duracionDelPartido" => 60 }: la clave es la de Ajustes.claveDuracion. torneo.json es el mismo formato que escribe la versión Kotlin: un texto con "equipos", "Rayo FC"…

Para partir de cero, desde la Terminal y con el simulador arrancado, lanza la app con el argumento nuevo (borra el torneo y la duración guardados). La app sigue abierta desde Cmd+R, y un argumento solo se aplica cuando la app arranca; por eso la orden lleva --terminate-running-process, que cierra la copia abierta antes de lanzarla de nuevo:

xcrun simctl launch --terminate-running-process booted com.sebastiannarvaez.marcador -reiniciarDatos YES

1.6 Pruebas

Dos pruebas unitarias nuevas y una de interfaz. La de interfaz necesita encontrar el resultado de cada fila de la lista; para eso, en ListaDePartidosView.swift, dentro de FilaDePartido, el texto del resultado gana un identificador de accesibilidad (la etiqueta con la que las pruebas lo localizan):

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
ListaDePartidosView.swift: el resultado con identificador
            Text(resultado).bold().monospacedDigit().accessibilityIdentifier("resultado")

Piezas de las pruebas que no viste arriba:

  • Ejemplo.ana y Ejemplo.rayo: la jugadora y el equipo del paquete Torneo (Ejemplo.swift, que pegaste en ARC). Un Equipo se compara por identidad (es una clase y == pregunta si es la misma instancia), y Torneo.registrar solo acepta un gol de un jugador de ese equipo. Funciona porque DatosDeEjemplo usa esas mismas instancias; con un Equipo recién creado, aunque se llamara igual, guardar() fallaría.
  • En la de interfaz, launchArguments (los argumentos con los que arranca la app: -reiniciarDatos YES los lee Configuracion), terminate() (cierra la app del todo, como cuando la deslizas fuera del selector de apps), XCTAssertNotEqual y XCTAssertEqual (fallan si dos valores son iguales o distintos, respectivamente) y app.buttons["partido-1"].staticTexts["resultado"], una búsqueda encadenada: el texto con identificador resultado que está dentro del botón partido-1 (la fila). launch(), buttons[…], staticTexts[…], .label, .isSelected, waitForExistence y el botón de atrás están en la tabla de piezas de XCUITest de Tus primeras pruebas en iOS.
  • El resultado de una fila es un texto como 3-2. Para saber cuántos goles ha marcado el local, la prueba lo parte por el guion con split(separator:), coge el primer trozo y lo convierte en número con Int(…), que devuelve nil si no puede (Opcionales); el ! final jura que sí puede, porque aquí el texto siempre es un número.

Por ejemplo, "3-2".split(separator: "-") da ["3", "2"], e Int("3")! + 1 da 4.

Intenta escribirlas tú. Las dos unitarias van al final del struct en MarcadorAppTests.swift (si aún no lo tienes, añade import Foundation encima de los import que ya hay); la de interfaz, dentro de MarcadorAppUITests, en MarcadorAppUITests.swift.

  1. elTorneoSobreviveAGuardarseYCargarse: con un almacén en una carpeta temporal propia, que se borra al acabar, el fichero no existe al principio; un repositorio con ese almacén registra un gol de Ana para el Rayo en el minuto 5 del partido 1, y entonces el fichero existe; otro repositorio con el mismo almacén tiene 18 partidos y el partido 1 con el mismo marcador.
  2. unJsonRotoNoImpideArrancar: con un fichero temporal que dice «esto no es json», el repositorio tiene igualmente 18 partidos.
  3. testLosDatosSobrevivenAReiniciarLaApp: arranca con minutos de 60 ms y partiendo de cero; apunta el resultado del partido 1 en la lista, entra, marca «Gol Rayo FC», elige «60 min» y vuelve atrás: el resultado cambió y los goles del local son uno más. Haz una captura. Cierra la app y ábrela otra vez sin partir de cero: el resultado sigue siendo el nuevo y, dentro del partido, «60 min» sigue elegido. Haz otra captura.

Lo que debes ver: las tres en verde.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests.swift: las dos pruebas nuevas
    // El torneo se guarda como JSON y se vuelve a leer igual.
    @Test func elTorneoSobreviveAGuardarseYCargarse() throws {
        // Una carpeta temporal propia de esta prueba.
        let carpeta = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
        try FileManager.default.createDirectory(at: carpeta, withIntermediateDirectories: true)
        // Al salir de la prueba, se borra.
        defer { try? FileManager.default.removeItem(at: carpeta) }
        let almacen = AlmacenDelTorneo(url: carpeta.appending(path: "torneo.json"))
        #expect(!almacen.existe)
 
        let repositorio = RepositorioDePartidos(almacen: almacen)
        repositorio.registrarGol(partidoId: 1, gol: .gol(minuto: 5, jugador: Ejemplo.ana, equipo: Ejemplo.rayo))
        #expect(almacen.existe)
        let esperado = repositorio.partido(id: 1)?.marcador()
 
        // Otro repositorio, que arranca leyendo el fichero: mismo partido, mismo marcador.
        let recargado = RepositorioDePartidos(almacen: almacen)
        #expect(recargado.partidos.count == 18)
        #expect(recargado.partido(id: 1)?.marcador() == esperado)
    }
 
    // Un JSON roto no impide arrancar: se usan los datos de ejemplo.
    @Test func unJsonRotoNoImpideArrancar() throws {
        let url = FileManager.default.temporaryDirectory.appending(path: "\(UUID().uuidString).json")
        // Un fichero que no es JSON.
        try Data("esto no es json".utf8).write(to: url)
        defer { try? FileManager.default.removeItem(at: url) }
        let repositorio = RepositorioDePartidos(almacen: AlmacenDelTorneo(url: url))
        #expect(repositorio.partidos.count == 18)
    }
MarcadorAppUITests.swift: los datos sobreviven
    // Un gol a mano y la duración elegida sobreviven a cerrar la app (torneo.json y @AppStorage).
    @MainActor
    func testLosDatosSobrevivenAReiniciarLaApp() throws {
        let app = XCUIApplication()
        // `-reiniciarDatos YES`: el primer arranque parte de cero.
        app.launchArguments = ["-msPorMinuto", "60", "-reiniciarDatos", "YES"]
        app.launch()
        let resultado = app.buttons["partido-1"].staticTexts["resultado"]
        XCTAssertTrue(resultado.waitForExistence(timeout: 5))
        let antes = resultado.label
        app.buttons["partido-1"].tap()
        app.buttons["Gol Rayo FC"].tap()
        app.buttons["60 min"].tap()
        // El botón de atrás de la barra de navegación.
        app.navigationBars.buttons.firstMatch.tap()
        let despues = app.buttons["partido-1"].staticTexts["resultado"].label
        XCTAssertNotEqual(antes, despues)
        // Los goles del local, del texto «3-2»: el primer trozo, convertido en número, más uno.
        let golesLocal = Int(antes.split(separator: "-")[0])! + 1
        XCTAssertEqual(despues.split(separator: "-")[0], Substring("\(golesLocal)"))
        capturar(app, "antes-de-cerrar")
 
        // Se cierra la app del todo y se abre otra vez, SIN `-reiniciarDatos`.
        app.terminate()
        let otra = XCUIApplication()
        otra.launchArguments = ["-msPorMinuto", "60"]
        otra.launch()
        XCTAssertEqual(otra.buttons["partido-1"].staticTexts["resultado"].label, despues)
        otra.buttons["partido-1"].tap()
        XCTAssertTrue(otra.buttons["60 min"].waitForExistence(timeout: 5))
        XCTAssertTrue(otra.buttons["60 min"].isSelected)
        capturar(otra, "tras-reabrir")
    }

Substring("\(golesLocal)") es el mismo tipo de trozo de texto que devuelve split, para poder compararlos con XCTAssertEqual. Y capturar(app, "…") es la ayuda de capturas que ya tienes en Ayudas.swift.

Ejecuta las pruebas. Cmd+U lanza todas (las unitarias y las de interfaz, que abren el simulador y tardan más); para lanzar solo las unitarias, en el Test navigator (Cmd+6) pulsa el botón de play que hay junto a MarcadorAppTests. Desde la Terminal, solo las unitarias:

cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:MarcadorAppTests test

Termina con ** TEST SUCCEEDED **. Si tu simulador se llama distinto, ve su nombre con xcrun simctl list devices available.

Ten en cuenta que el simulador conserva sus datos entre ejecuciones, también con Cmd+U y con xcodebuild: la duración guardada, torneo.json y los permisos siguen ahí. Si Cmd+U falla en testAbreYCierraUnPartidoParaMedirLaMemoria porque conserva los 60 minutos de antes, lanza la app una vez con -reiniciarDatos YES (el comando de arriba) y vuelve a ejecutar: parte de cero. Otra forma de dejarlo como nuevo es borrar la app del simulador o, con él apagado, xcrun simctl erase all.

Este mismo patrón (un almacén en Documents que se lee al arrancar) volverá con datos que llegan de un servidor: en Dos fuentes: el almacén y el repositorio y Dos fuentes: la pantalla y las pruebas la pantalla pintará siempre lo guardado y la red solo lo refrescará.

Punto de control

  • Elige 60 min, marca un gol en un partido, para la app (Cmd+.) y ejecútala otra vez (Cmd+R): el selector sigue en 60 y la fila de la lista conserva el marcador nuevo.
  • xcrun simctl get_app_container booted com.sebastiannarvaez.marcador data te lleva a una carpeta con Documents/torneo.json y Library/Preferences/com.sebastiannarvaez.marcador.plist.
  • No queda ninguna referencia a AjustesModelo ni a RepositorioEnMemoria: Cmd+B compila sin errores.
  • Las pruebas de la app pasan. Cmd+U lanza las 28: 14 unitarias y 14 de interfaz (13 de MarcadorAppUITests y testLaunch). Las unitarias solas, con -only-testing:MarcadorAppTests, terminan con ** TEST SUCCEEDED **.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.¿Por qué no se guarda nunca una ruta absoluta del sandbox (por ejemplo, …/Application/6F1E…/Documents)?

  2. 2.AlmacenDelTorneo.guardar escribe con options: .atomic. ¿Qué garantiza?

  3. 3.¿Qué hace defer { try? FileManager.default.removeItem(at: carpeta) } dentro de una función?

  4. 4.Al arrancar, torneo.json está roto (no es JSON válido). ¿Qué hace RepositorioDePartidos?

Faltan 4 de 4