🏆 Curso JavaScript · Nivel N5 Grado

Bloque 8: POO, patrones y proyecto final

Has aprendido a escribir código funcional; ahora aprenderás a organizarlo como un profesional. Verás clases y herencia, módulos ES para separar proyectos en archivos, patrones de diseño que resuelven problemas recurrentes, y tests que garantizan que tu código no se rompe. El bloque cierra con un proyecto final integrador que pone a prueba todo lo aprendido en los ocho bloques del curso.

⏱ ≈ 20 horas 📚 4 lecciones ✍️ Retos tipo entrevista técnica 📝 Examen global del curso
Lección 1 · Teoría

Clases, herencia y polimorfismo

Hasta ahora has usado objetos literales ({}) y funciones constructoras. Las clases son azúcar sintáctico sobre el mismo prototipo, pero con una sintaxis más clara y mejor soporte para herencia. Son el eje de la Programación Orientada a Objetos (POO).

1.1 · Definir una clase

una clase simple: Coche
class Coche {
  // el constructor se ejecuta al hacer new Coche()
  constructor(marca, modelo, anyo) {
    // propiedades públicas
    this.marca = marca;
    this.modelo = modelo;
    this.anyo = anyo;
    this.encendido = false;
  }

  // métodos: se definen sin function ni => 
  arrancar() {
    this.encendido = true;
    console.log(`${this.marca} ${this.modelo} arrancado`);
  }

  apagar() {
    this.encendido = false;
    console.log("Motor apagado");
  }

  info() {
    return `${this.anyo} ${this.marca} ${this.modelo} — ${this.encendido ? "encendido" : "apagado"}`;
  }
}

const miCoche = new Coche("Seat", "León", 2024);
miCoche.arrancar();  // Seat León arrancado
console.log(miCoche.info()); // 2024 Seat León — encendido
⚠️ Dentro de los métodos de clase, this se refiere a la instancia (el objeto creado). Fuera de la clase, su valor depende de cómo se llame al método.

1.2 · Encapsulación: campos privados

No todo debe ser visible desde fuera. Los campos privados se marcan con # y solo se accede desde dentro de la clase:

campo privado con #
class CuentaBancaria {
  #saldo; // privado: solo accesible dentro de la clase

  constructor(titular, saldoInicial) {
    this.titular = titular;
    this.#saldo = saldoInicial;
  }

  ingresar(cantidad) {
    if (cantidad <= 0) throw new Error("Cantidad no válida");
    this.#saldo += cantidad;
  }

  retirar(cantidad) {
    if (cantidad > this.#saldo) throw new Error("Fondos insuficientes");
    this.#saldo -= cantidad;
  }

  get saldo() { return this.#saldo; }
}

const cuenta = new CuentaBancaria("Ana", 1000);
cuenta.ingresar(500);
console.log(cuenta.saldo);   // 1500 (a través del getter)
// cuenta.#saldo = 999999;  ← SyntaxError: privado

1.3 · Herencia: extends y super

Una clase puede heredar de otra. La subclase tiene acceso a todos los métodos del padre y puede añadir o redefinir los suyos:

herencia: Vehiculo → CocheElectrico
class Vehiculo {
  constructor(marca, ruedas) {
    this.marca = marca;
    this.ruedas = ruedas;
  }

  descripcion() {
    return `${this.marca} con ${this.ruedas} ruedas`;
  }
}

class CocheElectrico extends Vehiculo {
  #bateria; // privado de la subclase

  constructor(marca, bateriaKwh) {
    super(marca, 4);           // llama al constructor del padre
    this.#bateria = bateriaKwh;
  }

  // sobrescribir un método del padre
  descripcion() {
    return `${super.descripcion()} · ${this.#bateria} kWh`;
  }

  autonomia() {
    return this.#bateria * 7; // ≈ 7 km por kWh
  }
}

const tesla = new CocheElectrico("Tesla", 75);
console.log(tesla.descripcion()); // Tesla con 4 ruedas · 75 kWh
console.log(tesla.autonomia());    // 525

1.4 · Polimorfismo

Distintas clases implementan el mismo método con comportamientos diferentes. El código que consume esos objetos no necesita saber de qué tipo son:

polimorfismo: el mismo método, distintos comportamientos
class Gato {
  hablar() { return "Miau"; }
}

class Perro {
  hablar() { return "Guau"; }
}

class Pato {
  hablar() { return "Cuac"; }
}

const animales = [new Gato(), new Perro(), new Pato()];

// cada animal responde a .hablar() a su manera
animales.forEach((a) => console.log(a.hablar()));
// → "Miau", "Guau", "Cuac"
💡 En JavaScript, el polimorfismo es natural: no necesitas declarar interfaces. Si un objeto tiene el método, funciona. Esto se llama duck typing: «si camina como pato y grazna como pato, es un pato».
🎉 Clases, privados, herencia y polimorfismo son los cuatro pilares de la POO en JavaScript. En la siguiente lección veremos cómo organizar estas clases en módulos ES para construir proyectos reales.
Lección 2 · Teoría

Módulos ES: organizar proyectos reales

Cuando un proyecto crece, todo en un solo archivo se vuelve inmanejable. Los módulos ES (ECMAScript Modules) permiten dividir el código en archivos independientes que se importan donde se necesitan.

2.1 · Estructura de un proyecto con módulos

estructura de carpetas
mi-tienda/
├── index.html
├── js/
│   ├── main.js          ← punto de entrada (type="module")
│   ├── Producto.js       ← clase Producto
│   ├── Carrito.js        ← clase Carrito (importa Producto)
│   └── utils.js          ← funciones auxiliares
└── css/
    └── styles.css

2.2 · Exportar y importar

Producto.js — exportar la clase
export class Producto {
  constructor(nombre, precio) {
    this.nombre = nombre;
    this.precio = precio;
  }

  formatear() {
    return `${this.nombre}: ${this.precio.toFixed(2)} €`;
  }
}
main.js — importar y usar
import { Producto } from "./Producto.js";
import { formatearEuro } from "./utils.js";

const p = new Producto("Auriculares", 49.99);
console.log(p.formatear());
// → "Auriculares: 49.99 €"

2.3 · Tipos de exportación

Formas de exportar en módulos ES
FormaSintaxisCuándo usarla
Named exportexport const fn = …Varias utilidades en un archivo (lo más habitual)
Default exportexport default class …Una exportación principal por archivo
Re-exportexport { X } from "./X.js"Agrupar varios módulos en un barrel file
default export — una por archivo
// utils.js
export default function formatearEuro(n) {
  return n.toFixed(2) + " €";
}

// main.js — importas el nombre que quieras
import formatear from "./utils.js";

2.4 · El atributo type="module"

Para que los módulos funcionen, el <script> debe incluir type="module". Esto activa el modo estricto automáticamente y aísla el alcance de cada archivo:

index.html
<!-- ¡sin type="module" no funcionan los imports! -->
<script type="module" src="js/main.js"></script>
⚠️ Los módulos ES no funcionan directamente abriendo file:// en el navegador. Necesitas un servidor local (live-server, VS Code Live Server, python -m http.server o similar).
🎉 Con módulos ES, cada clase vive en su archivo, los imports son declarativos y el código se mantiene limpio. En la siguiente lección veremos patrones de diseño: soluciones reutilizables a problemas recurrentes.
Lección 3 · Teoría

Patrones de diseño en JavaScript

Un patrón de diseño es una solución probada a un problema de diseño común. No son librerías niFrameworks: son ideas reutilizables. Veremos tres que se usan constantemente en JavaScript.

3.1 · Patrón Módulo (IIFE)

Antes de existir import/export, se usaba una IIFE (Immediately Invoked Function Expression) para encapsular código y exponer solo lo necesario:

patrón módulo con IIFE
const Contador = (() => {
  let cuenta = 0; // privado: no se puede acceder desde fuera

  return {              // solo exponemos lo que queremos
    incrementar() { cuenta++; },
    decrementar() { cuenta--; },
    valor()      { return cuenta; },
  };
})();

Contador.incrementar();
Contador.incrementar();
console.log(Contador.valor()); // 2
// Contador.cuenta → undefined (privado)
💡 Hoy en día, los módulos ES hacen este trabajo mejor. Pero el patrón IIFE sigue apareciendo en código legado y en entrevistas técnicas.

3.2 · Patrón Fábrica

Una fábrica es una función que devuelve objetos sin necesidad de new. Es ideal cuando la creación del objeto requiere lógica condicional o configuración:

patrón fábrica: crear usuarios
function crearUsuario(nombre, rol = "alumno") {
  if (!nombre) throw new Error("Nombre obligatorio");

  const permisos = {
    admin: ["leer", "escribir", "borrar"],
    profesor: ["leer", "escribir"],
    alumno: ["leer"],
  };

  return {
    nombre,
    rol,
    permisos: permisos[rol] || permisos.alumno,

    tienePermiso(accion) {
      return this.permisos.includes(accion);
    },

    toString() {
      return `${this.nombre} (${this.rol})`;
    },
  };
}

const admin = crearUsuario("Carlos", "admin");
console.log(admin.tienePermiso("borrar")); // true

const alumno = crearUsuario("Lucía");
console.log(alumno.tienePermiso("borrar")); // false

3.3 · Patrón Observador (pub/sub)

Un objeto (sujeto) notifica a una lista de suscriptores (observadores) cuando algo cambia. Es la base de los eventos del navegador y de frameworks como Vue o React ( state management).

patrón observador desde cero
class EventBus {
  #listeners = {};

  on(evento, callback) {
    if (!this.#listeners[evento])
      this.#listeners[evento] = [];
    this.#listeners[evento].push(callback);
  }

  off(evento, callback) {
    this.#listeners[evento] =
      this.#listeners[evento]?.filter((fn) => fn !== callback) || [];
  }

  emit(evento, datos) {
    this.#listeners[evento]?.forEach((fn) => fn(datos));
  }
}

// Uso
const bus = new EventBus();

bus.on("pedido", (item) =>
  console.log(`Añadido: ${item}`));

bus.on("pedido", (item) =>
  console.log(`Total actualizado`));

bus.emit("pedido", "Auriculares");
// → "Añadido: Auriculares"
// → "Total actualizado"
⚠️ El patrón Observador evita el acoplamiento directo: el emisor no sabe quién escucha, y los listeners no conocen al emisor. Esa flexibilidad es lo que lo hace tan potente.
🎉 Módulo, Fábrica y Observador son los tres patrones que más encontrarás en tu carrera. La siguiente lección cierra la teoría con testing: cómo asegurarte de que tu código funciona correctamente.
Lección 4 · Teoría

Testing: escribe código que no se rompe

Un test es un script que ejecuta tu código con datos de prueba y comprueba que el resultado es el esperado. Si algo cambia y se rompe, el test falla y lo sabes antes de llegar a producción.

4.1 · ¿Por qué testear?

Detectas bugs antes de que lleguen al usuario.
Refactorizas con confianza: si los tests pasan, no has roto nada.
Documentan el comportamiento esperado de tu código.
Facilitan el trabajo en equipo: el test define el contrato.

4.2 · Unit testing con Jest

Jest es el framework de testing más usado en JavaScript. Se instala con npm y ejecuta tests en Node.js.

instalación (una sola vez)
npm init -y
npm install --save-dev jest
package.json — añadir el script
{
  "scripts": {
    "test": "jest"
  }
}
utils.js — el código a testear
export function sumar(a, b) {
  return a + b;
}

export function capitalizar(str) {
  if (!str) return "";
  return str[0].toUpperCase() + str.slice(1);
}
utils.test.js — el test
import { sumar, capitalizar } from "./utils.js";

test("2 + 3 = 5", () => {
  expect(sumar(2, 3)).toBe(5);
});

test("capitaliza la primera letra", () => {
  expect(capitalizar("hola")).toBe("Hola");
});

test("string vacío devuelve vacío", () => {
  expect(capitalizar("")).toBe("");
});

4.3 · Matchers más usados

Matchers de Jest
MatcherComprueba que…Ejemplo
.toBe(x)igualdad estricta (===)expect(2+2).toBe(4)
.toEqual(obj)igualdad profunda de objetosexpect({a:1}).toEqual({a:1})
.toBeTruthy()que sea truthyexpect("ok").toBeTruthy()
.toContain(x)que un array contenga xexpect([1,2]).toContain(2)
.toThrow()que lance un errorexpect(() => f()).toThrow()
.toHaveLength(n)longitud de array/stringexpect("hi").toHaveLength(2)

4.4 · Describe: agrupar tests

agrupar tests relacionados
describe("capitalizar", () => {
  test("primera letra en mayúscula", () => {
    expect(capitalizar("hello")).toBe("Hello");
  });

  test("string vacío", () => {
    expect(capitalizar("")).toBe("");
  });

  test("ya está capitalizado", () => {
    expect(capitalizar("Hola")).toBe("Hola");
  });

  test("un solo carácter", () => {
    expect(capitalizar("a")).toBe("A");
  });
});
💡 Un buen test prueba un solo comportamiento por test(). Si un test falla, sabes exactamente qué se rompió.
🎉 Con clases, módulos, patrones y testing tienes las herramientas de un desarrollador JavaScript profesional. Los ejercicios del bloque te proponen retos tipo entrevista técnica para afianzar todo lo aprendido.
Manos a la obra

✍️ Retos tipo entrevista técnica

Crea una carpeta bloque8. Cada ejercicio es un archivo JavaScript (o HTML con <script type="module">). Estos retos son los que más aparecen en entrevistas de front-end: practícalos hasta que salgan fluidos.

Sección A · Clases y POO

1. bank-account.js — Cuenta bancaria

Crea una clase CuentaBancaria con campo privado #saldo, método ingresar(cantidad), retirar(cantidad) (que lance error si no hay fondos), y un getter saldo. Escribe al menos 3 tests con Jest.

  • No se puede acceder a #saldo desde fuera de la clase
  • ingresar(-5) lanza error "Cantidad no válida"
  • retirar(200) con saldo 100 lanza "Fondos insuficientes"
Ver solución
// bank-account.js
export class CuentaBancaria {
  #saldo;

  constructor(saldoInicial = 0) {
    if (saldoInicial < 0) throw new Error("Saldo inicial no válido");
    this.#saldo = saldoInicial;
  }

  get saldo() { return this.#saldo; }

  ingresar(cantidad) {
    if (cantidad <= 0) throw new Error("Cantidad no válida");
    this.#saldo += cantidad;
  }

  retirar(cantidad) {
    if (cantidad <= 0) throw new Error("Cantidad no válida");
    if (cantidad > this.#saldo) throw new Error("Fondos insuficientes");
    this.#saldo -= cantidad;
  }
}

// bank-account.test.js
import { CuentaBancaria } from "./bank-account.js";

test("saldo inicial", () => {
  expect(new CuentaBancaria(100).saldo).toBe(100);
});

test("ingresar incrementa saldo", () => {
  const c = new CuentaBancaria(100);
  c.ingresar(50);
  expect(c.saldo).toBe(150);
});

test("ingresar cantidad negativa lanza error", () => {
  expect(() => new CuentaBancaria(0).ingresar(-5)).toThrow("Cantidad no válida");
});

test("retirar sin fondos lanza error", () => {
  const c = new CuentaBancaria(100);
  expect(() => c.retirar(200)).toThrow("Fondos insuficientes");
});

test("retirar descuenta del saldo", () => {
  const c = new CuentaBancaria(200);
  c.retirar(70);
  expect(c.saldo).toBe(130);
});

2. vehicle-inheritance.js — Herencia de vehículos

Crea una clase base Vehiculo (marca, color, encender/apagar) y dos subclases: Coche (nº de puertas) y Moto (cilindrada). Ambas deben sobrescribir un método descripcion(). Demuestra polimorfismo con un array de vehículos mixtos.

Ver solución
class Vehiculo {
  #encendido = false;

  constructor(marca, color) {
    this.marca = marca;
    this.color = color;
  }

  get estaEncendido() { return this.#encendido; }
  encender() { this.#encendido = true; }
  apagar()  { this.#encendido = false; }

  descripcion() {
    return `${this.marca} ${this.color} — ${this.estaEncendido ? "encendido" : "apagado"}`;
  }
}

class Coche extends Vehiculo {
  constructor(marca, color, puertas) {
    super(marca, color);
    this.puertas = puertas;
  }

  descripcion() {
    return `${super.descripcion()} · ${this.puertas} puertas`;
  }
}

class Moto extends Vehiculo {
  constructor(marca, color, cc) {
    super(marca, color);
    this.cc = cc;
  }

  descripcion() {
    return `${super.descripcion()} · ${this.cc} cc`;
  }
}

// Polimorfismo
const flota = [
  new Coche("Seat", "rojo", 5),
  new Moto("Honda", "negra", 600),
  new Coche("Tesla", "blanco", 4),
];

flota.forEach(v => {
  v.encender();
  console.log(v.descripcion());
});

3. stack.js — Implementar una pila (Stack)

Implementa la estructura de datos Stack (pila) usando una clase: push(item), pop(), peek() (mirar el último sin sacarlo), isEmpty(), size (getter). Usa un array privado #items.

Ver solución
export class Stack {
  #items = [];

  push(item) { this.#items.push(item); }

  pop() {
    if (this.isEmpty()) throw new Error("Stack vacío");
    return this.#items.pop();
  }

  peek() {
    if (this.isEmpty()) throw new Error("Stack vacío");
    return this.#items[this.#items.length - 1];
  }

  isEmpty() { return this.#items.length === 0; }

  get size() { return this.#items.length; }
}

// test
const s = new Stack();
s.push(10);
s.push(20);
expect(s.peek()).toBe(20);
expect(s.pop()).toBe(20);
expect(s.size).toBe(1);
expect(s.isEmpty()).toBe(false);

Sección B · Módulos y patrones

4. event-bus.js — EventBus completo

Implementa la clase EventBus vista en teoría (con campos privados): on(evento, fn), off(evento, fn), emit(evento, datos). Escribe tests que comprueben que los listeners se ejecutan y que off los elimina correctamente.

Ver solución
export class EventBus {
  #listeners = {};

  on(evento, fn) {
    (this.#listeners[evento] ??= []).push(fn);
  }

  off(evento, fn) {
    this.#listeners[evento] =
      this.#listeners[evento]?.filter(f => f !== fn) ?? [];
  }

  emit(evento, datos) {
    this.#listeners[evento]?.forEach(fn => fn(datos));
  }
}

// tests
const bus = new EventBus();
let recibido = null;
const handler = (d) => { recibido = d; };

bus.on("test", handler);
bus.emit("test", 42);
expect(recibido).toBe(42);

bus.off("test", handler);
bus.emit("test", 99);
expect(recibido).toBe(42);  // no cambió: handler eliminado

5. product-factory.js — Patrón fábrica

Crea una función fábrica crearProducto(tipo, nombre, precio) que devuelva objetos con métodos info() y aplicarDescuento(pct). Para tipo "digital", añade propiedad descargable: true. Para tipo "fisico", añade peso (obligatorio como tercer argumento).

Ver solución
export function crearProducto(tipo, nombre, precio, extras = {}) {
  if (!["digital", "fisico"].includes(tipo))
    throw new Error(`Tipo "${tipo}" no soportado`);

  const producto = {
    tipo,
    nombre,
    precio,
    ...extras,

    aplicarDescuento(pct) {
      this.precio = +(this.precio * (1 - pct / 100)).toFixed(2);
    },

    info() {
      let base = `${this.nombre}: ${this.precio.toFixed(2)} € (${this.tipo})`;
      if (this.tipo === "digital") base += " [descargable]";
      if (this.tipo === "fisico") base += ` [${this.peso} kg]`;
      return base;
    },
  };

  if (tipo === "fisico" && extras.peso == null)
    throw new Error("Peso obligatorio para productos físicos");

  return producto;
}

// test
const e = crearProducto("digital", "Ebook", 9.99);
expect(e.descargable).toBe(true);
e.aplicarDescuento(20);
expect(e.precio).toBe(7.99);

const f = crearProducto("fisico", "Libro", 19.99, { peso: 0.4 });
expect(f.peso).toBe(0.4);

6. shopping-cart.js — Carrito con Patrón Observador

Crea una clase Carrito que use internamente un EventBus para notificar cuando se añade ("añadido") o elimina ("eliminado") un producto. Métodos: agregar(producto), eliminar(nombre), vaciar(), getter total.

Ver solución
import { EventBus } from "./event-bus.js";

export class Carrito {
  #items = [];
  #bus = new EventBus();

  on(evento, fn) { this.#bus.on(evento, fn); }
  off(evento, fn) { this.#bus.off(evento, fn); }

  agregar(producto) {
    this.#items.push(producto);
    this.#bus.emit("añadido", producto);
  }

  eliminar(nombre) {
    const idx = this.#items.findIndex(p => p.nombre === nombre);
    if (idx === -1) return null;
    const [elim] = this.#items.splice(idx, 1);
    this.#bus.emit("eliminado", elim);
    return elim;
  }

  vaciar() { this.#items = []; }

  get total() {
    return +this.#items.reduce((s, p) => s + p.precio, 0).toFixed(2);
  }

  get items() { return [...this.#items]; }
}

// test
const carrito = new Carrito();
let avisos = 0;
carrito.on("añadido", () => avisos++);

carrito.agregar({ nombre: "Camiseta", precio: 20 });
carrito.agregar({ nombre: "Gorra", precio: 15 });
expect(avisos).toBe(2);
expect(carrito.total).toBe(35);

carrito.eliminar("Camiseta");
expect(carrito.total).toBe(15);
expect(carrito.items.length).toBe(1);

Sección C · Retos de entrevista

7. flatten.js — Aplanar arrays anidados

Escribe una función aplanar(arr) que tome un array con cualquier nivel de anidamiento y devuelva uno plano. Sin Array.flat() (solo para practicar recursividad).

  • aplanar([1, [2, [3, [4]]]]) → [1, 2, 3, 4]
  • aplanar([]) → []
  • No usar flat ni flatMap
Ver solución
export function aplanar(arr) {
  const resultado = [];
  for (const item of arr) {
    if (Array.isArray(item)) {
      resultado.push(...aplanar(item));
    } else {
      resultado.push(item);
    }
  }
  return resultado;
}

// tests
test("aplanar arrays anidados", () => {
  expect(aplanar([1, [2, [3, [4]]]])).toEqual([1, 2, 3, 4]);
});

test("array vacío", () => {
  expect(aplanar([])).toEqual([]);
});

test("array ya plano", () => {
  expect(aplanar([1, 2, 3])).toEqual([1, 2, 3]);
});

8. debounce.js — Implementar debounce

Escribe una función debounce(fn, delay) que devuelva una nueva función: solo ejecuta fn cuando pasan delay ms sin invocarla. Muy usada en buscadores (no pedir a la API en cada tecla).

Ver solución
export function debounce(fn, delay) {
  let timerId = null;

  return function (...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn.apply(this, args), delay);
  };
}

// test (usando fake timers de Jest)
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

test("no ejecuta hasta que pasa el delay", () => {
  const fn = jest.fn();
  const debounced = debounce(fn, 300);

  debounced();
  debounced();
  debounced();

  expect(fn).not.toHaveBeenCalled();

  jest.advanceTimersByTime(300);
  expect(fn).toHaveBeenCalledTimes(1);
});

9. deep-clone.js — Clonar objetos profundos

Escribe una función deepClone(obj) que haga una copia profunda de un objeto (incluidos objetos anidados y arrays). Sin structuredClone (para practicar).

  • deepClone({ a: { b: 1 } }) debe devolver un objeto nuevo con a.b === 1
  • El clon no debe compartir referencias con el original
  • Manejar arrays y objetos anidados
Ver solución
export function deepClone(obj) {
  if (obj === null || typeof obj !== "object") return obj;
  if (Array.isArray(obj)) return obj.map(item => deepClone(item));

  const clon = {};
  for (const key of Object.keys(obj)) {
    clon[key] = deepClone(obj[key]);
  }
  return clon;
}

// tests
test("clon profundo de objetos anidados", () => {
  const original = { a: { b: { c: 42 } }, d: [1, [2, 3]] };
  const clon = deepClone(original);

  expect(clon).toEqual(original);
  expect(clon).not.toBe(original);
  expect(clon.a).not.toBe(original.a);
  expect(clon.a.b).not.toBe(original.a.b);
  expect(clon.d).not.toBe(original.d);
});

test("primitivos se devuelven tal cual", () => {
  expect(deepClone(42)).toBe(42);
  expect(deepClone("hola")).toBe("hola");
  expect(deepClone(null)).toBe(null);
});

10. 🏆 RETO INTEGRADOR — task-manager

Construye un gestor de tareas completo usando todo lo aprendido en el bloque. Debe estar dividido en módulos y pasar tests.

  • Task.js: clase Task con id, título, completada y prioridad ("baja", "media", "alta").
  • TaskManager.js: clase con un array privado de tareas y métodos agregar(titulo, prioridad), completar(id), eliminar(id), filtrarPorPrioridad(p), buscarPorTitulo(texto).
  • EventBus integrado: emite "tarea-creada", "tarea-completada", "tarea-eliminada".
  • Tests: al menos 8 tests cubriendo los métodos principales.
  • Bonus: método exportarJSON() que devuelva las tareas como string JSON.
Ver una solución posible
// Task.js
let nextId = 1;

export class Task {
  #completada = false;

  constructor(titulo, prioridad = "media") {
    if (!titulo) throw new Error("Título obligatorio");
    if (!["baja", "media", "alta"].includes(prioridad))
      throw new Error("Prioridad no válida");

    this.id = nextId++;
    this.titulo = titulo;
    this.prioridad = prioridad;
  }

  get completada() { return this.#completada; }
  completar() { this.#completada = true; }

  toJSON() {
    return {
      id: this.id,
      titulo: this.titulo,
      prioridad: this.prioridad,
      completada: this.completada,
    };
  }
}

// TaskManager.js
import { Task } from "./Task.js";
import { EventBus } from "./event-bus.js";

export class TaskManager {
  #tareas = [];
  #bus = new EventBus();

  on(evt, fn) { this.#bus.on(evt, fn); }
  off(evt, fn) { this.#bus.off(evt, fn); }

  agregar(titulo, prioridad) {
    const tarea = new Task(titulo, prioridad);
    this.#tareas.push(tarea);
    this.#bus.emit("tarea-creada", tarea);
    return tarea;
  }

  completar(id) {
    const tarea = this.#tareas.find(t => t.id === id);
    if (!tarea) throw new Error("Tarea no encontrada");
    tarea.completar();
    this.#bus.emit("tarea-completada", tarea);
    return tarea;
  }

  eliminar(id) {
    const idx = this.#tareas.findIndex(t => t.id === id);
    if (idx === -1) throw new Error("Tarea no encontrada");
    const [elim] = this.#tareas.splice(idx, 1);
    this.#bus.emit("tarea-eliminada", elim);
    return elim;
  }

  filtrarPorPrioridad(p) {
    return this.#tareas.filter(t => t.prioridad === p);
  }

  buscarPorTitulo(texto) {
    const min = texto.toLowerCase();
    return this.#tareas.filter(t =>
      t.titulo.toLowerCase().includes(min));
  }

  get tareas() { return [...this.#tareas]; }

  exportarJSON() {
    return JSON.stringify(
      this.#tareas.map(t => t.toJSON()), null, 2);
  }
}

// task-manager.test.js
import { TaskManager } from "./TaskManager.js";

let mgr;
beforeEach(() => { mgr = new TaskManager(); });

test("agregar tarea", () => {
  const t = mgr.agregar("Estudiar JS", "alta");
  expect(t.titulo).toBe("Estudiar JS");
  expect(t.prioridad).toBe("alta");
  expect(mgr.tareas.length).toBe(1);
});

test("completar tarea", () => {
  const t = mgr.agregar("Hacer ejercicio");
  mgr.completar(t.id);
  expect(t.completada).toBe(true);
});

test("eliminar tarea", () => {
  const t = mgr.agregar("Tarea temporal");
  mgr.eliminar(t.id);
  expect(mgr.tareas.length).toBe(0);
});

test("filtrar por prioridad", () => {
  mgr.agregar("A", "baja");
  mgr.agregar("B", "alta");
  mgr.agregar("C", "alta");
  expect(mgr.filtrarPorPrioridad("alta").length).toBe(2);
});

test("buscar por título", () => {
  mgr.agregar("Estudiar CSS");
  mgr.agregar("Estudiar JS");
  mgr.agregar("Comprar pan");
  expect(mgr.buscarPorTitulo("estudiar").length).toBe(2);
});

test("completar tarea inexistente lanza error", () => {
  expect(() => mgr.completar(999)).toThrow("Tarea no encontrada");
});

test("emite evento al agregar", () => {
  let emitido = null;
  mgr.on("tarea-creada", (t) => { emitido = t; });
  const t = mgr.agregar("Nueva");
  expect(emitido).toBe(t);
});

test("exportarJSON devuelve string válido", () => {
  mgr.agregar("Test");
  const json = mgr.exportarJSON();
  expect(typeof json).toBe("string");
  const arr = JSON.parse(json);
  expect(arr.length).toBe(1);
});
Evaluación

📝 Examen global del curso

Este examen cubre los ocho bloques del curso de JavaScript. 20 preguntas, una sola respuesta correcta por pregunta. Necesitas 70 % (14/20) para superarlo. Supéralo y habrás completado el itinerario completo de JavaScript de Técnico Web.

1 · ¿Cuál es la diferencia entre let y const?
2 · typeof null devuelve…
3 · ¿Qué devuelve "5" == 5?
4 · Un switch compara con…
5 · ¿Qué es un closure?
6 · Una arrow function…
7 · ¿Qué devuelve [1,2,3].map(x => x * 2)?
8 · Destructuring: const { a, b } = { a: 1, b: 2, c: 3 }. ¿Qué vale c?
9 · reduce se usa para…
10 · ¿Qué hace filter()?
11 · querySelector(".clase") devuelve…
12 · event.preventDefault() sirve para…
13 · localStorage guarda datos que…
14 · setTimeout(…, 0) se ejecuta…
15 · fetch(url) devuelve…
16 · async/await es…
17 · En una clase JS, un campo marcado con # es…
18 · super en el constructor de una subclase…
19 · El patrón fábrica se usa para…
20 · Un test unitario comprueba…

🧪 Parte B · Laboratorio: proyecto final integrador

Aplica todo lo aprendido en los ocho bloques para construir un gestor de tareas completo con interfaz web. Abre el laboratorio: