Saltar al contenido
K
Arquitectura en iOSPaso a paso47 min Intermedio

Formularios en SwiftUI: el estado y las reglas

Al terminar: Guardar el estado del formulario en un struct con campos tocados y calcular los errores como un tipo, con pruebas.

Al terminar, Marcador tendrá el cerebro del formulario «Añadir jugador», todavía sin pantalla: un struct con lo que el usuario escribió y qué campos ha tocado, las reglas que calculan los errores, y un ViewModel que las aplica con la plantilla guardada. Lo comprobarás con siete pruebas nuevas de las reglas: el recuento de pruebas unitarias sube de 35 a 42. La pantalla llega en la lección siguiente.

Importa porque un formulario que valida mal es peor que uno sin validar: o grita en rojo antes de que el usuario escriba, o deja guardar un dorsal repetido. Y porque la forma que le des al estado decide si el resto sale sencillo. Temas: dónde vive el texto de un formulario, el campo «tocado» con didSet, errores que se calculan en vez de guardarse, reglas que consultan lo ya guardado, formulario frente a dominio, y una prueba parametrizada. Nivel 2: usar.

Dónde vive el texto de un formulario

Hay dos sitios posibles. En la pantalla (un @State), si nadie más necesita lo escrito: así hiciste el saludo de Campos de texto en SwiftUI. Y en el ViewModel, si una regla tiene que consultarlo y consultar otras cosas. Aquí hay una regla así: «un dorsal no puede repetirse en el equipo», y para saberlo hace falta la plantilla guardada, que vive en el repositorio. Por eso el formulario irá en el ViewModel.

Dentro del ViewModel el texto va en un struct, FormularioJugador, con tres cosas:

  • Lo escrito, tal cual, siempre como texto: mientras alguien teclea un dorsal, «1», «12», «12a» y «» son todos valores posibles. Convertirlo a número se hace al final.
  • Qué campos están tocados: «tocado» quiere decir «el usuario ya escribió algo ahí». Antes de eso no se enseña ningún error: nadie quiere ver «Escribe un nombre» en rojo nada más abrir la pantalla.
  • Ningún error. Los errores no se guardan: se calculan cada vez a partir del texto y de la plantilla.

Un error guardado puede quedarse viejo: escribes un nombre repetido, se guarda «repetido», cambias el nombre y se te olvida borrar el error. Un error calculado no puede quedarse viejo, porque se vuelve a calcular con lo que haya escrito en ese momento. El tipo del error es un enum: «ninguno» (nil) o uno de estos motivos, y la pantalla decide el texto de cada uno. Las reglas no saben nada de textos.

Ejemplo: el alta de un socio de un club

Un ejemplo con datos ajenos a Marcador, para leer y ejecutar en el Playground: dar de alta a un socio con un correo y una edad. Mira cómo cada pieza hace una sola cosa:

ejemplo mínimo: un formulario con campos tocados y errores calculados (con comentarios)
import Foundation   // trae `trimmingCharacters`
 
// Los errores posibles de cada campo, como un tipo: ninguno, o uno de estos casos.
enum ErrorCorreo { case vacio, sinArroba }
enum ErrorEdad { case noEsNumero, menorDeEdad }
 
struct FormularioSocio {
    // `didSet` corre DESPUÉS de cada cambio; `oldValue` es el valor de antes. Solo cuenta como
    // tocado si el texto cambió de verdad: al tocar el campo, SwiftUI puede volver a escribir el
    // MISMO texto, y eso no es escribir.
    var correo = "" { didSet { if correo != oldValue { correoTocado = true } } }
    var edad = "" { didSet { if edad != oldValue { edadTocado = true } } }
    private(set) var correoTocado = false     // «el usuario ya escribió aquí»
    private(set) var edadTocado = false
 
    // Los errores NO se guardan: se calculan cada vez a partir de lo escrito.
    func errorCorreo() -> ErrorCorreo? {
        let limpio = correo.trimmingCharacters(in: .whitespaces)
        if limpio.isEmpty { return .vacio }
        if !limpio.contains("@") { return .sinArroba }
        return nil
    }
 
