Módulo 1: Del ejemplo a la propiedad — por qué existe el property-based testing

8. Mini-proyecto: tres propiedades de `refund_cents` chequeadas a mano

Descripción

Llegó el momento de juntar todo con las manos. Durante siete lecciones desarmamos el property-based en piezas: el límite del ejemplo, la definición de propiedad, cómo encontrarlas en Reservo, el contraste lado a lado, por qué la máquina caza los casos raros, cuándo la técnica paga. Este mini-proyecto es la síntesis: vas a escribir tú tres propiedades de refund_cents y a chequearlas a mano sobre 1000 entradas aleatorias, primero contra la implementación correcta —donde deben callar todas— y luego contra la del bono de lealtad sin tope —donde vas a ver cuál salta y cuáles no—. Es el módulo entero condensado en un solo script que corre.

Al terminar vas a tener una entrega concreta: tres propiedades enunciadas con precisión, el bucle que las prueba sobre 1000 casos, la corrida que muestra todo verde contra el código correcto, la corrida que muestra una propiedad saltando contra el código roto, y el contraejemplo mínimo que exhibe el bug. Y —esto es lo que de verdad se te queda— vas a comprobar en carne propia la lección más importante de la L4: distintas propiedades cazan distintos bugs. Las tres que escribas son ciertas las tres, pero solo una atrapa el bono sin tope; las otras dos siguen verdes contra el código roto. Ver eso con tus datos, no en una explicación, es lo que convierte el conocimiento en instinto.

Conexión con el módulo: esta es la lección-capstone. No introduce concepto nuevo; te pone a aplicar todos los del módulo en un flujo completo, de principio a fin, con una entrega. Es también el último eslabón antes de la herramienta: el mini-proyecto termina construyendo, con toda intención, la frustración justa —el contraejemplo feo, el for a mano, la falta de encogido— que hace que quieras Hypothesis. Y ahí te dejamos, en el umbral del M2. Por última vez lo hacemos a mano, con random; a partir del próximo módulo, la máquina toma el for.

El encargo, en una frase

Aquí está tu misión, tal cual la recibirías en un equipo real:

Tenemos refund_cents, la función que decide cuánto se reembolsa al cancelar una reserva. Maneja dinero, así que un bug ahí cuesta plata de verdad. Escribe un chequeo de propiedades —tres invariantes— que la vigile sobre cientos de casos aleatorios, y demuéstrame que tu chequeo sirve corriéndolo contra una versión con un bug conocido y viéndolo saltar.

Nota la última parte: no basta con escribir propiedades que pasen; hay que demostrar que pueden fallar. Es la disciplina de la L7 hecha requisito de entrega. Una propiedad que nunca viste roja no vale.

Una analogía: las tres cerraduras de una misma puerta

Piensa en la puerta de una bóveda con tres cerraduras distintas: una de llave, una de combinación y una de huella. No son redundantes; cada una defiende contra un ataque distinto. La de llave para en seco a quien no tiene la llave pero sí la combinación. La de huella para a quien copió la llave. Un ladrón tiene que vencer las tres, y como cada una vigila una debilidad diferente, juntas cubren mucho más que tres veces la misma cerradura.

Tus tres propiedades son esas tres cerraduras sobre refund_cents. El rango vigila que el reembolso no se salga de [0, pagado]. La monotonía vigila que cancelar antes nunca reembolse menos. El "pagado 0 ⇒ reembolso 0" vigila que quien no pagó nada no reciba nada. Un bug tiene que respetar las tres para pasar inadvertido, y como cada una mira una cosa distinta, juntas acorralan la corrección mucho mejor que cualquiera sola. Cuando corras el chequeo contra el bono sin tope vas a ver, literalmente, que ese bug vence dos cerraduras (monotonía y pagado-cero siguen verdes) pero se estrella contra la tercera (el rango). Sin la cerradura del rango, el ladrón habría entrado.

Paso 1: enunciar las tres propiedades con precisión

Antes de tocar código, escribe las tres reglas en español, en la forma "para toda entrada válida, se cumple...". Este paso parece trámite y es la mitad del trabajo: una propiedad mal enunciada produce un chequeo inútil.

  1. Rango. Para todo price_paid >= 0 y todo instante de cancelación, el reembolso cumple 0 <= refund <= price_paid. (No se cobra por cancelar; no se regala dinero.) Es un invariante de rango.
  2. Monotonía. Para todo price_paid y todo par de instantes, si cancelas con más anticipación, el reembolso no es menor que si cancelas con menos. (Cancelar antes nunca te perjudica.) Es una relación entre entradas.
  3. Pagado cero ⇒ reembolso cero. Para todo instante de cancelación, si price_paid == 0, entonces refund == 0. (Quien no pagó nada no recibe nada.) Es un caso-frontera del rango, elevado a regla propia.

Fíjate en que ninguna menciona un valor de salida concreto (nada de "6000"). Las tres son reglas con "para toda", no ejemplos. Y las tres son falsables: puedes imaginar un código que rompa cada una (una que reembolse de más rompe el rango; una que reembolse menos al cancelar antes rompe la monotonía; una que dé un centavo con pagado cero rompe la tercera). Esa falsabilidad es lo que las hace propiedades útiles y no triviales, como discutimos en la L7.

Paso 2: escribir el chequeo a mano

Ahora el código. Un solo script que prueba las tres propiedades sobre 1000 casos. Para la monotonía necesitamos dos instantes por caso (uno más temprano, uno más tardío); para la tercera, evaluamos con price_paid = 0. El import de arriba es el interruptor que nos deja alternar entre la implementación correcta y la buggy sin tocar nada más.

# check_refund_properties.py — tres propiedades de refund_cents, a mano, 1000 casos
import random
from datetime import datetime, timedelta
from reservo.models import Booking

# Elige UNA de las dos líneas:
from reservo.refunds import refund_cents        # implementación CORRECTA
# from refunds_buggy import refund_cents        # bono de lealtad SIN tope

START = datetime(2026, 3, 10, 12, 0)
random.seed(1000)


def a_booking():
    return Booking(id="bk", room_id="r", member_id="m",
                   start=START, end=START + timedelta(hours=2))


def refund_at(hours_before, price_paid):
    now = START - timedelta(hours=hours_before)
    return refund_cents(a_booking(), price_paid, now)


range_fail = mono_fail = zero_fail = 0
first_range = None
for _ in range(1000):
    price_paid = random.randint(0, 50_000)
    ha = random.uniform(-10, 200)
    hb = random.uniform(-10, 200)
    early, late = max(ha, hb), min(ha, hb)   # early = más anticipación

    r_early = refund_at(early, price_paid)
    r_late = refund_at(late, price_paid)

    # PROPIEDAD 1 (rango): 0 <= refund <= pagado
    if not (0 <= r_early <= price_paid):
        range_fail += 1
        if first_range is None:
            first_range = (early, price_paid, r_early)
    # PROPIEDAD 2 (monotonía): cancelar más temprano nunca reembolsa menos
    if not (r_early >= r_late):
        mono_fail += 1
    # PROPIEDAD 3 (pagado 0 => reembolso 0)
    if refund_at(early, 0) != 0:
        zero_fail += 1

print(f"Entradas probadas: 1000")
print(f"  P1 rango (0<=refund<=pagado):     {range_fail} violaciones")
print(f"  P2 monotonía (temprano>=tardío):  {mono_fail} violaciones")
print(f"  P3 pagado 0 => reembolso 0:       {zero_fail} violaciones")
if first_range:
    h, p, r = first_range
    print(f"  Primer contraejemplo de P1: {h:.2f} h, pagado={p}, reembolso={r}")

Reconoce las tres piezas de la L3 en el código: el espacio de entradas (precios de 0 a 50000, horas de −10 a 200, incluidas las cancelaciones tardías), el generador (random.randint y random.uniform), y las tres aserciones del invariante (los tres if not). Es la misma anatomía de todo el módulo, ahora con tres reglas en paralelo.

Paso 3: correr contra el código correcto (las tres cerraduras aguantan)

Con el import apuntando a reservo.refunds (la versión buena), corre el script.

Qué esperar. Silencio total: las tres propiedades en cero. La implementación correcta respeta las tres reglas en las 1000 entradas, incluidas las cancelaciones tardías.

$ python3 check_refund_properties.py
Entradas probadas: 1000
  P1 rango (0<=refund<=pagado):     0 violaciones
  P2 monotonía (temprano>=tardío):  0 violaciones
  P3 pagado 0 => reembolso 0:       0 violaciones

