01
Proceso y requisitos

¿Qué es la ingeniería de software?

Proceso y requisitos

Semana 1 — ¿Qué es la ingeniería de software?

Objetivos

  • Distinguir la ingeniería de software de la programación: el tiempo, la escala y las decisiones con costos.
  • Explicar con ejemplos la ley de Hyrum y por qué un cambio “inofensivo” puede romper a quien depende de un sistema.
  • Describir qué hacen bien y qué hacen mal las herramientas de IA que escriben código, y qué evidencia hay sobre su efecto real en la productividad.
  • Trabajar con Git en equipo: ramas, commits con mensajes útiles, pull requests y conflictos de merge.

Sesión A — Presentación del curso · ¿Qué es la ingeniería de software?

Lecturas:

Lecturas complementarias:

Temas:

  • Presentación: el programa, el proyecto con un cliente real, la política de uso de IA con registro y defensa oral, el entorno del curso.
  • Ingeniería de software como programación integrada en el tiempo. La pregunta que separa un script de un sistema: ¿cuánto tiempo va a vivir este código? Un script que corre una vez y el sistema de reservas de un complejo deportivo que va a cambiar durante años necesitan prácticas distintas.
  • Sostenibilidad: un proyecto es sostenible si puede reaccionar a los cambios que valen la pena (una ley nueva, una vulnerabilidad en una dependencia, un pedido del cliente) a un costo razonable.
  • La ley de Hyrum: con suficientes usuarios, todo comportamiento observable de un sistema termina siendo usado por alguien. Ejemplo con Kallpa: si la API devuelve las canchas siempre ordenadas por nombre sin haberlo prometido, algún cliente HTTP va a depender de ese orden.
  • Escala: políticas que escalan y políticas que no (que cada equipo actualice su copia de una dependencia a mano frente a que lo haga quien cambia la dependencia). Shifting left: encontrar un error al escribir el código cuesta menos que encontrarlo en revisión, en pruebas o en producción.
  • Decisiones con costos: todo es un trade-off (tiempo de las personas, tiempo de máquina, riesgo, deuda). Una decisión se toma con datos y se revisa cuando cambian.
  • Error común: medir un proyecto de software por cuánto código se escribió. Menos código que resuelve lo mismo es mejor: cada línea es algo que mantener.

Sesión B — La IA en el ciclo de desarrollo: qué hace bien, qué hace mal y cómo usarla con responsabilidad

Lecturas:

Lecturas complementarias:

Temas:

  • Qué es una herramienta de IA para programar: un modelo de lenguaje que predice texto, con acceso (según la herramienta) a los archivos del proyecto, a la terminal y a otras herramientas. Autocompletado, chat, agente que edita archivos y corre comandos.
  • Qué hace bien: código repetitivo, primeros borradores, explicar código ajeno, traducir entre lenguajes, proponer pruebas. Qué hace mal: código que compila, pasa sus propias pruebas y está mal; inventar funciones o paquetes que no existen; seguir instrucciones escondidas en lo que lee; no saber lo que el cliente quiso decir.
  • Evidencia, no anécdotas. En el estudio de METR de 2025, desarrolladores experimentados en sus propios proyectos tardaron 19 % más con IA, aunque antes esperaban ir 24 % más rápido y después creían haber ido 20 % más rápido. METR advierte que las herramientas cambiaron y que esos números ya no describen las actuales; la lección que sigue vigente es que la sensación de productividad no es una medición. El reporte de DORA de 2025 describe la IA como un amplificador de las fortalezas y debilidades que el equipo ya tiene.
  • La responsabilidad no se delega: quien hace commit responde por el código. Por eso el curso pide un registro de uso de IA y una defensa oral, y por eso las prácticas del curso (especificaciones, pruebas, revisión, CI) importan más con IA, no menos.
  • La política del curso, en concreto: qué va en el registro (qué se pidió, a qué herramienta, qué se aceptó, qué se rechazó y por qué), cuándo se cita, y que los exámenes son sin IA.
  • Error común: aceptar código generado porque “pasa las pruebas” cuando las pruebas también las generó la herramienta. Unas pruebas que nadie leyó no verifican nada.

Laboratorio — Entorno y Git en equipo: ramas, pull requests y conflictos sobre Kallpa

Guía: labs/README.md. Referencias: Pro Git en español, cap. 3 (ramificaciones); GitHub, About pull requests; SWEG, cap. 16, Version Control and Branch Management.

  1. Instalar Docker y Git, crear la cuenta de GitHub y abrir la shell del curso con ./env/run.sh. Comprobar node --version (26), tsc --version y git --version. Configurar git config --global user.name y user.email en la terminal de su computadora, no en el contenedor (labs/README.md).
  2. Crear un repositorio propio a partir de Kallpa (la carpeta kallpa/), siguiendo el paso 1 de P0, y correr make check y make start. Abrir http://localhost:3000 en el navegador y hacer una reserva.
  3. En parejas, sobre el mismo repositorio: cada integrante crea una rama con un cambio pequeño en el mismo archivo (por ejemplo, el texto de un mensaje de error), hace commit, sube la rama y abre un pull request. La segunda persona en hacer merge encuentra un conflicto: resolverlo, entender las marcas <<<<<<<, =======, >>>>>>>, y terminar el merge.
  4. Revisar el pull request de la otra persona en GitHub: un comentario en una línea, una sugerencia de cambio y la aprobación.
  5. Leer git log --oneline --graph del repositorio y explicar qué muestra.
  6. Leer el enunciado de P0 y elegir el issue según la regla del enunciado.

Entregas

  • P0 publicado: Primer aporte: un issue de Kallpa resuelto en una rama, con pruebas y un pull request revisado (projects/README.md). Entrega en semana 3.

Preguntas de repaso

  1. Den un ejemplo de código de un curso anterior que no necesitaba prácticas de ingeniería de software y otro, de su vida diaria, que sí. ¿Qué pregunta los separa?
  2. Expliquen la ley de Hyrum con un ejemplo de Kallpa distinto del de clase. ¿Qué podría hacer el equipo de Kallpa para que nadie dependa de ese comportamiento?
  3. ¿Por qué “encontrar el error antes” es más barato? Den un ejemplo de un error que se encuentre al escribir el código, otro en revisión y otro en producción.
  4. En el estudio de METR, ¿qué diferencia hubo entre lo que los desarrolladores creían y lo que se midió? ¿Qué conclusión se puede sacar y cuál no?
  5. Una herramienta les genera una función y sus pruebas, y todas pasan. ¿Qué tendrían que hacer antes de hacer commit? ¿Qué escribirían en el registro de uso de IA?

Código de la semana

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

Ejemplos (10)Pruebas (5)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

courts-order.tsDescargar courts-order.ts
// ¿En qué orden devuelve Kallpa las canchas? El README no lo promete; el
// código las ordena por nombre (ORDER BY name en court-repository.ts).
//   node courts-order.ts
import { CourtRepository } from '../../kallpa/src/store/court-repository.ts';
import { openDatabase } from '../../kallpa/src/store/database.ts';
import { seedCatalog } from '../../kallpa/src/store/seed.ts';

const db = openDatabase(':memory:');
seedCatalog(db);
const courts = new CourtRepository(db);
const show = () => courts.list().map((court) => `${court.id} ${court.name}`).join(' | ');

console.log(`Hoy:                    ${show()}`);
courts.insert({ name: 'Área techada', sport: 'basquet' });
console.log(`Con una cancha nueva:   ${show()}`);
db.close();
eslint.config.jsDescargar eslint.config.js
// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig(
  { ignores: ['build/'] },
  js.configs.recommended,
  tseslint.configs.recommended,
);
hyrum-clients.tsDescargar hyrum-clients.ts
// Dos clientes HTTP de Kallpa escritos por otras personas: el bot de WhatsApp
// de una liga de fulbito y un script de la administración. Cada uno tiene una
// versión que depende de lo que Kallpa promete y otra que depende de lo que
// Kallpa hace hoy sin haberlo prometido (ley de Hyrum).
export type CourtJson = { id: number; name: string; sport: string };
export type ErrorJson = { error: { code: string; message: string } };

// "La segunda cancha de la lista es Fulbito 1": cierto hoy, porque Kallpa
// ordena por nombre y 'Básquet' va antes que 'Fulbito 1'.
export function fulbitoOneFragile(courts: CourtJson[]): number {
  return courts[1].id;
}

// Busca por lo que la API sí da: el nombre de cada cancha.
export function fulbitoOne(courts: CourtJson[]): number | undefined {
  return courts.find((court) => court.name === 'Fulbito 1')?.id;
}

// Compara el texto del mensaje, que está escrito para personas.
export function isPastHourFragile(body: ErrorJson): boolean {
  return body.error.message === 'Esa hora ya pasó.';
}

// Compara el código, que el README de Kallpa promete estable.
export function isPastHour(body: ErrorJson): boolean {
  return body.error.code === 'IN_THE_PAST';
}
team-git.shDescargar team-git.sh
#!/usr/bin/env bash
# Laboratorio de la semana 1, paso a paso y sin GitHub: dos estudiantes, Ana y
# Bruno, cambian el mismo mensaje de error de Kallpa en dos ramas. El pull
# request de Ana se fusiona primero; Bruno trae main a su rama, encuentra el
# conflicto, lo resuelve y su pull request se fusiona después.
#
#   ./team-git.sh build/equipo     arma todo dentro de build/equipo
#
# Un repositorio "bare" (sin carpeta de trabajo) hace de GitHub: es a donde
# los dos suben y de donde traen los cambios. Los merges que en GitHub hace el
# botón "Merge pull request" se hacen aquí con `git merge --no-ff`, que es lo
# que ese botón hace por omisión. Los nombres, correos y fechas de los commits
# son fijos, así que los hashes salen siempre iguales.
set -euo pipefail

DIR="${1:?Uso: ./team-git.sh <carpeta>}"
KALLPA="${KALLPA:-$(cd "$(dirname "$0")/../../kallpa" && pwd)}"
FILE=src/domain/booking.ts
OLD="'Esa hora ya pasó.'"

rm -rf "$DIR"
mkdir -p "$DIR"
DIR="$(cd "$DIR" && pwd)"
export GIT_CONFIG_GLOBAL="$DIR/gitconfig"   # no usar el ~/.gitconfig de nadie
git config --global init.defaultBranch main
git config --global advice.detachedHead false
git config --global safe.directory '*'

# Quién hace cada paso y a qué hora (la hora de Lima del laboratorio).
as() {
  local who="$1" when="$2"
  export GIT_AUTHOR_NAME="$who" GIT_COMMITTER_NAME="$who"
  WHO="$(echo "${who%% *}" | tr '[:upper:]' '[:lower:]')"
  export GIT_AUTHOR_EMAIL="$WHO@uni.example" GIT_COMMITTER_EMAIL="$WHO@uni.example"
  export GIT_AUTHOR_DATE="2026-03-25T$when:00-05:00" GIT_COMMITTER_DATE="2026-03-25T$when:00-05:00"
  cd "$DIR/$WHO"
}

# Muestra el comando y su salida, como en la terminal.
run() {
  local shown="" arg
  for arg in "$@"; do
    case "$arg" in *" "*) arg="\"$arg\"" ;; esac
    shown="$shown $arg"
  done
  printf '%s$%s\n' "$WHO" "$shown"
  "$@" 2>&1
}

step() { printf '\n## %s\n' "$*"; }

step "GitHub: un repositorio vacío"
git init --quiet --bare "$DIR/github/kallpa.git"

step "Ana sube su copia de Kallpa"
mkdir -p "$DIR/ana"
cp -r "$KALLPA" "$DIR/ana/kallpa"
rm -rf "$DIR/ana/kallpa/node_modules" "$DIR/ana/kallpa"/data/*.db*
as "Ana Quispe" 10:00
cd kallpa
run git init --quiet
run git add -A
run git commit --quiet -m "Versión inicial de Kallpa"
run git remote add origin "$DIR/github/kallpa.git"
run git push --quiet -u origin main

step "Bruno clona el repositorio"
mkdir -p "$DIR/bruno"
as "Bruno Rojas" 10:05
run git clone --quiet "$DIR/github/kallpa.git" kallpa
cd kallpa
run git log --oneline --decorate

step "Ana: una rama y un commit"
as "Ana Quispe" 10:10
cd kallpa
run git switch -c mensaje-hora-pasada
sed -i "s/$OLD/'Esa hora ya pasó: elijan una hora libre.'/" "$FILE"
run git diff
run git commit -am "Sugiere elegir otra hora cuando la hora ya pasó"
run git push -u origin mensaje-hora-pasada

step "Bruno: otra rama, la misma línea"
as "Bruno Rojas" 10:12
cd kallpa
run git switch -c mensaje-hora-iniciada
sed -i "s/$OLD/'No se puede reservar una hora que ya empezó.'/" "$FILE"
run git commit -am "Aclara que tampoco se reserva una hora ya empezada"
run git push -u origin mensaje-hora-iniciada

step "Se fusiona el pull request de Ana"
as "Ana Quispe" 10:30
cd kallpa
run git switch main
run git merge --no-ff mensaje-hora-pasada -m "Merge pull request #1 from ana/mensaje-hora-pasada"
run git push origin main
run git cat-file -p HEAD

step "Bruno trae main a su rama: conflicto"
as "Bruno Rojas" 10:40
cd kallpa
run git fetch origin
run git log --oneline --graph --decorate --all
run git merge origin/main || true
run git status
run sed -n 64,70p "$FILE"

step "Bruno resuelve el conflicto"
sed -i "/^<<<<<<< /,/^>>>>>>> /c\\    return { code: 'IN_THE_PAST', message: 'Esa hora ya empezó: elijan una hora libre.' };" "$FILE"
run sed -n 64,66p "$FILE"
run git add "$FILE"
run git commit --no-edit
run git push origin mensaje-hora-iniciada

step "Se fusiona el pull request de Bruno"
as "Ana Quispe" 10:50
cd kallpa
run git switch main
run git fetch --quiet origin
run git merge --no-ff origin/mensaje-hora-iniciada -m "Merge pull request #2 from ana/mensaje-hora-iniciada"
run git push origin main

step "La historia de main"
run git log --oneline --graph --decorate
agent-change/parse-soles-fixed.tsDescargar agent-change/parse-soles-fixed.ts
// La versión corregida: lee los soles y los céntimos como enteros, sin pasar
// por un número decimal, y rechaza lo que no tiene la forma de un monto.
const AMOUNT = /^(?:S\/ ?)?(\d{1,7})(?:\.(\d{2}))?$/;

export function parseSoles(text: string): number | null {
  const match = AMOUNT.exec(text.trim());
  if (match === null) return null;
  const soles = Number(match[1]);
  const cents = match[2] === undefined ? 0 : Number(match[2]);
  return soles * 100 + cents;
}
agent-change/parse-soles.tsDescargar agent-change/parse-soles.ts
// Ilustración escrita para la clase: un cambio como los que suele proponer un
// agente cuando se le pide "una función que convierta a céntimos los montos
// que escribe la administración (por ejemplo, 'S/ 112.50')". Compila, pasa
// las pruebas que vienen con él (parse-soles.test.ts) y está mal: ver
// roundtrip.check.ts y el apunte de la semana 1.
export function parseSoles(text: string): number {
  const amount = parseFloat(text.replace('S/', '').trim());
  return Math.floor(amount * 100);
}
agent-change/roundtrip.check.tsDescargar agent-change/roundtrip.check.ts
// Revisa parse-soles.ts contra la regla de ida y vuelta, para todos los montos
// de S/ 0.00 a S/ 1000.00. Falla: `node --test agent-change/roundtrip.check.ts`.
// (No se llama *.test.ts para que `make test` no lo corra como prueba; lo
// corre agent-change.test.ts, que comprueba que falla.)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { formatSoles } from '../../../kallpa/src/domain/pricing.ts';
import { parseSoles } from './parse-soles.ts';
import { roundTripFailures } from './roundtrip.ts';

test('todo monto que muestra Kallpa se vuelve a leer igual', () => {
  const failures = roundTripFailures(parseSoles, 100_000);
  const shown = failures.slice(0, 3).map((cents) => `'${formatSoles(cents)}' -> ${parseSoles(formatSoles(cents))}`);
  assert.equal(failures.length, 0, `${failures.length} montos no vuelven: ${shown.join(', ')}...`);
});
agent-change/roundtrip.tsDescargar agent-change/roundtrip.ts
import { formatSoles } from '../../../kallpa/src/domain/pricing.ts';

// Una prueba que sale de la regla, no de la implementación: todo monto que
// Kallpa muestra con formatSoles tiene que volver a leerse igual. Devuelve
// los montos (en céntimos, de 0 a maxCents) que no vuelven.
export function roundTripFailures(parse: (text: string) => number | null, maxCents: number): number[] {
  const failures: number[] = [];
  for (let cents = 0; cents <= maxCents; cents++) {
    if (parse(formatSoles(cents)) !== cents) failures.push(cents);
  }
  return failures;
}
shift-left/price-typo.tsDescargar shift-left/price-typo.ts
// Un error de tipeo que Node no ve y tsc sí: la propiedad se llama `hours`,
// no `hour`. Node quita los tipos y ejecuta; tsc revisa los tipos antes.
//   node price-typo.ts    imprime NaN
//   tsc -p .              marca el error en la línea 7
import { quotePriceCents } from '../../../kallpa/src/domain/pricing.ts';

const cents = quotePriceCents({ sport: 'voley', date: '2026-03-10', startHour: 9, hour: 2, plan: 'regular' });
console.log(`Total: ${cents} céntimos`);
shift-left/tsconfig.jsonDescargar shift-left/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
  },
  "files": ["price-typo.ts"]
}

Pruebas

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

agent-change.test.tsDescargar agent-change.test.ts
// Comprueba lo que el apunte dice del cambio ilustrativo de agent-change/:
// pasa sus propias pruebas, falla la de ida y vuelta, y la versión corregida
// pasa las dos.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { parseSoles } from './agent-change/parse-soles-fixed.ts';
import { roundTripFailures } from './agent-change/roundtrip.ts';

const here = new URL('.', import.meta.url).pathname;
// Sin NODE_TEST_CONTEXT, el node --test hijo escribe su reporte normal.
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
const nodeTest = (file: string) => spawnSync('node', ['--test', file], { cwd: here, encoding: 'utf8', env });

test('con decimales, 0.1 + 0.2 no da 0.3 y 0.29 * 100 no da 29', () => {
  assert.notEqual(0.1 + 0.2, 0.3);
  assert.equal(0.29 * 100, 28.999999999999996);
});

test('el cambio pasa las pruebas que trae', () => {
  const run = nodeTest('agent-change/parse-soles.test.ts');
  assert.equal(run.status, 0);
  assert.match(run.stdout, /ℹ pass 4\nℹ fail 0/);
});

test('el cambio falla la prueba de ida y vuelta en 4586 montos', () => {
  const run = nodeTest('agent-change/roundtrip.check.ts');
  assert.equal(run.status, 1);
  assert.match(run.stdout, /4586 montos no vuelven: 'S\/ 0\.29' -> 28, 'S\/ 0\.57' -> 56, 'S\/ 0\.58' -> 57/);
});

test('la versión corregida pasa la ida y vuelta de S/ 0.00 a S/ 1000.00', () => {
  assert.deepEqual(roundTripFailures(parseSoles, 100_000), []);
});

test('la versión corregida pasa los ejemplos del cambio y rechaza lo que no es un monto', () => {
  assert.equal(parseSoles('S/ 112.50'), 11250);
  assert.equal(parseSoles('S/ 90'), 9000);
  assert.equal(parseSoles('S/ 62.50'), 6250);
  assert.equal(parseSoles('81.00'), 8100);
  assert.equal(parseSoles('S/ 0.29'), 29);
  for (const text of ['', 'S/', 'noventa', '12.5', '12.345', '-5.00', 'S/ 1e3']) {
    assert.equal(parseSoles(text), null, text);
  }
});
hyrum.test.tsDescargar hyrum.test.ts
// Comprueba lo que el apunte dice sobre la ley de Hyrum con la Kallpa del
// curso: el orden de GET /api/courts y los errores de POST /api/bookings.
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { getJson, postJson, startTestServer, type TestServer } from '../../kallpa/test/helpers.ts';
import { CourtRepository } from '../../kallpa/src/store/court-repository.ts';
import { fulbitoOne, fulbitoOneFragile, isPastHour, isPastHourFragile, type CourtJson } from './hyrum-clients.ts';

let server: TestServer;
before(async () => {
  server = await startTestServer();
});
after(() => server.close());

test('Kallpa devuelve las canchas ordenadas por nombre, no por id', async () => {
  const { body } = await getJson(`${server.url}/api/courts`);
  assert.deepEqual(body.courts.map((court: CourtJson) => court.id), [4, 1, 2, 3]);
});

test('hoy los dos clientes encuentran Fulbito 1', async () => {
  const { body } = await getJson(`${server.url}/api/courts`);
  assert.equal(fulbitoOneFragile(body.courts), 1);
  assert.equal(fulbitoOne(body.courts), 1);
});

test('si Kallpa ordenara por id, el cliente frágil reservaría otra cancha', async () => {
  const { body } = await getJson(`${server.url}/api/courts`);
  const byId = [...body.courts].sort((a: CourtJson, b: CourtJson) => a.id - b.id);
  assert.equal(fulbitoOneFragile(byId), 2);
  assert.equal(fulbitoOne(byId), 1);
});

test('el orden por nombre de SQLite no es el alfabético del español', () => {
  new CourtRepository(server.db).insert({ name: 'Área techada', sport: 'basquet' });
  const names = new CourtRepository(server.db).list().map((court) => court.name);
  assert.deepEqual(names, ['Básquet', 'Fulbito 1', 'Fulbito 2', 'Vóley', 'Área techada']);
});

test('una hora que ya pasó responde 422 con el código IN_THE_PAST', async () => {
  // El reloj de las pruebas marca el lunes 9 de marzo de 2026 a las 10:00.
  const response = await postJson(`${server.url}/api/bookings`, {
    courtId: 1, memberId: 2, date: '2026-03-09', startHour: 9, hours: 1, teamName: 'Real Barranco',
  });
  assert.equal(response.status, 422);
  assert.deepEqual(response.body, { error: { code: 'IN_THE_PAST', message: 'Esa hora ya pasó.' } });
  assert.equal(isPastHourFragile(response.body), true);
  assert.equal(isPastHour(response.body), true);
});

test('con el mensaje nuevo del laboratorio, solo sigue funcionando el cliente que compara el código', () => {
  const body = { error: { code: 'IN_THE_PAST', message: 'Esa hora ya empezó: elijan una hora libre.' } };
  assert.equal(isPastHourFragile(body), false);
  assert.equal(isPastHour(body), true);
});
shift-left.test.tsDescargar shift-left.test.ts
// El mismo error de tipeo, visto por Node (NaN, sin aviso) y por tsc (error).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';

const dir = new URL('./shift-left/', import.meta.url).pathname;

test('Node ejecuta price-typo.ts sin quejarse e imprime NaN', () => {
  const run = spawnSync('node', ['price-typo.ts'], { cwd: dir, encoding: 'utf8' });
  assert.equal(run.status, 0);
  assert.equal(run.stdout, 'Total: NaN céntimos\n');
});

test('tsc encuentra el error antes de ejecutar', () => {
  const run = spawnSync('tsc', ['-p', '.'], { cwd: dir, encoding: 'utf8' });
  assert.notEqual(run.status, 0);
  assert.match(run.stdout, /price-typo\.ts\(7,83\): error TS2561: .*'hour' does not exist in type 'PriceQuote'\. Did you mean to write 'hours'\?/);
});
team-git.test.tsDescargar team-git.test.ts
// Corre team-git.sh en una carpeta temporal y comprueba lo que el apunte
// muestra de su salida: el conflicto, su resolución y la historia final.
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const script = new URL('./team-git.sh', import.meta.url).pathname;
let dir: string;
let output: string;

const git = (repo: string, ...args: string[]) =>
  spawnSync('git', args, { cwd: join(dir, repo), encoding: 'utf8', env: { ...process.env, GIT_CONFIG_GLOBAL: join(dir, 'gitconfig') } }).stdout.trim();

before(() => {
  dir = mkdtempSync(join(tmpdir(), 'equipo-'));
  const run = spawnSync('bash', [script, dir], { encoding: 'utf8' });
  assert.equal(run.status, 0, run.stderr);
  output = run.stdout;
});
after(() => rmSync(dir, { recursive: true, force: true }));

test('el merge de Bruno choca con el de Ana en booking.ts', () => {
  assert.match(output, /CONFLICT \(content\): Merge conflict in src\/domain\/booking\.ts/);
  assert.match(output, /both modified: {3}src\/domain\/booking\.ts/);
  assert.match(output, /<<<<<<< HEAD\n.*'No se puede reservar una hora que ya empezó\.'.*\n=======\n.*'Esa hora ya pasó: elijan una hora libre\.'.*\n>>>>>>> origin\/main/);
});

test('main termina con el mensaje que eligió Bruno al resolver, sin marcas', () => {
  const file = readFileSync(join(dir, 'ana/kallpa/src/domain/booking.ts'), 'utf8');
  assert.match(file, /message: 'Esa hora ya empezó: elijan una hora libre\.'/);
  assert.doesNotMatch(file, /^(<<<<<<<|=======|>>>>>>>)/m);
});

test('la historia de main tiene tres merges de dos padres y dos commits de cambio', () => {
  const lines = git('ana/kallpa', 'log', '--format=%p|%s', 'main').split('\n');
  assert.equal(lines.length, 6);
  const merges = lines.filter((line) => line.split('|')[0].includes(' '));
  assert.deepEqual(merges.map((line) => line.split('|')[1]), [
    'Merge pull request #2 from ana/mensaje-hora-iniciada',
    "Merge remote-tracking branch 'origin/main' into mensaje-hora-iniciada",
    'Merge pull request #1 from ana/mensaje-hora-pasada',
  ]);
});

test('el GitHub de mentira y las dos copias quedan en el mismo commit', () => {
  const head = git('ana/kallpa', 'rev-parse', 'main');
  assert.equal(git('github/kallpa.git', 'rev-parse', 'main'), head);
  git('bruno/kallpa', 'fetch', '--quiet', 'origin');
  assert.equal(git('bruno/kallpa', 'rev-parse', 'origin/main'), head);
});

test('las pruebas de Kallpa siguen pasando: comparan códigos, no mensajes', () => {
  // Sin NODE_TEST_CONTEXT, el node --test hijo escribe su reporte normal.
  const env = { ...process.env };
  delete env.NODE_TEST_CONTEXT;
  const run = spawnSync('node', ['--test', 'test/**/*.test.ts'], { cwd: join(dir, 'ana/kallpa'), encoding: 'utf8', env });
  assert.equal(run.status, 0, run.stdout);
  assert.match(run.stdout, /ℹ fail 0/);
});

test('con nombres y fechas fijos, los hashes se repiten en otra corrida', () => {
  const again = mkdtempSync(join(tmpdir(), 'equipo-'));
  try {
    const run = spawnSync('bash', [script, again], { encoding: 'utf8' });
    assert.equal(run.status, 0, run.stderr);
    const graph = (text: string) => text.slice(text.indexOf('## La historia de main'));
    assert.equal(graph(run.stdout), graph(output));
  } finally {
    rmSync(again, { recursive: true, force: true });
  }
});
agent-change/parse-soles.test.tsDescargar agent-change/parse-soles.test.ts
// Las pruebas que vienen con el cambio de parse-soles.ts (también escritas
// para la clase). Todas pasan.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseSoles } from './parse-soles.ts';

test('convierte una tarifa de fulbito en hora punta', () => {
  assert.equal(parseSoles('S/ 112.50'), 11250);
});

test('convierte un monto sin céntimos', () => {
  assert.equal(parseSoles('S/ 90'), 9000);
});

test('convierte una tarifa de vóley en hora punta', () => {
  assert.equal(parseSoles('S/ 62.50'), 6250);
});

test('acepta el monto sin el símbolo de soles', () => {
  assert.equal(parseSoles('81.00'), 8100);
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 1: ingeniería de software, la ley de Hyrum, un cambio que pasa sus
# propias pruebas y Git en equipo. Se usa dentro del contenedor del curso.
#
#   make          arma en build/equipo el laboratorio de Git de ejemplo (Ana y
#                 Bruno, dos ramas, un conflicto y dos pull requests) y guarda
#                 lo que muestra la terminal en build/team-git.txt
#   make test     revisa los tipos (tsc), corre ESLint y las pruebas
#                 (*.test.ts), que comprueban lo que dice el apunte
#   make clean    borra build/
#
# Los ejemplos usan la Kallpa del curso (../../kallpa) sin modificarla.
# shift-left/price-typo.ts tiene un error de tipos a propósito: queda fuera de
# tsconfig.json y lo revisa shift-left.test.ts.

.PHONY: all test clean

all: build/team-git.txt

build/team-git.txt: team-git.sh
	mkdir -p build
	./team-git.sh build/equipo > $@
	@echo "Listo: la salida está en $@"

test:
	tsc -p .
	eslint .
	node --test "*.test.ts"

clean:
	rm -rf build
package.jsonDescargar package.json
{
  "name": "week-01",
  "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", "agent-change/*.ts"]
}