    func errorEdad() -> ErrorEdad? {
        guard let numero = Int(edad.trimmingCharacters(in: .whitespaces)) else { return .noEsNumero }
        return numero < 18 ? .menorDeEdad : nil
    }
 
    // Lo que se enseña: el error solo si el campo está tocado.
    func correoVisible() -> ErrorCorreo? { correoTocado ? errorCorreo() : nil }
    func edadVisible() -> ErrorEdad? { edadTocado ? errorEdad() : nil }
 
    func esValido() -> Bool { errorCorreo() == nil && errorEdad() == nil }
}
 
// Una línea de texto con lo que vería el usuario. Sobre un OPCIONAL, `.map { … }` transforma el
// valor si lo hay (aquí, el error pasa a texto) y deja `nil` si no; `?? "sin error"` pone otro
// texto si no hay ninguno. No es el `map` de las listas: no recorre nada.
func resumen(_ f: FormularioSocio) -> String {
    let correo = f.correoVisible().map { "\($0)" } ?? "sin error"
    let edad = f.edadVisible().map { "\($0)" } ?? "sin error"
    return "correo: \(correo) · edad: \(edad) · válido: \(f.esValido())"
}
 
let recienAbierto = FormularioSocio()
print("1.", resumen(recienAbierto))
 
var soloCorreo = FormularioSocio()
soloCorreo.correo = "ana.club.com"
print("2.", resumen(soloCorreo))
 
var menor = FormularioSocio()
menor.correo = "ana@club.com"
menor.edad = "16"
print("3.", resumen(menor))
 
var bueno = FormularioSocio()
bueno.correo = "ana@club.com"
bueno.edad = "30"
print("4.", resumen(bueno))
1. correo: sin error · edad: sin error · válido: false
2. correo: sinArroba · edad: sin error · válido: false
3. correo: sin error · edad: menorDeEdad · válido: false
4. correo: sin error · edad: sin error · válido: true

Una nota sobre la salida: al meter un caso de enum en un texto ("\($0)"), Swift escribe su nombre, por eso sale sinArroba o menorDeEdad. Y la primera línea es la que más enseña: el formulario recién abierto no es válido (el correo está vacío), pero no enseña ningún error, porque ningún campo está tocado. En la segunda solo se ha escrito el correo, y solo ese campo da error; la edad, sin tocar, calla aunque esté mal. En la tercera, el correo es válido y la edad no.

Qué hace cada plataforma

Android y iOS resuelven lo mismo con piezas distintas. Las reglas y los textos son idénticos:

IdeaAndroidiOS
Lo escrito y los campos tocadosuna data class que el ViewModel copia con copy(...) al escribirun struct que se modifica en su sitio; didSet marca el campo tocado
Dónde está el estadomutableStateOf dentro del ViewModeluna propiedad de un ViewModel @Observable
Los erroresenum class ErrorNombre { VACIO, LARGO, REPETIDO } y errorNombre(plantilla)enum ErrorNombre { case vacio, largo, repetido } y errorNombre(en:)
La plantilla guardadallega de Room como un Flow y se copia a un mutableStateOfse lee del repositorio, que ya es @Observable, en cada consulta

La última fila es la diferencia que ahorra código: en iOS no hay un Flow que recoger ni nada que copiar.

Reto · Ejercicio: las reglas de una reserva de mesa

Piezas que necesitas (todas son de Swift y caben en una línea):

  • limpio.count: cuántos caracteres tiene un texto («612345678».count es 9).
  • (1...12).contains(numero): un rango del 1 al 12, con los dos extremos incluidos, y la pregunta de si el número está dentro.
  • limpio.allSatisfy { $0.isNumber }: «¿todos los caracteres cumplen esto?»; aquí, «¿todos son cifras?». (También valdría Int(limpio) != nil, aunque aceptaría un signo como «-».)
  • En una lista de parejas como [("4", "612"), ("", "")], el for caso in casos te da la pareja entera y se leen sus partes con caso.0 (la primera) y caso.1 (la segunda).

En tu Playground (reutiliza el mismo y borra lo anterior), escribe las reglas de una reserva de mesa, con la misma forma que el ejemplo. Un struct ReservaDeMesa con dos textos, personas y telefono, y para cada uno si está tocado (marcado al escribir, con didSet, solo si el texto cambia: if personas != oldValue). Las reglas:

  • Personas: es obligatorio, y tiene que ser un número del 1 al 12. Su error es ErrorPersonas, con tres motivos: vacio, noEsNumero y fueraDeRango.
  • Teléfono: es opcional; vacío es válido. Si hay algo, tiene que tener exactamente 9 cifras. Su error es ErrorTelefono, con un solo motivo: noSonNueveCifras.

Dos funciones, errorPersonas() y errorTelefono(), que devuelven el error o nil. Es la misma reserva que escribiste en Kotlin en Formularios: el estado y las reglas, con los mismos datos. Prueba estas cinco reservas, en este orden, imprimiendo el error de cada una (o nil), y después comprueba que una reserva recién creada no está tocada y que escribir «2» en personas marca solo ese campo (el teléfono sigue sin tocar). Lo que debe salir:

personas «4», teléfono «612345678» -> nil / nil
personas «», teléfono «» -> vacio / nil
personas «seis», teléfono «612345678» -> noEsNumero / nil
personas «13», teléfono «» -> fueraDeRango / nil
personas «2», teléfono «6123» -> nil / noSonNueveCifras
tocada al abrir: false
tocada tras escribir: true · teléfono: false

Las cinco reservas son ("4", "612345678"), ("", ""), ("seis", "612345678"), ("13", "") y ("2", "6123").

Ver solución
solución: las reglas de una reserva de mesa
import Foundation
 
enum ErrorPersonas { case vacio, noEsNumero, fueraDeRango }
enum ErrorTelefono { case noSonNueveCifras }
 
struct ReservaDeMesa {
    var personas = "" { didSet { if personas != oldValue { personasTocado = true } } }
    var telefono = "" { didSet { if telefono != oldValue { telefonoTocado = true } } }
    private(set) var personasTocado = false
    private(set) var telefonoTocado = false
 
    func errorPersonas() -> ErrorPersonas? {
        let limpio = personas.trimmingCharacters(in: .whitespaces)
        if limpio.isEmpty { return .vacio }
        guard let numero = Int(limpio) else { return .noEsNumero }
        return (1...12).contains(numero) ? nil : .fueraDeRango
    }
 
    // El teléfono es opcional: vacío es válido. Si hay algo, tiene que ser de 9 cifras.
    func errorTelefono() -> ErrorTelefono? {
        let limpio = telefono.trimmingCharacters(in: .whitespaces)
        if limpio.isEmpty { return nil }
        let sonCifras = limpio.allSatisfy { $0.isNumber }
        return (limpio.count == 9 && sonCifras) ? nil : .noSonNueveCifras
    }
}
 
let casos = [("4", "612345678"), ("", ""), ("seis", "612345678"), ("13", ""), ("2", "6123")]
for caso in casos {
    var reserva = ReservaDeMesa()
    reserva.personas = caso.0
    reserva.telefono = caso.1
    // `.map { "\($0)" }` pasa el error a texto (el nombre del caso) si lo hay; `?? "nil"` es lo que se imprime si no hay.
    let errorDePersonas = reserva.errorPersonas().map { "\($0)" } ?? "nil"
    let errorDeTelefono = reserva.errorTelefono().map { "\($0)" } ?? "nil"
    print("personas «\(reserva.personas)», teléfono «\(reserva.telefono)» -> \(errorDePersonas) / \(errorDeTelefono)")
}
var nueva = ReservaDeMesa()
print("tocada al abrir:", nueva.personasTocado)
nueva.personas = "2"
print("tocada tras escribir:", nueva.personasTocado, "· teléfono:", nueva.telefonoTocado)

El error de las personas se decide con un orden: primero vacío, después «no es número» y al final el rango. El primer motivo que se cumple gana, y por eso "" es vacio y no noEsNumero. En el teléfono, vacío es válido: la regla de «opcional» es la primera línea.

Paso 1 · Las reglas: FormularioJugador.swift

