La pantalla de la crónica y sus pruebas
Al terminar: Publicar la crónica desde una pantalla y comprobar con pruebas los errores de red.
Al terminar, en la pantalla de un partido de Marcador habrá un botón «Crónica» que abre una pantalla con el título del partido, una línea por gol y un botón «Publicar». Al pulsarlo, la app manda la crónica al servidor y enseña «Crónica publicada con el n.º 101»; si algo falla, un mensaje claro en lugar de un error técnico. Además escribirás tus primeras pruebas contra un servicio falso: tres, una por cada resultado posible.
Importa porque es la primera pantalla que escribe en un servidor, y porque aquí se ve para qué sirvió el trabajo de la lección anterior: la pantalla enseña el texto de ErrorDeRed y no sabe nada de URLError ni de códigos HTTP.
Temas: un ViewModel que lee un partido y redacta su crónica, if case let para quedarse con un tipo de evento, una función static privada, la pantalla con sus tres secciones, el destino con un número, y el servicio falso con #expect(throws:). Nivel 2: usar.
Tres piezas de Swift antes de escribir el ViewModel
El ViewModel de esta lección tiene que redactar la crónica: recorrer los eventos de un partido, quedarse solo con los goles y juntar una línea por cada uno. Eso usa tres cosas que conviene ver antes, con otro ejemplo (un reparto de pedidos):
if case let .entrega(hora, cliente) = aviso: «¿este valor es el casoentrega? Si lo es, saca sus datos ahoraycliente». Es el patrón delswitchde Enums: el partido de eventos escrito como unif, para cuando solo te interesa un caso y el resto da igual. Sin sacar los datos se escribeif case .entrega = aviso.private static func: una funciónstatices del tipo y no de una instancia concreta (como elstatic func ==de struct frente a class), así que no puede usar las propiedades del objeto.privatela esconde del resto de la app. Dentro de un método del mismo tipo se llama conSelf., que quiere decir «el tipo en el que estoy».lineas.joined(separator: "\n")une una lista de textos en uno solo poniendo un separador entre ellos;"\n"es un salto de línea. Ycondicion ? a : beligeaobsegún la condición.
enum Aviso {
case entrega(hora: Int, cliente: String)
case retraso(minutos: Int)
}
struct Reparto {
let avisos: [Aviso]
// `static`: no usa `self`. `private`: solo se llama desde dentro de `Reparto`.
private static func lineas(de avisos: [Aviso]) -> String {
var lineas: [String] = []
for aviso in avisos {
// Solo nos quedamos con las entregas; los retrasos se ignoran.
if case let .entrega(hora, cliente) = aviso {
lineas.append("\(hora)h \(cliente)")
}
}
// Sin entregas, un texto fijo; con ellas, una línea por entrega.
return lineas.isEmpty ? "Sin entregas" : lineas.joined(separator: "\n")
}
func informe() -> String {
// `Self.`: llama a la función `static` del propio tipo.
Self.lineas(de: avisos)
}
}
let manana = Reparto(avisos: [.entrega(hora: 9, cliente: "Ana"), .retraso(minutos: 15), .entrega(hora: 11, cliente: "Luis")])
print(manana.informe())
print(Reparto(avisos: [.retraso(minutos: 5)]).informe())9h Ana
11h Luis
Sin entregasLas dos primeras líneas son del primer reparto (el retraso no sale); la tercera, del segundo, que no tiene ninguna entrega.
Reto · Ejercicio: el informe de una biblioteca
En tu Playground (reutiliza el mismo y borra lo anterior), escribe un enum Movimiento con dos casos, prestamo(dia:socio:) y devolucion(dia:socio:), y un struct Biblioteca con una lista de movimientos. Tiene que dar un informe() que:
- Escriba una línea por cada movimiento, sea del tipo que sea: «Día 1: Marta se llevó un libro» o «Día 2: Pedro devolvió un libro». Esas líneas las saca una función
private static, que se llama conSelf.. - Si no hay movimientos, esas líneas se sustituyen por «Sin movimientos».
- Termine con una última línea, «Préstamos: N», con cuántos movimientos son préstamos (cuéntalos con
if case, sin sacar los datos).
Pruébalo con tres movimientos (préstamo de Marta el día 1, devolución de Pedro el 2 y préstamo de Pedro el 3) y con una biblioteca vacía. Lo que debe salir:
Día 1: Marta se llevó un libro
Día 2: Pedro devolvió un libro
Día 3: Pedro se llevó un libro
Préstamos: 2
Sin movimientos
Préstamos: 0Ver solución
enum Movimiento {
case prestamo(dia: Int, socio: String)
case devolucion(dia: Int, socio: String)
}
struct Biblioteca {
let movimientos: [Movimiento]
// Una línea por movimiento, sea del tipo que sea: aquí hay que tratar los DOS casos.
private static func lineas(de movimientos: [Movimiento]) -> String {
var lineas: [String] = []
for movimiento in movimientos {
switch movimiento {
case let .prestamo(dia, socio):
lineas.append("Día \(dia): \(socio) se llevó un libro")
case let .devolucion(dia, socio):
lineas.append("Día \(dia): \(socio) devolvió un libro")
}
}
return lineas.isEmpty ? "Sin movimientos" : lineas.joined(separator: "\n")
}
func informe() -> String {
var prestamos = 0
for movimiento in movimientos {
// Solo preguntamos «¿es un préstamo?»; no hace falta sacar sus datos.
if case .prestamo = movimiento {
prestamos += 1
}
}
return Self.lineas(de: movimientos) + "\nPréstamos: \(prestamos)"
}
}
let semana = Biblioteca(movimientos: [
.prestamo(dia: 1, socio: "Marta"),
.devolucion(dia: 2, socio: "Pedro"),
.prestamo(dia: 3, socio: "Pedro"),
])
print(semana.informe())
print(Biblioteca(movimientos: []).informe())Aquí hay que tratar los dos casos, y por eso el switch; el if case sirve cuando solo importa uno.
Paso 1 · El ViewModel, que hace demasiado: CronicaViewModel.swift
Objetivo: el ViewModel de la pantalla de la crónica.
Este ViewModel hace demasiado, a propósito. Lee el partido del repositorio de partidos, decide cómo se escribe la crónica (el título es el resultado, y el texto una línea por gol) y la publica con el repositorio de crónicas. Son tres tareas en un solo sitio, y dos repositorios que conoce a la vez. Funciona, pero la regla de «cómo se escribe una crónica» no debería vivir en el ViewModel: es lo que arreglarás en Casos de uso: cuándo sí y cuándo sobran.
La pantalla de la crónica y su ViewModel van juntos en Cronica/UI (la carpeta Cronica ya la tienes de la lección anterior): clic derecho sobre Cronica → New Folder → UI. Después, clic derecho sobre UI → New File from Template… → Swift File, solo con el target MarcadorApp marcado, y de nombre CronicaViewModel. Con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorApp/Cronica/UI && touch ~/Marcador-Swift/MarcadorApp/MarcadorApp/Cronica/UI/CronicaViewModel.swift.
Intenta escribirlo tú. Dos tipos en el mismo fichero:
CronicaUiState, unstructque se puede comparar, con la crónica (puede no haberla, si el partido no existe), si está publicando (empieza en no) y un mensaje (puede no haberlo).CronicaViewModel, una clasefinalcon@MainActory@Observable, que recibe el id del partido, el repositorio de partidos y el de crónicas. Al crearse busca el partido. Si existe, redacta la crónica con una funciónprivate static: el título es «local marcador visitante» y el texto, una línea «minuto' jugador (equipo)» por cada gol, o «Sin goles» si no hubo. Si no existe, el mensaje es «No existe el partido» y su número. Supublicar()marca que está publicando, publica con el repositorio de crónicas y deja el mensaje «Crónica publicada con el n.º» y el número, o el texto del error (localizedDescription). Al terminar, anota el resultado conRegistro.anotary marca que ya no publica.
@ObservationIgnored private let cronicas aparece marcado así porque ninguna pantalla pinta el repositorio: es una pieza de trabajo, no un dato de la vista. (En un let la marca no cambia nada, como viste en @Observable; la ponemos para dejar claro que no forma parte de lo que se pinta.)
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Observation
import Torneo
// LO QUE VE LA PANTALLA DE LA CRÓNICA.
struct CronicaUiState: Equatable {
var cronica: Cronica? // nil: el partido no existe
var publicando = false
var mensaje: String? // «Crónica publicada con el n.º 101», o el error
}
// OJO: este ViewModel hace DEMASIADO, a propósito. Lee el partido de un repositorio, decide cómo se
// escribe la crónica y la publica con otro. Más adelante esa regla se irá a un caso de uso.
@MainActor
@Observable
final class CronicaViewModel {
private(set) var uiState: CronicaUiState
@ObservationIgnored private let cronicas: any CronicasRepositorio
init(partidoId: Int, partidos: any PartidosRepositorio, cronicas: any CronicasRepositorio) {
self.cronicas = cronicas
if let partido = partidos.partido(id: partidoId) {
uiState = CronicaUiState(cronica: Self.redactar(partido))
} else {
uiState = CronicaUiState(mensaje: "No existe el partido \(partidoId)")
}
}
// La regla: el título es el resultado y el texto, una línea por gol («12' Ana (Rayo FC)»).
private static func redactar(_ partido: Partido) -> Cronica {
var lineas: [String] = []
for evento in partido.eventos {
if case let .gol(minuto, jugador, equipo) = evento {
lineas.append("\(minuto)' \(jugador.nombre) (\(equipo.nombre))")
}
}
return Cronica(
titulo: "\(partido.local.nombre) \(partido.marcador()) \(partido.visitante.nombre)",
texto: lineas.isEmpty ? "Sin goles" : lineas.joined(separator: "\n")
)
}
// El botón «Publicar».
func publicar() async {
guard let cronica = uiState.cronica else { return }
uiState.publicando = true
uiState.mensaje = nil
do {
let id = try await cronicas.publicar(titulo: cronica.titulo, texto: cronica.texto)
uiState.mensaje = "Crónica publicada con el n.º \(id)"
} catch {
// `ErrorDeRed` es `LocalizedError`: aquí sale «No hay conexión» o «El servidor respondió…».
uiState.mensaje = error.localizedDescription
}
uiState.publicando = false
Registro.anotar("crónica: \(uiState.mensaje ?? "")")
}
}Paso 2 · La pantalla: CronicaView.swift
Objetivo: la vista con el título, los goles y el botón «Publicar».
Intenta escribirla tú: CronicaView, que recibe su ViewModel como la de los árbitros. Una List con tres secciones: «Título» (el título en negrita), «Goles» (el texto) y una con el botón «Publicar» (desactivado mientras publica, y con una ProgressView() al lado). Debajo, si hay mensaje, un Text con él y el identificador de accesibilidad mensaje-cronica (una etiqueta invisible que usarán las pruebas de interfaz; no cambia lo que ves). El título de la barra es «Crónica del partido».
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import SwiftUI
// La pantalla de la crónica: el título, los goles y el botón «Publicar».
struct CronicaView: View {
@State private var viewModel: CronicaViewModel
init(viewModel: CronicaViewModel) {
_viewModel = State(wrappedValue: viewModel)
}
var body: some View {
let estado = viewModel.uiState
List {
if let cronica = estado.cronica {
Section("Título") {
Text(cronica.titulo).bold()
}
Section("Goles") {
Text(cronica.texto)
}
Section {
Button("Publicar") {
Task { await viewModel.publicar() }
}
.disabled(estado.publicando)
if estado.publicando {
ProgressView()
}
}
}
if let mensaje = estado.mensaje {
Text(mensaje).accessibilityIdentifier("mensaje-cronica")
}
}
.navigationTitle("Crónica del partido")
.navigationBarTitleDisplayMode(.inline)
}
}Paso 3 · Los botones y el destino
Objetivo: llegar a la pantalla desde un partido.
Tres cambios pequeños. Hasta que acabes los tres, el proyecto estará en rojo (el switch de ListaDePartidosView aún no tiene el caso .cronica, y Xcode dice switch must be exhaustive); es normal, y vuelve a compilar al terminar este paso. Primero, un caso nuevo con el número del partido en Destino.swift:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
enum Destino: Hashable {
case partido(Int) // el id del partido (viaja en la clave, en un solo sitio)
case goleadores
case arbitros // la lista de árbitros (botón «Árbitros» de la barra de Partidos)
case cronica(Int) // la crónica del partido con ese id (botón «Crónica» del partido)
}Después, un enlace «Crónica» en PartidoEnDirectoView.swift, justo debajo del de «Ver goleadores». datos es el parámetro de la función contenido(_ datos:) de ese fichero, el mismo que ya usan las líneas de alrededor (datos.partido, datos.minuto); y partidoId es el número del partido:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
NavigationLink("Ver goleadores", value: Destino.goleadores) // ← esta línea ya la tienes
// La crónica de ESTE partido: el id viaja en el destino.
NavigationLink("Crónica", value: Destino.cronica(datos.partidoId))El resto del fichero no cambia: solo se añaden estas dos líneas debajo de la que ya tenías (el comentario y el enlace «Crónica»).
Y el caso nuevo en el switch de .navigationDestination de ListaDePartidosView.swift, después del de .arbitros. El repositorio que se le pasa al ViewModel es el que esa vista ya tiene arriba del todo (@Environment(RepositorioDePartidos.self) private var repositorio), el mismo que pinta la lista:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
case .arbitros:
// OJO: la pantalla crea aquí, a escondidas, el servicio y el repositorio. Funciona,
// pero cada vez que se abre nace un repositorio nuevo. Se arregla con la inyección.
ArbitrosView(viewModel: ArbitrosViewModel(
repositorio: RepositorioDeArbitrosEnRed(servicio: ServicioJSONPlaceholder())
))
case let .cronica(id):
// También a escondidas: el servicio y el repositorio de crónicas nacen aquí.
CronicaView(viewModel: CronicaViewModel(
partidoId: id,
partidos: repositorio,
cronicas: RepositorioDeCronicasEnRed(servicio: ServicioJSONPlaceholder())
))El resto del fichero no cambia.
Ejecuta con Cmd+R. Abre el partido 1 y toca «Crónica». El partido que estaba en marcha se pausa mientras la pantalla de la crónica está encima (lo cancela el .task al apilar otra pantalla, como en Navegar a los goleadores). Verás «Rayo FC 3-2 Toros» como título y cinco líneas de goles; si marcaste goles a mano en ese partido desde la última vez que arrancaste la app de cero, salen también en el título y en las líneas. Pulsa «Publicar»: tras un momento, «Crónica publicada con el n.º 101». Si vuelves y publicas otra vez, sale 101 otra vez: el servidor de pruebas no guarda nada y siempre contesta lo mismo. Con el wifi del Mac apagado, «Publicar» enseña «No hay conexión».
Paso 4 · Las pruebas
Objetivo: probar los dos fallos y el éxito del repositorio, con un servicio falso.
En Dobles.swift añade, al final, el falso del servicio. Responde lo que le digas, sin red, y anota las crónicas que recibe para poder comprobarlas. Dos detalles: arbitrosQueDevuelve es un Result como el del falso de los árbitros (try arbitrosQueDevuelve.get() devuelve la lista o lanza el fallo), y if let falloAlPublicar { throw falloAlPublicar } es la forma corta de if let falloAlPublicar = falloAlPublicar: «si hay un fallo preparado, lánzalo».
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// … lo que ya tenías no cambia …
// El servicio falso: responde lo que le digas, sin red, y anota las crónicas que le enviaron.
final class ServicioFalso: ServicioDelTorneo {
var arbitrosQueDevuelve: Result<[ArbitroDTO], ErrorDeRed> = .success([])
var idQueAsigna = 101
var falloAlPublicar: ErrorDeRed?
private(set) var publicadas: [CronicaNuevaDTO] = []
func arbitros() async throws -> [ArbitroDTO] {
try arbitrosQueDevuelve.get()
}
func publicarCronica(_ cronica: CronicaNuevaDTO) async throws -> CronicaPublicadaDTO {
if let falloAlPublicar { throw falloAlPublicar }
publicadas.append(cronica)
return CronicaPublicadaDTO(id: idQueAsigna, title: cronica.title)
}
}Después, crea CronicasRepositorioTests.swift. Prueba el repositorio de Cronica/Data, así que va en el mismo sitio de las pruebas: clic derecho sobre MarcadorAppTests → New Folder → Cronica, dentro otra New Folder → Data, y en Data New File from Template… → Swift File, con solo el target MarcadorAppTests marcado. Con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Cronica/Data && touch ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Cronica/Data/CronicasRepositorioTests.swift. Tres pruebas en Swift Testing:
publicaYDevuelveElIdDelServidor: publica y recibe 101; el servicio falso anotó el título y el texto enviados.sinConexionLanzaSinConexion: si el servicio falla con.sinConexion,publicarlanza.sinConexion.unQuinientosLanzaElErrorDelServidorConSuCodigo: si falla con.servidor(codigo: 500),publicarlanza ese error.
await #expect(throws: …) { … } comprueba que el bloque lanza justo ese error (por eso ErrorDeRed es Equatable).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Testing
@testable import MarcadorApp
// El repositorio de crónicas con un servicio FALSO: ni red ni servidor, y los fallos a la carta.
@MainActor
struct CronicasRepositorioTests {
@Test func publicaYDevuelveElIdDelServidor() async throws {
let servicio = ServicioFalso()
let repositorio = RepositorioDeCronicasEnRed(servicio: servicio)
let id = try await repositorio.publicar(titulo: "Rayo FC 2-1 Toros", texto: "12' Ana (Rayo FC)")
#expect(id == 101)
// Lo que viajó al servidor: el DTO con el título y el texto.
#expect(servicio.publicadas.first?.title == "Rayo FC 2-1 Toros")
#expect(servicio.publicadas.first?.body == "12' Ana (Rayo FC)")
}
@Test func sinConexionLanzaSinConexion() async {
let servicio = ServicioFalso()
servicio.falloAlPublicar = .sinConexion
let repositorio = RepositorioDeCronicasEnRed(servicio: servicio)
await #expect(throws: ErrorDeRed.sinConexion) {
try await repositorio.publicar(titulo: "Rayo FC 2-1 Toros", texto: "Sin goles")
}
}
@Test func unQuinientosLanzaElErrorDelServidorConSuCodigo() async {
let servicio = ServicioFalso()
servicio.falloAlPublicar = .servidor(codigo: 500)
let repositorio = RepositorioDeCronicasEnRed(servicio: servicio)
await #expect(throws: ErrorDeRed.servidor(codigo: 500)) {
try await repositorio.publicar(titulo: "Rayo FC 2-1 Toros", texto: "Sin goles")
}
}
}Lanza Cmd+U.
Punto de control
- Cmd+B termina en
Build Succeeded, y Cmd+U da 46 pruebas en verde: 30 unitarias y 16 de interfaz. Las tres nuevas son las deCronicasRepositorioTests. Si dejaste el ejercicio de la taquilla enMarcadorAppTests/Ejercicios, salen 49: los ejercicios con pruebas suman a la cuenta. - En el simulador, abrir el partido 1 → «Crónica» → «Publicar» enseña «Crónica publicada con el n.º 101»; al repetirlo, sigue siendo 101.
- La pantalla de árbitros sigue funcionando igual.
- El ejercicio de la biblioteca imprime las seis líneas.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué hace
if case let .gol(minuto, jugador, equipo) = evento { … }?2.La pantalla de la crónica enseña «No hay conexión» y no un texto técnico. ¿Por qué?
3.¿Para qué sirve
ServicioFalsoen las pruebas del repositorio de crónicas?4.¿Qué comprueba
await #expect(throws: ErrorDeRed.sinConexion) { try await repositorio.publicar(…) }?5.El
CronicaViewModellee el partido, decide cómo se redacta la crónica y la publica. ¿Qué se dice de él en la lección?