El juego de la Vida en TypeScript con TDD y medida de cobertura en Jest

0
6599

Antes de empezar recordar que este tutorial forma parte de una cadena de tutoriales en las que pretendo simplemente probar tecnologías actuales. Aquí abajo podrás encontrarlos :

También  me gustaría que vieras este video de 2 minutos para entender dónde estamos y a dónde vamos


Mi equipo es Mac

macOS Catalina
Versión 10.15.2

MacBook Pro (15-inch, 2018)
Procesador 2,9 GHz Intel Core i9 de 6 núcleos
Memoria 32 GB 2400 MHz DDR4
Gráficos Intel UHD Graphics 630 1536 MB


Aunque me propuse un reto construyendo un Arcanoid en TypeScript, que todavía tengo que completar (ver referencias) antes quiero revisar un poquito el lenguaje (recordad que no hablo como experto sino como estudiante).

Un buen ejemplo para manejar arrays y hacer pruebas unitarias y TDD puede ser crear el juego de la vida de Conway: https://es.wikipedia.org/wiki/Juego_de_la_vida

Las reglas son sencillas. Creas dentro de un tablero de un tamaño fijo una semilla (que voy a crear aleatoriamente) que representan células y se gobierna en base a tres reglas.

1 – Si en una casilla vacía hay 3 células alrededor vivas, nace una célula.

2 – Si en una casilla hay una célula vida y alrededor solo 2 o tres vivas, sobrevive.

3 – En cualquier otro caso, la célula de esa casilla muere, por superpoblación.

Para empezar voy a crear un test que voy a llamar tdd.test.ts.

El código lo voy a guardar en índex.ts y para visualizarlo voy a crear una página HTML llamada índex.html.

También crearé un fichero de comandos por si necesito usar alguno desde el terminal.

 

El fichero index.html es sencillo.

Solamente tiene un canvas de 300 x 300 donde pintar los elementos.

Este canvas tiene un id=“areadejuego”

 

<html>
  <head>
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta http-equiv="Expires" content="0" />
    <meta http-equiv="Last-Modified" content="0" />
    <meta htt p-equiv="Cache-Control" content="no-cache, mustrevalidate" />
    <meta http-equiv="Pragma" content="no-cache" />
    <style>
      canvas {
        border: solid;
      }
    </style>
  </head>
  <body>
    <h2>El juego de la vida TDD con TypeScript</h2>

    <canvas width=300 height= 300 id="areajuego"></canvas>

    <script src="./index.ts"></script>
  </body>
</html>

 

El fichero índex.ts inicialmente solo va a tener el enlace entre el html y JavaScript.

window.addEventListener("load", main);

function main() {
  let juego: JuegoDeLaVidaTDD;

  let micanvas: HTMLCanvasElement = document.getElementById(
    "areajuego"
  ) as HTMLCanvasElement;

  let ctx: CanvasRenderingContext2D | null = micanvas.getContext("2d");

  if (ctx == null) {
    console.log("Imposible recuperar contexto pintado");
    return;
  }

  juego = new JuegoDeLaVidaTDD(ctx);
  juego.timer = setInterval(juego.anima, 100);
}

Como veis, nos hace solamente falta una clase llamada JuegoDeLaVidaTDD

Y necesitamos que tenga un timer y una función que se ejecute periódicamente.

El comportamiento es sencillo: se calculará una nueva generación en cada ciclo y se pintará.

  anima = () => {
    console.log("Animamos el juego");
    this.nuevaGeneracion();
    this.pinta();
  };

El código nos queda tal que así

class JuegoDeLaVidaTDD {
  timer: any;
  tama = { anchoCanvas: 0, altoCanvas: 0 };
  dim = { columnas: 0, filas: 0 };
  color: string = "FFFFFF";
  tamaCuadrado: number = 5;
  matriz: number[][];
  ctx: CanvasRenderingContext2D;

  constructor(ctx: CanvasRenderingContext2D) {
    this.ctx = ctx;
    this.tama.anchoCanvas = ctx.canvas.width;
    this.tama.altoCanvas = ctx.canvas.height;

    this.dim.columnas = this.tama.anchoCanvas / this.tamaCuadrado;
    this.dim.filas = this.tama.altoCanvas / this.tamaCuadrado;

    this.matriz = this.inicializaMatrizAleatoria(
      this.dim.columnas,
      this.dim.filas
    );
  }