Objetivo: el struct con el texto de los dos campos, los campos tocados y las reglas. Todavía no lo usa nadie.

MarcadorApp (proyecto)
MarcadorApp
Plantilla
UI
FormularioJugador.swiftse crea
AnadirJugadorViewModel.swiftse crea
MarcadorAppTests
Plantilla
UI
FormularioJugadorTests.swiftse crea
Los ficheros de la lección en el Project navigator.

Crea FormularioJugador.swift: clic derecho sobre Plantilla/UI (la carpeta que creaste en la lección anterior, porque el formulario es de la plantilla) → New File from Template… → Swift File, solo con el target MarcadorApp. Va en UI y no en Domain porque son reglas de la pantalla (qué campo tocó el usuario, qué error enseñar), no del fútbol.

Las reglas de Marcador, que son las mismas de Android:

CampoReglaTexto del error (lo pone la pantalla)
Nombreobligatorio, tras quitar los espacios de los bordes«Escribe un nombre»
Nombrecomo mucho 25 letras«Como mucho 25 letras»
Nombreque no esté ya en la plantilla, sin distinguir mayúsculas«Ya hay un jugador con ese nombre»
Dorsalopcional: vacío es válido—
Dorsalsi hay algo, un número«Escribe solo números»
Dorsaldel 1 al 99«Del 1 al 99»
Dorsalque no lo lleve ya otro jugador del equipo«Ese dorsal ya lo lleva otro jugador»

En el nombre y en el dorsal gana el primer motivo que se cumple, en el orden de la tabla.

Piezas nuevas de este paso:

  • static let maximoNombre = 25: una constante del tipo, no de cada formulario. Dentro del tipo se lee con Self.maximoNombre: Self con mayúscula es «este mismo tipo» (FormularioJugador), mientras que self con minúscula sería «este formulario en concreto».
  • limpio.count y (1...99).contains(numero): el recuento de caracteres y el rango, que acabas de usar en el ejercicio.
  • plantilla.contains(where: { … }): «¿alguno de la lista cumple esto?».
  • caseInsensitiveCompare(limpio) == .orderedSame: comparar dos textos sin distinguir mayúsculas de minúsculas. Viene de Foundation.
  • dorsalDesdeTexto(_:), que escribiste en guard let y textos que no son números: quita los espacios de los bordes y devuelve el número, o nil si el texto no lo es. Por eso " 7" (con un espacio delante) da 7 sin que el formulario lo limpie antes.
  • struct FormularioJugador: Equatable: Equatable es «se puede comparar con ==». Los dos enum de errores lo necesitan para que las pruebas escriban == .vacio; en el struct es solo la costumbre de dejar que los datos se puedan comparar.

Intenta escribirlo tú: un struct FormularioJugador que se pueda comparar, con la constante del máximo, el nombre y el dorsal (textos que empiezan vacíos y marcan su campo como tocado al cambiar), los dos «tocado» (de solo lectura desde fuera), una función por campo que recibe la plantilla y devuelve el error o nil, esValido(en:), y aJugador(), que da el Jugador ya limpio. Y debajo, los dos enum de errores, que también se pueden comparar.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Plantilla/UI/FormularioJugador.swift
import Foundation
import Torneo
 
// EL FORMULARIO «AÑADIR JUGADOR», COMO DATOS (gemelo de `FormularioJugador.kt`).
//
// Un `struct`: lo que el usuario ESCRIBIÓ, tal cual, y qué campos ha TOCADO. Los errores no se
// guardan: se CALCULAN a partir del texto y de la plantilla guardada.
struct FormularioJugador: Equatable {
    static let maximoNombre = 25
 
    // `didSet` corre cada vez que se escribe en la propiedad; `oldValue` es el valor de antes. Solo
    // si el texto cambió de verdad el campo queda tocado. Así el `TextField` puede escribir
    // DIRECTAMENTE aquí (`$viewModel.formulario.nombre`).
    var nombre = "" { didSet { if nombre != oldValue { nombreTocado = true } } }
    var dorsal = "" { didSet { if dorsal != oldValue { dorsalTocado = true } } }
    private(set) var nombreTocado = false
    private(set) var dorsalTocado = false
 
    func errorNombre(en plantilla: [Jugador]) -> ErrorNombre? {
        let limpio = nombre.trimmingCharacters(in: .whitespaces)
        if limpio.isEmpty { return .vacio }
        if limpio.count > Self.maximoNombre { return .largo }
        if plantilla.contains(where: { $0.nombre.caseInsensitiveCompare(limpio) == .orderedSame }) { return .repetido }
        return nil
    }
 
    func errorDorsal(en plantilla: [Jugador]) -> ErrorDorsal? {
        if dorsal.trimmingCharacters(in: .whitespaces).isEmpty { return nil }   // opcional
        guard let numero = dorsalDesdeTexto(dorsal) else { return .noEsNumero }
        if !(1...99).contains(numero) { return .fueraDeRango }
        if plantilla.contains(where: { $0.dorsal == numero }) { return .repetido }
        return nil
    }
 
    func esValido(en plantilla: [Jugador]) -> Bool {
        errorNombre(en: plantilla) == nil && errorDorsal(en: plantilla) == nil
    }
 
    func aJugador() -> Jugador {
        Jugador(nombre: nombre.trimmingCharacters(in: .whitespaces), dorsal: dorsalDesdeTexto(dorsal))
    }
}
 
// Un `enum` por campo; la vista elige el texto de cada caso.
enum ErrorNombre: Equatable { case vacio, largo, repetido }
enum ErrorDorsal: Equatable { case noEsNumero, fueraDeRango, repetido }

Compila con Cmd+B: Build Succeeded. Fíjate en tres decisiones. Los errores reciben la plantilla por parámetro (en plantilla:) en lugar de guardarla: el formulario no sabe de repositorios, y se prueba con una lista cualquiera. aJugador() solo tiene sentido con el formulario válido: limpia los espacios y convierte el dorsal en número, o en nil si estaba vacío. Y didSet permite que el TextField escriba directamente en el struct ($viewModel.formulario.nombre, en la lección siguiente) y que el campo quede tocado sin ningún código más. La comparación con oldValue es necesaria porque el TextField puede volver a escribir el mismo texto en el campo cuando lo tocas para ponerte a escribir: tocar no es escribir, y sin la comparación el error rojo saldría nada más tocar el campo.

Paso 2 · El ViewModel: AnadirJugadorViewModel.swift

Objetivo: una clase que guarda el formulario, sabe cuál es la plantilla guardada de su equipo y decide qué errores se ven.

Créalo en Plantilla/UI, junto al formulario (clic derecho sobre esa carpeta → New File from Template… → Swift File, solo con el target MarcadorApp). Intenta escribirlo tú: una clase final, @MainActor y @Observable (como los otros ViewModel), con el nombre del equipo (let), el formulario (una var que empieza vacía) y el repositorio como @ObservationIgnored private let del tipo any PartidosRepositorio, recibidos por el init. Una propiedad calculada plantilla que busca el equipo por nombre en repositorio.equipos y devuelve su plantilla (o una lista vacía). Tres propiedades calculadas para la vista: errorNombre y errorDorsal, que dan el error solo si el campo está tocado, y sePuedeGuardar, que dice si el formulario entero es válido, tocado o no. Y guardar() -> Bool: si no se puede guardar, devuelve false; si se puede, pide al repositorio que añada el jugador y devuelve lo que este conteste.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorApp/Plantilla/UI/AnadirJugadorViewModel.swift
import Foundation
import Observation
import Torneo
 
// El formulario vive en el ViewModel (`@Observable`): la vista escribe en `formulario` con un
// `Binding` y lee los errores, que se calculan cada vez con la plantilla GUARDADA.
@MainActor
@Observable
final class AnadirJugadorViewModel {
    let equipo: String
    var formulario = FormularioJugador()
 
    @ObservationIgnored private let repositorio: any PartidosRepositorio
 
    init(equipo: String, repositorio: any PartidosRepositorio) {
        self.equipo = equipo
        self.repositorio = repositorio
    }
 