Tres ceros. Podría parecer anticlimático —"corrí 1000 casos para no ver nada"—, pero es exactamente la señal de confianza que buscas: probaste tres reglas distintas en 1000 puntos repartidos por el espacio, incluidos los raros, y ninguno las rompió. Esto es lo que hace un buen conjunto de propiedades contra código correcto: callar. Guarda esta corrida; es la primera mitad de tu entrega.

Pero recuerda la advertencia de la L7: un verde que nunca fue rojo no prueba nada. Tres ceros contra el código bueno podrían significar "las propiedades son fuertes y el código es correcto" o "las propiedades son triviales y siempre pasan". Para distinguir, hay que verlas fallar.

Paso 4: correr contra el código roto (una cerradura cede, dos aguantan)

Cambia el import: comenta la línea de reservo.refunds y descomenta la de refunds_buggy (el bono de lealtad sin tope). Nada más cambia. Corre otra vez.

Qué esperar. Aquí está la revelación del proyecto. El rango (P1) explota con cientos de violaciones. Pero la monotonía (P2) y el pagado-cero (P3) siguen en cero, contra el mismísimo código roto.

$ python3 check_refund_properties.py
Entradas probadas: 1000
  P1 rango (0<=refund<=pagado):     935 violaciones
  P2 monotonía (temprano>=tardío):  0 violaciones
  P3 pagado 0 => reembolso 0:       0 violaciones
  Primer contraejemplo de P1: 130.66 h, pagado=28113, reembolso=51165

Detente y saborea este resultado, porque es la lección entera del módulo en cuatro líneas. El bono sin tope reembolsa de más con mucha anticipación. Eso rompe el rango (P1): 935 de 1000 casos violan refund <= pagado. Pero mira las otras dos:

  • La monotonía (P2) sigue verde porque el bono crece con la anticipación —más horas, más bono, más reembolso—, así que la función sigue siendo una escalera que solo sube. El bug no invierte el orden; solo infla los valores. La monotonía no lo ve.
  • El pagado-cero (P3) sigue verde porque, con price_paid = 0, el bono 0 + 0 * bonus // 100 sigue dando 0. El bug multiplica el precio pagado por un porcentaje; si el precio es cero, no hay nada que inflar. La tercera propiedad no lo ve.

Esto es, con tus propios datos, la moraleja de la L4: una sola propiedad casi nunca basta. Si hubieras escrito solo la monotonía, o solo el pagado-cero, este bug de dinero habría pasado en verde y habrías dormido tranquilo con una fuga de plata en producción. Fue la propiedad de rango —una de tres— la que lo cazó. Por eso se escriben varias: cada una vigila una debilidad distinta, y no sabes de antemano cuál será la que atrape el próximo bug. Tres cerraduras, y el ladrón se estrelló contra una sola.

Paso 5: encoger el contraejemplo a su forma mínima

El bucle reportó el primer contraejemplo de P1: 130.66 h, pagado=28113, reembolso=51165. Es verdadero pero enorme y difícil de leer —¿importan las 130 horas?, ¿el precio raro?—. La entrega profesional incluye el contraejemplo mínimo, el que grita dónde está el bug. Encojámoslo a mano, razonando sobre el código.

El bono se activa apenas pasas las 48 horas. A 49 horas, bonus_percent = int(49 - 48) = 1, así que el reembolso es price + price * 1 // 100. Ese price // 100 (división entera) solo suma un centavo cuando price >= 100. Entonces el caso más pequeño que aún exhibe el bug es 49 horas con 100 centavos:

$ python3 -c "..."   # evaluando refunds_buggy en tres puntos vecinos
49 h, pagó 100 -> 101   (viola: 101 > 100)
49 h, pagó 99  -> 99    (no viola: el 1% de 99 se redondea a 0)
48 h, pagó 100 -> 100   (no viola: el bono es 0 justo en el umbral)

El contraejemplo mínimo es 49 horas, pagó 100 centavos, reembolsa 101 —un solo centavo de regalo, apenas cruzado el umbral de 48 horas—. Dice exactamente lo mismo que el monstruo de 130 horas (el bug vive por encima de 48, en la división entera del bono), pero cualquiera lo entiende de un vistazo: "justo pasando las 48 horas, con un precio de al menos 100, se devuelve un centavo de más". Ese es el caso que le llevas a quien tiene que arreglar el código.