  inicializaMatrizAleatoria(columnas: number, filas: number) {
    let matrizAux = new Array<Array<number>>();

    for (let fila = 0; fila < filas; fila++) {
      let filaArray: number[] = new Array<number>();

      for (let columna = 0; columna < columnas; columna++) {
        filaArray.push(Math.round(Math.random() * 2));
      }

      matrizAux.push(filaArray);
    }

    return matrizAux;
  }

  nuevaGeneracion() {}

  pintaCasilla(columna: number, fila: number, color: string) {
    this.ctx.fillStyle = color;
    this.ctx.fillRect(
      columna * this.tamaCuadrado + 1,
      fila * this.tamaCuadrado + 1,
      this.tamaCuadrado - 2,
      this.tamaCuadrado - 2
    );
  }

  pinta() {
pinta() {
    this.ctx.fillStyle = "#FFFFFF";
    this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);

    for (let fila = 0; fila < this.back.dim.filas; fila++) {
      for (let columna = 0; columna < this.back.dim.columnas; columna++) {
        if (this.back.matriz[fila][columna] == 0) {
          this.color = "#FFFFFF";
        } else if (this.back.matriz[fila][columna] == 1) {
          this.color = "#0000FF";
        }

        this.pintaCasilla(fila, columna, this.color);
      }
    }
  }


  anima = () => {
    console.log("Animamos el juego");
    this.nuevaGeneracion();
    this.pinta();
  };
}

window.addEventListener("load", main);

function main() {
  let juego: JuegoDeLaVidaTDD;

  let micanvas: HTMLCanvasElement = document.getElementById(
    "areajuego"
  ) as HTMLCanvasElement;

  let ctx: CanvasRenderingContext2D | null = micanvas.getContext("2d");

  if (ctx == null) {
    console.log("Imposible recuperar contexto pintado");
    return;
  }

  juego = new JuegoDeLaVidaTDD(ctx);
  juego.timer = setInterval(juego.anima, 100);
}

Este es el resultado que obtenemos. Un esqueleto donde se han inicializado a 1 aleatoriamente las casillas.

Bueno, tal vez tendríamos que preguntarnos si no le estoy dando antes de empezar demasiada responsabilidad en a la clase del juego porque métodos como inicializaMatrizAleatoria podrían estar en una clase dedicada a operaciones con matrices.

Además, si el constructor de la clase principal requiere un objeto CanvasRenderingContext2D

¿cómo pretendemos pasárselo desde un test? Tendremos acoplada la capa de presentación de la capa de cálculos.

Podríamos desdoblar nuestro código en dos clases, una dedicada a presentación y otra a las estructuras de datos y operaciones.

export class JuegoDeLaVidaTDDBack {
  matriz: number[][];
  dim = { columnas: 0, filas: 0 };

  constructor(columnas: number, filas: number) {
    this.dim.columnas = columnas;
    this.dim.filas = filas;

    this.matriz = this.inicializaMatrizAleatoria(columnas, filas);
  }


  inicializaMatrizAleatoria(
    columnas: number,
    filas: number
  ): Array<Array<number>> {
    let matrizAux = new Array<Array<number>>();

    for (let fila = 0; fila < filas; fila++) {
      let filaArray: number[] = new Array<number>();

      for (let columna = 0; columna < columnas; columna++) {
        filaArray.push(Math.round(Math.random() * 2));
      }

      matrizAux.push(filaArray);
    }

    return matrizAux;
  }

  nuevaGeneracion() {}
}

export class JuegoDeLaVidaTDDFront {
  timer: any;

  color: string = "FFFFFF";
  tamaCuadrado: number = 5;
  ctx: CanvasRenderingContext2D;
  back: JuegoDeLaVidaTDDBack;

  constructor(ctx: CanvasRenderingContext2D) {
    this.ctx = ctx;

    this.back = new JuegoDeLaVidaTDDBack(
      this.ctx.canvas.width / this.tamaCuadrado,
      this.ctx.canvas.height / this.tamaCuadrado
    );
  }