    // Se lee del repositorio en cada consulta: si cambia, los errores cambian con él.
    private var plantilla: [Jugador] {
        repositorio.equipos.first { $0.nombre == equipo }?.plantilla ?? []
    }
 
    // Lo que ve la vista: el error SOLO si el campo está tocado.
    var errorNombre: ErrorNombre? { formulario.nombreTocado ? formulario.errorNombre(en: plantilla) : nil }
    var errorDorsal: ErrorDorsal? { formulario.dorsalTocado ? formulario.errorDorsal(en: plantilla) : nil }
    var sePuedeGuardar: Bool { formulario.esValido(en: plantilla) }
 
    // Devuelve si se guardó: la vista cierra la pantalla solo entonces.
    func guardar() -> Bool {
        guard sePuedeGuardar else { return false }
        return repositorio.anadirJugador(formulario.aJugador(), a: equipo)
    }
}

Dos detalles. plantilla no es una propiedad guardada: es calculada, y lee repositorio.equipos cada vez que alguien le pregunta. Y no hay contradicción con @ObservationIgnored: lo que se ignora es la propiedad repositorio (la caja que guarda el repositorio, que nunca cambia de uno a otro), no lo que hay dentro; repositorio.equipos se sigue observando porque el repositorio es @Observable. (En un let la marca no cambia nada que veas; se pone para dejar claro que esa caja no forma parte de lo que se pinta, como en los demás ViewModel del proyecto.) Así, cuando se añada un jugador, los errores se recalculan solos: tras guardar a Bea con el 7, un segundo «Bea» o un segundo 7 ya dan error. Y sePuedeGuardar no mira si los campos están tocados: al abrir el formulario el botón estará apagado, pero sin errores en rojo. Así el botón dice «todavía no» sin gritar.

Paso 3 · Las pruebas de las reglas: FormularioJugadorTests.swift

Objetivo: siete pruebas de las reglas, sin vista ni ViewModel.

Las pruebas copian las carpetas de la app, y esta prueba es de Plantilla/UI. Clic derecho sobre MarcadorAppTests → New Folder, de nombre Plantilla; clic derecho sobre Plantilla → New Folder, de nombre UI (con la Terminal: mkdir -p ~/Marcador-Swift/MarcadorApp/MarcadorAppTests/Plantilla/UI). Crea FormularioJugadorTests.swift: clic derecho sobre MarcadorAppTests/Plantilla/UI → New File from Template… → Swift File, solo con el target MarcadorAppTests. Siete pruebas:

  1. Un nombre vacío o de solo espacios es vacio.
  2. Con 26 letras es largo, y con 25 no hay error.
  3. « ana » ya está en la plantilla del Rayo (Ana), así que es repetido: sin distinguir mayúsculas y sin contar espacios.
  4. El dorsal es opcional: sin escribirlo no hay error y el formulario es válido.
  5. Los dorsales que no valen, en una sola prueba parametrizada: «diez» es noEsNumero, «0» y «100» son fueraDeRango y «9» es repetido (Ana lo lleva).
  6. Escribir en el nombre lo marca como tocado, y el dorsal sigue sin tocar.
  7. Un formulario válido da el jugador limpio: " Bea " y " 7" dan Jugador(nombre: "Bea", dorsal: 7).

Ya la conoces, con dos detalles nuevos: @Test(arguments: [...]) ejecuta la misma prueba una vez por cada elemento de la lista, como en La regla de los puntos y tu primer test. Aquí cada elemento es una tupla de un texto y un error, y sus dos valores llegan a los parámetros de la función. En Xcode se ve una fila con cuatro casos dentro. Y en la lista, solo el primer elemento escribe el tipo completo, ErrorDorsal.noEsNumero; los demás pueden poner .fueraDeRango, porque Swift ya sabe que ese lado de la tupla es un ErrorDorsal. @MainActor delante de la struct de pruebas hace que corran en el hilo principal, como el ViewModel. Y String(repeating: "a", count: 26) fabrica un texto con la «a» repetida 26 veces, para no escribirlas a mano.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests/Plantilla/UI/FormularioJugadorTests.swift
import Testing
import Torneo
@testable import MarcadorApp
 
// Las reglas del formulario, sin vista ni ViewModel. La plantilla del Rayo FC: Ana #9, Luis sin
// dorsal y Marta #1.
@MainActor
struct FormularioJugadorTests {
    let rayo = Ejemplo.rayo.plantilla
 
    func formulario(nombre: String = "", dorsal: String = "") -> FormularioJugador {
        var formulario = FormularioJugador()
        formulario.nombre = nombre
        formulario.dorsal = dorsal
        return formulario
    }
 
    @Test func unNombreVacioOSoloConEspaciosEsUnError() {
        #expect(formulario(nombre: "").errorNombre(en: rayo) == .vacio)
        #expect(formulario(nombre: "   ").errorNombre(en: rayo) == .vacio)
    }
 
    @Test func unNombreDeMasDe25LetrasEsDemasiadoLargo() {
        #expect(formulario(nombre: String(repeating: "a", count: 26)).errorNombre(en: rayo) == .largo)
        #expect(formulario(nombre: String(repeating: "a", count: 25)).errorNombre(en: rayo) == nil)
    }
 
    @Test func unNombreQueYaEstaEnLaPlantillaEsUnError() {
        #expect(formulario(nombre: " ana ").errorNombre(en: rayo) == .repetido)
    }
 
    @Test func elDorsalEsOpcional() {
        #expect(formulario(nombre: "Bea").errorDorsal(en: rayo) == nil)
        #expect(formulario(nombre: "Bea").esValido(en: rayo))
    }
 
    @Test(arguments: [("diez", ErrorDorsal.noEsNumero), ("0", .fueraDeRango), ("100", .fueraDeRango), ("9", .repetido)])
    func losDorsalesQueNoValen(texto: String, error: ErrorDorsal) {
        #expect(formulario(nombre: "Bea", dorsal: texto).errorDorsal(en: rayo) == error)
    }
 
    @Test func escribirMarcaElCampoComoTocado() {
        var formulario = FormularioJugador()
        #expect(!formulario.nombreTocado)
        formulario.nombre = "B"
        #expect(formulario.nombreTocado)
        #expect(!formulario.dorsalTocado)
    }
 
    @Test func unFormularioValidoDaElJugadorLimpio() {
        #expect(formulario(nombre: "  Bea ", dorsal: " 7").aJugador() == Jugador(nombre: "Bea", dorsal: 7))
    }
}

Dos detalles del fichero de pruebas. La función auxiliar formulario(nombre:dorsal:) tiene valores por defecto (nombre: String = "": si no pasas ese argumento, vale vacío), y en la prueba escribirMarcaElCampoComoTocado la variable local formulario tapa a esa función mientras dura la prueba: dentro de ella formulario es la variable, no la función.

Lanza Cmd+U. Hay una cuenta que sorprende: son 7 pruebas nuevas, pero la parametrizada cuenta una en el recuento aunque ejecute cuatro casos, así que el total sube en 7, no en 10.

Punto de control

  • Cmd+B termina en Build Succeeded y Cmd+U da 58 pruebas en verde: 42 unitarias y 16 de interfaz (con tus ejercicios de pruebas, 61). Las siete nuevas son las de FormularioJugadorTests; la parametrizada sale con 4 casos.
  • La app se ve y se comporta igual que antes: el formulario todavía no tiene pantalla.
  • El ejercicio de la reserva de mesa imprime las siete líneas.
  • Sabes decir por qué un error se calcula y no se guarda, qué quiere decir «campo tocado» y por qué el botón puede estar apagado sin que haya ningún error a la vista.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Por qué los errores del formulario se calculan en vez de guardarse?

  2. 2.En FormularioJugador, ¿qué significa que un campo esté «tocado»?

  3. 3.Recién abierto el formulario, ¿qué dicen sePuedeGuardar y los errores visibles?

  4. 4.En iOS, el ViewModel no copia la plantilla guardada: la lee del repositorio en cada consulta. ¿Por qué eso basta para que los errores se actualicen?

  5. 5.Alguien llama a anadirJugador con un dorsal repetido sin pasar por el formulario. ¿Qué pasa?

Faltan 5 de 5