Fíjate en el trabajo que costó reducirlo: tuviste que razonar sobre la aritmética del bono, probar puntos vecinos, encontrar el borde. Es tedioso y fácil de equivocar. Recuerda esa molestia —es la última pieza que a tu chequeo a mano le falta, y la que Hypothesis te va a regalar automáticamente en el M5 con el shrinking—.

La entrega

Tu mini-proyecto terminado consta de cinco cosas. Júntalas; son el producto del módulo:

  1. Las tres propiedades enunciadas en español, cada una en la forma "para toda entrada válida, se cumple..." (rango, monotonía, pagado-cero). — Paso 1.
  2. El script check_refund_properties.py que las chequea sobre 1000 casos, con el import como interruptor entre las dos implementaciones. — Paso 2.
  3. La corrida contra el código correcto: tres ceros. La evidencia de que las propiedades callan cuando deben. — Paso 3.
  4. La corrida contra el código roto: P1 con 935 violaciones, P2 y P3 en cero. La evidencia de que el chequeo sirve (puede fallar) y de que distintas propiedades cazan distintos bugs. — Paso 4.
  5. El contraejemplo mínimo: 49 h, pagó 100, reembolsa 101. El diagnóstico legible que le entregas a quien arregla. — Paso 5.

Con eso cumpliste el encargo completo: no solo escribiste propiedades que pasan, demostraste que pueden fallar y entregaste el caso mínimo que expone el bug. Eso es property-based hecho con oficio, aunque todavía sea a mano.

Profundización: lo que este mini-proyecto te dejó listo para el M2

Da un paso atrás y mira lo que acabas de construir, porque es —casi exactamente— lo que Hypothesis hace, solo que a mano. Tu script tiene un generador (random), un conjunto de aserciones de invariante (los tres if not), un bucle que corre muchos casos (1000), y un reporte de contraejemplo. Le agregaste, con esfuerzo humano, el encogido al mínimo. Esas son, pieza por pieza, las partes de una herramienta de property-based profesional.

Ahora nombra lo que te costó y lo que te faltó, porque es el argumento de venta del M2:

  • Escribir el generador a mano es tedioso y frágil. Tuviste que elegir rangos (0 a 50000, −10 a 200), acordarte de incluir las cancelaciones tardías, generar dos instantes para la monotonía. Con Hypothesis, describes el espacio con estrategias (st.integers, st.datetimes) y la herramienta genera por ti, incluyendo los bordes que tu random.uniform uniforme casi nunca produce.
  • El generador uniforme es ciego a los bugs de punto. Como viste en la L6, random.uniform casi nunca clava un valor exacto como 48.0. Los generadores de Hypothesis siembran a propósito los valores extremos y los umbrales, así que atacan también esos bugs-de-alfiler.
  • El encogido a mano es un dolor. Reducir 130.66 h, 28113 a 49 h, 100 te costó razonar sobre la aritmética. Hypothesis lo hace solo: te entrega siempre el contraejemplo mínimo, no el primero que tropezó.
  • La reproducibilidad la manejaste con una semilla. Hypothesis va más lejos: guarda una base de datos de ejemplos fallidos y los vuelve a probar automáticamente, para que un bug que apareció una vez no se escape nunca más.

En otras palabras: entiendes el motor. No vas a llegar al M2 a aprender un concepto nuevo y misterioso; vas a llegar a reemplazar tu for a mano por una herramienta que hace lo mismo, mejor, y que te quita justo las partes tediosas —el generador, el encogido, la reproducibilidad— para dejarte la única que de verdad importa y que ninguna máquina hace por ti: decidir qué propiedad debe cumplir tu código. Esa habilidad —la que practicaste en este módulo entero— es la que te llevas. La herramienta es plomería alrededor de ella.

Errores comunes

Escribir las tres propiedades pero correrlas solo contra el código bueno. Tres ceros contra la implementación correcta no demuestran que tus propiedades sirvan; podrían ser triviales. El encargo exige verlas fallar. Si te saltas el Paso 4, entregas un chequeo que no sabes si funciona. La disciplina es siempre correr contra las dos versiones: la que las calla y la que las grita.

Concluir que la monotonía y el pagado-cero "no sirven" porque no cazaron este bug. Al revés: sirven, solo que vigilan otras debilidades. La monotonía cazaría un bug que reembolsara menos al cancelar antes; el pagado-cero cazaría uno que diera dinero a quien no pagó. Que no atrapen este bug del bono no las invalida —las valida como cerraduras distintas—. Descartar una propiedad porque no cazó un bug concreto es como quitar la cerradura de huella porque el ladrón de hoy entró por la ventana.

Entregar el primer contraejemplo (feo) en vez del mínimo. 130.66 h, 28113 es correcto pero pésimo para depurar: hace perder tiempo en detalles irrelevantes. El caso que ilumina el bug es el mínimo, 49 h, 100. Saltarte el Paso 5 le pasa a quien arregla el código un rompecabezas en vez de un diagnóstico. El encogido es parte del trabajo, aunque a mano cueste.

Fijar el precio en un valor cómodo y generar solo las horas. Si en el bucle hubieras dejado price_paid = 6000 fijo y solo variado las horas, habrías perdido toda una dimensión del espacio —y un bug que dependiera del precio (como el redondeo de la división entera, que solo se ve con precios chicos) se te escaparía—. Genera todas las dimensiones a la vez; congelar una en un valor de ejemplo reintroduce el sesgo del autor que el property-based venía a eliminar.

Ejercicios

Ejercicio 1

Agrega una cuarta propiedad a check_refund_properties.py y córrela contra las dos implementaciones. Propuesta: "el reembolso nunca supera la política del 100% más un margen razonable" no sirve (¿qué margen?), así que elige una falsable de verdad. Pista: piensa en una relación entre el reembolso y el precio pagado que la versión correcta cumpla siempre y que el bono sin tope rompa —distinta del rango que ya tienes—.

Ver solución

Una buena cuarta propiedad, hermana del rango pero enunciada distinto: "el reembolso nunca supera el precio pagado" por sí sola (la cota superior aislada). O, más interesante y no redundante con el rango, una sobre el tramo del 100%: "para toda cancelación con 48 horas o más de anticipación, el reembolso es exactamente el precio pagado, ni más ni menos". El chequeo:

# PROPIEDAD 4: con 48+ horas de anticipación, el reembolso es exactamente lo pagado
if early >= 48 and refund_at(early, price_paid) != price_paid:
    p4_fail += 1

Contra la correcta: cero (a 48+ horas devuelve exactamente lo pagado). Contra el bono sin tope: salta con fuerza, porque a 48+ horas el bono infla el reembolso por encima de lo pagado. Esta propiedad es interesante porque es más ajustada que el rango: el rango solo exige <= pagado, esta exige == pagado en el tramo del 100%, así que cazaría también un bug que reembolsara de menos en ese tramo (que el rango dejaría pasar). Más cerraduras, más finas, más bugs cubiertos. Cuidado con un detalle: usa >= 48, coherente con el if hours_until >= 48 del código, para no crear un falso fallo en el borde exacto.

Ejercicio 2

El bono sin tope rompe el rango en 935 de 1000 casos con random.seed(1000). Cambia el generador de horas de random.uniform(-10, 200) a random.uniform(-10, 47) —solo cancelaciones de menos de 48 horas— y vuelve a correr contra refunds_buggy. ¿Cuántas violaciones de P1 esperas ahora? ¿Por qué? ¿Qué te enseña sobre la relación entre el generador y lo que una propiedad puede cazar?

Ver solución

Esperas cero violaciones de P1, y eso es lo que sale. Con anticipaciones solo entre −10 y 47 horas, el código nunca entra en la rama if hours_until >= 48, que es donde vive el bono sin tope. Todas esas entradas caen en el 50% (24–48 h) o en el 0% (menos de 24 h), tramos donde el código con bug es idéntico al correcto. El chequeo daría tres ceros... contra un código roto.

La lección, la misma que cerró la L1 pero ahora en tu propio proyecto: una propiedad solo puede cazar bugs en la región que su generador visita. La propiedad de rango es perfecta, pero si el generador no produce horas de 48+, es ciega al bug de esa región. La regla correcta con un generador angosto atrapa poco. Por eso describir bien el espacio de entradas —cubrir sus zonas interesantes— es tan importante como elegir la propiedad, y por eso los generadores de Hypothesis se esfuerzan por incluir los extremos en vez de tirar puntos uniformes en un rango cómodo. Elegir el rango del generador es una decisión de diseño, no un detalle.

Ejercicio 3

Inventa una versión nueva de refund_cents con un bug distinto al del bono sin tope, uno que rompa la monotonía o el pagado-cero en vez del rango. Escríbela, córrele el chequeo de las tres propiedades, y confirma que salta la propiedad que corresponde y no las otras. Pista para romper la monotonía: haz que a cierta hora la función reembolse menos que a una hora más tardía (invierte un tramo).

Ver solución

Un bug clásico que rompe la monotonía: invertir el orden de los umbrales por descuido, de modo que cancelar más temprano reembolse menos en cierto rango. Por ejemplo, una "promoción de última hora" mal pensada que da 100% a quien cancela entre 24 y 48 horas, pero solo 50% a quien cancela con más de 48:

# refunds_inverted.py — rompe la MONOTONÍA (bug distinto)
def refund_cents(booking, price_paid_cents, now):
    hours_until = (booking.start - now).total_seconds() / 3600
    if hours_until >= 48:
        return price_paid_cents * 50 // 100   # BUG: los más previsores reciben menos
    if hours_until >= 24:
        return price_paid_cents
    return 0

Al correr el chequeo contra esta versión:

  • P1 (rango) sigue verde: nunca reembolsa más de lo pagado ni menos que cero; los valores caben en [0, pagado]. El rango no ve este bug.
  • P2 (monotonía) salta: cancelar con 72 horas (50%) reembolsa menos que cancelar con 36 (100%), lo que rompe "cancelar antes nunca reembolsa menos". La cerradura de la monotonía es la que cede.
  • P3 (pagado-cero) sigue verde: con price_paid = 0 todo da 0.

Es el espejo perfecto del bono sin tope: allá saltaba solo el rango; aquí salta solo la monotonía. Dos bugs distintos, cazados por dos propiedades distintas, y en cada caso las otras dos cerraduras ni se enteran. Acabas de demostrarte a ti mismo, con dos bugs y tres propiedades, por qué se escriben varias: no sabes cuál será la que atrape el próximo error, así que las pones todas.

Resumen y siguiente paso

Cierras el módulo con un proyecto completo en las manos:

  • Escribiste y chequeaste tres propiedades de refund_cents sobre 1000 casos: rango (0 <= refund <= pagado), monotonía (cancelar antes no reembolsa menos) y pagado-cero (pagó 0 ⇒ reembolso 0). Tres cerraduras, cada una vigilando una debilidad distinta.
  • Contra el código correcto, las tres callaron (tres ceros). Contra el bono sin tope, saltó solo el rango (935 violaciones) mientras la monotonía y el pagado-cero siguieron en cero —la demostración, con tus datos, de que distintas propiedades cazan distintos bugs y de que una sola casi nunca basta—.
  • Redujiste el contraejemplo feo (130.66 h, 28113) a su forma mínima (49 h, pagó 100, reembolsa 101), el diagnóstico legible que le entregas a quien arregla —a mano, con esfuerzo—.
  • La entrega tiene cinco piezas: las propiedades enunciadas, el script, la corrida verde, la corrida roja y el contraejemplo mínimo. No solo propiedades que pasan: la demostración de que pueden fallar.
  • Construiste, a mano, casi toda una herramienta de property-based —generador, aserciones, muchos casos, reporte, encogido—. Sabes qué te costó (el generador, el encogido, la reproducibilidad) y por eso vas a valorar lo que la herramienta automatiza.

Y con esto termina el M1. Repasa el arco: empezaste viendo que los tests por ejemplo solo cubren los casos que se te ocurrieron (L1-L2), aprendiste qué es una propiedad y su anatomía (L3), a encontrarlas en Reservo (L4), a contrastarlas con los ejemplos (L5), por qué la máquina caza los casos raros (L6), cuándo la técnica paga (L7) y —aquí— a aplicarlo todo de punta a punta. Cambiaste el farol por la linterna, y ahora sabes usarla a mano.

En el Módulo 2 dejamos el for casero y entra la herramienta: Hypothesis. Vas a instalarla (pip install hypothesis), conocer el decorador @given y las estrategias básicas (st.integers, st.floats, st.text, st.datetimes), y ver a la máquina hacer, en dos líneas, lo que aquí hiciste en veinte —generar cientos de casos, incluidos los bordes, y reportarte el ejemplo que falsifica—. Todo lo que entendiste a mano en este módulo es el mapa; el M2 te da el vehículo. Nos vemos allá.

Recursos