  pintaCasilla(columna: number, fila: number, color: string) {
    this.ctx.fillStyle = color;
    this.ctx.fillRect(
      columna * this.tamaCuadrado + 1,
      fila * this.tamaCuadrado + 1,
      this.tamaCuadrado - 2,
      this.tamaCuadrado - 2
    );
  }

  consigueNuevaGeneracion() {}

  pinta() {
    this.ctx.fillStyle = "#FFFFFF";
    this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);

    for (let fila = 0; fila < this.back.dim.filas; fila++) {
      for (let columna = 0; columna < this.back.dim.columnas; columna++) {
        if (this.back.matriz[fila][columna] == 0) {
          this.color = "#FFFFFF";
        } else if (this.back.matriz[fila][columna] == 1) {
          this.color = "#0000FF";
        }

        this.pintaCasilla(fila, columna, this.color);
      }
    }
  }

  anima = () => {
    console.log("Animamos el juego");
    this.consigueNuevaGeneracion();
    this.pinta();
  };
}

window.addEventListener("load", main);

function main() {
  let juego: JuegoDeLaVidaTDDFront;

  let micanvas: HTMLCanvasElement = document.getElementById(
    "areajuego"
  ) as HTMLCanvasElement;

  let ctx: CanvasRenderingContext2D | null = micanvas.getContext("2d");

  if (ctx == null) {
    console.log("Imposible recuperar contexto pintado");
    return;
  }

  juego = new JuegoDeLaVidaTDDFront(ctx);
  juego.timer = setInterval(juego.anima, 100);
}

Vamos ahora a crear la estructura del fichero de test.

Inicialmente vamos a crear una función que nos retorne con certeza si el cálculo de los vecinos vivos de una célula son los correctos.

Para ello, voy a crear un conjunto de datos y voy a llamar a la clase del juego para que me lo calcule y comprobemos que el algoritmo es correcto.

Esta es la matriz inicial

let muestra1: number[][] = [
  [1, 1, 1],
  [0, 0, 1],
  [1, 0, 1]
];

 

 

Primero escribimos el test y lo ejecutamos.

Fallará porque no existen los métodos adecuados en la clase del juego.

Es decir, se llama Test Driven Development o TDD porque el test me dice los métodos que tengo que escribir.

Si ahora aportamos los métodos que faltan.

  setMatriz(matriz: number[][]) {
    this.matriz = matriz;

    this.dim.filas = matriz.length;
    this.dim.columnas = matriz[0].length;
  }


  calculaVecinosVivos(columna: number, fila: number) {
    return 0;
  }

Ahora falla porque no está haciendo bien el cálculo, ya que siempre retoma 0 calculaVecinosVivos

Por tanto, tenemos que escribir el algoritmo.

Este es el código. Realmente creamos unos índices para recorrer las casillas alrededor y sumar los unos. Tenemos que tener cuidado con los indices en el caso de que la fila o columna sea cero o estemos en la última.

calculaVecinosVivos(columna: number, fila: number): number {
    let filaInicio: number = fila;
    let filaFin: number = fila;
    let columnaInicio: number = columna;
    let columnaFin: number = columna;

    let contador: number = 0;

    if (fila > 0) {
      filaInicio--;
    }
    if (fila < this.dim.filas - 1) {
      filaFin++;
    }

    if (columna > 0) {
      columnaInicio--;
    }
    if (columna < this.dim.columnas - 1) {
      columnaFin++;
    }

    for (let a = filaInicio; a <= filaFin; a++) {
      for (let b = columnaInicio; b <= columnaFin; b++) {
        if (fila == a && columna == b) {
          // ignorar la propia casilla
        } else {
          contador += this.matriz[a][b];
        }
      }
    }

    return contador;
  }

Y ahora podemos incluir todos los test que queramos de un modo sencillo, estando seguro de que el cálculo siempre estará bien o el test nos lo soplará.

Si os fijáis, el porcentaje de cobertura de pruebas automáticas en este caso se está limitando a la función calculaVecinosVivos.

