diff --git a/docs/contenido.md b/docs/contenido.md index 868c372..e6acecf 100644 --- a/docs/contenido.md +++ b/docs/contenido.md @@ -8,17 +8,77 @@ a la base. ``` prisma/content/ -├── types.ts # la forma de todo lo de abajo -├── index.ts # registro de CURSOS -├── unidad-01-primer-programa.ts … unidad-10-matrices.ts # curso de C++ -├── csharp/ # curso de POO I en C# -│ ├── index.ts # el curso y su lenguaje/perfil +├── types.ts # la forma de una lección/unidad/curso (IR) +├── authoring.ts # defineLesson / defineUnit / defineCourse / registry +├── validate.ts # validación semántica (sin DB, sin compiladores) +├── index.ts # re-exports legacy (cursoCpp, allCourses) +├── courses/ +│ ├── index.ts # EL REGISTRY ÚNICO — un curso nuevo se agrega aquí +│ ├── cpp-desde-cero/index.ts # ensambla el curso legacy de C++ (ver abajo) +│ └── csharp-poo-1/index.ts # ensambla el curso legacy de C# +├── unidad-01-primer-programa.ts … unidad-10-matrices.ts # curso de C++ (legacy) +├── csharp/ # curso de POO I en C# (legacy) +│ ├── index.ts │ └── unidad-01-modelar.ts … unidad-08-integrador.ts └── exercises/ # práctica por unidad - ├── u01-…-u10-… # banco de C++ - └── csharp/ # banco de C# + ├── index.ts # re-export legacy (allPracticeSets) + ├── u01-…-u10-… # banco de C++ (legacy) + └── csharp/ # banco de C# (legacy) ``` +C++ y C# son contenido **legacy**: sus unidades y su práctica viven en los archivos +grandes de siempre y NO se movieron. `prisma/content/courses/cpp-desde-cero/index.ts` y +`.../csharp-poo-1/index.ts` sólo los ENSAMBLAN con `adaptLegacyUnits` + `defineCourse` +para que entren al mismo registry que un curso nuevo. No repitas ese layout para +contenido viejo — es sólo el punto de entrada. + +## Agregar un curso NUEVO + +Un curso nuevo SÍ usa el layout completo, con la práctica de cada unidad colocalizada +junto a sus lecciones: + +``` +prisma/content/courses// +├── index.ts # defineCourse({...metadata, units}) +└── units/ + └── 01-/ + ├── index.ts # defineUnit({...metadata, lessons, practice}) + ├── practice.ts # PracticeExerciseDefinition[] de la unidad + └── lessons/ + ├── 01-.ts # defineLesson({...}) + ├── 02-.ts + └── ... +``` + +- `defineLesson` y `defineUnit` son identidad type-safe: no aplican defaults, no + reordenan, no mutan lo que les pasas — sólo ayudan a que TypeScript infiera el tipo + correcto. +- `practice` vive DENTRO de `AuthoredUnitDefinition` (colocalizada con la unidad, en vez + de un registry aparte que hay que mantener sincronizado a mano). `defineCourse` la + separa en su propio `PracticeUnitSetDefinition`, derivando `courseSlug`, `unitSlug`, + `unitTitle` e `unitIcon` de la unidad — no los repitas. +- Registra el curso UNA sola vez, en el orden en que debe aparecer, en + [`prisma/content/courses/index.ts`](../prisma/content/courses/index.ts): + + ```ts + import { cppDesdeCero } from "./cpp-desde-cero"; + import { csharpPoo1 } from "./csharp-poo-1"; + import { miCursoNuevo } from "./mi-curso-nuevo"; + + const packages = [cppDesdeCero, csharpPoo1, miCursoNuevo] satisfies + readonly CoursePackageDefinition[]; + + export const { allCourses, allPracticeSets } = buildContentRegistry(packages); + ``` + +- Antes de sembrar, corre `npm run content:validate`: valida TODO el contenido (slugs + únicos, quiz/fill_blank/code_challenge bien formados, referencias de práctica, el par + lenguaje/perfil, etc.) sin tocar la base ni compilar código. Si algo falla, imprime + CADA problema con su `path` (ej. `courses[mi-curso].units[u1].lessons[l1].steps[3]`) y + sale con código 1 — corre esto en vez de intentar depurar un `db:seed` a medias. + +No hace falta migrar el contenido viejo a este layout: C++ y C# se quedan como están. + ## El curso declara su lenguaje Cada `CourseDefinition` trae `language` y `executionProfile`. **De ahí sale todo**: diff --git a/package.json b/package.json index 4a4a446..3b8444a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "vitest run", "test:watch": "vitest", "test:integration": "dotenv -e .env.local -- vitest run --config vitest.integration.config.ts", + "content:validate": "tsx scripts/validate-content.ts", "postinstall": "prisma generate", "db:generate": "prisma generate", "db:push": "dotenv -e .env.local -- prisma db push", diff --git a/prisma/content/authoring.ts b/prisma/content/authoring.ts new file mode 100644 index 0000000..75879d4 --- /dev/null +++ b/prisma/content/authoring.ts @@ -0,0 +1,185 @@ +// ===================================================================== +// Capa de authoring — composición type-safe sobre el IR existente +// (`CourseDefinition` / `PracticeUnitSetDefinition`, sin cambios). +// +// No introduce un formato nuevo: sigue siendo TypeScript. Lo que da es +// una forma de escribir un curso NUEVO con su práctica colocalizada por +// unidad (`AuthoredUnitDefinition.practice`) y un único punto donde se +// ensamblan todos los cursos (`buildContentRegistry`), en vez de mantener +// a mano dos arreglos paralelos (`units` y `practiceSets`) que se pueden +// desincronizar. +// +// `defineLesson` / `defineUnit` son identidad: no aplican defaults, no +// reordenan, no mutan. Los defaults (xpReward, published, etc.) siguen +// viviendo exclusivamente en los seeds — ver `prisma/seed-content.ts` y +// `prisma/seed-practice.ts`. +// ===================================================================== + +import { validateContentRegistry } from "./validate"; + +import type { + CourseDefinition, + LessonDefinition, + UnitDefinition, +} from "./types"; +import type { + PracticeExerciseDefinition, + PracticeUnitSetDefinition, +} from "./exercises/types"; + +/** + * Una unidad tal como se autora: sus lecciones, más su práctica + * colocalizada (si tiene). `practice` NUNCA llega a la DB como parte de + * la unidad — `defineCourse` lo separa en su propio + * `PracticeUnitSetDefinition`. + */ +export interface AuthoredUnitDefinition + extends Omit { + lessons: LessonDefinition[]; + practice?: PracticeExerciseDefinition[]; +} + +/** Lo que produce `defineCourse`: el curso (IR de siempre) + su práctica. */ +export interface CoursePackageDefinition { + course: CourseDefinition; + practiceSets: PracticeUnitSetDefinition[]; +} + +/** + * Identidad type-safe. No aplica defaults, no clona ni reordena — sólo + * ayuda a que TypeScript infiera el tipo correcto en el sitio donde se + * declara la lección. + */ +export function defineLesson(lesson: LessonDefinition): LessonDefinition { + return lesson; +} + +/** + * Identidad type-safe para unidades autoradas. Preserva metadata, lessons + * y practice tal cual se pasaron — no aplica defaults ni ordena nada. + */ +export function defineUnit(unit: AuthoredUnitDefinition): AuthoredUnitDefinition { + return unit; +} + +/** + * Ensambla un `CoursePackageDefinition` a partir de metadata de curso + + * unidades autoradas. Separa la práctica colocalizada de cada unidad en + * su propio `PracticeUnitSetDefinition`, derivando `courseSlug`, + * `unitSlug`, `unitTitle` y `unitIcon` de la unidad — nunca se infieren + * de otro lado ni se piden por duplicado. + * + * Preserva EXACTAMENTE el orden de units/lessons/steps/practice/tests + * recibido: no ordena alfabéticamente, no infiere `language` ni + * `executionProfile`, no muta el input. + */ +export function defineCourse( + course: Omit & { + units: AuthoredUnitDefinition[]; + }, +): CoursePackageDefinition { + const units: UnitDefinition[] = []; + const practiceSets: PracticeUnitSetDefinition[] = []; + + for (const authoredUnit of course.units) { + const { practice, ...unit } = authoredUnit; + units.push(unit); + + if (practice) { + practiceSets.push({ + courseSlug: course.slug, + unitSlug: unit.slug, + unitTitle: unit.title, + unitIcon: unit.icon, + exercises: practice, + }); + } + } + + return { + course: { ...course, units }, + practiceSets, + }; +} + +/** + * Adapta un curso legacy (unidades grandes en un solo archivo, práctica + * en un registry aparte por `unitSlug`) a la misma capa de authoring que + * usan los cursos nuevos, SIN mover ni tocar su contenido. + * + * Preserva el orden de `course.units`. Empareja cada set de práctica con + * su unidad por `unitSlug` y falla (Error, no `ContentValidationError`: + * esto es un error de ENSAMBLAJE, detectado antes de que exista un + * registry que validar) si: + * - un set declara un `courseSlug` que no es el de `course`; + * - un set apunta a una unidad que no existe en `course`; + * - hay más de un set para la misma unidad. + * + * `unitTitle`/`unitIcon` del set legacy son metadata duplicada antigua: + * NO se comparan contra la unidad. El IR canónico que sale de + * `defineCourse` siempre deriva esos campos de `unit.title`/`unit.icon`. + */ +export function adaptLegacyUnits( + course: CourseDefinition, + practiceSets: readonly PracticeUnitSetDefinition[], +): AuthoredUnitDefinition[] { + const practiceByUnitSlug = new Map(); + + for (const set of practiceSets) { + if (set.courseSlug !== course.slug) { + throw new Error( + `adaptLegacyUnits: el set de práctica de la unidad "${set.unitSlug}" ` + + `declara courseSlug "${set.courseSlug}", pero se está adaptando ` + + `el curso "${course.slug}".`, + ); + } + + const unitExists = course.units.some((u) => u.slug === set.unitSlug); + if (!unitExists) { + throw new Error( + `adaptLegacyUnits: el set de práctica declara la unidad ` + + `"${set.unitSlug}", que no existe en el curso "${course.slug}".`, + ); + } + + if (practiceByUnitSlug.has(set.unitSlug)) { + throw new Error( + `adaptLegacyUnits: hay más de un set de práctica para la unidad ` + + `"${set.unitSlug}" del curso "${course.slug}".`, + ); + } + + practiceByUnitSlug.set(set.unitSlug, set); + } + + return course.units.map((unit): AuthoredUnitDefinition => { + const set = practiceByUnitSlug.get(unit.slug); + return set ? { ...unit, practice: set.exercises } : unit; + }); +} + +/** + * Aplana una lista de paquetes de curso en el registry canónico + * (`allCourses` + `allPracticeSets`), preservando el orden del arreglo + * `packages` y, dentro de cada curso, el orden curso→unidad de su + * práctica. Síncrona, no lee filesystem, no toca DB, no compila nada. + * + * Corre la validación semántica completa (`validateContentRegistry`) + * antes de devolver el registry: un `ContentValidationError` aquí + * significa que ALGO del contenido (de cualquier curso) es inválido, y + * el import de `./courses` falla con ese error en vez de dejar pasar un + * registry a medias. + */ +export function buildContentRegistry( + packages: readonly CoursePackageDefinition[], +): { + allCourses: CourseDefinition[]; + allPracticeSets: PracticeUnitSetDefinition[]; +} { + const allCourses = packages.map((p) => p.course); + const allPracticeSets = packages.flatMap((p) => p.practiceSets); + + validateContentRegistry(allCourses, allPracticeSets); + + return { allCourses, allPracticeSets }; +} diff --git a/prisma/content/courses/cpp-desde-cero/index.ts b/prisma/content/courses/cpp-desde-cero/index.ts new file mode 100644 index 0000000..260cd83 --- /dev/null +++ b/prisma/content/courses/cpp-desde-cero/index.ts @@ -0,0 +1,94 @@ +// ===================================================================== +// Paquete de curso: C++ desde cero. +// +// Legacy: las 10 unidades y los 10 sets de práctica siguen viviendo en +// sus archivos grandes de siempre (`prisma/content/unidad-*.ts` y +// `prisma/content/exercises/u*.ts`) — NO se movieron ni se editaron. Lo +// único que vive aquí es el ENSAMBLAJE: la metadata del curso (relocada +// tal cual desde el `prisma/content/index.ts` anterior) y el paso por +// `adaptLegacyUnits` + `defineCourse` para entrar a la misma capa de +// authoring que usan los cursos nuevos. +// ===================================================================== + +import { adaptLegacyUnits, defineCourse } from "../../authoring"; +import type { CourseDefinition } from "../../types"; + +import { unidad01 } from "../../unidad-01-primer-programa"; +import { unidadCin } from "../../unidad-02-cin"; +import { unidadVariables } from "../../unidad-03-variables"; +import { unidad04 } from "../../unidad-04-control-flujo"; +import { unidad05 } from "../../unidad-05-loops"; +import { unidad06 } from "../../unidad-06-funciones"; +import { unidad07 } from "../../unidad-07-printf-scanf"; +import { unidad08 } from "../../unidad-08-arreglos"; +import { unidad09 } from "../../unidad-09-archivos"; +import { unidad10 } from "../../unidad-10-matrices"; + +import { u01PrimerProgramaExercises } from "../../exercises/u01-primer-programa"; +import { u02CinExercises } from "../../exercises/u02-cin"; +import { u03VariablesExercises } from "../../exercises/u03-variables"; +import { u04ControlFlujoExercises } from "../../exercises/u04-control-flujo"; +import { u05LoopsExercises } from "../../exercises/u05-loops"; +import { u06FuncionesExercises } from "../../exercises/u06-funciones"; +import { u07PrintfScanfExercises } from "../../exercises/u07-printf-scanf"; +import { u08ArreglosExercises } from "../../exercises/u08-arreglos"; +import { u09ArchivosExercises } from "../../exercises/u09-archivos"; +import { u10MatricesExercises } from "../../exercises/u10-matrices"; + +const cursoCppLegacy: CourseDefinition = { + // El slug es identidad histórica: NO se renombra. Todas las URLs viejas, + // el progreso y los intentos de los alumnos cuelgan de él. + slug: "cpp-desde-cero", + title: "C++ desde cero", + description: + "El curso completo de C++ pensado para estudiantes del CETI Guadalajara. " + + "Cada concepto va seguido de práctica inmediata.", + subjectName: "Programación en C++", + academicContext: "Curso introductorio CETI", + language: "cpp", + executionProfile: "cpp17-wandbox", + // El ORDEN de este arreglo es el orden del curso (el seed numera por + // posición). "Variables y tipos" va ANTES de "Leer datos con cin": la + // unidad de `cin` ya usaba `int`, `double`, `string`, aritmética y + // `setprecision`, es decir, exactamente lo que la de variables enseña. + // Los slugs NO cambian —`leer-datos` y `variables-y-tipos` siguen siendo + // los mismos recursos, con el mismo progreso y los mismos enlaces—; lo + // único que cambia es en qué posición aparecen. + units: [ + unidad01, + unidadVariables, + unidadCin, + unidad04, + unidad05, + unidad06, + unidad07, + unidad08, + unidad09, + unidad10, + ], +}; + +// Orden histórico del registry de práctica (`prisma/content/exercises/index.ts` +// de antes de este refactor): por archivo, NO por posición de unidad en el +// curso. `adaptLegacyUnits` empareja por `unitSlug`, así que este arreglo +// sólo necesita traer los 10 sets — el orden de SALIDA de la práctica lo fija +// `defineCourse` recorriendo `course.units` (ver `authoring.ts`). +const legacyPracticeSets = [ + u01PrimerProgramaExercises, + u02CinExercises, + u03VariablesExercises, + u04ControlFlujoExercises, + u05LoopsExercises, + u06FuncionesExercises, + u07PrintfScanfExercises, + u08ArreglosExercises, + u09ArchivosExercises, + u10MatricesExercises, +]; + +const authoredUnits = adaptLegacyUnits(cursoCppLegacy, legacyPracticeSets); + +export const cppDesdeCero = defineCourse({ + ...cursoCppLegacy, + units: authoredUnits, +}); diff --git a/prisma/content/courses/csharp-poo-1/index.ts b/prisma/content/courses/csharp-poo-1/index.ts new file mode 100644 index 0000000..e276856 --- /dev/null +++ b/prisma/content/courses/csharp-poo-1/index.ts @@ -0,0 +1,21 @@ +// ===================================================================== +// Paquete de curso: Programación Orientada a Objetos I con C#. +// +// Legacy: las 8 unidades y los 8 sets de práctica siguen viviendo en +// `prisma/content/csharp/unidad-*.ts` y +// `prisma/content/exercises/csharp/u*.ts` — NO se movieron ni se +// editaron. Aquí sólo se reutilizan y se pasan por `adaptLegacyUnits` + +// `defineCourse` para entrar a la misma capa de authoring que los cursos +// nuevos. +// ===================================================================== + +import { adaptLegacyUnits, defineCourse } from "../../authoring"; +import { cursoCsharpPoo1 } from "../../csharp"; +import { csharpPracticeSets } from "../../exercises/csharp"; + +const authoredUnits = adaptLegacyUnits(cursoCsharpPoo1, csharpPracticeSets); + +export const csharpPoo1 = defineCourse({ + ...cursoCsharpPoo1, + units: authoredUnits, +}); diff --git a/prisma/content/courses/index.ts b/prisma/content/courses/index.ts new file mode 100644 index 0000000..3b4c3d1 --- /dev/null +++ b/prisma/content/courses/index.ts @@ -0,0 +1,25 @@ +// ===================================================================== +// REGISTRY ÚNICO de cursos. +// +// Un solo arreglo explícito, en el orden de presentación por default en +// el selector de curso. Sin discovery, sin glob, sin filesystem, sin +// codegen: agregar un curso nuevo es agregar una línea aquí. +// +// `buildContentRegistry` valida TODO el contenido (de todos los cursos) +// antes de que este módulo termine de importarse — un +// `ContentValidationError` aquí rompe el import, no llega silencioso a +// un seed o a un build. +// ===================================================================== + +import { buildContentRegistry } from "../authoring"; +import type { CoursePackageDefinition } from "../authoring"; + +import { cppDesdeCero } from "./cpp-desde-cero"; +import { csharpPoo1 } from "./csharp-poo-1"; + +const packages = [ + cppDesdeCero, + csharpPoo1, +] satisfies readonly CoursePackageDefinition[]; + +export const { allCourses, allPracticeSets } = buildContentRegistry(packages); diff --git a/prisma/content/exercises/index.ts b/prisma/content/exercises/index.ts index 0b2ea51..a50b9be 100644 --- a/prisma/content/exercises/index.ts +++ b/prisma/content/exercises/index.ts @@ -1,35 +1,11 @@ import type { PracticeUnitSetDefinition } from "./types"; -import { csharpPracticeSets } from "./csharp"; - -import { u01PrimerProgramaExercises } from "./u01-primer-programa"; -import { u02CinExercises } from "./u02-cin"; -import { u03VariablesExercises } from "./u03-variables"; -import { u04ControlFlujoExercises } from "./u04-control-flujo"; -import { u05LoopsExercises } from "./u05-loops"; -import { u06FuncionesExercises } from "./u06-funciones"; -import { u07PrintfScanfExercises } from "./u07-printf-scanf"; -import { u08ArreglosExercises } from "./u08-arreglos"; -import { u09ArchivosExercises } from "./u09-archivos"; -import { u10MatricesExercises } from "./u10-matrices"; +import { allPracticeSets as canonicalAllPracticeSets } from "../courses"; /** - * Registro de todos los conjuntos de ejercicios de práctica. - * El orden aquí determina el orden de aparición en la UI. - * - * Vamos llenando por unidades — cada vez que una unidad queda completa, - * la agregamos aquí. + * Registro de todos los conjuntos de ejercicios de práctica. Ahora se + * construye en `../courses` a partir de `buildContentRegistry`; este + * re-export existe para no romper a los consumidores actuales + * (`prisma/seed-practice.ts`, tests, `scripts/verify-content.ts`). */ -export const allPracticeSets: PracticeUnitSetDefinition[] = [ - u01PrimerProgramaExercises, - u02CinExercises, - u03VariablesExercises, - u04ControlFlujoExercises, - u05LoopsExercises, - u06FuncionesExercises, - u07PrintfScanfExercises, - u08ArreglosExercises, - u09ArchivosExercises, - u10MatricesExercises, - ...csharpPracticeSets, -]; +export const allPracticeSets: PracticeUnitSetDefinition[] = canonicalAllPracticeSets; diff --git a/prisma/content/index.ts b/prisma/content/index.ts index d3f0c0f..7def8a6 100644 --- a/prisma/content/index.ts +++ b/prisma/content/index.ts @@ -1,54 +1,19 @@ import type { CourseDefinition } from "./types"; -import { cursoCsharpPoo1 } from "./csharp"; +import { cppDesdeCero } from "./courses/cpp-desde-cero"; +import { allCourses as canonicalAllCourses } from "./courses"; -import { unidad01 } from "./unidad-01-primer-programa"; -import { unidadCin } from "./unidad-02-cin"; -import { unidadVariables } from "./unidad-03-variables"; -import { unidad04 } from "./unidad-04-control-flujo"; -import { unidad05 } from "./unidad-05-loops"; -import { unidad06 } from "./unidad-06-funciones"; -import { unidad07 } from "./unidad-07-printf-scanf"; -import { unidad08 } from "./unidad-08-arreglos"; -import { unidad09 } from "./unidad-09-archivos"; -import { unidad10 } from "./unidad-10-matrices"; - -export const cursoCpp: CourseDefinition = { - // El slug es identidad histórica: NO se renombra. Todas las URLs viejas, - // el progreso y los intentos de los alumnos cuelgan de él. - slug: "cpp-desde-cero", - title: "C++ desde cero", - description: - "El curso completo de C++ pensado para estudiantes del CETI Guadalajara. " + - "Cada concepto va seguido de práctica inmediata.", - subjectName: "Programación en C++", - academicContext: "Curso introductorio CETI", - language: "cpp", - executionProfile: "cpp17-wandbox", - // El ORDEN de este arreglo es el orden del curso (el seed numera por - // posición). "Variables y tipos" va ANTES de "Leer datos con cin": la - // unidad de `cin` ya usaba `int`, `double`, `string`, aritmética y - // `setprecision`, es decir, exactamente lo que la de variables enseña. - // Los slugs NO cambian —`leer-datos` y `variables-y-tipos` siguen siendo - // los mismos recursos, con el mismo progreso y los mismos enlaces—; lo - // único que cambia es en qué posición aparecen. - units: [ - unidad01, - unidadVariables, - unidadCin, - unidad04, - unidad05, - unidad06, - unidad07, - unidad08, - unidad09, - unidad10, - ], -}; +/** + * Alias legacy: el curso de C++ tal como lo exponía este archivo antes de + * la capa de authoring (`./authoring.ts`, `./courses/`). El slug es + * identidad histórica y no cambia; el valor ahora sale del registry + * canónico en vez de definirse aquí, pero es el mismo curso. + */ +export const cursoCpp: CourseDefinition = cppDesdeCero.course; /** - * Registro de cursos. El orden aquí es el orden de presentación por default - * en el selector de curso. Cada curso es independiente: su progreso, sus - * unidades, sus prácticas y su compilador. + * Registro de cursos. Ahora se construye en `./courses` a partir de + * `buildContentRegistry`; este re-export existe para no romper a los + * consumidores actuales (`prisma/seed-content.ts`, tests). */ -export const allCourses: CourseDefinition[] = [cursoCpp, cursoCsharpPoo1]; +export const allCourses: CourseDefinition[] = canonicalAllCourses; diff --git a/prisma/content/validate.ts b/prisma/content/validate.ts new file mode 100644 index 0000000..95899a3 --- /dev/null +++ b/prisma/content/validate.ts @@ -0,0 +1,415 @@ +// ===================================================================== +// Validación semántica del contenido, SIN tocar DB ni compiladores. +// +// `buildContentRegistry` (ver `./authoring.ts`) corre esta validación +// antes de aceptar un registry canónico: cualquier problema estructural +// (slug vacío o duplicado, quiz sin opciones, fill_blank con huecos mal +// formados, etc.) se detecta al importar el contenido, no al sembrar. +// +// Acumula TODOS los issues encontrados y lanza un único +// `ContentValidationError` al final — así un `npm run content:validate` +// reporta la lista completa en una sola corrida en vez de un error a la +// vez. +// ===================================================================== + +import { assertLanguagePair } from "../../src/lib/code-languages"; +import { structureContractSchema } from "../../src/lib/structure/contract"; + +import type { + CourseDefinition, + LessonDefinition, + StepDefinition, + UnitDefinition, +} from "./types"; +import type { + PracticeExerciseDefinition, + PracticeUnitSetDefinition, +} from "./exercises/types"; + +export interface ContentValidationIssue { + path: string; + message: string; +} + +export class ContentValidationError extends Error { + readonly issues: ContentValidationIssue[]; + + constructor(issues: ContentValidationIssue[]) { + super( + `Contenido inválido: ${issues.length} problema(s).\n` + + issues.map((issue) => ` ${issue.path}: ${issue.message}`).join("\n"), + ); + this.name = "ContentValidationError"; + this.issues = issues; + } +} + +function push( + issues: ContentValidationIssue[], + path: string, + message: string, +): void { + issues.push({ path, message }); +} + +/** + * Valida un registry completo (cursos + prácticas). Lanza + * `ContentValidationError` con TODOS los issues acumulados si algo falla. + * No usa DB, no ejecuta compiladores, es síncrona. + */ +export function validateContentRegistry( + courses: readonly CourseDefinition[], + practiceSets: readonly PracticeUnitSetDefinition[], +): void { + const issues: ContentValidationIssue[] = []; + const seenCourseSlugs = new Set(); + + for (const course of courses) { + validateCourse(course, issues, seenCourseSlugs); + } + + validatePracticeSets(courses, practiceSets, issues); + + if (issues.length > 0) { + throw new ContentValidationError(issues); + } +} + +function validateCourse( + course: CourseDefinition, + issues: ContentValidationIssue[], + seenCourseSlugs: Set, +): void { + const path = `courses[${course.slug || "?"}]`; + + if (!course.slug.trim()) { + push(issues, path, "slug vacío"); + } else if (seenCourseSlugs.has(course.slug)) { + push(issues, path, `slug de curso duplicado: "${course.slug}"`); + } else { + seenCourseSlugs.add(course.slug); + } + + if (!course.title.trim()) push(issues, path, "title vacío"); + if (!course.description.trim()) push(issues, path, "description vacía"); + if (!course.subjectName.trim()) push(issues, path, "subjectName vacío"); + if (!course.academicContext.trim()) { + push(issues, path, "academicContext vacío"); + } + if (course.units.length === 0) { + push(issues, path, "debe tener al menos 1 unidad"); + } + + try { + assertLanguagePair(course.language, course.executionProfile, path); + } catch (err) { + push(issues, path, err instanceof Error ? err.message : String(err)); + } + + const seenUnitSlugs = new Set(); + for (const unit of course.units) { + validateUnit(unit, path, issues, seenUnitSlugs); + } +} + +function validateUnit( + unit: UnitDefinition, + coursePath: string, + issues: ContentValidationIssue[], + seenUnitSlugs: Set, +): void { + const path = `${coursePath}.units[${unit.slug || "?"}]`; + + if (!unit.slug.trim()) { + push(issues, path, "slug vacío"); + } else if (seenUnitSlugs.has(unit.slug)) { + push(issues, path, `slug de unidad duplicado dentro del curso: "${unit.slug}"`); + } else { + seenUnitSlugs.add(unit.slug); + } + + if (!unit.title.trim()) push(issues, path, "title vacío"); + if (!unit.description.trim()) push(issues, path, "description vacía"); + if (unit.lessons.length === 0) { + push(issues, path, "debe tener al menos 1 lección"); + } + + const seenLessonSlugs = new Set(); + for (const lesson of unit.lessons) { + validateLesson(lesson, path, issues, seenLessonSlugs); + } +} + +function validateLesson( + lesson: LessonDefinition, + unitPath: string, + issues: ContentValidationIssue[], + seenLessonSlugs: Set, +): void { + const path = `${unitPath}.lessons[${lesson.slug || "?"}]`; + + if (!lesson.slug.trim()) { + push(issues, path, "slug vacío"); + } else if (seenLessonSlugs.has(lesson.slug)) { + push( + issues, + path, + `slug de lección duplicado dentro de la unidad: "${lesson.slug}"`, + ); + } else { + seenLessonSlugs.add(lesson.slug); + } + + if (!lesson.title.trim()) push(issues, path, "title vacío"); + if (!lesson.description.trim()) push(issues, path, "description vacía"); + if (lesson.steps.length === 0) { + push(issues, path, "debe tener al menos 1 step"); + } + + lesson.steps.forEach((step, index) => { + validateStep(step, path, index, issues); + }); +} + +function validateStep( + step: StepDefinition, + lessonPath: string, + index: number, + issues: ContentValidationIssue[], +): void { + const path = `${lessonPath}.steps[${index}]`; + + switch (step.type) { + case "theory": { + if (!step.markdown.trim()) push(issues, path, "markdown vacío"); + break; + } + + case "code_example": { + if (!step.code.trim()) push(issues, path, "code vacío"); + if (!step.explanation.trim()) push(issues, path, "explanation vacía"); + if (step.localOnlyNote && step.runnable === true) { + push( + issues, + path, + "localOnlyNote es incompatible con runnable: true", + ); + } + break; + } + + case "quiz": { + if (step.options.length < 2) { + push(issues, path, "debe tener al menos 2 opciones"); + } + if (step.correctIndex < 0 || step.correctIndex >= step.options.length) { + push(issues, path, "correctIndex fuera de rango"); + } + if ( + step.feedbackPerOption && + step.feedbackPerOption.length !== step.options.length + ) { + push( + issues, + path, + "feedbackPerOption debe tener la misma longitud que options", + ); + } + break; + } + + case "fill_blank": { + validateFillBlank(step.template, step.blanks, path, issues); + break; + } + + case "code_challenge": { + const ex = step.exercise; + if (!ex.prompt.trim()) push(issues, path, "exercise.prompt vacío"); + if (!ex.solutionCode.trim()) { + push(issues, path, "exercise.solutionCode vacío"); + } + if (ex.testCases.length === 0) { + push(issues, path, "exercise debe tener al menos 1 test case"); + } + if (ex.structure) { + const result = structureContractSchema.safeParse(ex.structure); + if (!result.success) { + push( + issues, + path, + `exercise.structure inválido: ${result.error.message}`, + ); + } + } + break; + } + + case "matching": { + if (step.pairs.length < 2) { + push(issues, path, "debe tener al menos 2 pares"); + } + break; + } + + case "code_completion": { + if (step.lines.length < 2) { + push(issues, path, "debe tener al menos 2 líneas"); + } + break; + } + } +} + +function validateFillBlank( + template: string, + blanks: { answer: string; pattern?: string; matchBlank?: number }[], + path: string, + issues: ContentValidationIssue[], +): void { + const holes = [...template.matchAll(/\{\{(\d+)\}\}/g)].map((m) => + Number(m[1]), + ); + + const counts = new Map(); + for (const h of holes) counts.set(h, (counts.get(h) ?? 0) + 1); + for (const [h, count] of counts) { + if (count > 1) { + push(issues, path, `hueco {{${h}}} aparece ${count} veces en template`); + } + } + + const uniqueHoles = new Set(holes); + for (const h of uniqueHoles) { + if (h < 0 || h >= blanks.length) { + push(issues, path, `hueco {{${h}}} fuera de rango de blanks`); + } + } + + blanks.forEach((blank, i) => { + if (!uniqueHoles.has(i)) { + push(issues, path, `falta el hueco {{${i}}} en template`); + } + if (!blank.answer.length) { + push(issues, path, `blank ${i}: answer vacío`); + } + if (blank.matchBlank !== undefined) { + if (blank.matchBlank < 0 || blank.matchBlank >= blanks.length) { + push(issues, path, `blank ${i}: matchBlank fuera de rango`); + } else if (blank.matchBlank === i) { + push(issues, path, `blank ${i}: matchBlank no puede apuntarse a sí mismo`); + } + } + if (blank.pattern !== undefined) { + try { + new RegExp(blank.pattern); + } catch { + push(issues, path, `blank ${i}: pattern inválido: "${blank.pattern}"`); + } + } + }); +} + +function validatePracticeSets( + courses: readonly CourseDefinition[], + practiceSets: readonly PracticeUnitSetDefinition[], + issues: ContentValidationIssue[], +): void { + const courseBySlug = new Map(courses.map((c) => [c.slug, c] as const)); + const seenUnitPerCourse = new Set(); + const exerciseSlugsByCourse = new Map>(); + + for (const set of practiceSets) { + const path = `practiceSets[${set.courseSlug}/${set.unitSlug}]`; + + const course = courseBySlug.get(set.courseSlug); + if (!course) { + push(issues, path, `no existe el curso "${set.courseSlug}"`); + } else { + const unit = course.units.find((u) => u.slug === set.unitSlug); + if (!unit) { + push( + issues, + path, + `el curso "${set.courseSlug}" no tiene la unidad "${set.unitSlug}"`, + ); + } + } + + const unitKey = `${set.courseSlug}::${set.unitSlug}`; + if (seenUnitPerCourse.has(unitKey)) { + push( + issues, + path, + `más de un set de práctica para la unidad "${set.unitSlug}"`, + ); + } else { + seenUnitPerCourse.add(unitKey); + } + + const exerciseSlugs = + exerciseSlugsByCourse.get(set.courseSlug) ?? new Set(); + exerciseSlugsByCourse.set(set.courseSlug, exerciseSlugs); + + set.exercises.forEach((exercise, index) => { + validatePracticeExercise(exercise, path, index, exerciseSlugs, issues); + }); + } +} + +function validatePracticeExercise( + ex: PracticeExerciseDefinition, + setPath: string, + index: number, + seenSlugsInCourse: Set, + issues: ContentValidationIssue[], +): void { + const path = `${setPath}.exercises[${ex.slug || index}]`; + + if (!ex.slug.trim()) { + push(issues, path, "slug vacío"); + } else if (seenSlugsInCourse.has(ex.slug)) { + push(issues, path, `slug de ejercicio duplicado dentro del curso: "${ex.slug}"`); + } else { + seenSlugsInCourse.add(ex.slug); + } + + if (!ex.title.trim()) push(issues, path, "title vacío"); + if (!ex.description.trim()) push(issues, path, "description vacía"); + if (!ex.prompt.trim()) push(issues, path, "prompt vacío"); + if (!ex.solutionCode.trim()) push(issues, path, "solutionCode vacío"); + if (!["easy", "medium", "hard"].includes(ex.difficulty)) { + push(issues, path, `difficulty inválida: "${ex.difficulty}"`); + } + if (ex.testCases.length === 0) { + push(issues, path, "debe tener al menos 1 test"); + } + + if (ex.structure) { + const result = structureContractSchema.safeParse(ex.structure); + if (!result.success) { + push(issues, path, `structure inválido: ${result.error.message}`); + } + } + + const readsStdin = ex.testCases.some( + (tc) => (tc.stdin ?? "").trim().length > 0, + ); + if (readsStdin) { + const visibleInputs = new Set( + ex.testCases + .filter((tc) => tc.visible !== false) + .map((tc) => tc.stdin ?? ""), + ); + const discriminating = ex.testCases.filter( + (tc) => tc.visible === false && !visibleInputs.has(tc.stdin ?? ""), + ); + if (discriminating.length === 0) { + push( + issues, + path, + "ningún test oculto usa una entrada distinta a las visibles", + ); + } + } +} diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts new file mode 100644 index 0000000..8a28648 --- /dev/null +++ b/scripts/validate-content.ts @@ -0,0 +1,85 @@ +/** + * Valida el registry canónico de contenido (`prisma/content/courses`) sin + * tocar DB ni ejecutar compiladores. + * + * La validación semántica (`ContentValidationError`) corre DURANTE el + * import del registry — `buildContentRegistry` la ejecuta antes de dejar + * pasar un registry canónico. Por eso el import va con dynamic import + * dentro de un try/catch: así un error de contenido se imprime como una + * lista de issues legible, no como un stack trace de un módulo que + * truena al cargarse. + * + * Uso: + * npx tsx scripts/validate-content.ts + * npm run content:validate + */ +import { ContentValidationError } from "../prisma/content/validate"; + +async function main() { + let registry: { + allCourses: import("../prisma/content/types").CourseDefinition[]; + allPracticeSets: import("../prisma/content/exercises/types").PracticeUnitSetDefinition[]; + }; + + try { + registry = await import("../prisma/content/courses"); + } catch (err) { + if (err instanceof ContentValidationError) { + console.error(`❌ Contenido inválido — ${err.issues.length} problema(s):\n`); + for (const issue of err.issues) { + console.error(` · ${issue.path}: ${issue.message}`); + } + process.exit(1); + } + console.error("❌ Error inesperado cargando el registry de contenido:"); + console.error(err); + process.exit(1); + } + + const { allCourses, allPracticeSets } = registry; + + const totalUnits = allCourses.reduce((n, c) => n + c.units.length, 0); + const totalLessons = allCourses.reduce( + (n, c) => n + c.units.reduce((m, u) => m + u.lessons.length, 0), + 0, + ); + const totalSteps = allCourses.reduce( + (n, c) => + n + + c.units.reduce( + (m, u) => + m + u.lessons.reduce((k, l) => k + l.steps.length, 0), + 0, + ), + 0, + ); + const totalExercises = allPracticeSets.reduce( + (n, s) => n + s.exercises.length, + 0, + ); + + console.log("✅ Contenido válido.\n"); + console.log(` Cursos: ${allCourses.length}`); + for (const course of allCourses) { + const lessons = course.units.reduce((n, u) => n + u.lessons.length, 0); + const steps = course.units.reduce( + (n, u) => n + u.lessons.reduce((m, l) => m + l.steps.length, 0), + 0, + ); + const exercises = allPracticeSets + .filter((s) => s.courseSlug === course.slug) + .reduce((n, s) => n + s.exercises.length, 0); + console.log( + ` - ${course.slug}: ${course.units.length} unidades, ${lessons} lecciones, ` + + `${steps} steps, ${exercises} ejercicios de práctica`, + ); + } + console.log(` Unidades: ${totalUnits}`); + console.log(` Lecciones: ${totalLessons}`); + console.log(` Steps: ${totalSteps}`); + console.log(` Prácticas: ${totalExercises}`); + + process.exit(0); +} + +main(); diff --git a/tests/content/authoring.test.ts b/tests/content/authoring.test.ts new file mode 100644 index 0000000..94d3eec --- /dev/null +++ b/tests/content/authoring.test.ts @@ -0,0 +1,665 @@ +import { describe, expect, it } from "vitest"; + +import { + adaptLegacyUnits, + buildContentRegistry, + defineCourse, + defineLesson, + defineUnit, +} from "../../prisma/content/authoring"; +import type { + AuthoredUnitDefinition, + CoursePackageDefinition, +} from "../../prisma/content/authoring"; +import { ContentValidationError } from "../../prisma/content/validate"; +import type { + CourseDefinition, + LessonDefinition, +} from "../../prisma/content/types"; +import type { PracticeUnitSetDefinition } from "../../prisma/content/exercises/types"; + +import { allCourses, allPracticeSets } from "../../prisma/content/courses"; + +// --------------------------------------------------------------------- +// Reconstrucción INDEPENDIENTE de los dos cursos legacy (C++ y C#), tal +// como los ensamblaban `prisma/content/index.ts` y +// `prisma/content/exercises/index.ts` ANTES de este refactor — a partir +// de las mismas fuentes crudas (`unidad-*.ts`, `exercises/u*.ts`), SIN +// pasar por `authoring.ts`. Es el oráculo de la prueba de equivalencia: +// si el registry nuevo no coincide con esto, el refactor cambió +// contenido. +// --------------------------------------------------------------------- + +import { unidad01 } from "../../prisma/content/unidad-01-primer-programa"; +import { unidadCin } from "../../prisma/content/unidad-02-cin"; +import { unidadVariables } from "../../prisma/content/unidad-03-variables"; +import { unidad04 } from "../../prisma/content/unidad-04-control-flujo"; +import { unidad05 } from "../../prisma/content/unidad-05-loops"; +import { unidad06 } from "../../prisma/content/unidad-06-funciones"; +import { unidad07 } from "../../prisma/content/unidad-07-printf-scanf"; +import { unidad08 } from "../../prisma/content/unidad-08-arreglos"; +import { unidad09 } from "../../prisma/content/unidad-09-archivos"; +import { unidad10 } from "../../prisma/content/unidad-10-matrices"; + +import { u01PrimerProgramaExercises } from "../../prisma/content/exercises/u01-primer-programa"; +import { u02CinExercises } from "../../prisma/content/exercises/u02-cin"; +import { u03VariablesExercises } from "../../prisma/content/exercises/u03-variables"; +import { u04ControlFlujoExercises } from "../../prisma/content/exercises/u04-control-flujo"; +import { u05LoopsExercises } from "../../prisma/content/exercises/u05-loops"; +import { u06FuncionesExercises } from "../../prisma/content/exercises/u06-funciones"; +import { u07PrintfScanfExercises } from "../../prisma/content/exercises/u07-printf-scanf"; +import { u08ArreglosExercises } from "../../prisma/content/exercises/u08-arreglos"; +import { u09ArchivosExercises } from "../../prisma/content/exercises/u09-archivos"; +import { u10MatricesExercises } from "../../prisma/content/exercises/u10-matrices"; + +import { cursoCsharpPoo1 } from "../../prisma/content/csharp"; +import { csharpPracticeSets } from "../../prisma/content/exercises/csharp"; + +const legacyCursoCpp: CourseDefinition = { + slug: "cpp-desde-cero", + title: "C++ desde cero", + description: + "El curso completo de C++ pensado para estudiantes del CETI Guadalajara. " + + "Cada concepto va seguido de práctica inmediata.", + subjectName: "Programación en C++", + academicContext: "Curso introductorio CETI", + language: "cpp", + executionProfile: "cpp17-wandbox", + units: [ + unidad01, + unidadVariables, + unidadCin, + unidad04, + unidad05, + unidad06, + unidad07, + unidad08, + unidad09, + unidad10, + ], +}; + +const legacyCppPracticeSets: PracticeUnitSetDefinition[] = [ + u01PrimerProgramaExercises, + u02CinExercises, + u03VariablesExercises, + u04ControlFlujoExercises, + u05LoopsExercises, + u06FuncionesExercises, + u07PrintfScanfExercises, + u08ArreglosExercises, + u09ArchivosExercises, + u10MatricesExercises, +]; + +const legacyCursoCsharp = cursoCsharpPoo1; +const legacyCsharpPracticeSets = csharpPracticeSets; + +function byUnitSlug(sets: readonly PracticeUnitSetDefinition[]) { + return new Map(sets.map((s) => [s.unitSlug, s] as const)); +} + +// ======================================================================= +// defineLesson / defineUnit — identidad +// ======================================================================= + +describe("defineLesson / defineUnit", () => { + it("defineLesson devuelve la misma lección sin modificarla", () => { + const lesson: LessonDefinition = { + slug: "l1", + title: "Lección 1", + description: "Una lección de prueba.", + steps: [{ type: "theory", markdown: "# Hola" }], + }; + const result = defineLesson(lesson); + expect(result).toBe(lesson); + }); + + it("defineUnit devuelve la misma unidad sin modificarla, preservando practice", () => { + const lesson = defineLesson({ + slug: "l1", + title: "Lección 1", + description: "Una lección de prueba.", + steps: [{ type: "theory", markdown: "# Hola" }], + }); + const unit: AuthoredUnitDefinition = { + slug: "u1", + title: "Unidad 1", + description: "Una unidad de prueba.", + icon: "🧪", + lessons: [lesson], + practice: [ + { + slug: "ex1", + title: "Ejercicio 1", + description: "desc", + prompt: "prompt", + starterCode: "", + solutionCode: "sol", + difficulty: "easy", + testCases: [{ expectedStdout: "ok" }], + }, + ], + }; + const result = defineUnit(unit); + expect(result).toBe(unit); + expect(result.lessons).toBe(unit.lessons); + expect(result.practice).toBe(unit.practice); + }); +}); + +// ======================================================================= +// defineCourse — deriva courseSlug/unitSlug/unitTitle/unitIcon y separa +// la práctica colocalizada en su propio PracticeUnitSetDefinition +// ======================================================================= + +describe("defineCourse", () => { + function buildToyPackage(): CoursePackageDefinition { + const unitA = defineUnit({ + slug: "unidad-a", + title: "Unidad A", + icon: "🅰️", + description: "desc A", + lessons: [ + defineLesson({ + slug: "leccion-a1", + title: "Lección A1", + description: "desc", + steps: [ + { type: "theory", markdown: "teoría 1" }, + { type: "theory", markdown: "teoría 2" }, + ], + }), + ], + practice: [ + { + slug: "ex-a-1", + title: "Ejercicio A1", + description: "desc", + prompt: "prompt", + starterCode: "", + solutionCode: "sol", + difficulty: "easy", + testCases: [ + { expectedStdout: "1" }, + { expectedStdout: "2" }, + { expectedStdout: "3" }, + ], + }, + { + slug: "ex-a-2", + title: "Ejercicio A2", + description: "desc", + prompt: "prompt", + starterCode: "", + solutionCode: "sol", + difficulty: "medium", + testCases: [{ expectedStdout: "x" }], + }, + ], + }); + + const unitB = defineUnit({ + slug: "unidad-b", + title: "Unidad B", + description: "desc B", + lessons: [ + defineLesson({ + slug: "leccion-b1", + title: "Lección B1", + description: "desc", + steps: [{ type: "theory", markdown: "teoría b" }], + }), + ], + // sin practice + }); + + return defineCourse({ + slug: "curso-juguete", + title: "Curso juguete", + description: "desc curso", + subjectName: "Materia", + academicContext: "Contexto", + language: "cpp", + executionProfile: "cpp17-wandbox", + units: [unitA, unitB], + }); + } + + it("deriva courseSlug, unitSlug, unitTitle y unitIcon de la unidad", () => { + const pkg = buildToyPackage(); + expect(pkg.practiceSets).toHaveLength(1); + const [set] = pkg.practiceSets; + expect(set.courseSlug).toBe("curso-juguete"); + expect(set.unitSlug).toBe("unidad-a"); + expect(set.unitTitle).toBe("Unidad A"); + expect(set.unitIcon).toBe("🅰️"); + }); + + it("la práctica vive colocalizada en la unidad pero sale como PracticeUnitSetDefinition aparte", () => { + const pkg = buildToyPackage(); + // El curso emitido NO trae `practice` colgando de la unidad. + expect((pkg.course.units[0] as unknown as { practice?: unknown }).practice).toBeUndefined(); + expect(pkg.practiceSets[0].exercises).toHaveLength(2); + expect(pkg.practiceSets[0].exercises[0].slug).toBe("ex-a-1"); + }); + + it("preserva el orden de units, lessons, steps, practice exercises y test cases", () => { + const pkg = buildToyPackage(); + expect(pkg.course.units.map((u) => u.slug)).toEqual(["unidad-a", "unidad-b"]); + expect(pkg.course.units[0].lessons.map((l) => l.slug)).toEqual(["leccion-a1"]); + expect( + pkg.course.units[0].lessons[0].steps.map((s) => + s.type === "theory" ? s.markdown : "?", + ), + ).toEqual(["teoría 1", "teoría 2"]); + expect(pkg.practiceSets[0].exercises.map((e) => e.slug)).toEqual([ + "ex-a-1", + "ex-a-2", + ]); + expect(pkg.practiceSets[0].exercises[0].testCases.map((t) => t.expectedStdout)).toEqual([ + "1", + "2", + "3", + ]); + }); + + it("una unidad sin practice no produce PracticeUnitSetDefinition", () => { + const pkg = buildToyPackage(); + expect(pkg.practiceSets.find((s) => s.unitSlug === "unidad-b")).toBeUndefined(); + }); +}); + +// ======================================================================= +// buildContentRegistry — preserva orden de cursos, valida antes de +// aceptar, rechaza slugs de curso duplicados +// ======================================================================= + +describe("buildContentRegistry", () => { + function minimalPackage(slug: string): CoursePackageDefinition { + return defineCourse({ + slug, + title: `Curso ${slug}`, + description: "desc", + subjectName: "Materia", + academicContext: "Contexto", + language: "cpp", + executionProfile: "cpp17-wandbox", + units: [ + defineUnit({ + slug: "u1", + title: "Unidad 1", + description: "desc", + lessons: [ + defineLesson({ + slug: "l1", + title: "Lección 1", + description: "desc", + steps: [{ type: "theory", markdown: "hola" }], + }), + ], + }), + ], + }); + } + + it("preserva el orden del arreglo packages en allCourses", () => { + const pkgB = minimalPackage("curso-b"); + const pkgA = minimalPackage("curso-a"); + const registry = buildContentRegistry([pkgB, pkgA]); + expect(registry.allCourses.map((c) => c.slug)).toEqual(["curso-b", "curso-a"]); + }); + + it("rechaza course slugs duplicados", () => { + const pkg1 = minimalPackage("curso-dup"); + const pkg2 = minimalPackage("curso-dup"); + expect(() => buildContentRegistry([pkg1, pkg2])).toThrow(ContentValidationError); + }); +}); + +// ======================================================================= +// adaptLegacyUnits +// ======================================================================= + +describe("adaptLegacyUnits", () => { + const baseCourse: CourseDefinition = { + slug: "curso-legacy", + title: "Curso legacy", + description: "desc", + subjectName: "Materia", + academicContext: "Contexto", + language: "cpp", + executionProfile: "cpp17-wandbox", + units: [ + { + slug: "unidad-1", + title: "Unidad Uno", + icon: "1️⃣", + description: "desc", + lessons: [ + { + slug: "l1", + title: "Lección 1", + description: "desc", + steps: [{ type: "theory", markdown: "hola" }], + }, + ], + }, + ], + }; + + const validSet: PracticeUnitSetDefinition = { + courseSlug: "curso-legacy", + unitSlug: "unidad-1", + unitTitle: "Un título legacy que ya NO coincide", + unitIcon: "🐌", + exercises: [ + { + slug: "ex1", + title: "Ejercicio 1", + description: "desc", + prompt: "prompt", + starterCode: "", + solutionCode: "sol", + difficulty: "easy", + testCases: [{ expectedStdout: "ok" }], + }, + ], + }; + + it("preserva el orden de course.units", () => { + const course: CourseDefinition = { + ...baseCourse, + units: [ + { ...baseCourse.units[0], slug: "u1" }, + { ...baseCourse.units[0], slug: "u2" }, + { ...baseCourse.units[0], slug: "u3" }, + ], + }; + const authored = adaptLegacyUnits(course, []); + expect(authored.map((u) => u.slug)).toEqual(["u1", "u2", "u3"]); + }); + + it("rechaza un courseSlug que no es el del curso", () => { + const badSet: PracticeUnitSetDefinition = { + ...validSet, + courseSlug: "otro-curso", + }; + expect(() => adaptLegacyUnits(baseCourse, [badSet])).toThrow(); + }); + + it("rechaza un unitSlug inexistente", () => { + const badSet: PracticeUnitSetDefinition = { + ...validSet, + unitSlug: "unidad-fantasma", + }; + expect(() => adaptLegacyUnits(baseCourse, [badSet])).toThrow(); + }); + + it("rechaza dos sets para la misma unidad", () => { + expect(() => + adaptLegacyUnits(baseCourse, [validSet, { ...validSet }]), + ).toThrow(); + }); + + it("NO falla por unitTitle/unitIcon legacy distintos; el IR canónico usa unit.title/unit.icon", () => { + const authored = adaptLegacyUnits(baseCourse, [validSet]); + expect(authored).toHaveLength(1); + expect(authored[0].practice).toBe(validSet.exercises); + + const pkg = defineCourse({ ...baseCourse, units: authored }); + expect(pkg.practiceSets[0].unitTitle).toBe("Unidad Uno"); + expect(pkg.practiceSets[0].unitIcon).toBe("1️⃣"); + // Explícitamente NO igual a la metadata legacy del set original. + expect(pkg.practiceSets[0].unitTitle).not.toBe(validSet.unitTitle); + expect(pkg.practiceSets[0].unitIcon).not.toBe(validSet.unitIcon); + }); + + it("permite unidades sin práctica", () => { + const authored = adaptLegacyUnits(baseCourse, []); + expect(authored).toHaveLength(1); + expect(authored[0].practice).toBeUndefined(); + }); +}); + +// ======================================================================= +// Errores del validador incluyen un path preciso +// ======================================================================= + +describe("validate: paths precisos", () => { + it("un curso sin unidades reporta un path courses[slug]", () => { + const pkg = defineCourse({ + slug: "curso-vacio", + title: "t", + description: "d", + subjectName: "s", + academicContext: "a", + language: "cpp", + executionProfile: "cpp17-wandbox", + units: [], + }); + try { + buildContentRegistry([pkg]); + throw new Error("se esperaba que buildContentRegistry lanzara"); + } catch (err) { + expect(err).toBeInstanceOf(ContentValidationError); + const validationErr = err as ContentValidationError; + expect( + validationErr.issues.some( + (i) => i.path === "courses[curso-vacio]" && /unidad/.test(i.message), + ), + ).toBe(true); + } + }); + + it("un quiz con correctIndex fuera de rango reporta el path del step", () => { + const pkg = defineCourse({ + slug: "curso-quiz", + title: "t", + description: "d", + subjectName: "s", + academicContext: "a", + language: "cpp", + executionProfile: "cpp17-wandbox", + units: [ + defineUnit({ + slug: "u1", + title: "U1", + description: "d", + lessons: [ + defineLesson({ + slug: "leccion-x", + title: "L", + description: "d", + steps: [ + { type: "theory", markdown: "ok" }, + { + type: "quiz", + question: "?", + options: ["a", "b"], + correctIndex: 5, + explanation: "e", + }, + ], + }), + ], + }), + ], + }); + try { + buildContentRegistry([pkg]); + throw new Error("se esperaba que buildContentRegistry lanzara"); + } catch (err) { + expect(err).toBeInstanceOf(ContentValidationError); + const validationErr = err as ContentValidationError; + expect( + validationErr.issues.some( + (i) => + i.path === "courses[curso-quiz].units[u1].lessons[leccion-x].steps[1]" && + /correctIndex/.test(i.message), + ), + ).toBe(true); + } + }); +}); + +// ======================================================================= +// C++ y C# actuales pasan por el registry nuevo +// ======================================================================= + +describe("los cursos actuales pasan por el registry nuevo", () => { + it("cpp-desde-cero y csharp-poo-1 están en allCourses", () => { + expect(allCourses.map((c) => c.slug).sort()).toEqual( + ["cpp-desde-cero", "csharp-poo-1"].sort(), + ); + }); + + it("sus prácticas están en allPracticeSets", () => { + const slugs = new Set(allPracticeSets.map((s) => s.courseSlug)); + expect(slugs.has("cpp-desde-cero")).toBe(true); + expect(slugs.has("csharp-poo-1")).toBe(true); + }); +}); + +// ======================================================================= +// PRUEBA DE EQUIVALENCIA LEGACY +// +// Compara el contenido que emite el registry nuevo (`prisma/content/courses`) +// contra una reconstrucción independiente hecha a mano desde las mismas +// fuentes crudas, SIN pasar por `authoring.ts`. +// +// La única normalización permitida: `unitTitle`/`unitIcon` de +// `PracticeUnitSetDefinition` se derivan de la `UnitDefinition` canónica +// en vez de copiarse del set legacy. Hoy, para C++ y C#, ambos valores YA +// coinciden -- así que esto no cambia ningún dato real, sólo su origen. +// +// El orden EN QUE APARECEN LOS GRUPOS de práctica top-level (qué unidad +// va primero en el arreglo) no es contenido: ninguna consulta de la app +// ni del seed lo usa como posición (`seed-practice.ts` numera `position` +// DENTRO de cada set; `practice/queries.ts` ordena por +// `{unitSlug: "asc"}`, no por índice de arreglo). Por eso +// `buildContentRegistry`, que aplana curso→unidad en el orden de +// `course.units` (arquitectura ya cerrada), agrupa el mismo contenido en +// un orden distinto al del arreglo legacy escrito a mano para C++ (que +// listaba `leer-datos` antes que `variables-y-tipos`, aunque el CURSO ya +// tenía a `variables-y-tipos` primero). Esta prueba compara los GRUPOS +// como conjunto (por unitSlug) y, dentro de cada grupo, el orden de +// ejercicios y de test cases ESTRICTAMENTE. +// ======================================================================= + +describe("equivalencia legacy: el refactor de authoring no cambia contenido", () => { + it("C++: metadata de curso idéntica", () => { + const cpp = allCourses.find((c) => c.slug === "cpp-desde-cero")!; + expect(cpp.slug).toBe(legacyCursoCpp.slug); + expect(cpp.title).toBe(legacyCursoCpp.title); + expect(cpp.description).toBe(legacyCursoCpp.description); + expect(cpp.subjectName).toBe(legacyCursoCpp.subjectName); + expect(cpp.academicContext).toBe(legacyCursoCpp.academicContext); + expect(cpp.language).toBe(legacyCursoCpp.language); + expect(cpp.executionProfile).toBe(legacyCursoCpp.executionProfile); + }); + + it("C++: mismo orden de unidades", () => { + const cpp = allCourses.find((c) => c.slug === "cpp-desde-cero")!; + expect(cpp.units.map((u) => u.slug)).toEqual( + legacyCursoCpp.units.map((u) => u.slug), + ); + }); + + it("C++: unidades, lecciones, orden de lecciones, steps y orden de steps idénticos", () => { + const cpp = allCourses.find((c) => c.slug === "cpp-desde-cero")!; + expect(cpp.units).toEqual(legacyCursoCpp.units); + }); + + it("C#: metadata de curso idéntica", () => { + const csharp = allCourses.find((c) => c.slug === "csharp-poo-1")!; + expect(csharp.slug).toBe(legacyCursoCsharp.slug); + expect(csharp.title).toBe(legacyCursoCsharp.title); + expect(csharp.description).toBe(legacyCursoCsharp.description); + expect(csharp.subjectName).toBe(legacyCursoCsharp.subjectName); + expect(csharp.academicContext).toBe(legacyCursoCsharp.academicContext); + expect(csharp.language).toBe(legacyCursoCsharp.language); + expect(csharp.executionProfile).toBe(legacyCursoCsharp.executionProfile); + }); + + it("C#: mismo orden de unidades, unidades, lecciones y steps idénticos", () => { + const csharp = allCourses.find((c) => c.slug === "csharp-poo-1")!; + expect(csharp.units.map((u) => u.slug)).toEqual( + legacyCursoCsharp.units.map((u) => u.slug), + ); + expect(csharp.units).toEqual(legacyCursoCsharp.units); + }); + + it("C++: mismos grupos de práctica (por unidad), exercises y test cases idénticos y en el mismo orden dentro de cada grupo", () => { + const canonical = byUnitSlug( + allPracticeSets.filter((s) => s.courseSlug === "cpp-desde-cero"), + ); + const legacy = byUnitSlug(legacyCppPracticeSets); + + expect(new Set(canonical.keys())).toEqual(new Set(legacy.keys())); + + for (const [unitSlug, legacySet] of legacy) { + const canonicalSet = canonical.get(unitSlug)!; + expect(canonicalSet.courseSlug).toBe(legacySet.courseSlug); + expect(canonicalSet.exercises).toEqual(legacySet.exercises); + // Normalización permitida: título/ícono derivan de la unidad + // canónica. Hoy coincide con el legacy, pero la fuente de verdad + // cambió de "el set" a "la unidad". + const cpp = allCourses.find((c) => c.slug === "cpp-desde-cero")!; + const unit = cpp.units.find((u) => u.slug === unitSlug)!; + expect(canonicalSet.unitTitle).toBe(unit.title); + expect(canonicalSet.unitIcon).toBe(unit.icon); + } + }); + + it("C#: mismos grupos de práctica (por unidad), exercises y test cases idénticos y en el mismo orden dentro de cada grupo", () => { + const canonical = byUnitSlug( + allPracticeSets.filter((s) => s.courseSlug === "csharp-poo-1"), + ); + const legacy = byUnitSlug(legacyCsharpPracticeSets); + + expect(new Set(canonical.keys())).toEqual(new Set(legacy.keys())); + // Para C# el orden del arreglo legacy YA coincide con el orden de + // unidades del curso: lo comprobamos también posicionalmente. + expect( + allPracticeSets + .filter((s) => s.courseSlug === "csharp-poo-1") + .map((s) => s.unitSlug), + ).toEqual(legacyCsharpPracticeSets.map((s) => s.unitSlug)); + + for (const [unitSlug, legacySet] of legacy) { + const canonicalSet = canonical.get(unitSlug)!; + expect(canonicalSet.courseSlug).toBe(legacySet.courseSlug); + expect(canonicalSet.exercises).toEqual(legacySet.exercises); + const csharp = allCourses.find((c) => c.slug === "csharp-poo-1")!; + const unit = csharp.units.find((u) => u.slug === unitSlug)!; + expect(canonicalSet.unitTitle).toBe(unit.title); + expect(canonicalSet.unitIcon).toBe(unit.icon); + } + }); + + it("no hay ninguna otra diferencia: mismos published, xp, tiempos y runtime metadata", () => { + const cpp = allCourses.find((c) => c.slug === "cpp-desde-cero")!; + expect(cpp.language).toBe(legacyCursoCpp.language); + expect(cpp.executionProfile).toBe(legacyCursoCpp.executionProfile); + for (let i = 0; i < cpp.units.length; i++) { + expect(cpp.units[i].published).toBe(legacyCursoCpp.units[i].published); + for (let j = 0; j < cpp.units[i].lessons.length; j++) { + expect(cpp.units[i].lessons[j].xpReward).toBe( + legacyCursoCpp.units[i].lessons[j].xpReward, + ); + expect(cpp.units[i].lessons[j].estimatedMinutes).toBe( + legacyCursoCpp.units[i].lessons[j].estimatedMinutes, + ); + expect(cpp.units[i].lessons[j].published).toBe( + legacyCursoCpp.units[i].lessons[j].published, + ); + } + } + + const csharp = allCourses.find((c) => c.slug === "csharp-poo-1")!; + for (let i = 0; i < csharp.units.length; i++) { + expect(csharp.units[i].published).toBe(legacyCursoCsharp.units[i].published); + } + }); +});