03
Proceso y requisitos

Requisitos e historias de usuario

Proceso y requisitos

Semana 3 — Requisitos e historias de usuario

Objetivos

  • Distinguir requisitos funcionales y no funcionales, y separar el problema del cliente de la solución que propone.
  • Preparar y conducir una entrevista con un cliente: preguntas abiertas, pedir ejemplos concretos, no inducir respuestas y tomar notas que se puedan citar.
  • Escribir historias de usuario con criterios de aceptación verificables en la forma dado, cuando, entonces, y revisarlas con INVEST.
  • Dividir historias grandes y priorizar un backlog con MoSCoW y por valor y riesgo.
  • Usar una herramienta de IA para encontrar huecos en los requisitos sin dejar que invente lo que el cliente no dijo.

Sesión A — Requisitos: elicitación y entrevistas

Lecturas:

Lecturas complementarias:

  • Nielsen Norman Group, Field Studies: observar el trabajo donde ocurre.
  • Nielsen Norman Group, Paper Prototyping: probar una idea antes de escribir código.

Temas:

  • Un requisito es una condición que el sistema tiene que cumplir para que el cliente resuelva su problema. Funcional: lo que el sistema hace (“se puede cancelar una reserva hasta 3 horas antes”). No funcional: una cualidad medible de cómo lo hace (“la disponibilidad carga en menos de un segundo en un celular con 4G”, “la página se puede usar solo con teclado”). Un requisito no funcional sin número (“rápido”, “fácil”) todavía no es un requisito.
  • Problema frente a solución. El cliente suele llegar con una solución (“quiero un botón para bloquear la cancha”); el trabajo es encontrar el problema (“los martes en la tarde viene el técnico de las luces y alguien ya reservó”). Con el problema claro aparecen otras soluciones, a veces más simples.
  • Elicitación: entrevistas, observación del trabajo real (cómo cobra hoy la caja de Kallpa, qué anota en su cuaderno), revisión de documentos (el reglamento del complejo) y prototipos. Cada técnica encuentra cosas distintas: en una entrevista la gente cuenta lo que cree que hace; observando se ve lo que hace.
  • Preparar la entrevista: un objetivo (“entender cómo se reparten hoy las horas fijas de los equipos”), una guía de preguntas de lo general a lo particular y roles en el equipo (quien pregunta, quien anota, quien controla el tiempo). Se entrevista a varias personas con intereses distintos: la administración, un vecino que reserva, la persona de la caja.
  • Durante la entrevista: preguntas abiertas (“¿cómo fue la última vez que…?”) antes que cerradas (“¿les sirve…?”). Pedir el último caso concreto en lugar de opiniones sobre el futuro. Repetir con sus palabras lo que entendieron. Error común: hacer preguntas que inducen la respuesta (“¿no sería mejor que pudieran pagar por Yape?”). Casi todos dicen que sí a una idea que les presentan, y eso no prueba que la vayan a usar.
  • Después de la entrevista: notas el mismo día, separando lo que el cliente dijo (con sus palabras), lo que el equipo supone y lo que falta preguntar. Un glosario con las palabras del cliente (“turno”, “socio”, “la caja”) evita que el equipo use otras.
  • Con IA. Un modelo de lenguaje puede proponer preguntas que el equipo no pensó o resumir una transcripción, si el cliente aceptó que se grabe. Lo que no puede hacer es saber qué quiere este cliente: si le piden requisitos, los inventa con mucha seguridad. Todo requisito tiene que poder rastrearse hasta algo que el cliente dijo o aprobó.

Sesión B — Historias de usuario, criterios de aceptación y priorización

Lecturas:

Lecturas complementarias:

Temas:

  • Una historia de usuario es una promesa de conversación, no una especificación completa: “Como vecino socio, quiero ver cuánto voy a pagar antes de confirmar, para no llevarme sorpresas en la caja”. Las tres partes (quién, qué, para qué) obligan a decir quién gana y por qué. Las tres C de Ron Jeffries: card (la tarjeta), conversation (la conversación con el cliente) y confirmation (los criterios de aceptación).
  • Criterios de aceptación: condiciones concretas que se pueden comprobar con un sí o un no. En la forma dado (el estado inicial), cuando (la acción), entonces (el resultado observable). “Dado que hoy es lunes 9 de marzo a las 10:00, cuando Rosa cancela su reserva del martes 10 a las 9:00, entonces la respuesta es 200 y la hora vuelve a estar libre”. Los issues de P0 están escritos así: úsenlos de modelo.
  • Error común: criterios que no se pueden verificar (“la reserva es fácil de hacer”, “el sistema responde bien”) o que describen la implementación (“se agrega una columna recurring a la tabla”) en lugar del comportamiento.
  • INVEST: una buena historia es Independiente, Negociable, Valiosa para alguien, Estimable, Pequeña (cabe en un sprint) y Testeable. No es una ley: es una lista para revisar una historia antes de llevarla a un sprint.
  • Dividir historias: una épica como “torneos” no cabe en un sprint. Con SPIDR se divide por spikes (investigar primero), por caminos (el caso feliz primero, los errores después), por interfaces, por datos (solo fulbito, luego los demás deportes) o por reglas (sin descuento de socio, luego con descuento). Error común: dividir por capas técnicas (“la tabla”, “el endpoint”, “la pantalla”): ninguna de esas partes le sirve sola al cliente.
  • Priorizar. MoSCoW: Must (sin esto no hay producto), Should (importante, pero hay forma de seguir sin él), Could (si hay tiempo) y Won’t (esta vez no, dicho de forma explícita). Además: valor para el cliente frente a costo estimado, y riesgo (lo más incierto se hace temprano, para aprender rápido). El product owner ordena el backlog; el equipo informa los costos y los riesgos.
  • Requisitos no funcionales como criterios: “dado un celular con la red 4G simulada en el navegador, cuando se abre la disponibilidad, entonces aparece en menos de 1 s”. También pueden ir en la Definition of Done, si valen para todas las historias.
  • Con IA. Pidan a una herramienta que revise sus historias: ambigüedades, criterios que no se pueden verificar, casos que faltan (¿y si la fecha es feriado?, ¿y si la cancha no existe?). Cada caso nuevo que proponga es una pregunta para el cliente, no un requisito.

Laboratorio — Entrevista simulada y backlog de una funcionalidad de Kallpa

Guía: este documento. Referencias: los issues de P0 como modelo de historias con criterios de aceptación; SPIDR.

El pedido llega así, en un mensaje de Doris Chumpitaz, de la administración del complejo: “Los equipos de siempre quieren tener su hora fija todas las semanas, como la liga de vóley de los jueves. ¿Se puede hacer en la página?”. El docente hace de Doris, con una tarjeta de personaje que los grupos no leen: lo que Doris sabe solo sale si se pregunta bien.

  1. Preparar (20 min). En grupos de 4 o 5, escribir el objetivo de la entrevista y una guía de 10 a 12 preguntas abiertas, de lo general a lo particular. Repartir roles: quien pregunta, dos que anotan con las palabras de Doris y quien controla el tiempo. Revisar la guía de otro grupo y marcar las preguntas que inducen la respuesta.
  2. Entrevistar (30 min, con toda la clase). Doris tiene poco tiempo, como un cliente real: hay una sola entrevista y los grupos preguntan por turnos, una pregunta por grupo en cada vuelta, sin repetir lo que ya se respondió. Todos anotan todas las respuestas. Pidan casos concretos (“¿qué pasó la última vez que…?”) y repitan lo que entendieron. Anoten también lo que Doris no sabe y a quién habría que preguntarle.
  3. Ordenar las notas (15 min). Tres listas: lo que Doris dijo, lo que el grupo supone y las preguntas abiertas. Un glosario con sus palabras (por ejemplo, qué quiere decir para ella turno fijo y temporada).
  4. Escribir las historias (30 min). De 6 a 10 historias “Como…, quiero…, para…”, cada una con 2 a 4 criterios dado, cuando, entonces que usen datos de Kallpa (canchas, miembros y códigos de error de su README). Al menos un requisito no funcional con número.
  5. Revisar con INVEST. Intercambiar con otro grupo. Marcar en cada historia de la otra la letra de INVEST que no cumple y un criterio que no se pueda verificar. Dividir con SPIDR la historia más grande.
  6. Priorizar. Clasificar las historias con MoSCoW y elegir las que entrarían en una primera entrega de una semana. Dibujar en papel la pantalla de esa primera entrega.
  7. Validar (15 min, con toda la clase). Cada grupo le lee a Doris un criterio de aceptación y muestra su prototipo en papel; los demás anotan lo que ella corrige, porque también vale para sus historias. Si una herramienta de IA les sugirió casos, pregúntenle por ellos aquí.
  8. Crear las historias como issues en el tablero de la semana 2, con la etiqueta turnos-fijos, sus puntos y la prioridad. No hay que implementarlas.

Entregas

  • P0 entrega: el pull request del issue elegido, revisado y con make check ISSUE=NN en verde (projects/p0-primer-aporte/).
  • P1 publicado: Requisitos: cliente, entrevistas, historias de usuario con criterios de aceptación, backlog priorizado y prototipo (projects/p1-requisitos/). Entrega en semana 5. Esta semana cada equipo propone a su cliente.

Preguntas de repaso

  1. Den un requisito funcional y uno no funcional para Kallpa. Reescriban el no funcional para que se pueda verificar.
  2. Doris pide “un botón para bloquear la cancha”. ¿Qué preguntas harían para encontrar el problema detrás del pedido?
  3. ¿Por qué “¿les gustaría poder pagar por Yape?” es una mala pregunta de entrevista? Reescríbanla.
  4. Escriban dos criterios dado, cuando, entonces para “Como socio, quiero ver el precio con mi descuento antes de reservar”. ¿Qué letra de INVEST cumple peor esta historia?
  5. Una herramienta de IA les devuelve 20 historias para el proyecto de su cliente a partir de una descripción de dos líneas. ¿Qué hacen con ellas?

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (3)Pruebas (2)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

historias-con-problemas.mdDescargar historias-con-problemas.md
# Mantenimiento de canchas: un primer borrador con problemas

## HU-01 · Botón de bloqueo

Como usuario, quiero un botón para bloquear la cancha para bloquearla.

- **Dado** que soy administrador, **cuando** bloqueo la cancha, **entonces**
  el sistema responde rápido y bien.
- **Dado** un bloqueo nuevo, **cuando** se guarda, **entonces** se agrega una
  fila a la tabla `blocks` con la columna `reason`.

## HU-02 · Avisar a los vecinos

Como administradora del complejo, quiero que los vecinos se enteren de los
mantenimientos para que no vayan en vano.

## HU-03 · Mantenimiento que choca con una reserva

Como administradora del complejo, quiero ver las reservas con las que choca un
mantenimiento para avisar a los equipos.

- **Dado** que hay una reserva el martes 10 a las 16:00, **cuando** separo
  la cancha de 15:00 a 17:00, la reserva aparece en la lista.
historias-ejemplo.mdDescargar historias-ejemplo.md
# Mantenimiento de canchas y precio: historias de usuario

HU-01 y HU-02 salieron de la entrevista con Doris Chumpitaz (administración)
del 2026-03-09 sobre su pedido de "un botón para bloquear la cancha". HU-03 es
la historia de ejemplo de la guía de la semana 3 sobre el precio. Hoy es lunes
9 de marzo de 2026 a las 10:00 en todos los criterios.

## HU-01 · Separar una cancha para mantenimiento

Como administradora del complejo, quiero separar una cancha para
mantenimiento en un horario para que ningún vecino la reserve mientras
trabaja el técnico.

**Estimación:** 3 puntos

- **Dado** que la cancha de vóley está libre el martes 10 de 16:00 a 18:00,
  **cuando** Doris la separa para mantenimiento en ese horario, **entonces**
  la disponibilidad muestra las 16:00 y las 17:00 ocupadas con el texto
  "Mantenimiento".
- **Dado** ese mantenimiento, **cuando** Rosa intenta reservar la cancha de
  vóley el martes 10 a las 17:00, **entonces** la respuesta es `409` con el
  código `SLOT_TAKEN`.
- **Dado** ese mantenimiento, **cuando** Rosa reserva la cancha de vóley el
  martes 10 a las 18:00, **entonces** la reserva se confirma.

## HU-02 · Ver con qué reservas choca un mantenimiento

Como administradora del complejo, quiero ver qué reservas chocan con un
mantenimiento antes de separar la cancha para avisar a esos equipos con
tiempo.

**Estimación:** 2 puntos

- **Dado** que "Las Matadoras" tienen la cancha de vóley el martes 10 de
  16:00 a 18:00, **cuando** Doris pide separarla de 15:00 a 17:00,
  **entonces** ve esa reserva con el nombre y el celular de quien reservó, y
  el mantenimiento no se crea hasta que ella lo confirme.
- **Dado** que la cancha de vóley no tiene reservas el martes 10,
  **cuando** Doris pide separarla de 15:00 a 17:00, **entonces** el
  mantenimiento se crea sin preguntar nada.

## HU-03 · Saber el precio antes de confirmar

Como vecino que reserva, quiero ver cuánto voy a pagar antes de confirmar
para no llevarme sorpresas en la caja.

**Estimación:** 2 puntos

- **Dado** que Rosa es regular, **cuando** elige fulbito el martes 10 a las
  10:00 por una hora, **entonces** ve "S/ 90.00" antes de confirmar.
- **Dado** que Luis es socio, **cuando** elige vóley el miércoles 11 a las
  10:00 por una hora, **entonces** ve "S/ 45.00" antes de confirmar.
- **Dado** que Rosa ve "S/ 112.50" para el sábado 14 a las 10:00, **cuando**
  confirma, **entonces** la reserva queda con `priceCents` igual a `11250`.
revisar-historias.tsDescargar revisar-historias.ts
// Un revisor de historias de usuario escritas en Markdown, con el formato de P1:
//
//   ## HU-01 · Título corto
//   Como <rol>, quiero <acción> para <beneficio>.
//   - **Dado** ..., **cuando** ..., **entonces** ...
//
// Revisa lo que un programa puede revisar: que esté la frase, que el rol no sea
// genérico, que cada criterio tenga sus tres partes, que no use palabras vagas
// ni hable de la implementación y que tenga algún dato concreto. No sabe si la
// historia le sirve al cliente: eso lo decide una persona que estuvo en la
// entrevista.
//
// Uso: node revisar-historias.ts historias.md

import { readFileSync } from 'node:fs';

export type Finding = { story: string; criterion: number | null; message: string };

export type Review = { stories: number; criteria: number; findings: Finding[] };

// Palabras que no se pueden comprobar con un sí o un no.
const VAGUE_WORDS = [
  'rápido', 'rápida', 'rápidamente', 'lento', 'fácil', 'fácilmente', 'sencillo', 'sencilla',
  'intuitivo', 'intuitiva', 'amigable', 'bien', 'adecuado', 'adecuada', 'adecuadamente',
  'correctamente', 'eficiente', 'moderno', 'moderna', 'cómodo', 'cómoda',
];

// Palabras que describen cómo se construye y no qué observa el cliente.
const IMPLEMENTATION_WORDS = ['tabla', 'columna', 'sql', 'función', 'método', 'clase', 'variable', 'endpoint', 'campo'];

// Roles que no dicen quién gana con la historia.
const GENERIC_ROLES = ['usuario', 'el usuario', 'un usuario', 'el sistema', 'persona', 'alguien'];

const STORY_SENTENCE = /\bcomo\s+(.+?),\s*quiero\s+(.+?)\s+para\s+\S/is;

// Separa el texto en palabras: letras de cualquier idioma, sin puntuación.
function words(text: string): string[] {
  return text.toLowerCase().match(/[\p{L}]+/gu) ?? [];
}

// Las secciones `## HU-NN ...` con su texto, hasta la siguiente `##`.
function sections(markdown: string): { title: string; body: string }[] {
  const result: { title: string; body: string }[] = [];
  let current: { title: string; body: string } | null = null;
  for (const line of markdown.split('\n')) {
    const heading = /^##\s+(.*)$/.exec(line);
    if (heading) {
      current = /^HU-\d+/.test(heading[1]) ? { title: heading[1], body: '' } : null;
      if (current) result.push(current);
    } else if (current) {
      current.body += line + '\n';
    }
  }
  return result;
}

// Los ítems de lista que empiezan con "Dado", con sus líneas de continuación.
function criteriaOf(body: string): string[] {
  const items: string[] = [];
  let current: string | null = null;
  for (const line of body.split('\n')) {
    if (/^[-*]\s+/.test(line)) {
      current = /^[-*]\s+(\*\*)?dado\b/i.test(line) ? line.replace(/^[-*]\s+/, '') : null;
      if (current !== null) items.push(current);
    } else if (current !== null && /^\s+\S/.test(line)) {
      items[items.length - 1] += ' ' + line.trim();
    } else {
      current = null;
    }
  }
  return items.map((item) => item.replaceAll('**', ''));
}

export function reviewStories(markdown: string): Review {
  const findings: Finding[] = [];
  let criteriaCount = 0;
  const found = sections(markdown);
  for (const { title, body } of found) {
    const story = title.split(/\s/)[0];
    const add = (criterion: number | null, message: string) => findings.push({ story, criterion, message });

    const sentence = STORY_SENTENCE.exec(body);
    if (!sentence) {
      add(null, 'falta la frase "Como …, quiero … para …"');
    } else if (GENERIC_ROLES.includes(sentence[1].trim().toLowerCase())) {
      add(null, `el rol es genérico: «${sentence[1].trim()}»`);
    }

    const criteria = criteriaOf(body);
    criteriaCount += criteria.length;
    if (criteria.length === 0) add(null, 'no tiene criterios de aceptación');

    criteria.forEach((text, i) => {
      const n = i + 1;
      const ws = words(text);
      for (const part of ['cuando', 'entonces']) {
        if (!ws.includes(part)) add(n, `le falta el "${part}"`);
      }
      for (const w of VAGUE_WORDS) {
        if (ws.includes(w)) add(n, `palabra vaga «${w}»: ¿cómo se comprueba?`);
      }
      for (const w of IMPLEMENTATION_WORDS) {
        if (ws.includes(w)) add(n, `habla de la implementación («${w}»), no del comportamiento`);
      }
      if (!/\d/.test(text)) add(n, 'no tiene datos concretos: ni una hora, ni una fecha, ni un monto');
    });
  }
  return { stories: found.length, criteria: criteriaCount, findings };
}

export function formatReview(review: Review): string {
  const lines = review.findings.map((f) => `${f.story}${f.criterion === null ? '' : ` · criterio ${f.criterion}`}: ${f.message}`);
  lines.push(`${review.stories} historias, ${review.criteria} criterios, ${review.findings.length} observaciones`);
  return lines.join('\n');
}

if (import.meta.main) {
  const file = process.argv[2];
  if (!file) {
    console.error('Uso: node revisar-historias.ts historias.md');
    process.exit(2);
  }
  const review = reviewStories(readFileSync(file, 'utf8'));
  console.log(formatReview(review));
  process.exitCode = review.findings.length === 0 ? 0 : 1;
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

criterios.test.tsDescargar criterios.test.ts
// Los criterios de aceptación del capítulo, escritos como pruebas contra Kallpa.
// Cada `describe` es una historia y cada `test` es un criterio dado/cuando/entonces.
// El servidor de prueba (build/kallpa/test/helpers.ts) tiene el reloj fijo en el
// lunes 9 de marzo de 2026 a las 10:00 de Lima, las cuatro canchas y los diez
// vecinos de ejemplo, y ninguna reserva.
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, test } from 'node:test';
import { getJson, postJson, startTestServer, type TestServer } from './build/kallpa/test/helpers.ts';

const ROSA = 1; // regular
const LUIS = 2; // socio
const CARMEN = 3; // regular
const FULBITO_1 = 1;
const VOLEY = 3;

let server: TestServer;

beforeEach(async () => {
  server = await startTestServer();
});

afterEach(async () => {
  await server.close();
});

type Request = { courtId: number; memberId: number; date: string; startHour: number; hours?: number; teamName?: string };

function book(request: Request) {
  return postJson(`${server.url}/api/bookings`, { hours: 1, teamName: 'Los Pumas de Comas', ...request });
}

async function slot(courtId: number, date: string, hour: number) {
  const { body } = await getJson(`${server.url}/api/courts/${courtId}/availability?date=${date}`);
  return body.slots.find((s: { hour: number }) => s.hour === hour);
}

describe('Como vecina, quiero cancelar mi reserva para que otro equipo pueda usar la hora', () => {
  test('dado que hoy es lunes 9 a las 10:00, cuando Rosa cancela su reserva del martes 10 a las 9:00, entonces la respuesta es 200 y la hora vuelve a estar libre', async () => {
    const created = await book({ courtId: FULBITO_1, memberId: ROSA, date: '2026-03-10', startHour: 9 });
    assert.equal(created.status, 200);
    assert.equal((await slot(FULBITO_1, '2026-03-10', 9)).available, false);

    const cancelled = await postJson(`${server.url}/api/bookings/${created.body.booking.id}/cancel`);
    assert.equal(cancelled.status, 200);
    assert.equal(cancelled.body.booking.status, 'cancelled');
    assert.equal((await slot(FULBITO_1, '2026-03-10', 9)).available, true);
  });

  test('dado que hoy es lunes 9 a las 10:00, cuando Rosa cancela su reserva de hoy a las 12:00, entonces la respuesta es 422 TOO_LATE_TO_CANCEL y la reserva sigue confirmada', async () => {
    const created = await book({ courtId: FULBITO_1, memberId: ROSA, date: '2026-03-09', startHour: 12 });
    assert.equal(created.status, 200);

    const cancelled = await postJson(`${server.url}/api/bookings/${created.body.booking.id}/cancel`);
    assert.equal(cancelled.status, 422);
    assert.equal(cancelled.body.error.code, 'TOO_LATE_TO_CANCEL');
    assert.equal((await slot(FULBITO_1, '2026-03-09', 12)).available, false);
  });
});

describe('Como delegado de un equipo, quiero reservar una cancha libre para asegurar dónde jugamos', () => {
  test('dado que Luis tiene la cancha 1 el martes 10 de 19:00 a 21:00, cuando Carmen reserva a las 20:00, entonces la respuesta es 409 SLOT_TAKEN', async () => {
    assert.equal((await book({ courtId: FULBITO_1, memberId: LUIS, date: '2026-03-10', startHour: 19, hours: 2, teamName: 'Real Barranco' })).status, 200);
    const taken = await book({ courtId: FULBITO_1, memberId: CARMEN, date: '2026-03-10', startHour: 20 });
    assert.equal(taken.status, 409);
    assert.equal(taken.body.error.code, 'SLOT_TAKEN');
  });

  test('dado lo mismo, cuando Carmen reserva a las 21:00, entonces la respuesta es 200', async () => {
    assert.equal((await book({ courtId: FULBITO_1, memberId: LUIS, date: '2026-03-10', startHour: 19, hours: 2, teamName: 'Real Barranco' })).status, 200);
    const next = await book({ courtId: FULBITO_1, memberId: CARMEN, date: '2026-03-10', startHour: 21 });
    assert.equal(next.status, 200);
  });

  test('dado que hoy es lunes 9 a las 10:00, cuando Rosa reserva hoy a las 10:00, entonces la respuesta es 422 IN_THE_PAST', async () => {
    const started = await book({ courtId: FULBITO_1, memberId: ROSA, date: '2026-03-09', startHour: 10 });
    assert.equal(started.status, 422);
    assert.equal(started.body.error.code, 'IN_THE_PAST');
  });

  test('dado que hoy es lunes 9, cuando Rosa reserva el lunes 23 (14 días después), entonces la respuesta es 200', async () => {
    const inWindow = await book({ courtId: FULBITO_1, memberId: ROSA, date: '2026-03-23', startHour: 10 });
    assert.equal(inWindow.status, 200);
  });

  test('dado que hoy es lunes 9, cuando Rosa reserva el martes 24 (15 días después), entonces la respuesta es 422 TOO_FAR_AHEAD', async () => {
    const tooFar = await book({ courtId: FULBITO_1, memberId: ROSA, date: '2026-03-24', startHour: 10 });
    assert.equal(tooFar.status, 422);
    assert.equal(tooFar.body.error.code, 'TOO_FAR_AHEAD');
  });

  test('cuando Rosa pide 4 horas seguidas en una sola reserva, entonces la respuesta es 422 TOO_MANY_HOURS', async () => {
    const tooLong = await book({ courtId: FULBITO_1, memberId: ROSA, date: '2026-03-10', startHour: 9, hours: 4 });
    assert.equal(tooLong.status, 422);
    assert.equal(tooLong.body.error.code, 'TOO_MANY_HOURS');
  });
});

describe('Como vecino, quiero saber cuánto voy a pagar para no llevarme sorpresas en la caja', () => {
  test('cuando Rosa (regular) reserva fulbito el martes 10 a las 10:00, entonces paga S/ 90.00', async () => {
    const { body } = await book({ courtId: FULBITO_1, memberId: ROSA, date: '2026-03-10', startHour: 10 });
    assert.equal(body.booking.priceCents, 9000);
  });

  test('cuando Rosa reserva fulbito el martes 10 a las 19:00 (hora punta), entonces paga S/ 112.50', async () => {
    const { body } = await book({ courtId: FULBITO_1, memberId: ROSA, date: '2026-03-10', startHour: 19 });
    assert.equal(body.booking.priceCents, 11250);
  });

  test('cuando Rosa reserva fulbito el sábado 14 a las 10:00, entonces paga S/ 112.50, porque el fin de semana es hora punta todo el día', async () => {
    const { body } = await book({ courtId: FULBITO_1, memberId: ROSA, date: '2026-03-14', startHour: 10 });
    assert.equal(body.booking.priceCents, 11250);
  });

  test('cuando Luis (socio) reserva vóley el miércoles 11 a las 10:00, entonces paga S/ 45.00: 10 % menos que S/ 50', async () => {
    const { body } = await book({ courtId: VOLEY, memberId: LUIS, date: '2026-03-11', startHour: 10 });
    assert.equal(body.booking.priceCents, 4500);
  });
});
revisar-historias.test.tsDescargar revisar-historias.test.ts
// Pruebas del revisor de historias: lo que encuentra y lo que no puede encontrar.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { reviewStories } from './revisar-historias.ts';

const messages = (markdown: string) =>
  reviewStories(markdown).findings.map((f) => `${f.story}${f.criterion === null ? '' : `/${f.criterion}`}: ${f.message}`);

test('las historias de ejemplo del capítulo no tienen observaciones', () => {
  const review = reviewStories(readFileSync('historias-ejemplo.md', 'utf8'));
  assert.equal(review.stories, 3);
  assert.equal(review.criteria, 8);
  assert.deepEqual(review.findings, []);
});

test('el borrador con problemas tiene las 9 observaciones del capítulo', () => {
  const found = messages(readFileSync('historias-con-problemas.md', 'utf8'));
  assert.equal(found.length, 9);
  assert.ok(found.includes('HU-01: el rol es genérico: «usuario»'));
  assert.ok(found.includes('HU-01/1: palabra vaga «rápido»: ¿cómo se comprueba?'));
  assert.ok(found.includes('HU-01/2: habla de la implementación («tabla»), no del comportamiento'));
  assert.ok(found.includes('HU-02: no tiene criterios de aceptación'));
  assert.ok(found.includes('HU-03/1: le falta el "entonces"'));
});

test('"bien" dentro de "también" no es una palabra vaga', () => {
  const md = '## HU-01 · X\n\nComo socia, quiero algo para otra cosa.\n\n- **Dado** que también es lunes 9, **cuando** pasa algo a las 10:00, **entonces** la respuesta es 200.\n';
  assert.deepEqual(messages(md), []);
});

test('un criterio puede seguir en las líneas siguientes, con sangría', () => {
  const md = '## HU-01 · X\n\nComo socia, quiero algo para otra cosa.\n\n- **Dado** que es lunes 9,\n  **cuando** reservo a las 10:00,\n  **entonces** pago S/ 90.\n';
  assert.deepEqual(messages(md), []);
});

test('no detecta un beneficio que repite la acción: eso lo ve una persona', () => {
  const md = '## HU-01 · X\n\nComo administradora del complejo, quiero bloquear la cancha para bloquearla.\n\n- **Dado** que es lunes 9, **cuando** bloqueo a las 10:00, **entonces** la cancha aparece ocupada.\n';
  assert.deepEqual(messages(md), []);
});

test('las secciones que no son historias se ignoran', () => {
  const review = reviewStories('# Título\n\n## Notas\n\nComo sea.\n');
  assert.equal(review.stories, 0);
  assert.deepEqual(review.findings, []);
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 3: requisitos, historias de usuario y criterios de aceptación.
# Se usa dentro del contenedor del curso (./env/run.sh desde la carpeta del curso).
#
#   make          copia el código de Kallpa en build/kallpa (las pruebas de
#                 aceptación corren contra esa copia, sin tocar kallpa/)
#   make test     corre las pruebas: los criterios de aceptación del capítulo
#                 contra Kallpa y las pruebas del revisor de historias
#   make revisar  pasa el revisor por los dos archivos de historias de ejemplo
#   make typecheck revisa los tipos con tsc
#   make clean    borra build/

KALLPA ?= ../../kallpa

.PHONY: all test revisar typecheck clean

all: build/kallpa/test/helpers.ts

build/kallpa/test/helpers.ts: $(wildcard $(KALLPA)/src/*.ts $(KALLPA)/src/*/*.ts $(KALLPA)/test/helpers.ts)
	rm -rf build/kallpa
	mkdir -p build/kallpa/test
	cp -r $(KALLPA)/src build/kallpa/src
	cp $(KALLPA)/test/helpers.ts build/kallpa/test/helpers.ts
	cp $(KALLPA)/package.json build/kallpa/package.json

test: all
	node --test "*.test.ts"

revisar:
	-node revisar-historias.ts historias-ejemplo.md
	-node revisar-historias.ts historias-con-problemas.md

typecheck: all
	tsc -p .

clean:
	rm -rf build
package.jsonDescargar package.json
{
  "name": "week-03",
  "private": true,
  "type": "module"
}
tsconfig.jsonDescargar tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": ["es2024"],
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["*.ts"]
}