Podemos ampliar la cobertura y también verificar que el resultado de una nueva generación completa es lo que esperamos.

Creamos el método nuevaGeneracion  que luego tendremos que implementar porque nos fallará la compilación.

Para comprobar el resultado creo una matriz resultado1 con lo que tiene que salir y tras procesar la nueva generación verificamos automáticamente.

Por tanto, cuando toquemos en un futuro la función que calcula los vecinos o la nueva generación podremos tener una red de seguridad que nos proporcionarán los test.

Este es el código completo.

xport class JuegoDeLaVidaTDDBack {
  matriz: number[][];
  dim = { columnas: 0, filas: 0 };

  constructor(columnas: number, filas: number) {
    this.dim.columnas = columnas;
    this.dim.filas = filas;

    this.matriz = this.inicializaMatriz(columnas, filas, true);
  }

  setMatriz(matriz: number[][]) {
    this.matriz = matriz;

    this.dim.filas = matriz.length;
    this.dim.columnas = matriz[0].length;
  }

  calculaVecinosVivos(columna: number, fila: number): number {
    let filaInicio: number = fila;
    let filaFin: number = fila;
    let columnaInicio: number = columna;
    let columnaFin: number = columna;

    let contador: number = 0;

    if (fila > 0) {
      filaInicio--;
    }
    if (fila < this.dim.filas - 1) {
      filaFin++;
    }

    if (columna > 0) {
      columnaInicio--;
    }
    if (columna < this.dim.columnas - 1) {
      columnaFin++;
    }

    for (let a = filaInicio; a <= filaFin; a++) {
      for (let b = columnaInicio; b <= columnaFin; b++) {
        if (fila == a && columna == b) {
          // ignorar la propia casilla
        } else {
          contador += this.matriz[a][b];
        }
      }
    }
    return contador;
  }

  inicializaMatriz(
    columnas: number,
    filas: number,
    aleatoria = true
  ): Array<Array<number>> {
    let matrizAux = new Array<Array<number>>();

    for (let fila = 0; fila < filas; fila++) {
      let filaArray: number[] = new Array<number>();

      for (let columna = 0; columna < columnas; columna++) {
        if (aleatoria == true) {
          filaArray.push(Math.round(Math.random() * 2));
        } else {
          filaArray.push(0);
        }
      }

      matrizAux.push(filaArray);
    }

    return matrizAux;
  }

  nuevaGeneracion() : Array<Array<number>>{
    let matrizAux: number[][] = this.inicializaMatriz(
      this.dim.columnas,
      this.dim.filas,
      false
    );

    for (let fila = 0; fila < this.dim.filas; fila++) {
      for (let columna = 0; columna < this.dim.columnas; columna++) {
        let vecinos: number = this.calculaVecinosVivos(columna, fila);

        if (this.matriz[fila][columna] == 0 && vecinos == 3) {
          matrizAux[fila][columna] = 1;
        } else if (
          this.matriz[fila][columna] == 1 &&
          (vecinos == 2 || vecinos == 3)
        ) {
          matrizAux[fila][columna] = 1;
        } else {
          matrizAux[fila][columna] = 0;
        }
      }
    }

    return matrizAux;
  }
}

export class JuegoDeLaVidaTDDFront {
  timer: any;

  color: string = "FFFFFF";
  tamaCuadrado: number = 5;
  ctx: CanvasRenderingContext2D;
  back: JuegoDeLaVidaTDDBack;

  constructor(ctx: CanvasRenderingContext2D) {
    this.ctx = ctx;

    this.back = new JuegoDeLaVidaTDDBack(
      this.ctx.canvas.width / this.tamaCuadrado,
      this.ctx.canvas.height / this.tamaCuadrado
    );
  }

  pintaCasilla(columna: number, fila: number, color: string) {
    this.ctx.fillStyle = color;
    this.ctx.fillRect(
      columna * this.tamaCuadrado + 1,
      fila * this.tamaCuadrado + 1,
      this.tamaCuadrado - 2,
      this.tamaCuadrado - 2
    );
  }

  consigueNuevaGeneracion() {
    this.back.matriz = this.back.nuevaGeneracion();
  }

  pinta() {
    this.ctx.fillStyle = "#FFFFFF";
    this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);

    for (let fila = 0; fila < this.back.dim.filas; fila++) {
      for (let columna = 0; columna < this.back.dim.columnas; columna++) {
        if (this.back.matriz[fila][columna] == 0) {
          this.color = "#FFFFFF";
        } else if (this.back.matriz[fila][columna] == 1) {
          this.color = "#0000FF";
        }

        this.pintaCasilla(fila, columna, this.color);
      }
    }
  }

  anima = () => {
    console.log("Animamos el juego");
    this.consigueNuevaGeneracion();
    this.pinta();
  };
}

window.addEventListener("load", main);

function main() {
  let juego: JuegoDeLaVidaTDDFront;

  let micanvas: HTMLCanvasElement = document.getElementById(
    "areajuego"
  ) as HTMLCanvasElement;

  let ctx: CanvasRenderingContext2D | null = micanvas.getContext("2d");

  if (ctx == null) {
    console.log("Imposible recuperar contexto pintado");
    return;
  }

  juego = new JuegoDeLaVidaTDDFront(ctx);
  juego.timer = setInterval(juego.anima, 100);
}

Y este es nuestro test.

let muestra1: number[][] = [
  [1, 1, 1],
  [0, 0, 1],
  [1, 0, 1]
];

let resultado1: number[][] = [
  [0, 1, 1],
  [1, 0, 1],
  [0, 1, 0]
];

import { JuegoDeLaVidaTDDBack } from "../index";

test("Recupera número de vecinos", () => {
  let juego: JuegoDeLaVidaTDDBack = new JuegoDeLaVidaTDDBack(0, 0);
  juego.setMatriz(muestra1);
  expect(juego.calculaVecinosVivos(0, 0)).toBe(1);
  expect(juego.calculaVecinosVivos(1, 0)).toBe(3);
  expect(juego.calculaVecinosVivos(2, 0)).toBe(2);
  expect(juego.calculaVecinosVivos(2, 1)).toBe(3);
  expect(juego.calculaVecinosVivos(2, 2)).toBe(1);
});

test("Comprueba nueva generación", () => {
  let juego: JuegoDeLaVidaTDDBack = new JuegoDeLaVidaTDDBack(0, 0);
  juego.setMatriz(muestra1);
  let matrizResuelta: number[][] = juego.nuevaGeneracion();

  for (let fila = 0; fila < matrizResuelta.length; fila++) {
    for (let columna = 0; columna < matrizResuelta[fila].length; columna++) {
      console.log("Procesando fila " + fila + " columna " + columna);
      expect(matrizResuelta[fila][columna]).toBe(resultado1[fila][columna]);
    }
  }
});

Podemos configurar Jest para que nos diga la cobertura de código que tenemos en jest.config.js

module.exports = {
  collectCoverage: true,
  coverageDirectory: "./coverage",

  roots: ["<rootDir>/src"],
  testMatch: [
    "**/__tests__/**/*.+(ts|tsx|js)",
    "**/?(*.)+(spec|test).+(ts|tsx|js)"
  ],
  transform: {
    "^.+\\.(ts|tsx)$": "ts-jest"
  }
};

En la carpeta seleccionada almacenan los resultados.

Y comprobamos la cantidad de veces que se testa cada porción.

Y el porcentaje de cobertura. Recordad que esto es una herramienta y que los números nos pueden llegar a esclavizar. Si decimos “yo no hago código que tenga menos del 80% de cobertura” es posible que trabajemos solo para ese número probando cosas que no hay que probar.

Y podemos ver nuestro resultado, Obviamente animado queda mucho más chulo.

Os puedo decir que es hipnótico ver la progresión de las células en cada partida.

Bueno, espero que os haya gustado. Poner test unitarios en nuestra vida y trabajar desde el principio pensado en ello, como nos obliga TDD tiene efectos secundarios, desacoplar capas y hacer código verificable.

Si veis algún fallo o algo mejorable no dudéis en comentarlo.

Aquí os dejo el enlace al siguiente tutorial:

Configuración de Cucumber.js y Jest-Cucumber en Visual Studio Code con TypeScript

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad