Semana 5 — Complejidad, módulos profundos y conceptos
Objetivos
- Definir la complejidad de un sistema como lo que lo hace difícil de entender y de cambiar, y reconocer sus síntomas (amplificación de cambios, carga cognitiva, incógnitas desconocidas) y sus causas (dependencias y oscuridad).
- Distinguir módulos profundos de módulos superficiales, y diseñar una interfaz que esconda una decisión de diseño.
- Detectar abstracciones que se filtran, métodos que solo reenvían la llamada y conocimiento repetido en varios lugares.
- Describir una aplicación por sus conceptos (propósito, estado, acciones y principio operacional) y razonar sobre el acoplamiento y la cohesión entre módulos.
- Refactorizar sin cambiar el comportamiento, con las pruebas como red de seguridad.
Sesión A — Diseño: complejidad, módulos profundos y ocultamiento de información
Lecturas:
- John Ousterhout, A Philosophy of Software Design, 2.ª ed., caps. 2–5 (The Nature of Complexity, Working Code Isn’t Enough, Modules Should Be Deep, Information Hiding (and Leakage)). El libro no es gratuito: biblioteca o copia propia (página del libro).
- Si no tienen el libro: la charla de Ousterhout A Philosophy of Software Design (Talks at Google), que cubre las mismas ideas.
Lecturas complementarias:
- David Parnas, On the Criteria To Be Used in Decomposing Systems into Modules, Communications of the ACM, 1972.
- Ousterhout, caps. 6 (General-Purpose Modules are Deeper) y 7 (Different Layer, Different Abstraction).
Temas:
- Complejidad, según Ousterhout: todo lo que en la estructura de un sistema lo hace difícil de entender y de modificar. No es el tamaño: un sistema grande puede ser simple de cambiar y uno pequeño, imposible. Lo que cuenta es el costo de los cambios que de verdad se hacen.
- Tres síntomas. Amplificación de cambios: un cambio simple obliga a tocar muchos lugares (si el horario de cierre estuviera escrito como
22en cinco archivos, cerrar a las 23:00 tocaría los cinco). Carga cognitiva: cuánto hay que saber para hacer un cambio sin romper nada. Incógnitas desconocidas: no se sabe qué hay que tocar, ni siquiera que hay algo que saber. La tercera es la peor. Dos causas: las dependencias (no se puede entender un trozo de código solo) y la oscuridad (información importante que no es evidente). - Programación táctica frente a estratégica. La táctica busca que la tarea de hoy funcione; la estratégica invierte un poco en cada cambio (del 10 al 20 % del tiempo, propone el libro) para que el diseño mejore. “El código funciona” no basta. Error común: llamar “deuda técnica que pagaremos luego” a lo que en realidad es diseño que nadie va a revisar.
- Módulos profundos. Un módulo (una función, una clase, un archivo, un servicio) tiene una interfaz, que es lo que hay que saber para usarlo, y una implementación. El beneficio es la funcionalidad que da; el costo, la interfaz que hay que aprender. Un módulo profundo da mucho con una interfaz pequeña:
open,read,write,lseekyclosede Unix esconden discos, caches, permisos y sistemas de archivos. Un módulo superficial tiene una interfaz casi tan grande como lo que hace. Su señal más clara: métodos que solo reenvían la llamada a otro objeto con los mismos parámetros (pass-through methods, que Ousterhout trata en el cap. 7). - Ocultamiento de información (Parnas, 1972): cada módulo esconde una decisión de diseño que puede cambiar. Parnas compara dos formas de dividir el mismo programa: por los pasos del diagrama de flujo, o por las decisiones que cada módulo esconde. La segunda resiste mejor los cambios. En Kallpa,
src/domain/clock.tsesconde cómo se pasa de un instante a la hora de Lima: si mañana se cambiaIntl.DateTimeFormatpor otra cosa, nadie más se entera. - Abstracción que se filtra (information leakage): la misma decisión aparece en varios módulos, y cambiarla obliga a tocarlos todos. Ejemplos típicos: los nombres de las columnas de una tabla que llegan hasta la respuesta HTTP; un formato de archivo que conocen el lector y el escritor por separado. Una forma frecuente es la descomposición temporal: dividir el código según el orden en que pasan las cosas (leer, validar, guardar) en lugar de según lo que cada parte sabe.
- Error común: confundir “muchas clases pequeñas” con buen diseño. Separar todo en clases de tres líneas (classitis) multiplica las interfaces sin esconder nada. Un módulo más grande y profundo suele ser más simple de usar.
- Con IA. Un agente de programación trabaja de forma táctica por defecto: resuelve la tarea pedida y agrega código donde le resulta más cómodo, a veces con capas que solo reenvían llamadas. Con módulos profundos, al agente le basta leer una interfaz pequeña para usar bien un módulo, y su contexto alcanza para más. Revisen el diseño de lo que genera, no solo si las pruebas pasan.
Sesión B — Conceptos, acoplamiento y cohesión
Lecturas:
- Daniel Jackson, The Essence of Software, tutoriales en línea: Software = concepts, Concept purposes y Operational principles.
Lecturas complementarias:
- Jackson, Concept modularity y Concept dependencies and subsets; el sitio del libro.
- Ousterhout, cap. 9 (Better Together Or Better Apart?).
Temas:
- Un concepto (Jackson) es una unidad de funcionalidad que el usuario reconoce y que tiene sentido por sí sola. Tiene un propósito (para qué existe), un estado, unas acciones y un principio operacional: el escenario típico que muestra cómo cumple su propósito. Concepto reserva: propósito, asegurar el uso exclusivo de un recurso por un tiempo; principio, “si reservo la cancha de 19:00 a 21:00, a esa hora la cancha es mía y nadie más la puede reservar”.
- Kallpa como conjunto de conceptos: reserva, membresía (socio o regular, con descuento), tarifa con hora punta, cancelación con plazo, notificación. Cada uno se puede entender sin los demás, y se combinan con sincronizaciones: cuando se confirma una reserva, se envía una notificación. Un concepto con dos propósitos, o un propósito repartido en dos conceptos, son señales de mal diseño.
- Conceptos y requisitos: los turnos fijos de la semana 3, ¿son un concepto nuevo o una forma de usar reserva? La respuesta cambia el modelo de datos, la interfaz y las reglas de cancelación. Mejor decidirlo antes de escribir código.
- Acoplamiento: cuánto obliga un cambio en un módulo a cambiar otro. Cohesión: cuánto pertenecen juntas las partes de un módulo (una sola razón para cambiar). Se busca bajo acoplamiento y alta cohesión, pero la medida es la misma de la Sesión A: ¿cuánto cuesta el cambio que de verdad va a pasar?
- Formas de acoplamiento, de más a menos dañina: depender de los detalles internos de otro módulo; compartir estado global; pasar banderas que deciden qué hace la función que se llama; pasar estructuras enteras cuando se usa un campo; pasar solo los datos necesarios. El acoplamiento también puede ser oculto: dos módulos que tienen que cambiar juntos porque comparten un supuesto que no está escrito en ninguna parte.
- DRY (don’t repeat yourself) es sobre conocimiento, no sobre texto: cada regla del negocio debe tener una sola representación. Dos funciones con código parecido que representan reglas distintas no se juntan; una regla (“qué reservas ocupan la cancha”) escrita en tres lugares sí es un problema, aunque cada copia se vea distinta. Error común: juntar código solo porque se parece. Esa abstracción falsa acopla dos cosas que iban a cambiar por separado.
- Juntar o separar (cap. 9 de Ousterhout): se junta lo que comparte información, lo que siempre se usa junto o lo que se entiende mejor visto junto. Se separa lo que es de propósito general de lo que es de un caso particular.
- Dirección de las dependencias: en Kallpa,
httpusaservices,servicesusastoreydomainno depende de nada. Las reglas del negocio se prueban sin base de datos ni HTTP gracias a eso. Una dependencia que va al revés (el dominio que importa algo dehttp) rompe esa propiedad.
Laboratorio — Refactorizar un módulo superficial de Kallpa
Guía: este documento. Referencias: Ousterhout, caps. 4 y 5 (o la charla); Martin Fowler, Refactoring.
Refactorizar es cambiar la estructura del código sin cambiar su comportamiento. La regla del laboratorio: make check pasa al final sin tocar nada en test/. Las pruebas HTTP de Kallpa levantan el servidor completo, así que son la red de seguridad. En parejas, en una rama nueva de su copia de Kallpa.
- Punto de partida:
make checken verde. Medir el código de la capa HTTP y de servicios conwc -l src/http/handlers.ts src/services/*.ts, y ver qué importahandlers.tsleyendo sus primeras líneas (head -25 src/http/handlers.ts): de qué carpetas trae cosas y qué nombres. Anotar los números. - Diagnóstico, en
docs/diseno-reservas.md, con evidencia (archivo y función). ¿Qué módulo desrc/services/tiene métodos que solo reenvían la llamada? ¿Qué sabe la capa HTTP que no es de HTTP (columnas de la base de datos, el reloj, el precio, qué estados ocupan una cancha)? ¿Qué conocimiento está repetido en más de un lugar? (grep -rn "confirmed" srces un buen comienzo.) ¿Qué conversiones de tipo no revisa nadie? - Diseñar la interfaz antes de implementar: escribir en TypeScript el tipo del módulo nuevo (por ejemplo, un
Bookingsconbook(request)ycancel(id)) y un comentario por operación con lo que promete y lo que esconde. Los errores del negocio salen como un resultado tipado o un error del dominio, no como unHttpError. Otra pareja revisa la interfaz con una pregunta: ¿qué tiene que saber un handler para usarla? - Implementar el módulo en
src/services/, usandostore/ydomain/, y conectarlo ensrc/http/server.tssin cambiar la firma decreateServer, que es lo que usan las pruebas. Los handlers quedan en traducir: leer la solicitud, llamar al módulo y convertir el resultado en una respuesta con su código HTTP. - Hacer el cambio en pasos pequeños, con un commit por paso y
make checkdespués de cada uno. Si encuentran un comportamiento que parece un error, no lo corrijan aquí: abran un issue en el tablero. Un refactoring que cambia el comportamiento ya no es un refactoring, y mezclar los dos cambios hace imposible revisarlos. - Medir de nuevo, como en el paso 1. ¿Cuántos lugares conocen ahora los nombres de las columnas y la regla de qué reservas ocupan una cancha? ¿La interfaz nueva es más pequeña que lo que esconde?
- Abrir un pull request con el antes y el después (números del paso 1 y del paso 6), lo que el módulo esconde ahora y lo que todavía se filtra. Revisar el de otra pareja.
- Opcional: pedirle el mismo refactoring a un agente, con la misma regla escrita en el pedido. ¿Respetó
test/? ¿Su módulo es profundo o agregó una capa que reenvía? Anótenlo endocs/registro-ia.md.
Entregas
- P1 entrega: cliente, entrevistas, historias de usuario con criterios de aceptación, backlog priorizado y prototipo (
projects/p1-requisitos/). - P2 publicado: Diseño y primer sprint: design doc, ADRs, especificación de la API y un esqueleto que funciona de punta a punta (
projects/p2-diseno/). Entrega en semana 8, con la demo del sprint 1.
Preguntas de repaso
- Den un ejemplo de amplificación de cambios, uno de carga cognitiva y uno de incógnita desconocida en un programa que hayan escrito.
- ¿Por qué un módulo con diez métodos de una línea que llaman a otro objeto es superficial? ¿Qué tendría que esconder para volverse profundo?
- Expliquen el argumento de Parnas con un ejemplo de Kallpa: ¿qué decisión de diseño escondería cada módulo?
- Describan el concepto cancelación con plazo de Kallpa con propósito, estado, acciones y principio operacional.
- Dos funciones de Kallpa tienen el mismo código de tres líneas. ¿Cuándo conviene juntarlas en una y cuándo no?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (13)Pruebas (6)Compilación (3)
Ejemplos
Los programas que usan los apuntes y la clase.
// 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/', 'solution/'] },
js.configs.recommended,
tseslint.configs.recommended,
);// Las dependencias entre las capas de Kallpa, leídas de los imports.
//
// node layers.ts [carpeta src] (por omisión, ../../kallpa/src)
//
// Una capa puede usar las de abajo, nunca las de arriba. Las de abajo no
// saben que existen las de arriba: por eso domain se prueba sin base de datos
// ni HTTP.
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import ts from 'typescript';
export const LEVEL: Record<string, number> = {
domain: 0,
store: 1,
notifications: 1,
services: 2,
http: 3,
'(raíz)': 4, // main.ts, config.ts y seed.ts arman todo
};
export type Edge = { from: string; to: string; file: string; target: string; typeOnly: boolean };
function layerOf(srcDir: string, file: string): string {
const parts = relative(srcDir, file).split(sep);
return parts.length === 1 ? '(raíz)' : parts[0];
}
function listTs(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return listTs(path);
return entry.name.endsWith('.ts') ? [path] : [];
});
}
export function edges(srcDir: string): Edge[] {
const root = resolve(srcDir);
const result: Edge[] = [];
for (const file of listTs(root).sort()) {
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
for (const node of source.statements) {
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) continue;
const spec = node.moduleSpecifier.text;
if (!spec.startsWith('.')) continue; // node:sqlite, node:http...
const target = resolve(dirname(file), spec);
const from = layerOf(root, file);
const to = layerOf(root, target);
if (from === to) continue;
const clause = node.importClause;
const typeOnly =
clause !== undefined &&
(clause.isTypeOnly ||
(clause.name === undefined &&
clause.namedBindings !== undefined &&
ts.isNamedImports(clause.namedBindings) &&
clause.namedBindings.elements.every((e) => e.isTypeOnly)));
result.push({ from, to, file: relative(root, file), target: relative(root, target), typeOnly });
}
}
return result;
}
// Una dependencia va "hacia arriba" si la capa importada no está más abajo.
export function violations(all: Edge[]): Edge[] {
return all.filter((e) => !(LEVEL[e.to] < LEVEL[e.from]));
}
export function summary(all: Edge[]): string {
const groups = new Map<string, Edge[]>();
for (const e of all) {
const key = `${e.from} → ${e.to}`;
groups.set(key, [...(groups.get(key) ?? []), e]);
}
const lines = [...groups.entries()]
.sort(([, a], [, b]) => LEVEL[b[0].from] - LEVEL[a[0].from] || LEVEL[b[0].to] - LEVEL[a[0].to])
.map(([key, list]) => {
const types = list.filter((e) => e.typeOnly).length;
const note = types === list.length ? ' (solo tipos)' : types > 0 ? ` (${types} solo tipos)` : '';
return `${key.padEnd(24)} ${String(list.length).padStart(2)} ${list.length === 1 ? 'import' : 'imports'}${note}`;
});
const bad = violations(all);
lines.push(
bad.length === 0
? 'Ninguna dependencia va hacia arriba.'
: bad.map((e) => `HACIA ARRIBA: ${e.file} importa ${e.target}`).join('\n'),
);
return lines.join('\n');
}
if (import.meta.main) {
const src = process.argv[2] ?? new URL('../../kallpa/src', import.meta.url).pathname;
const all = edges(src);
console.log(summary(all));
if (violations(all).length > 0) process.exit(1);
}// Busca métodos que solo reenvían la llamada (pass-through methods): su cuerpo
// es una sola llamada a un método de un campo del objeto. Usa el compilador de
// TypeScript para leer el código, no expresiones regulares.
//
// node pass-through.ts archivo.ts [otro.ts ...]
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import ts from 'typescript';
export type MethodReport = {
name: string;
params: string[];
// A dónde reenvía ('this.#repo.findItem'), o null si hace algo más.
forwardsTo: string | null;
// true si pasa exactamente los mismos parámetros, en el mismo orden.
sameArguments: boolean;
};
export type ClassReport = { name: string; lines: number; methods: MethodReport[] };
export function analyze(source: string, fileName = 'input.ts'): ClassReport[] {
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
const classes: ClassReport[] = [];
file.forEachChild((node) => {
if (!ts.isClassDeclaration(node) || !node.name) return;
const start = file.getLineAndCharacterOfPosition(node.getStart()).line;
const end = file.getLineAndCharacterOfPosition(node.getEnd()).line;
const methods = node.members
.filter(ts.isMethodDeclaration)
.filter((m) => !ts.isPrivateIdentifier(m.name) && !isPrivate(m))
.map((m) => report(m, file));
classes.push({ name: node.name.text, lines: end - start + 1, methods });
});
return classes;
}
function isPrivate(m: ts.MethodDeclaration): boolean {
return (ts.getCombinedModifierFlags(m) & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) !== 0;
}
function report(method: ts.MethodDeclaration, file: ts.SourceFile): MethodReport {
const name = method.name.getText(file);
const params = method.parameters.map((p) => p.name.getText(file));
const call = singleCall(method.body);
if (!call || !isFieldMethod(call.expression)) return { name, params, forwardsTo: null, sameArguments: false };
const args = call.arguments.map((a) => a.getText(file));
return {
name,
params,
forwardsTo: call.expression.getText(file),
sameArguments: args.length === params.length && args.every((a, i) => a === params[i]),
};
}
// El cuerpo es `return x.y(...);` o `x.y(...);` y nada más.
function singleCall(body: ts.Block | undefined): ts.CallExpression | null {
if (!body || body.statements.length !== 1) return null;
const [statement] = body.statements;
const expression = ts.isReturnStatement(statement)
? statement.expression
: ts.isExpressionStatement(statement)
? statement.expression
: undefined;
return expression && ts.isCallExpression(expression) ? expression : null;
}
// this.campo.metodo o this.#campo.metodo
function isFieldMethod(callee: ts.Expression): boolean {
return (
ts.isPropertyAccessExpression(callee) &&
ts.isPropertyAccessExpression(callee.expression) &&
callee.expression.expression.kind === ts.SyntaxKind.ThisKeyword
);
}
export function format(file: string, classes: ClassReport[]): string {
const out: string[] = [];
for (const c of classes) {
const forwarding = c.methods.filter((m) => m.forwardsTo !== null);
out.push(`${basename(file)}: clase ${c.name}, ${c.lines} líneas, ${c.methods.length} métodos públicos`);
for (const m of c.methods) {
const signature = `${m.name}(${m.params.join(', ')})`;
const verdict = m.forwardsTo === null ? 'hace algo más' : `reenvía a ${m.forwardsTo}${m.sameArguments ? '' : ' (cambia los argumentos)'}`;
out.push(` ${signature.padEnd(36)} ${verdict}`);
}
out.push(` ${forwarding.length} de ${c.methods.length} métodos solo reenvían la llamada`);
}
return out.join('\n');
}
if (import.meta.main) {
const files = process.argv.slice(2);
if (files.length === 0) {
console.error('Uso: node pass-through.ts archivo.ts [otro.ts ...]');
process.exit(2);
}
for (const file of files) console.log(format(file, analyze(readFileSync(file, 'utf8'), file)));
}--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -59,10 +59,7 @@
export function courtAvailability(deps: AppDependencies): Handler {
return ({ res, url, params }) => {
const courtId = Number(params.id);
- const date = url.searchParams.get('date');
- if (!isValidDate(date)) {
- throw new HttpError(400, 'INVALID_DATE', 'Indiquen la fecha con el formato AAAA-MM-DD (?date=2026-03-14).');
- }
+ const date = requireDate(url);
const taken = deps.service.bookingsForCourt(courtId, date).filter((b) => b.status === 'confirmed');
const slots = [];
for (let hour = OPENING_HOUR; hour < CLOSING_HOUR; hour++) {
@@ -155,15 +152,22 @@
// GET /api/reports/daily?date=YYYY-MM-DD: la caja del día, para la administración.
export function dailyReport(deps: AppDependencies): Handler {
return ({ res, url }) => {
- const date = url.searchParams.get('date');
- if (!isValidDate(date)) {
- throw new HttpError(400, 'INVALID_DATE', 'Indiquen la fecha con el formato AAAA-MM-DD (?date=2026-03-14).');
- }
+ const date = requireDate(url);
const bookings = deps.service.bookingsOn(date).map(toBookingJson);
sendJson(res, 200, dailySummary(date, deps.service.listCourts(), bookings));
};
}
+// La fecha de la consulta (?date=AAAA-MM-DD): la piden la disponibilidad y la
+// caja del día.
+function requireDate(url: URL): string {
+ const date = url.searchParams.get('date');
+ if (!isValidDate(date)) {
+ throw new HttpError(400, 'INVALID_DATE', 'Indiquen la fecha con el formato AAAA-MM-DD (?date=2026-03-14).');
+ }
+ return date;
+}
+
// Revisa la forma del cuerpo; las reglas del negocio se revisan después.
function parseBookingRequest(body: unknown): BookingRequest {
if (typeof body !== 'object' || body === null || Array.isArray(body)) {// Una aplicación pequeña armada con tres conceptos independientes. Ningún
// concepto importa a otro: solo este archivo los conoce y los sincroniza.
//
// cuando Reserva.reserve(u, cancha, bloque) devuelve id
// entonces Plazo.register(id, inicio del bloque)
// y Notificación.notify(u, "reserva confirmada")
// cuando alguien pide cancelar id
// si Plazo.stillOpen(id, ahora) entonces Reserva.cancel(id)
import { Deadline } from './deadline.ts';
import { Notification } from './notification.ts';
import { Reservation, type Slot } from './reservation.ts';
export type WallTime = { day: string; hour: number; minute: number };
// Minutos desde un origen fijo, para comparar dos horas de pared.
function minutes({ day, hour, minute }: WallTime): number {
const [y, m, d] = day.split('-').map(Number);
return Date.UTC(y, m - 1, d, hour, minute) / 60_000;
}
export class CourtApp {
readonly reservations = new Reservation<number, string>(); // cancha, celular
readonly deadlines = new Deadline<number>(3);
readonly notifications = new Notification<string>();
book(phone: string, courtId: number, slot: Slot): number | 'taken' {
const result = this.reservations.reserve(phone, courtId, slot);
if (!result.ok) return 'taken';
this.deadlines.register(result.id, minutes({ day: slot.day, hour: slot.from, minute: 0 }));
this.notifications.notify(phone, `Reserva confirmada: cancha ${courtId}, ${slot.day} de ${slot.from}:00 a ${slot.to}:00.`);
return result.id;
}
cancel(id: number, now: WallTime): 'cancelled' | 'too-late' | 'unknown' {
if (this.reservations.slotOf(id) === undefined) return 'unknown';
if (!this.deadlines.stillOpen(id, minutes(now))) return 'too-late';
return this.reservations.cancel(id) ? 'cancelled' : 'unknown';
}
}// concepto Plazo [Cosa]
//
// propósito dejar que alguien se arrepienta sin que el otro lado se entere
// a último momento
// principio si una cosa empieza en el instante t y el plazo es de h horas,
// antes de t - h todavía se puede deshacer; desde t - h, ya no
// estado inicio: Cosa -> instante; horas de aviso (fijas)
// acciones register(cosa, inicio)
// stillOpen(cosa, ahora) -> sí | no
//
// Tampoco sabe de reservas: el mismo concepto sirve para devolver una compra
// o para retirarse de un curso. Los instantes son minutos contados desde un
// origen cualquiera; quien lo usa decide cuál.
export class Deadline<T> {
#noticeMinutes: number;
#startsAt = new Map<T, number>();
constructor(noticeHours: number) {
this.#noticeMinutes = noticeHours * 60;
}
register(thing: T, startsAt: number): void {
this.#startsAt.set(thing, startsAt);
}
stillOpen(thing: T, now: number): boolean {
const start = this.#startsAt.get(thing);
return start !== undefined && start - now >= this.#noticeMinutes;
}
}// concepto Notificación [Destinatario]
//
// propósito avisar a alguien de algo que le importa sin que tenga que
// revisar la aplicación
// principio si se notifica a un destinatario, el mensaje le llega por su
// canal
// estado enviados: lista de (destinatario, texto)
// acciones notify(destinatario, texto)
//
// El canal (WhatsApp, correo, la consola) es un detalle de la implementación:
// aquí los mensajes se guardan en una lista para poder revisarlos.
export class Notification<To> {
readonly sent: { to: To; text: string }[] = [];
notify(to: To, text: string): void {
this.sent.push({ to, text });
}
}// concepto Reserva [Recurso, Usuario]
//
// propósito asegurar a alguien el uso exclusivo de un recurso durante un
// tiempo
// principio si reservo un recurso para un bloque de horas, en esas horas el
// recurso es mío: nadie más lo puede reservar; si cancelo, el
// bloque vuelve a estar libre
// estado reservas: Id -> (recurso, usuario, día, desde, hasta, activa)
// acciones reserve(usuario, recurso, día, desde, hasta) -> Id | ocupado
// cancel(id)
// holder(recurso, día, hora) -> Usuario | nadie
//
// Es genérico: no sabe qué es una cancha ni qué es un vecino. Funcionaría
// igual para salas de reuniones o para la parrilla del club.
export type Slot = { day: string; from: number; to: number };
type Entry<R, U> = { resource: R; user: U; slot: Slot; active: boolean };
export class Reservation<R, U> {
#entries = new Map<number, Entry<R, U>>();
#nextId = 1;
reserve(user: U, resource: R, slot: Slot): { ok: true; id: number } | { ok: false } {
for (const e of this.#entries.values()) {
if (e.active && e.resource === resource && e.slot.day === slot.day && e.slot.from < slot.to && slot.from < e.slot.to) {
return { ok: false };
}
}
const id = this.#nextId++;
this.#entries.set(id, { resource, user, slot, active: true });
return { ok: true, id };
}
cancel(id: number): boolean {
const entry = this.#entries.get(id);
if (!entry || !entry.active) return false;
entry.active = false;
return true;
}
holder(resource: R, day: string, hour: number): U | undefined {
for (const e of this.#entries.values()) {
if (e.active && e.resource === resource && e.slot.day === day && e.slot.from <= hour && hour < e.slot.to) return e.user;
}
return undefined;
}
slotOf(id: number): Slot | undefined {
return this.#entries.get(id)?.slot;
}
}// El índice KWIC de Parnas (1972), dividido como un diagrama de flujo: un
// módulo por paso (leer, rotar, ordenar, imprimir). Los módulos se pasan
// arreglos y todos conocen su formato.
// El formato compartido: todas las palabras en un solo texto, cada una
// terminada en END; lineStart[i] es dónde empieza la línea i en ese texto.
export const END = '\u0000';
export type Store = { chars: string; lineStart: number[] };
// Un corrimiento circular: [línea, posición en chars donde empieza].
export type Shift = [line: number, start: number];
// Módulo 1, entrada: lee las líneas y las guarda en el formato de arriba.
export function input(text: string): Store {
let chars = '';
const lineStart: number[] = [];
for (const line of text.split('\n').filter((l) => l.trim() !== '')) {
lineStart.push(chars.length);
for (const word of line.trim().split(/\s+/)) chars += word + END;
}
return { chars, lineStart };
}
// Módulo 2, corrimientos circulares: un par por cada palabra de cada línea.
export function circularShift(store: Store): Shift[] {
const shifts: Shift[] = [];
store.lineStart.forEach((start, line) => {
const end = store.lineStart[line + 1] ?? store.chars.length;
shifts.push([line, start]);
for (let i = start; i < end - 1; i++) if (store.chars[i] === END) shifts.push([line, i + 1]);
});
return shifts;
}
// Para comparar, rearma el texto de un corrimiento: tiene que saber cómo
// están guardadas las palabras y dónde termina cada línea.
function shiftText(store: Store, [line, start]: Shift): string {
const lineBegin = store.lineStart[line];
const lineEnd = store.lineStart[line + 1] ?? store.chars.length;
const rotated = store.chars.slice(start, lineEnd) + store.chars.slice(lineBegin, start);
return rotated.split(END).filter(Boolean).join(' ');
}
// Módulo 3, orden alfabético: recibe los arreglos de los módulos 1 y 2.
export function alphabetize(store: Store, shifts: Shift[]): Shift[] {
return [...shifts].sort((a, b) => compare(shiftText(store, a), shiftText(store, b)));
}
export function compare(a: string, b: string): number {
const x = a.toLowerCase();
const y = b.toLowerCase();
return x < y ? -1 : x > y ? 1 : 0;
}
// Módulo 4, salida: vuelve a leer chars y lineStart para imprimir.
export function output(store: Store, sorted: Shift[]): string {
return sorted.map((shift) => shiftText(store, shift)).join('\n');
}
// Módulo 5, control: llama a los demás en orden.
export function kwic(text: string): string {
const store = input(text);
return output(store, alphabetize(store, circularShift(store)));
}// El mismo índice KWIC dividido según lo que cada módulo esconde (la segunda
// propuesta de Parnas). Nadie fuera de LineStorage sabe cómo se guardan las
// palabras; nadie fuera de CircularShifter sabe si los corrimientos se
// guardan o se calculan; nadie fuera de Alphabetizer sabe cuándo se ordena.
import { compare } from './flowchart.ts';
// Lo que los demás ven del almacenamiento de líneas.
export type Lines = {
addLine(words: string[]): void;
lineCount(): number;
wordCount(line: number): number;
word(line: number, w: number): string;
};
// Esconde el formato: aquí, el mismo texto empaquetado de flowchart.ts.
export class PackedLines implements Lines {
#chars = '';
#wordStarts: number[][] = [];
addLine(words: string[]): void {
const starts: number[] = [];
for (const word of words) {
starts.push(this.#chars.length);
this.#chars += word + '\u0000';
}
this.#wordStarts.push(starts);
}
lineCount(): number {
return this.#wordStarts.length;
}
wordCount(line: number): number {
return this.#wordStarts[line].length;
}
word(line: number, w: number): string {
const start = this.#wordStarts[line][w];
return this.#chars.slice(start, this.#chars.indexOf('\u0000', start));
}
}
// Otra decisión, la misma interfaz: un arreglo de palabras por línea.
export class ArrayLines implements Lines {
#lines: string[][] = [];
addLine(words: string[]): void {
this.#lines.push([...words]);
}
lineCount(): number {
return this.#lines.length;
}
wordCount(line: number): number {
return this.#lines[line].length;
}
word(line: number, w: number): string {
return this.#lines[line][w];
}
}
export function input(text: string, lines: Lines): void {
for (const line of text.split('\n').filter((l) => l.trim() !== '')) lines.addLine(line.trim().split(/\s+/));
}
// Lo que los demás ven de los corrimientos: "como si" estuvieran guardados.
export type Shifts = { count(): number; words(i: number): string[] };
// Esconde que guarda un índice [línea, primera palabra] en vez de copias.
export class IndexedShifter implements Shifts {
#lines: Lines;
#index: [line: number, first: number][] = [];
constructor(lines: Lines) {
this.#lines = lines;
for (let line = 0; line < lines.lineCount(); line++) {
for (let first = 0; first < lines.wordCount(line); first++) this.#index.push([line, first]);
}
}
count(): number {
return this.#index.length;
}
words(i: number): string[] {
const [line, first] = this.#index[i];
const n = this.#lines.wordCount(line);
return Array.from({ length: n }, (_, k) => this.#lines.word(line, (first + k) % n));
}
}
// Esconde el orden: se ordena una vez, al construirlo; ith(i) es el i-ésimo.
export class Alphabetizer {
#order: number[];
constructor(shifts: Shifts) {
const text = (i: number) => shifts.words(i).join(' ');
this.#order = Array.from({ length: shifts.count() }, (_, i) => i).sort((a, b) => compare(text(a), text(b)));
}
ith(i: number): number {
return this.#order[i];
}
}
export function output(shifts: Shifts, alphabetizer: Alphabetizer): string {
return Array.from({ length: shifts.count() }, (_, i) => shifts.words(alphabetizer.ith(i)).join(' ')).join('\n');
}
export function kwic(text: string, lines: Lines = new PackedLines()): string {
input(text, lines);
const shifts = new IndexedShifter(lines);
return output(shifts, new Alphabetizer(shifts));
}import type { DatabaseSync } from 'node:sqlite';
import type { Now, Request, Response } from './schema.ts';
// Versión profunda. Mismo comportamiento que shallow.ts, pero un solo módulo
// sabe cómo se guardan los préstamos y cuándo un préstamo está abierto.
export type Loan = {
id: number;
itemId: number;
memberId: number;
lentAt: string;
returnedAt: string | null;
};
export type ItemStatus = { id: number; name: string; available: boolean };
export type LoanError = {
code: 'ITEM_NOT_FOUND' | 'ITEM_LENT' | 'MEMBER_HAS_LOAN' | 'LOAN_NOT_FOUND' | 'ALREADY_RETURNED';
message: string;
};
export type LoanResult = { ok: true; loan: Loan } | { ok: false; error: LoanError };
type LoanRow = { id: number; item_id: number; member_id: number; lent_at: string; returned_at: string | null };
// Interfaz: tres operaciones. Esconde la tabla y sus columnas, la regla de
// qué préstamo está abierto (está escrita una sola vez, en el SQL de abajo),
// el reloj y el orden en que se revisan las reglas.
export class Loans {
#db: DatabaseSync;
#now: Now;
constructor(db: DatabaseSync, now: Now) {
this.#db = db;
this.#now = now;
}
// Los implementos con su estado: disponible si no tiene un préstamo abierto.
items(): ItemStatus[] {
const rows = this.#db
.prepare(
`SELECT id, name, NOT EXISTS (SELECT 1 FROM loans WHERE ${OPEN} AND loans.item_id = items.id) AS available
FROM items ORDER BY id`,
)
.all() as { id: number; name: string; available: number }[];
return rows.map((r) => ({ id: r.id, name: r.name, available: r.available === 1 }));
}
// Presta el implemento si existe, está libre y el vecino no tiene otro.
lend(itemId: number, memberId: number): LoanResult {
if (!this.#db.prepare('SELECT 1 FROM items WHERE id = ?').get(itemId)) {
return fail('ITEM_NOT_FOUND', `No existe el implemento ${itemId}.`);
}
if (this.#openLoan('item_id', itemId)) return fail('ITEM_LENT', 'El implemento está prestado.');
if (this.#openLoan('member_id', memberId)) {
return fail('MEMBER_HAS_LOAN', 'Primero hay que devolver el implemento anterior.');
}
const { lastInsertRowid } = this.#db
.prepare('INSERT INTO loans (item_id, member_id, lent_at) VALUES (?, ?, ?)')
.run(itemId, memberId, this.#now());
return { ok: true, loan: this.#find(Number(lastInsertRowid))! };
}
// Registra la devolución; un préstamo se devuelve una sola vez.
giveBack(loanId: number): LoanResult {
const loan = this.#find(loanId);
if (!loan) return fail('LOAN_NOT_FOUND', `No existe el préstamo ${loanId}.`);
if (loan.returnedAt !== null) return fail('ALREADY_RETURNED', 'El implemento ya fue devuelto.');
this.#db.prepare('UPDATE loans SET returned_at = ? WHERE id = ?').run(this.#now(), loanId);
return { ok: true, loan: this.#find(loanId)! };
}
#openLoan(column: 'item_id' | 'member_id', id: number): boolean {
return this.#db.prepare(`SELECT 1 FROM loans WHERE ${OPEN} AND ${column} = ?`).get(id) !== undefined;
}
#find(id: number): Loan | undefined {
const row = this.#db.prepare('SELECT * FROM loans WHERE id = ?').get(id) as LoanRow | undefined;
return row && { id: row.id, itemId: row.item_id, memberId: row.member_id, lentAt: row.lent_at, returnedAt: row.returned_at };
}
}
// La regla "un préstamo está abierto", en un solo lugar.
const OPEN = 'returned_at IS NULL';
function fail(code: LoanError['code'], message: string): LoanResult {
return { ok: false, error: { code, message } };
}
// La capa HTTP solo traduce: lee la solicitud, llama al módulo y elige el
// código HTTP.
const STATUS: Record<LoanError['code'], number> = {
ITEM_NOT_FOUND: 404,
LOAN_NOT_FOUND: 404,
ITEM_LENT: 409,
MEMBER_HAS_LOAN: 409,
ALREADY_RETURNED: 409,
};
function respond(result: LoanResult): Response {
return result.ok
? { status: 200, body: { loan: result.loan } }
: { status: STATUS[result.error.code], body: { error: result.error } };
}
export function handleDeep(loans: Loans, req: Request): Response {
if (req.method === 'GET' && req.path === '/items') return { status: 200, body: { items: loans.items() } };
if (req.method === 'POST' && req.path === '/loans') {
return respond(loans.lend(Number(req.body?.itemId), Number(req.body?.memberId)));
}
const match = /^\/loans\/(\d+)\/return$/.exec(req.path);
if (req.method === 'POST' && match) return respond(loans.giveBack(Number(match[1])));
return { status: 404, body: { error: { code: 'NOT_FOUND', message: `No existe la ruta ${req.path}.` } } };
}import { DatabaseSync } from 'node:sqlite';
// Préstamo de implementos: en la administración del complejo se prestan
// pelotas y redes a quien tiene una reserva. Las dos versiones del ejemplo
// (shallow.ts y deep.ts) usan este mismo esquema y los mismos datos.
export function openLoansDatabase(): DatabaseSync {
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE items (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE loans (
id INTEGER PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES items (id),
member_id INTEGER NOT NULL,
lent_at TEXT NOT NULL,
returned_at TEXT
);
`);
const insert = db.prepare('INSERT INTO items (name) VALUES (?)');
for (const name of ['Pelota de fulbito', 'Pelota de vóley', 'Red de vóley']) insert.run(name);
return db;
}
// Lo que ve quien llama: una solicitud y una respuesta, sin servidor HTTP de
// por medio. Alcanza para comparar las dos versiones con el mismo guion.
export type Request = { method: 'GET' | 'POST'; path: string; body?: Record<string, unknown> };
export type Response = { status: number; body: unknown };
// "Ahora", inyectable como en Kallpa: las pruebas fijan la hora.
export type Now = () => string;import type { DatabaseSync } from 'node:sqlite';
import type { Now, Request, Response } from './schema.ts';
// Versión superficial. Funciona y pasa sus pruebas, pero el diseño reparte el
// conocimiento: las columnas de la tabla, la regla "un préstamo está abierto
// si no tiene returned_at" y los códigos HTTP aparecen en varios lugares.
export type ItemRow = { id: number; name: string };
export type LoanRow = {
id: number;
item_id: number;
member_id: number;
lent_at: string;
returned_at: string | null;
};
export class LoanRepository {
#db: DatabaseSync;
constructor(db: DatabaseSync) {
this.#db = db;
}
listItems(): ItemRow[] {
return this.#db.prepare('SELECT id, name FROM items ORDER BY id').all() as ItemRow[];
}
findItem(id: number): ItemRow | undefined {
return this.#db.prepare('SELECT id, name FROM items WHERE id = ?').get(id) as ItemRow | undefined;
}
findLoan(id: number): LoanRow | undefined {
return this.#db.prepare('SELECT * FROM loans WHERE id = ?').get(id) as LoanRow | undefined;
}
// Todos los préstamos del implemento, también los ya devueltos.
loansForItem(itemId: number): LoanRow[] {
return this.#db.prepare('SELECT * FROM loans WHERE item_id = ?').all(itemId) as LoanRow[];
}
loansForMember(memberId: number): LoanRow[] {
return this.#db.prepare('SELECT * FROM loans WHERE member_id = ?').all(memberId) as LoanRow[];
}
insert(itemId: number, memberId: number, lentAt: string): number {
const result = this.#db
.prepare('INSERT INTO loans (item_id, member_id, lent_at) VALUES (?, ?, ?)')
.run(itemId, memberId, lentAt);
return Number(result.lastInsertRowid);
}
setReturnedAt(id: number, returnedAt: string): void {
this.#db.prepare('UPDATE loans SET returned_at = ? WHERE id = ?').run(returnedAt, id);
}
}
// La "capa de servicios": cada método reenvía la llamada al repositorio.
export class LoanService {
#repo: LoanRepository;
constructor(db: DatabaseSync) {
this.#repo = new LoanRepository(db);
}
listItems(): ItemRow[] {
return this.#repo.listItems();
}
findItem(id: number): ItemRow | undefined {
return this.#repo.findItem(id);
}
findLoan(id: number): LoanRow | undefined {
return this.#repo.findLoan(id);
}
loansForItem(itemId: number): LoanRow[] {
return this.#repo.loansForItem(itemId);
}
loansForMember(memberId: number): LoanRow[] {
return this.#repo.loansForMember(memberId);
}
saveLoan(itemId: number, memberId: number, lentAt: string): number {
return this.#repo.insert(itemId, memberId, lentAt);
}
markReturned(id: number, returnedAt: string): void {
this.#repo.setReturnedAt(id, returnedAt);
}
}
function toLoanJson(row: LoanRow) {
return { id: row.id, itemId: row.item_id, memberId: row.member_id, lentAt: row.lent_at, returnedAt: row.returned_at };
}
function error(status: number, code: string, message: string): Response {
return { status, body: { error: { code, message } } };
}
// Los "handlers" deciden todo: qué préstamo está abierto, qué se puede prestar
// y qué se guarda.
export function handleShallow(service: LoanService, now: Now, req: Request): Response {
if (req.method === 'GET' && req.path === '/items') {
const items = service.listItems().map((item) => ({
...item,
available: !service.loansForItem(item.id).some((l) => l.returned_at === null),
}));
return { status: 200, body: { items } };
}
if (req.method === 'POST' && req.path === '/loans') {
const itemId = Number(req.body?.itemId);
const memberId = Number(req.body?.memberId);
const item = service.findItem(itemId);
if (!item) return error(404, 'ITEM_NOT_FOUND', `No existe el implemento ${itemId}.`);
if (service.loansForItem(itemId).some((l) => l.returned_at === null)) {
return error(409, 'ITEM_LENT', 'El implemento está prestado.');
}
if (service.loansForMember(memberId).some((l) => !l.returned_at)) {
return error(409, 'MEMBER_HAS_LOAN', 'Primero hay que devolver el implemento anterior.');
}
const id = service.saveLoan(itemId, memberId, now());
return { status: 200, body: { loan: toLoanJson(service.findLoan(id)!) } };
}
const match = /^\/loans\/(\d+)\/return$/.exec(req.path);
if (req.method === 'POST' && match) {
const loan = service.findLoan(Number(match[1]));
if (!loan) return error(404, 'LOAN_NOT_FOUND', `No existe el préstamo ${match[1]}.`);
if (loan.returned_at !== null) return error(409, 'ALREADY_RETURNED', 'El implemento ya fue devuelto.');
service.markReturned(loan.id, now());
return { status: 200, body: { loan: toLoanJson(service.findLoan(loan.id)!) } };
}
return error(404, 'NOT_FOUND', `No existe la ruta ${req.path}.`);
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
// Amplificación de cambios, medida: en una copia de Kallpa se cambia el cierre
// de las 22:00 a las 23:00 en la única línea que lo define, y se corren las
// pruebas. El código entero se entera del cambio; fallan las dos pruebas que
// tienen el horario escrito a mano (15 horas libres, y 21:00 + 2 horas como
// "después del cierre").
test('cerrar a las 23:00 cambia una constante y rompe dos pruebas', () => {
const copy = mkdtempSync(join(tmpdir(), 'kallpa-23-'));
try {
cpSync(new URL('../../kallpa', import.meta.url).pathname, copy, { recursive: true });
const file = join(copy, 'src/domain/booking.ts');
const source = readFileSync(file, 'utf8');
assert.match(source, /export const CLOSING_HOUR = 22;/);
writeFileSync(file, source.replace('export const CLOSING_HOUR = 22;', 'export const CLOSING_HOUR = 23;'));
// Sin NODE_TEST_CONTEXT: si no, el node --test de adentro le habla al de
// afuera por otro canal y no escribe nada en stdout.
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
const run = spawnSync('node', ['--test', '--test-reporter=tap', 'test/**/*.test.ts'], { cwd: copy, env, encoding: 'utf8' });
assert.match(run.stdout, /# fail 2\n/);
assert.match(run.stdout, /not ok \d+ - rechaza una reserva que termina después del cierre/);
assert.match(run.stdout, /not ok \d+ - una cancha sin reservas está libre todo el día/);
assert.match(run.stdout, /16 !== 15/);
} finally {
rmSync(copy, { recursive: true });
}
});import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
import { CourtApp } from './concepts/app.ts';
import { Deadline } from './concepts/deadline.ts';
import { Reservation } from './concepts/reservation.ts';
// Cada principio operacional, escrito como una prueba.
describe('principios operacionales', () => {
test('Reserva: si reservo de 19 a 21, a esa hora nadie más puede reservar; si cancelo, queda libre', () => {
const r = new Reservation<string, string>();
const mine = r.reserve('Rosa', 'Fulbito 1', { day: '2026-03-14', from: 19, to: 21 });
assert.ok(mine.ok);
assert.equal(r.holder('Fulbito 1', '2026-03-14', 20), 'Rosa');
assert.deepEqual(r.reserve('Luis', 'Fulbito 1', { day: '2026-03-14', from: 20, to: 22 }), { ok: false });
r.cancel(mine.id);
assert.equal(r.reserve('Luis', 'Fulbito 1', { day: '2026-03-14', from: 20, to: 22 }).ok, true);
});
test('Reserva es genérica: sirve igual para la parrilla del club', () => {
const grill = new Reservation<'parrilla', number>();
assert.equal(grill.reserve(7, 'parrilla', { day: '2026-03-15', from: 12, to: 16 }).ok, true);
assert.equal(grill.holder('parrilla', '2026-03-15', 13), 7);
});
test('Plazo: con 3 horas de aviso, a las 15:00 se deshace algo de las 18:00; a las 15:01, ya no', () => {
const d = new Deadline<string>(3);
d.register('reserva', 18 * 60);
assert.equal(d.stillOpen('reserva', 15 * 60), true);
assert.equal(d.stillOpen('reserva', 15 * 60 + 1), false);
});
});
describe('la aplicación: tres conceptos y sus sincronizaciones', () => {
test('al reservar se avisa; al cancelar a tiempo se libera la cancha', () => {
const app = new CourtApp();
const id = app.book('987654321', 1, { day: '2026-03-14', from: 19, to: 21 });
assert.equal(typeof id, 'number');
assert.deepEqual(app.notifications.sent, [
{ to: '987654321', text: 'Reserva confirmada: cancha 1, 2026-03-14 de 19:00 a 21:00.' },
]);
assert.equal(app.book('912345678', 1, { day: '2026-03-14', from: 20, to: 21 }), 'taken');
assert.equal(app.cancel(id as number, { day: '2026-03-14', hour: 16, minute: 30 }), 'too-late');
assert.equal(app.cancel(id as number, { day: '2026-03-14', hour: 16, minute: 0 }), 'cancelled');
assert.equal(app.reservations.holder(1, '2026-03-14', 20), undefined);
});
test('ningún concepto importa a otro', () => {
for (const name of ['reservation', 'deadline', 'notification']) {
const source = readFileSync(new URL(`./concepts/${name}.ts`, import.meta.url), 'utf8');
assert.doesNotMatch(source, /^import /m, `${name}.ts no debería importar nada`);
}
});
});import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { kwic as kwicFlowchart } from './kwic/flowchart.ts';
import { ArrayLines, kwic as kwicHiding, PackedLines } from './kwic/hiding.ts';
const TEXT = `Kallpa reserva canchas
canchas de vóley
reserva confirmada por WhatsApp`;
describe('índice KWIC', () => {
test('lista cada corrimiento circular en orden alfabético', () => {
assert.equal(
kwicFlowchart(TEXT),
[
'canchas de vóley',
'canchas Kallpa reserva',
'confirmada por WhatsApp reserva',
'de vóley canchas',
'Kallpa reserva canchas',
'por WhatsApp reserva confirmada',
'reserva canchas Kallpa',
'reserva confirmada por WhatsApp',
'vóley canchas de',
'WhatsApp reserva confirmada por',
].join('\n'),
);
});
test('las dos divisiones en módulos dan exactamente la misma salida', () => {
assert.equal(kwicHiding(TEXT), kwicFlowchart(TEXT));
});
test('cambiar cómo se guardan las líneas no cambia la salida', () => {
assert.equal(kwicHiding(TEXT, new ArrayLines()), kwicHiding(TEXT, new PackedLines()));
});
});import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import { edges, violations } from './layers.ts';
const KALLPA_SRC = new URL('../../kallpa/src', import.meta.url).pathname;
describe('dirección de las dependencias en Kallpa', () => {
test('domain no importa nada de otra capa', () => {
assert.deepEqual(edges(KALLPA_SRC).filter((e) => e.from === 'domain'), []);
});
test('ni store ni domain saben de HTTP', () => {
const toHttp = edges(KALLPA_SRC).filter((e) => e.to === 'http' && (e.from === 'store' || e.from === 'domain'));
assert.deepEqual(toHttp, []);
});
test('ninguna dependencia va hacia arriba', () => {
assert.deepEqual(violations(edges(KALLPA_SRC)), []);
});
test('detecta un dominio que importa algo de http', () => {
const src = mkdtempSync(join(tmpdir(), 'capas-'));
try {
mkdirSync(join(src, 'domain'));
mkdirSync(join(src, 'http'));
writeFileSync(join(src, 'http', 'json.ts'), 'export class HttpError extends Error {}\n');
writeFileSync(
join(src, 'domain', 'booking.ts'),
"import { HttpError } from '../http/json.ts';\nexport const fail = () => new HttpError();\n",
);
const bad = violations(edges(src));
assert.equal(bad.length, 1);
assert.equal(bad[0].file, join('domain', 'booking.ts'));
assert.equal(bad[0].typeOnly, false);
} finally {
rmSync(src, { recursive: true });
}
});
});import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { handleDeep, Loans } from './loans/deep.ts';
import { openLoansDatabase, type Request, type Response } from './loans/schema.ts';
import { handleShallow, LoanService } from './loans/shallow.ts';
// Un guion de solicitudes que recorre los casos felices y todos los errores.
const SCRIPT: Request[] = [
{ method: 'GET', path: '/items' },
{ method: 'POST', path: '/loans', body: { itemId: 1, memberId: 7 } },
{ method: 'POST', path: '/loans', body: { itemId: 1, memberId: 8 } }, // ITEM_LENT
{ method: 'POST', path: '/loans', body: { itemId: 2, memberId: 7 } }, // MEMBER_HAS_LOAN
{ method: 'POST', path: '/loans', body: { itemId: 9, memberId: 8 } }, // ITEM_NOT_FOUND
{ method: 'GET', path: '/items' },
{ method: 'POST', path: '/loans/1/return' },
{ method: 'POST', path: '/loans/1/return' }, // ALREADY_RETURNED
{ method: 'POST', path: '/loans/5/return' }, // LOAN_NOT_FOUND
{ method: 'POST', path: '/loans', body: { itemId: 1, memberId: 8 } },
{ method: 'GET', path: '/items' },
{ method: 'GET', path: '/prestamos' }, // NOT_FOUND
];
// Un reloj que avanza un minuto en cada llamada, igual para las dos versiones.
function minuteClock(): () => string {
let minute = 0;
return () => new Date(Date.UTC(2026, 2, 14, 15, minute++)).toISOString();
}
function runShallow(): Response[] {
const service = new LoanService(openLoansDatabase());
const now = minuteClock();
return SCRIPT.map((req) => handleShallow(service, now, req));
}
function runDeep(): Response[] {
const loans = new Loans(openLoansDatabase(), minuteClock());
return SCRIPT.map((req) => handleDeep(loans, req));
}
describe('préstamo de implementos', () => {
test('las dos versiones responden lo mismo a todo el guion', () => {
assert.deepEqual(runDeep(), runShallow());
});
test('el guion pasa por cada código de error', () => {
const codes = runShallow().map((r) => (r.body as { error?: { code: string } }).error?.code ?? r.status);
assert.deepEqual(codes, [
200, 200, 'ITEM_LENT', 'MEMBER_HAS_LOAN', 'ITEM_NOT_FOUND', 200,
200, 'ALREADY_RETURNED', 'LOAN_NOT_FOUND', 200, 200, 'NOT_FOUND',
]);
});
test('un implemento prestado deja de estar disponible y vuelve al devolverlo', () => {
const loans = new Loans(openLoansDatabase(), minuteClock());
const lent = loans.lend(2, 7);
assert.ok(lent.ok);
assert.equal(loans.items().find((i) => i.id === 2)?.available, false);
loans.giveBack(lent.loan.id);
assert.equal(loans.items().find((i) => i.id === 2)?.available, true);
});
test('los errores del módulo no saben de HTTP', () => {
const loans = new Loans(openLoansDatabase(), minuteClock());
assert.deepEqual(loans.giveBack(42), {
ok: false,
error: { code: 'LOAN_NOT_FOUND', message: 'No existe el préstamo 42.' },
});
});
});import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
import { analyze } from './pass-through.ts';
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');
describe('métodos que solo reenvían la llamada', () => {
test('todos los métodos de LoanService reenvían al repositorio', () => {
const service = analyze(read('./loans/shallow.ts')).find((c) => c.name === 'LoanService')!;
assert.equal(service.methods.length, 7);
assert.ok(service.methods.every((m) => m.forwardsTo?.startsWith('this.#repo.')));
});
test('Loans, la versión profunda, no tiene ninguno', () => {
const [loans] = analyze(read('./loans/deep.ts'));
assert.equal(loans.name, 'Loans');
assert.deepEqual(
loans.methods.map((m) => [m.name, m.forwardsTo]),
[['items', null], ['lend', null], ['giveBack', null]],
);
});
test('distingue si cambia los argumentos', () => {
const [c] = analyze(`class A {
#b = { f: (x: number, y: number) => x + y };
same(x: number, y: number) { return this.#b.f(x, y); }
swapped(x: number, y: number) { return this.#b.f(y, x); }
more(x: number) { const y = 2; return this.#b.f(x, y); }
#hidden(x: number) { return this.#b.f(x, x); }
}`);
assert.deepEqual(
c.methods.map((m) => [m.name, m.forwardsTo !== null, m.sameArguments]),
[['same', true, true], ['swapped', true, false], ['more', false, false]],
);
});
});Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 5: complejidad, módulos profundos y conceptos. Se usa dentro del
# contenedor del curso.
#
# make copia Kallpa en build/kallpa, para medirla sin tocar la
# original
# make test revisa los tipos (tsc), corre ESLint, las pruebas
# (*.test.ts), que comprueban lo que dice el apunte, y el
# refactoring de ejemplo (make refactor)
# make medir los números del paso 1 del laboratorio, sobre build/kallpa
# make capas las dependencias entre las capas de Kallpa (layers.ts)
# make refactor aplica refactor-fecha.diff a una copia de Kallpa en
# build/refactor y corre su make check
# make clean borra build/
#
# Los ejemplos leen la Kallpa del curso (../../kallpa) sin modificarla.
KALLPA ?= ../../kallpa
GIT = git -c user.name=Kallpa -c user.email=kallpa@example.com -c init.defaultBranch=main
.PHONY: all test medir capas refactor clean
all: build/kallpa
build/kallpa:
mkdir -p build
cp -r $(KALLPA) $@
rm -f $@/data/*.db $@/data/*.db-*
test:
tsc -p .
eslint .
node --test "*.test.ts"
$(MAKE) --no-print-directory refactor
medir: build/kallpa
cd build/kallpa && wc -l src/http/handlers.ts src/services/*.ts
cd build/kallpa && grep -n "from " src/http/handlers.ts
capas:
node layers.ts $(KALLPA)/src
# Un refactoring pequeño: la validación de ?date= estaba copiada en dos
# handlers. Las pruebas de Kallpa no cambian y tienen que seguir pasando.
refactor:
rm -rf build/refactor
mkdir -p build
cp -r $(KALLPA) build/refactor
rm -f build/refactor/data/*.db build/refactor/data/*.db-*
cd build/refactor && $(GIT) init -q && $(GIT) add -A && $(GIT) commit -qm "Kallpa publicada"
cd build/refactor && $(GIT) apply ../../refactor-fecha.diff
cd build/refactor && $(GIT) diff --stat
$(MAKE) --no-print-directory -C build/refactor check
cd build/refactor && test -z "$$($(GIT) status --porcelain -- test)" && echo "test/ sin cambios"
clean:
rm -rf build{
"name": "week-05",
"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",
"loans",
"kwic",
"concepts"
],
"exclude": [
"build",
"solution"
]
}