Semana 8 — Repaso y examen parcial
Objetivos
- Repasar las partes 1 y 2 del curso (semanas 1 a 7) y conectar sus ideas en un mismo caso: del pedido de un cliente a una API y una arquitectura documentadas.
- Resolver, sin herramientas de IA, problemas del tipo del examen: historias de usuario, especificaciones, diseño de módulos, APIs y ADRs.
- Revisar críticamente las decisiones de su propio P2 (historias, design doc, API, ADRs) con los criterios de las semanas 3 a 7, y poder defender cada una con sus alternativas.
- Cerrar el sprint 1 del proyecto con una demo al cliente y la entrega de P2.
Sesión A — Repaso
Lecturas:
- Las Preguntas de repaso de las guías de las semanas 1, 2, 3, 4, 5, 6 y 7.
exams/README.md: formato del examen y cómo estudiar.
Lecturas complementarias:
- Las lecturas principales de cada semana que no hayan alcanzado a leer. Para el examen pesan más Software Engineering at Google, cap. 1 y cap. 10, la Guía de Scrum, Design Docs at Google y el ADR de Nygard.
Temas:
- Semanas 1 y 2 (1, 2): ingeniería de software como programación en el tiempo, ley de Hyrum, shifting left, trade-offs; qué hace bien y mal la IA y qué dice la evidencia (METR, DORA). Cascada, iterativo, Scrum (roles, eventos, artefactos, Definition of Done) y Kanban (límites de WIP); estimación relativa y por qué una estimación no es un compromiso; las lecciones de proceso del caso Boeing 737 MAX.
- Semana 3 (3): elicitación y entrevistas (preguntas abiertas, sobre el pasado, sin proponer la solución); historias de usuario con INVEST; criterios de aceptación en Dado/Cuando/Entonces que se puedan verificar; requisitos no funcionales medibles; priorización.
- Semana 4 (4): especificaciones con precondiciones, postcondiciones e invariantes; los tipos como especificación (uniones de literales, uniones discriminadas, parse, don’t validate) y lo que los tipos no pueden decir; design docs (contexto, objetivos y no objetivos, diseño, alternativas); desarrollo guiado por especificaciones con un agente y cómo se comprueba lo que genera.
- Semana 5 (5): complejidad (carga cognitiva, cambios que se propagan, incógnitas), módulos profundos y superficiales, ocultamiento de información y abstracciones que se filtran; conceptos (Jackson), acoplamiento y cohesión. Saber señalarlos en un fragmento de TypeScript y proponer una interfaz mejor.
- Semana 6 (6): diseño de APIs REST (recursos, métodos, códigos de estado, forma de los errores), métodos seguros e idempotentes según RFC 9110 (
GET,PUTyDELETEson idempotentes;POSTno, salvo con una clave de idempotencia), cambios compatibles e incompatibles, versionado y deprecación; OpenAPI; los patrones que siguen vigentes (strategy, adapter, decorator, observer, repository, dependency injection) y cuándo basta una función. - Semana 7 (7): arquitectura como decisiones caras de cambiar; capas, hexagonal, monolito, monolito modular y microservicios; ley de Conway; atributos de calidad como escenarios medibles; ADRs con alternativas; ✂️ los niveles de C4.
- Ejercicios tipo examen resueltos en clase sobre un caso nuevo (no Kallpa ni los clientes de los equipos): un párrafo de un cliente → historias y criterios; una operación → su especificación; un fragmento de código → sus problemas de diseño; una funcionalidad → su API; una decisión → su ADR.
- Error común: estudiar memorizando definiciones. El examen da un caso que no han visto y pide aplicar la idea: no basta saber qué es un módulo profundo, hay que reconocer uno superficial en código ajeno y decir cómo cambiar su interfaz.
Sesión B — Examen parcial
Temas: partes 1 y 2 del curso, semanas 1 a 7. Se rinde sin herramientas de IA. Formato y reglas en exams/README.md.
Qué entra:
- Ingeniería de software y procesos: preguntas cortas sobre la ley de Hyrum, trade-offs, Scrum y Kanban, y sobre qué se puede concluir (y qué no) de un estudio de productividad con IA.
- Requisitos: a partir de la descripción de un cliente, escribir historias de usuario con criterios de aceptación verificables, y encontrar requisitos ambiguos o contradictorios.
- Especificaciones: escribir precondiciones, postcondiciones e invariantes de una operación, y los tipos de TypeScript que impiden un estado inválido.
- Diseño: en un fragmento de TypeScript, identificar módulos superficiales, información que se filtra y acoplamiento, y proponer una interfaz mejor.
- APIs: diseñar las rutas, los códigos de estado y los errores de una funcionalidad; decir qué operaciones son idempotentes y cómo hacer idempotente una que no lo es.
- Arquitectura: elegir entre alternativas para un caso dado y escribir el ADR con sus consecuencias; escribir un atributo de calidad como escenario medible.
Cómo estudiar:
- Las Preguntas de repaso de las semanas 1 a 7, respondidas por escrito y sin mirar las lecturas.
- Su propio P2: expliquen a otra persona del equipo cada decisión del design doc y de los ADRs, y por qué se descartaron las alternativas.
- Rehacer en papel los ejercicios del laboratorio: las historias del backlog de Kallpa (semana 3), la refactorización del módulo superficial (semana 5), la especificación OpenAPI (semana 6) y el ADR (semana 7).
Laboratorio — Sin laboratorio
No hay laboratorio esta semana. La demo del sprint 1 se hace con el cliente según el enunciado de P2; el tiempo que queda es para cerrar el sprint (revisión del sprint, retrospectiva con acciones concretas) y planificar el sprint 2 con el enunciado de P3.
Entregas
- P2 entrega: Diseño y primer sprint: design doc, ADRs, especificación de la API, un esqueleto que funciona de punta a punta y la demo del sprint 1 (
projects/p2-diseno/README.md). - P3 publicado: Segundo sprint, calidad: funcionalidades con pruebas unitarias y de propiedades, cobertura mínima, revisión de todos los pull requests y análisis estático, con la demo del sprint 2 (
projects/p3-calidad/README.md). Entrega en semana 11.
Preguntas de repaso
- Un cliente dice: “quiero que la app avise cuando haya un problema con un pedido”. Escriban dos historias de usuario con dos criterios de aceptación cada una, y digan qué le preguntarían al cliente antes de escribirlas.
- Escriban la especificación (precondiciones, postcondiciones, errores) de
cancel(bookingId)en Kallpa, solo con lo que dice su README, sin mirar el código. - Muestren con un ejemplo de código una abstracción que se filtra y digan cómo cambiarían la interfaz para que deje de filtrarse.
- ¿Por qué un
POSTrepetido después de un timeout es un problema y unPUTrepetido no? ¿Qué cambiarían en la API de Kallpa para resolverlo? - Para el producto de su equipo, ¿qué decisión de P2 fue de arquitectura? Escriban en cinco líneas su contexto, dos alternativas y sus consecuencias.
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (5)Errores (4)Pruebas (4)Compilación (3)
Ejemplos
Los programas que usan los apuntes y la clase.
// La API del ejercicio: crear un pedido con clave de idempotencia y anularlo.
// node:http y node:sqlite, sin framework, como Kallpa.
import { createServer } from 'node:http';
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { quotePriceCents } from './order.ts';
import type { Service } from './order.ts';
type OrderRequest = { phone: string; weightGrams: number; service: Service };
type Reply = { status: number; body: unknown; location?: string };
// Parse, don't validate: de aquí en adelante, el pedido ya tiene su forma.
function parseOrderRequest(value: unknown): OrderRequest | null {
if (typeof value !== 'object' || value === null) return null;
const { phone, weightGrams, service } = value as Record<string, unknown>;
if (typeof phone !== 'string' || typeof weightGrams !== 'number') return null;
if (service !== 'normal' && service !== 'express') return null;
return { phone, weightGrams, service };
}
function failure(status: number, code: string, message: string): Reply {
return { status, body: { error: { code, message } } };
}
export function openDatabase(): DatabaseSync {
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
phone TEXT NOT NULL,
weight_grams INTEGER NOT NULL,
service TEXT NOT NULL,
price_cents INTEGER NOT NULL,
status TEXT NOT NULL
);
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request TEXT NOT NULL,
reply TEXT NOT NULL
);
`);
return db;
}
function createOrder(db: DatabaseSync, raw: string, key: string | undefined): Reply {
if (key !== undefined) {
const seen = db.prepare('SELECT request, reply FROM idempotency_keys WHERE key = ?').get(key) as
| { request: string; reply: string }
| undefined;
if (seen) {
if (seen.request !== raw) {
return failure(422, 'IDEMPOTENCY_KEY_REUSED', 'Esa clave ya se usó con otro cuerpo.');
}
return JSON.parse(seen.reply) as Reply;
}
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return failure(400, 'INVALID_JSON', 'El cuerpo no es JSON.');
}
const request = parseOrderRequest(parsed);
if (!request) return failure(400, 'VALIDATION_ERROR', 'Falta un campo o tiene el tipo equivocado.');
if (!Number.isInteger(request.weightGrams) || request.weightGrams <= 0) {
return failure(422, 'INVALID_WEIGHT', 'El peso tiene que ser un entero positivo, en gramos.');
}
const priceCents = quotePriceCents(request.weightGrams, request.service);
// El pedido y la clave se guardan en la misma transacción: si se guardara
// solo el pedido, un reintento lo duplicaría.
db.exec('BEGIN');
try {
const { lastInsertRowid } = db
.prepare('INSERT INTO orders (phone, weight_grams, service, price_cents, status) VALUES (?, ?, ?, ?, ?)')
.run(request.phone, request.weightGrams, request.service, priceCents, 'received');
const id = Number(lastInsertRowid);
const reply: Reply = {
status: 201,
location: `/api/orders/${id}`,
body: { order: { id, ...request, priceCents, status: 'received' } },
};
if (key !== undefined) {
db.prepare('INSERT INTO idempotency_keys (key, request, reply) VALUES (?, ?, ?)').run(key, raw, JSON.stringify(reply));
}
db.exec('COMMIT');
return reply;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function cancelOrder(db: DatabaseSync, id: number): Reply {
const row = db.prepare('SELECT status FROM orders WHERE id = ?').get(id) as { status: string } | undefined;
if (!row) return failure(404, 'ORDER_NOT_FOUND', 'El pedido no existe.');
if (row.status === 'cancelled') return failure(409, 'ALREADY_CANCELLED', 'El pedido ya estaba anulado.');
if (row.status !== 'received') return failure(409, 'ORDER_IN_PROCESS', 'La ropa ya entró a lavar.');
db.prepare("UPDATE orders SET status = 'cancelled' WHERE id = ?").run(id);
return { status: 200, body: { order: { id, status: 'cancelled' } } };
}
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString('utf8');
}
function send(res: ServerResponse, reply: Reply): void {
const headers: Record<string, string> = { 'content-type': 'application/json' };
if (reply.location) headers.location = reply.location;
res.writeHead(reply.status, headers);
res.end(JSON.stringify(reply.body));
}
export function createApi(db: DatabaseSync): Server {
return createServer(async (req, res) => {
const raw = await readBody(req);
const cancel = /^\/api\/orders\/(\d+)\/cancel$/.exec(req.url ?? '');
if (req.method === 'POST' && req.url === '/api/orders') {
const key = req.headers['idempotency-key'];
send(res, createOrder(db, raw, typeof key === 'string' ? key : undefined));
} else if (req.method === 'POST' && cancel) {
send(res, cancelOrder(db, Number(cancel[1])));
} else {
send(res, failure(404, 'NOT_FOUND', 'La ruta no existe.'));
}
});
}// Una interfaz mejor para el mismo trabajo: quien llama pide lo que necesita y
// no sabe cómo se guardan los pedidos, qué transiciones existen ni cómo se
// calcula el precio.
import { deliver, quotePriceCents } from './order.ts';
import type { DeliverResult, Order, ReadyOrder, Service } from './order.ts';
export type StepResult = { ok: true; order: Order } | { ok: false; code: 'NOT_FOUND' | 'WRONG_STATE' };
// La interfaz del módulo, escrita aparte para mostrarla en el capítulo. La prueba
// comprueba que la clase la cumple.
export type OrderDeskInterface = {
receive(phone: string, weightGrams: number, service: Service): Order;
startWashing(id: number): StepResult;
markReady(id: number): StepResult;
deliver(id: number, paidCents: number): DeliverResult | { ok: false; code: 'NOT_FOUND' };
readyFor(phone: string): ReadyOrder[];
revenueCents(): number;
};
export class OrderDesk {
#orders = new Map<number, Order>();
#nextId = 1;
#now: () => string;
constructor(now: () => string) {
this.#now = now;
}
receive(phone: string, weightGrams: number, service: Service): Order {
const order: Order = {
id: this.#nextId++,
phone,
weightGrams,
service,
priceCents: quotePriceCents(weightGrams, service),
receivedAt: this.#now(),
status: 'received',
};
this.#orders.set(order.id, order);
return structuredClone(order);
}
startWashing(id: number): StepResult {
const order = this.#orders.get(id);
if (!order) return { ok: false, code: 'NOT_FOUND' };
if (order.status !== 'received') return { ok: false, code: 'WRONG_STATE' };
return this.#save({ ...order, status: 'washing' });
}
markReady(id: number): StepResult {
const order = this.#orders.get(id);
if (!order) return { ok: false, code: 'NOT_FOUND' };
if (order.status !== 'washing') return { ok: false, code: 'WRONG_STATE' };
return this.#save({ ...order, status: 'ready', readyAt: this.#now() });
}
deliver(id: number, paidCents: number): DeliverResult | { ok: false; code: 'NOT_FOUND' } {
const order = this.#orders.get(id);
if (!order) return { ok: false, code: 'NOT_FOUND' };
const result = deliver(order, paidCents, this.#now());
if (result.ok) this.#orders.set(id, result.order);
return structuredClone(result);
}
readyFor(phone: string): ReadyOrder[] {
return [...this.#orders.values()]
.filter((o): o is ReadyOrder => o.status === 'ready' && o.phone === phone)
.map((o) => structuredClone(o));
}
revenueCents(): number {
return [...this.#orders.values()]
.filter((o) => o.status === 'delivered')
.reduce((sum, o) => sum + o.priceCents, 0);
}
#save(order: Order): StepResult {
this.#orders.set(order.id, order);
return { ok: true, order: structuredClone(order) };
}
}// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint. bad/ falla a propósito.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig(
{ ignores: ['build/', 'bad/'] },
js.configs.recommended,
tseslint.configs.recommended,
);// Los pedidos de la lavandería: los tipos dicen qué datos existen en cada
// estado, y las funciones dicen qué transiciones se permiten.
export type Service = 'normal' | 'express';
type OrderBase = {
id: number;
phone: string;
weightGrams: number;
service: Service;
priceCents: number;
receivedAt: string;
};
export type ReceivedOrder = OrderBase & { status: 'received' };
export type WashingOrder = OrderBase & { status: 'washing' };
export type ReadyOrder = OrderBase & { status: 'ready'; readyAt: string };
export type DeliveredOrder = OrderBase & { status: 'delivered'; readyAt: string; deliveredAt: string };
export type CancelledOrder = OrderBase & { status: 'cancelled'; cancelledAt: string };
export type Order = ReceivedOrder | WashingOrder | ReadyOrder | DeliveredOrder | CancelledOrder;
export const PRICE_PER_KILO_CENTS = 700;
// Parcial. Precondición: weightGrams es un entero positivo. Postcondición: el
// precio de los kilos completos, redondeando hacia arriba; el exprés cuesta el
// doble. El resultado es un entero, en céntimos.
export function quotePriceCents(weightGrams: number, service: Service): number {
const kilos = Math.ceil(weightGrams / 1000);
const base = kilos * PRICE_PER_KILO_CENTS;
return service === 'express' ? base * 2 : base;
}
// Parcial, con la precondición en el tipo: solo acepta un pedido listo, y tsc
// rechaza cualquier otro. Postcondición: un pedido nuevo, entregado en `now`,
// con los demás datos iguales; `order` no cambia.
export function markDelivered(order: ReadyOrder, now: string): DeliveredOrder {
return { ...order, status: 'delivered', deliveredAt: now };
}
export type DeliverResult =
| { ok: true; order: DeliveredOrder }
| { ok: false; code: 'NOT_READY' | 'ALREADY_DELIVERED' | 'CANCELLED' }
| { ok: false; code: 'WRONG_AMOUNT'; expectedCents: number };
export function assertNever(value: never): never {
throw new Error(`Caso no manejado: ${JSON.stringify(value)}`);
}
// Total: para cualquier pedido y cualquier monto dice qué pasó. Esta es la que
// se usa en la frontera, donde el pedido viene de la base de datos y su estado
// no se conoce al compilar. Que el monto sea el precio no lo pueden decir los
// tipos: se revisa al ejecutar.
export function deliver(order: Order, paidCents: number, now: string): DeliverResult {
switch (order.status) {
case 'received':
case 'washing':
return { ok: false, code: 'NOT_READY' };
case 'delivered':
return { ok: false, code: 'ALREADY_DELIVERED' };
case 'cancelled':
return { ok: false, code: 'CANCELLED' };
case 'ready':
if (paidCents !== order.priceCents) {
return { ok: false, code: 'WRONG_AMOUNT', expectedCents: order.priceCents };
}
return { ok: true, order: markDelivered(order, now) };
default:
return assertNever(order);
}
}// El fragmento del ejercicio de diseño: funciona y tiene varios problemas.
// est: 1 recibido, 2 lavando, 3 listo, 4 entregado.
export type OrderRow = { id: number; cust_phone: string; kg: number; est: number; amount: number };
export class OrderTable {
#rows: OrderRow[] = [];
all(): OrderRow[] {
return this.#rows;
}
insert(row: OrderRow): void {
this.#rows.push(row);
}
setEst(id: number, est: number): void {
const row = this.#rows.find((r) => r.id === id);
if (row) row.est = est;
}
}
export class OrderManager {
#table: OrderTable;
constructor(table: OrderTable) {
this.#table = table;
}
all(): OrderRow[] {
return this.#table.all();
}
insert(row: OrderRow): void {
this.#table.insert(row);
}
setEst(id: number, est: number): void {
this.#table.setEst(id, est);
}
}
// counter.ts: lo que ve quien atiende el mostrador.
export function readyForPickup(manager: OrderManager, phone: string): OrderRow[] {
return manager.all().filter((r) => r.cust_phone === phone && r.est === 3);
}
// cash.ts: la caja del día.
export function revenue(manager: OrderManager): number {
return manager.all().filter((r) => r.est === 4).reduce((sum, r) => sum + r.amount, 0);
}Errores
Ejemplos con errores a propósito: no compilan, fallan o dan un resultado incorrecto. Los apuntes explican cada uno.
// No compila: la precondición de markDelivered está en su tipo.
import { markDelivered } from '../order.ts';
import type { WashingOrder } from '../order.ts';
export function finish(order: WashingOrder) {
return markDelivered(order, '2026-03-18T17:00:00Z');
}// No compila: un pedido entregado sin fecha de entrega no se puede construir.
import type { DeliveredOrder } from '../order.ts';
export const order: DeliveredOrder = {
id: 7,
phone: '999111222',
weightGrams: 3200,
service: 'normal',
priceCents: 2800,
receivedAt: '2026-03-16T10:00:00Z',
readyAt: '2026-03-18T09:00:00Z',
status: 'delivered',
};// No compila: falta el caso 'cancelled', y assertNever recibe un valor que
// todavía puede existir.
import { assertNever } from '../order.ts';
import type { Order } from '../order.ts';
export function label(order: Order): string {
switch (order.status) {
case 'received':
return 'Recibido';
case 'washing':
return 'Lavando';
case 'ready':
return 'Listo para recoger';
case 'delivered':
return 'Entregado';
default:
return assertNever(order);
}
}{
"extends": "../tsconfig.json",
"include": ["*.ts"],
"exclude": []
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
// El ejercicio de APIs del capítulo, contra un servidor de verdad en un puerto
// libre: un POST repetido duplica, salvo que lleve una clave de idempotencia.
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
import { after, before, test } from 'node:test';
import type { Server } from 'node:http';
import type { DatabaseSync } from 'node:sqlite';
import { createApi, openDatabase } from './api.ts';
let db: DatabaseSync;
let server: Server;
let base: string;
before(async () => {
db = openDatabase();
server = createApi(db);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://localhost:${(server.address() as AddressInfo).port}`;
});
after(() => {
server.close();
});
function count(): number {
const { n } = db.prepare('SELECT count(*) AS n FROM orders').get() as { n: number };
return n;
}
function post(body: unknown, key?: string): Promise<Response> {
const headers: Record<string, string> = { 'content-type': 'application/json' };
if (key) headers['idempotency-key'] = key;
return fetch(`${base}/api/orders`, { method: 'POST', headers, body: JSON.stringify(body) });
}
const shirts = { phone: '999111222', weightGrams: 3200, service: 'normal' };
test('sin clave, repetir el POST crea dos pedidos', async () => {
const before = count();
const first = await post(shirts);
const second = await post(shirts);
assert.equal(first.status, 201);
assert.equal(second.status, 201);
assert.equal(count(), before + 2);
});
test('con clave, el reintento devuelve la misma respuesta y crea un solo pedido', async () => {
const before = count();
const first = await post(shirts, 'a1f3');
const second = await post(shirts, 'a1f3');
assert.equal(first.status, 201);
assert.equal(second.status, 201);
assert.equal(second.headers.get('location'), first.headers.get('location'));
assert.deepEqual(await second.json(), await first.json());
assert.equal(count(), before + 1);
});
test('la misma clave con otro cuerpo se rechaza con 422', async () => {
await post(shirts, 'b2c4');
const before = count();
const other = await post({ ...shirts, weightGrams: 5000 }, 'b2c4');
assert.equal(other.status, 422);
const body = (await other.json()) as { error: { code: string } };
assert.equal(body.error.code, 'IDEMPOTENCY_KEY_REUSED');
assert.equal(count(), before);
});
test('el cuerpo mal formado da 400 y la regla incumplida da 422', async () => {
const noWeight = await post({ phone: '999', service: 'normal' });
assert.equal(noWeight.status, 400);
const negative = await post({ ...shirts, weightGrams: -1 });
assert.equal(negative.status, 422);
});
test('anular: el efecto de repetirlo es el mismo, aunque la respuesta cambie', async () => {
const created = (await (await post(shirts)).json()) as { order: { id: number } };
const url = `${base}/api/orders/${created.order.id}/cancel`;
const status = () => db.prepare('SELECT status FROM orders WHERE id = ?').get(created.order.id);
const first = await fetch(url, { method: 'POST' });
assert.equal(first.status, 200);
const afterFirst = status();
const second = await fetch(url, { method: 'POST' });
assert.equal(second.status, 409);
assert.deepEqual(status(), afterFirst);
});
test('un pedido que no existe da 404', async () => {
const res = await fetch(`${base}/api/orders/99999/cancel`, { method: 'POST' });
assert.equal(res.status, 404);
});// El ejercicio de diseño del capítulo. Las primeras pruebas muestran que los
// problemas de shallow.ts son reales; las últimas, que OrderDesk los resuelve.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { OrderDesk } from './desk.ts';
import type { OrderDeskInterface } from './desk.ts';
import { OrderManager, OrderTable, readyForPickup, revenue } from './shallow.ts';
function manager(): OrderManager {
const m = new OrderManager(new OrderTable());
m.insert({ id: 1, cust_phone: '999', kg: 3, est: 3, amount: 2100 });
m.insert({ id: 2, cust_phone: '999', kg: 2, est: 4, amount: 1400 });
return m;
}
test('shallow: all() entrega la lista interna y se puede vaciar desde afuera', () => {
const m = manager();
m.all().length = 0;
assert.equal(revenue(m), 0);
assert.deepEqual(readyForPickup(m, '999'), []);
});
test('shallow: setEst acepta un código que no existe y saltos de estado', () => {
const m = manager();
m.setEst(1, 99);
assert.equal(m.all()[0]?.est, 99);
m.setEst(2, 1);
assert.equal(revenue(m), 0, 'un pedido entregado "volvió" a recibido y la caja bajó');
});
test('shallow: un estado nuevo no le avisa a nadie', () => {
const m = manager();
// 5 = anulado: un código que nadie declaró. Compila, y los que leen est
// simplemente dejan de ver el pedido, sin que nada les pregunte si está bien.
m.setEst(1, 5);
assert.deepEqual(readyForPickup(m, '999'), []);
});
function desk(): OrderDeskInterface {
return new OrderDesk(() => '2026-03-18T17:00:00Z');
}
test('desk: el camino completo, de recibido a entregado', () => {
const d = desk();
const order = d.receive('999', 3200, 'normal');
assert.equal(order.priceCents, 2800);
assert.equal(d.startWashing(order.id).ok, true);
assert.equal(d.markReady(order.id).ok, true);
assert.deepEqual(d.readyFor('999').map((o) => o.id), [order.id]);
assert.equal(d.deliver(order.id, 2800).ok, true);
assert.equal(d.revenueCents(), 2800);
assert.deepEqual(d.readyFor('999'), []);
});
test('desk: no se puede saltar un estado', () => {
const d = desk();
const order = d.receive('999', 1000, 'normal');
assert.deepEqual(d.markReady(order.id), { ok: false, code: 'WRONG_STATE' });
assert.deepEqual(d.deliver(order.id, 700), { ok: false, code: 'NOT_READY' });
assert.deepEqual(d.startWashing(999), { ok: false, code: 'NOT_FOUND' });
});
test('desk: lo que devuelve es una copia, no el estado interno', () => {
const d = desk();
const order = d.receive('999', 1000, 'normal');
order.priceCents = 1;
d.startWashing(order.id);
d.markReady(order.id);
const [ready] = d.readyFor('999');
assert.equal(ready?.priceCents, 700);
if (ready) ready.phone = 'otro';
assert.equal(d.readyFor('999').length, 1);
});// El ejercicio de especificación del capítulo: cada prueba es una cláusula de
// la especificación de deliver y de quotePriceCents.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { deliver, quotePriceCents } from './order.ts';
import type { Order, ReadyOrder } from './order.ts';
const NOW = '2026-03-18T17:00:00Z';
const ready: ReadyOrder = {
id: 7,
phone: '999111222',
weightGrams: 3200,
service: 'normal',
priceCents: 2800,
receivedAt: '2026-03-16T10:00:00Z',
status: 'ready',
readyAt: '2026-03-18T09:00:00Z',
};
test('el precio redondea al kilo superior y el exprés cuesta el doble', () => {
assert.equal(quotePriceCents(3200, 'normal'), 2800);
assert.equal(quotePriceCents(3000, 'normal'), 2100);
assert.equal(quotePriceCents(3200, 'express'), 5600);
});
test('entregar un pedido listo con el monto exacto cumple la postcondición', () => {
const result = deliver(ready, 2800, NOW);
assert.deepEqual(result, { ok: true, order: { ...ready, status: 'delivered', deliveredAt: NOW } });
});
test('entregar no cambia el pedido original', () => {
const before = structuredClone(ready);
deliver(ready, 2800, NOW);
assert.deepEqual(ready, before);
});
test('un monto distinto del precio se rechaza y dice cuánto era', () => {
assert.deepEqual(deliver(ready, 2000, NOW), { ok: false, code: 'WRONG_AMOUNT', expectedCents: 2800 });
});
test('deliver es total: cada estado tiene una respuesta definida', () => {
const { readyAt, ...base } = ready;
const cases: [Order, string][] = [
[{ ...base, status: 'received' }, 'NOT_READY'],
[{ ...base, status: 'washing' }, 'NOT_READY'],
[{ ...base, status: 'delivered', readyAt, deliveredAt: NOW }, 'ALREADY_DELIVERED'],
[{ ...base, status: 'cancelled', cancelledAt: NOW }, 'CANCELLED'],
];
for (const [order, code] of cases) {
assert.deepEqual(deliver(order, 2800, NOW), { ok: false, code });
}
});// Lo que el capítulo dice de tsc, comprobado con tsc: los archivos de bad/
// fallan donde el capítulo dice.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';
test('cada archivo de bad/ falla con el error que muestra el capítulo', () => {
const result = spawnSync('tsc', ['-p', 'bad', '--pretty', 'false'], { encoding: 'utf8' });
assert.notEqual(result.status, 0);
const errors = result.stdout
.split('\n')
.filter((line) => line.startsWith('bad/'))
.map((line) => line.replace(/^(bad\/[\w-]+\.ts)\((\d+),\d+\): error (TS\d+):.*$/, '$1:$2 $3'));
assert.deepEqual(errors, [
'bad/deliver-washing.ts:6 TS2345',
'bad/delivered-without-date.ts:4 TS2322',
'bad/missing-case.ts:17 TS2345',
]);
});Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 8: repaso para el examen parcial. Los ejercicios resueltos del
# capítulo son de una lavandería, un caso nuevo; no usan Kallpa. Se usa dentro
# del contenedor del curso.
#
# make no hay nada que preparar esta semana
# make test revisa los tipos (tsc), corre ESLint y las pruebas
# (*.test.ts), que comprueban lo que dice el capítulo
# make errores muestra los errores de tsc de bad/, que fallan a propósito
# make clean borra build/
.PHONY: all test errores clean
all:
@echo "Nada que preparar: prueben con make test."
test:
tsc -p .
eslint .
node --test "*.test.ts"
errores:
-tsc -p bad --pretty false
clean:
rm -rf build{
"name": "week-08",
"private": true,
"type": "module"
}{
"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"],
"exclude": ["build", "bad"]
}