diff --git a/docs/contenido.md b/docs/contenido.md index e6acecf..5423e2e 100644 --- a/docs/contenido.md +++ b/docs/contenido.md @@ -71,6 +71,54 @@ prisma/content/courses// export const { allCourses, allPracticeSets } = buildContentRegistry(packages); ``` +## Agrupar las unidades de un curso por semestre (`curriculum`) + +Un `Course` puede tener 0, 1 o varias agrupaciones curriculares (p. ej. "1.er +semestre — Fundamentos de Desarrollo de Software"). Es organización, no identidad: +no crea rutas, no crea progreso ni XP propios, y `Unit.order` sigue siendo el único +orden real de navegación del curso — la agrupación sólo describe cómo se leen esas +unidades ya ordenadas. + +`defineCourse` acepta exactamente uno de estos dos caminos (nunca ambos): + +```ts +// CURSO GENERAL — sin agrupación curricular. Comportamiento de siempre. +defineCourse({ + ...metadata, + units: [unidadA, unidadB, /* ... */], +}); + +// CURSO CURRICULAR — unidades agrupadas por sección. +defineCourse({ + ...metadata, + curriculum: [ + { + key: "s1-fundamentos-desarrollo-software", // identidad estable, NO va en URLs + semester: 1, + subjectName: "Fundamentos de Desarrollo de Software", + units: [unidadA, unidadB, unidadC], + }, + { + key: "s2-programacion-estructurada", + semester: 2, + subjectName: "Programación Estructurada", + units: [unidadD, unidadE], + }, + ], +}); +``` + +- El `order` de cada sección se deriva de su posición en el arreglo `curriculum` + (base 1) — no se declara a mano. +- `defineCourse` APLANA las unidades de todas las secciones, en el orden en que + aparecen, a `CourseDefinition.units`: ese aplanado es lo que el seed numera como + `Unit.order`, el orden real de navegación del curso. +- `practice` sigue viviendo colocalizada dentro de cada `AuthoredUnitDefinition`, + exactamente igual que en el camino sin `curriculum` — la agrupación curricular no + cambia en nada cómo se autora la práctica. +- Un curso sin `curriculum` es tan válido como uno con una o varias secciones: + `npm run content:validate` no lo exige. + - 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 diff --git a/prisma/content/authoring.ts b/prisma/content/authoring.ts index 75879d4..1122d08 100644 --- a/prisma/content/authoring.ts +++ b/prisma/content/authoring.ts @@ -19,6 +19,7 @@ import { validateContentRegistry } from "./validate"; import type { CourseDefinition, + CurriculumSectionDefinition, LessonDefinition, UnitDefinition, } from "./types"; @@ -45,6 +46,43 @@ export interface CoursePackageDefinition { practiceSets: PracticeUnitSetDefinition[]; } +/** + * Una agrupación curricular tal como se autora: sus unidades EN LÍNEA (en + * vez de una lista de slugs apuntando a unidades declaradas aparte). El + * `order` de la sección se deriva de su posición en el arreglo + * `curriculum` — no se declara aquí. + */ +export interface AuthoredCurriculumSectionDefinition { + key: string; + semester: number; + subjectName: string; + units: AuthoredUnitDefinition[]; +} + +/** Metadata de curso sin `units` ni `curriculum` — común a ambos caminos. */ +type CourseMetadata = Omit; + +/** + * Lo que acepta `defineCourse`. Exactamente uno de los dos caminos: + * - `units`: el curso NO tiene agrupación curricular (comportamiento de + * siempre, intacto). + * - `curriculum`: el curso se declara como secciones curriculares, cada + * una con sus unidades; `defineCourse` las aplana a `CourseDefinition.units` + * y deriva las `CurriculumSectionDefinition` correspondientes. + * No se acepta mezclar `units` sueltas con `curriculum` en el mismo curso. + */ +export type AuthoredCourseDefinition = CourseMetadata & + ( + | { + units: AuthoredUnitDefinition[]; + curriculum?: never; + } + | { + curriculum: AuthoredCurriculumSectionDefinition[]; + units?: never; + } + ); + /** * 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 @@ -64,30 +102,86 @@ export function defineUnit(unit: AuthoredUnitDefinition): AuthoredUnitDefinition /** * 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. + * unidades autoradas (`units`) O secciones curriculares (`curriculum`), + * nunca ambas. 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 sections/units/lessons/steps/practice/ + * tests recibido: no ordena alfabéticamente, no aplica defaults, no + * infiere `language` ni `executionProfile`, no muta el input. * - * Preserva EXACTAMENTE el orden de units/lessons/steps/practice/tests - * recibido: no ordena alfabéticamente, no infiere `language` ni - * `executionProfile`, no muta el input. + * Camino `curriculum`: recorre las secciones en el orden recibido y, + * dentro de cada una, sus unidades en el orden recibido; aplana todas las + * unidades a `CourseDefinition.units` (ese aplanado es lo que define el + * orden GLOBAL de navegación del curso, vía `Unit.order` en el seed) y + * deriva `CurriculumSectionDefinition.order` (= posición de la sección + + * 1) y `unitSlugs` (de las unidades de esa sección). */ export function defineCourse( - course: Omit & { - units: AuthoredUnitDefinition[]; - }, + course: AuthoredCourseDefinition, ): CoursePackageDefinition { + if (course.curriculum) { + return assembleFromCurriculum(course, course.curriculum); + } + return assembleFromUnits(course, course.units); +} + +function assembleFromUnits( + metadata: CourseMetadata, + authoredUnits: AuthoredUnitDefinition[], +): CoursePackageDefinition { + const { units, practiceSets } = flattenUnits(metadata.slug, authoredUnits); + return { + course: { ...metadata, units }, + practiceSets, + }; +} + +function assembleFromCurriculum( + metadata: CourseMetadata, + sections: AuthoredCurriculumSectionDefinition[], +): CoursePackageDefinition { + const units: UnitDefinition[] = []; + const practiceSets: PracticeUnitSetDefinition[] = []; + const curriculum: CurriculumSectionDefinition[] = []; + + sections.forEach((section, sectionIndex) => { + const flattened = flattenUnits(metadata.slug, section.units); + units.push(...flattened.units); + practiceSets.push(...flattened.practiceSets); + + curriculum.push({ + key: section.key, + semester: section.semester, + subjectName: section.subjectName, + order: sectionIndex + 1, + unitSlugs: flattened.units.map((u) => u.slug), + }); + }); + + return { + course: { ...metadata, units, curriculum }, + practiceSets, + }; +} + +/** Aplana unidades autoradas a `UnitDefinition[]` + sus `PracticeUnitSetDefinition[]`. */ +function flattenUnits( + courseSlug: string, + authoredUnits: AuthoredUnitDefinition[], +): { units: UnitDefinition[]; practiceSets: PracticeUnitSetDefinition[] } { const units: UnitDefinition[] = []; const practiceSets: PracticeUnitSetDefinition[] = []; - for (const authoredUnit of course.units) { + for (const authoredUnit of authoredUnits) { const { practice, ...unit } = authoredUnit; units.push(unit); if (practice) { practiceSets.push({ - courseSlug: course.slug, + courseSlug, unitSlug: unit.slug, unitTitle: unit.title, unitIcon: unit.icon, @@ -96,10 +190,7 @@ export function defineCourse( } } - return { - course: { ...course, units }, - practiceSets, - }; + return { units, practiceSets }; } /** diff --git a/prisma/content/courses/cpp-desde-cero/index.ts b/prisma/content/courses/cpp-desde-cero/index.ts index 260cd83..185ac5a 100644 --- a/prisma/content/courses/cpp-desde-cero/index.ts +++ b/prisma/content/courses/cpp-desde-cero/index.ts @@ -5,12 +5,15 @@ // 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. +// tal cual desde el `prisma/content/index.ts` anterior), el paso por +// `adaptLegacyUnits` para entrar a la capa de authoring, y la agrupación +// curricular EN DOS SEMESTRES (`curriculum`) que `defineCourse` aplana de +// vuelta a `CourseDefinition.units` — ese aplanado es el único orden real +// de navegación del curso (`Unit.order`, numerado por el seed). // ===================================================================== import { adaptLegacyUnits, defineCourse } from "../../authoring"; +import type { AuthoredUnitDefinition } from "../../authoring"; import type { CourseDefinition } from "../../types"; import { unidad01 } from "../../unidad-01-primer-programa"; @@ -88,7 +91,54 @@ const legacyPracticeSets = [ const authoredUnits = adaptLegacyUnits(cursoCppLegacy, legacyPracticeSets); +const unitsBySlug = new Map(authoredUnits.map((u) => [u.slug, u] as const)); + +/** Selecciona una Unit legacy ya adaptada por su slug. Falla cerrado. */ +function unit(slug: string): AuthoredUnitDefinition { + const found = unitsBySlug.get(slug); + if (!found) { + throw new Error( + `courses/cpp-desde-cero: no existe la unidad "${slug}" en cursoCppLegacy.units.`, + ); + } + return found; +} + +const { slug, title, description, subjectName, academicContext, language, executionProfile } = + cursoCppLegacy; + export const cppDesdeCero = defineCourse({ - ...cursoCppLegacy, - units: authoredUnits, + slug, + title, + description, + subjectName, + academicContext, + language, + executionProfile, + curriculum: [ + { + key: "s1-fundamentos-desarrollo-software", + semester: 1, + subjectName: "Fundamentos de Desarrollo de Software", + units: [ + unit("primer-programa"), + unit("variables-y-tipos"), + unit("leer-datos"), + unit("control-de-flujo"), + unit("loops"), + unit("printf-scanf"), + ], + }, + { + key: "s2-programacion-estructurada", + semester: 2, + subjectName: "Programación Estructurada", + units: [ + unit("funciones"), + unit("arreglos"), + unit("archivos"), + unit("matrices"), + ], + }, + ], }); diff --git a/prisma/content/courses/csharp-poo-1/index.ts b/prisma/content/courses/csharp-poo-1/index.ts index e276856..07b19f8 100644 --- a/prisma/content/courses/csharp-poo-1/index.ts +++ b/prisma/content/courses/csharp-poo-1/index.ts @@ -7,6 +7,11 @@ // editaron. Aquí sólo se reutilizan y se pasan por `adaptLegacyUnits` + // `defineCourse` para entrar a la misma capa de authoring que los cursos // nuevos. +// +// Segundo caso de `curriculum`, deliberadamente distinto de C++: UNA sola +// sección (3.er semestre) que envuelve las 8 unidades existentes tal +// cual, en su mismo orden. Demuestra que la agrupación curricular no está +// hardcodeada para C++ ni para dos semestres. // ===================================================================== import { adaptLegacyUnits, defineCourse } from "../../authoring"; @@ -15,7 +20,30 @@ import { csharpPracticeSets } from "../../exercises/csharp"; const authoredUnits = adaptLegacyUnits(cursoCsharpPoo1, csharpPracticeSets); +const { + slug, + title, + description, + subjectName, + academicContext, + language, + executionProfile, +} = cursoCsharpPoo1; + export const csharpPoo1 = defineCourse({ - ...cursoCsharpPoo1, - units: authoredUnits, + slug, + title, + description, + subjectName, + academicContext, + language, + executionProfile, + curriculum: [ + { + key: "s3-programacion-orientada-objetos-1", + semester: 3, + subjectName: "Programación Orientada a Objetos I", + units: authoredUnits, + }, + ], }); diff --git a/prisma/content/types.ts b/prisma/content/types.ts index a79b23a..9fc195d 100644 --- a/prisma/content/types.ts +++ b/prisma/content/types.ts @@ -29,6 +29,36 @@ export interface CourseDefinition { language: LanguageId; executionProfile: ExecutionProfileId; units: UnitDefinition[]; + /** + * Agrupaciones curriculares OPCIONALES de las `units` de este curso (p. + * ej. "1.er semestre — Fundamentos de Desarrollo de Software"). Un curso + * sin `curriculum` sigue siendo válido: la agrupación es organización, + * no identidad — no crea rutas, no afecta progreso/XP y `Unit.order` + * sigue siendo el único orden real de navegación. + */ + curriculum?: CurriculumSectionDefinition[]; +} + +/** + * Una agrupación curricular (p. ej. una materia de un semestre) dentro de + * UN Course. Agrupa `Unit`s ya existentes del curso — no es una entidad de + * identidad propia: no aparece en URLs, no tiene progreso ni XP propios. + */ +export interface CurriculumSectionDefinition { + /** Identidad estable dentro del Course. No forma parte de URLs. */ + key: string; + + /** Entero positivo. NO limitar a 1..8. */ + semester: number; + + /** Nombre académico de la materia/bloque. */ + subjectName: string; + + /** Derivado del orden del array. Base 1. */ + order: number; + + /** Slugs de las Units pertenecientes a esta section. */ + unitSlugs: string[]; } export interface UnitDefinition { diff --git a/prisma/content/validate.ts b/prisma/content/validate.ts index 95899a3..c611e2a 100644 --- a/prisma/content/validate.ts +++ b/prisma/content/validate.ts @@ -110,6 +110,114 @@ function validateCourse( for (const unit of course.units) { validateUnit(unit, path, issues, seenUnitSlugs); } + + if (course.curriculum !== undefined) { + validateCurriculum(course, path, issues); + } +} + +/** + * Un curso SIN `curriculum` es válido: la agrupación curricular es + * opcional. Si existe, valida: al menos 1 sección, `key`/`order` únicos y + * contiguos desde 1, `semester` entero positivo (sin máximo), cada + * sección con al menos 1 unidad, y que cada `unitSlug` referenciado + * exista en `course.units` y pertenezca a UNA sola sección. + */ +function validateCurriculum( + course: CourseDefinition, + coursePath: string, + issues: ContentValidationIssue[], +): void { + const curriculum = course.curriculum ?? []; + const path = `${coursePath}.curriculum`; + + if (curriculum.length === 0) { + push(issues, path, "si se declara, debe tener al menos 1 sección"); + return; + } + + const courseUnitSlugs = new Set(course.units.map((u) => u.slug)); + const seenKeys = new Set(); + const seenOrders = new Set(); + const unitOwner = new Map(); + + curriculum.forEach((section, index) => { + const sectionPath = `${path}[${section.key || index}]`; + + if (!section.key.trim()) { + push(issues, sectionPath, "key vacía"); + } else if (seenKeys.has(section.key)) { + push(issues, sectionPath, `key duplicada dentro del curso: "${section.key}"`); + } else { + seenKeys.add(section.key); + } + + if (!Number.isInteger(section.semester) || section.semester < 1) { + push( + issues, + sectionPath, + `semester debe ser un entero >= 1 (recibido: ${section.semester})`, + ); + } + + if (!section.subjectName.trim()) { + push(issues, sectionPath, "subjectName vacío"); + } + + if (section.unitSlugs.length === 0) { + push(issues, sectionPath, "debe contener al menos 1 unidad"); + } + + if (!Number.isInteger(section.order)) { + push(issues, sectionPath, `order debe ser un entero (recibido: ${section.order})`); + } else if (seenOrders.has(section.order)) { + push(issues, sectionPath, `order duplicado: ${section.order}`); + } else { + seenOrders.add(section.order); + } + + section.unitSlugs.forEach((unitSlug, unitIndex) => { + const membershipPath = `${sectionPath}.unitSlugs[${unitIndex}]`; + + if (!courseUnitSlugs.has(unitSlug)) { + push( + issues, + membershipPath, + `la unidad "${unitSlug}" no existe en course.units`, + ); + return; + } + + const owner = unitOwner.get(unitSlug); + if (owner !== undefined && owner !== section.key) { + push( + issues, + membershipPath, + `la unidad "${unitSlug}" ya pertenece a la sección "${owner}"`, + ); + } else { + unitOwner.set(unitSlug, section.key); + } + }); + }); + + // order: entero, base 1, único (ya verificado arriba) y CONTIGUO. + const orders = curriculum + .map((s) => s.order) + .filter((o) => Number.isInteger(o)) + .sort((a, b) => a - b); + const expected = orders.map((_, i) => i + 1); + const isContiguousFromOne = + orders.length > 0 && + orders[0] === 1 && + orders.every((o, i) => o === expected[i]); + if (orders.length === curriculum.length && !isContiguousFromOne) { + push( + issues, + path, + `order de las secciones debe ser contiguo desde 1 (recibido: ${orders.join(", ")})`, + ); + } } function validateUnit( diff --git a/prisma/migrations/20260902120000_add_curriculum_sections/migration.sql b/prisma/migrations/20260902120000_add_curriculum_sections/migration.sql new file mode 100644 index 0000000..d56d872 --- /dev/null +++ b/prisma/migrations/20260902120000_add_curriculum_sections/migration.sql @@ -0,0 +1,55 @@ +-- ===================================================================== +-- Agrupaciones curriculares (CurriculumSection) dentro de un Course. +-- +-- 100% ADITIVA: crea una tabla nueva y una columna nullable en `unit`. +-- No toca ninguna identidad/unicidad existente, no borra ni recrea +-- ninguna fila. El backfill semántico (qué Unit pertenece a qué +-- sección) NO vive aquí — lo hace el seed (`prisma/seed-content.ts`), +-- que es la fuente de verdad del contenido. +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- 1. Tabla curriculum_section +-- --------------------------------------------------------------------- +CREATE TABLE "curriculum_section" ( + "id" TEXT NOT NULL, + "courseId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "semester" INTEGER NOT NULL, + "subjectName" TEXT NOT NULL, + "order" INTEGER NOT NULL, + + CONSTRAINT "curriculum_section_pkey" PRIMARY KEY ("id"), + CONSTRAINT "curriculum_section_semester_check" CHECK ("semester" > 0), + CONSTRAINT "curriculum_section_order_check" CHECK ("order" > 0) +); + +-- --------------------------------------------------------------------- +-- 2. Unicidad e índices +-- --------------------------------------------------------------------- +CREATE UNIQUE INDEX "curriculum_section_courseId_key_key" + ON "curriculum_section"("courseId", "key"); + +CREATE INDEX "curriculum_section_courseId_order_idx" + ON "curriculum_section"("courseId", "order"); + +-- --------------------------------------------------------------------- +-- 3. FK courseId -> course.id +-- --------------------------------------------------------------------- +ALTER TABLE "curriculum_section" + ADD CONSTRAINT "curriculum_section_courseId_fkey" + FOREIGN KEY ("courseId") REFERENCES "course"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- --------------------------------------------------------------------- +-- 4. unit.curriculumSectionId — nullable, sin backfill (lo hace el seed) +-- --------------------------------------------------------------------- +ALTER TABLE "unit" ADD COLUMN "curriculumSectionId" TEXT; + +CREATE INDEX "unit_curriculumSectionId_idx" + ON "unit"("curriculumSectionId"); + +ALTER TABLE "unit" + ADD CONSTRAINT "unit_curriculumSectionId_fkey" + FOREIGN KEY ("curriculumSectionId") REFERENCES "curriculum_section"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index adcc2c9..67eefcc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -188,14 +188,42 @@ model Course { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - units Unit[] - progress UserCourseProgress[] - practiceExercises PracticeExercise[] - socialEvents SocialEvent[] + units Unit[] + progress UserCourseProgress[] + practiceExercises PracticeExercise[] + socialEvents SocialEvent[] + curriculumSections CurriculumSection[] @@map("course") } +/// Agrupación curricular (p. ej. "1.er semestre — Fundamentos de +/// Desarrollo de Software") de las `Unit`s de UN Course. NO es una +/// entidad de identidad propia: no aparece en URLs, no tiene progreso ni +/// XP propios, y `Unit.order` sigue siendo el único orden real de +/// navegación del curso. Un Course puede tener cero, una o varias. +model CurriculumSection { + id String @id @default(cuid()) + + courseId String + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + /// Identidad estable dentro del Course. No forma parte de URLs. + key String + /// Entero positivo. Sin máximo — no se asume una institución de 8 semestres. + semester Int + /// Nombre académico de la materia/bloque. + subjectName String + /// Orden de presentación de la sección dentro del curso (base 1, contiguo). + order Int + + units Unit[] + + @@unique([courseId, key]) + @@index([courseId, order]) + @@map("curriculum_section") +} + model Unit { id String @id @default(cuid()) courseId String @@ -210,12 +238,20 @@ model Unit { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// Sección curricular a la que pertenece esta unidad, si el curso + /// declara `curriculum`. Nullable: la mayoría de los cursos no tienen + /// agrupación curricular, y una unidad que deja de pertenecer a una + /// sección (contenido reorganizado) cae a `null` en vez de romper FK. + curriculumSectionId String? + curriculumSection CurriculumSection? @relation(fields: [curriculumSectionId], references: [id], onDelete: SetNull) + lessons Lesson[] practiceExercises PracticeExercise[] socialEvents SocialEvent[] @@unique([courseId, slug]) @@index([courseId, order]) + @@index([curriculumSectionId]) @@map("unit") } diff --git a/prisma/seed-content.ts b/prisma/seed-content.ts index 7110955..5b5e830 100644 --- a/prisma/seed-content.ts +++ b/prisma/seed-content.ts @@ -10,6 +10,7 @@ import { allCourses } from "./content"; import { contentRevision, trackRevision } from "./seed-revisions"; import type { CourseDefinition, + CurriculumSectionDefinition, LessonDefinition, StepDefinition, UnitDefinition, @@ -82,8 +83,87 @@ async function upsertCourse( }, }); + const sections = course.curriculum ?? []; + const unitToSectionId = await upsertCurriculumSections(db, dbCourse.id, sections); + for (let i = 0; i < course.units.length; i++) { - await upsertUnit(db, dbCourse.id, course.units[i], i, runtime); + const unit = course.units[i]; + await upsertUnit( + db, + dbCourse.id, + unit, + i, + runtime, + unitToSectionId.get(unit.slug) ?? null, + ); + } + + // Las Units ya quedaron reasignadas (o en null) arriba: cualquier + // sección que ya no esté en el contenido no tiene referencias vivas, así + // que borrarla es seguro. `onDelete: SetNull` en `Unit.curriculumSection` + // es sólo la red de seguridad, no el mecanismo principal. + await pruneStaleCurriculumSections( + db, + dbCourse.id, + sections.map((s) => s.key), + ); +} + +/** + * Upsert de las `CurriculumSection` declaradas por el curso, por + * `(courseId, key)`. Devuelve un `Map` para + * que `upsertUnit` sepa a qué sección (si alguna) pertenece cada unidad. + * Un curso sin `curriculum` (`sections` vacío) no crea ninguna. + */ +async function upsertCurriculumSections( + db: PrismaClient, + courseId: string, + sections: CurriculumSectionDefinition[], +): Promise> { + const unitToSectionId = new Map(); + + for (const section of sections) { + console.log( + ` 🗂️ Sección: ${section.subjectName} (semestre ${section.semester})`, + ); + const dbSection = await db.curriculumSection.upsert({ + where: { courseId_key: { courseId, key: section.key } }, + update: { + semester: section.semester, + subjectName: section.subjectName, + order: section.order, + }, + create: { + courseId, + key: section.key, + semester: section.semester, + subjectName: section.subjectName, + order: section.order, + }, + }); + + for (const unitSlug of section.unitSlugs) { + unitToSectionId.set(unitSlug, dbSection.id); + } + } + + return unitToSectionId; +} + +/** Borra las `CurriculumSection` del curso cuya `key` ya no está declarada. */ +async function pruneStaleCurriculumSections( + db: PrismaClient, + courseId: string, + declaredKeys: string[], +): Promise { + const { count } = await db.curriculumSection.deleteMany({ + where: { + courseId, + key: declaredKeys.length > 0 ? { notIn: declaredKeys } : undefined, + }, + }); + if (count > 0) { + console.log(` 🗑️ ${count} sección(es) curricular(es) obsoleta(s) eliminada(s).`); } } @@ -93,6 +173,7 @@ async function upsertUnit( unit: UnitDefinition, index: number, runtime: CourseRuntime, + curriculumSectionId: string | null, ) { console.log(` 📦 Unidad: ${unit.title}`); const dbUnit = await db.unit.upsert({ @@ -104,6 +185,7 @@ async function upsertUnit( colorAccent: unit.colorAccent ?? null, published: unit.published ?? true, order: index + 1, + curriculumSectionId, }, create: { courseId, @@ -114,6 +196,7 @@ async function upsertUnit( colorAccent: unit.colorAccent ?? null, published: unit.published ?? true, order: index + 1, + curriculumSectionId, }, }); diff --git a/src/app/app/(global)/cursos/page.tsx b/src/app/app/(global)/cursos/page.tsx index b8d8c56..9e1aa71 100644 --- a/src/app/app/(global)/cursos/page.tsx +++ b/src/app/app/(global)/cursos/page.tsx @@ -58,6 +58,7 @@ export default async function TusCursosPage() { description: course.description, subjectName: course.subjectName, academicContext: course.academicContext, + curriculumSummary: course.curriculumSummary, languageLabel: isLanguageId(course.language) ? LANGUAGE_PROFILES[course.language].label : course.language, @@ -93,7 +94,9 @@ export default async function TusCursosPage() { {course.title}

- {course.subjectName} + {course.curriculumSummary + ? `${course.subjectName} · ${course.curriculumSummary}` + : course.subjectName}

diff --git a/src/features/courses/components/course-picker.tsx b/src/features/courses/components/course-picker.tsx index 67d9f64..5758faf 100644 --- a/src/features/courses/components/course-picker.tsx +++ b/src/features/courses/components/course-picker.tsx @@ -12,6 +12,8 @@ export interface CoursePickerItem { description: string; subjectName: string; academicContext: string; + /** `null` si el curso no declara `curriculum`; ahí se usa `academicContext`. */ + curriculumSummary: string | null; languageLabel: string; unitCount: number; lessonCount: number; @@ -37,7 +39,7 @@ export function CoursePicker({ courses }: { courses: CoursePickerItem[] }) { {course.languageLabel} - {course.academicContext} + {course.curriculumSummary ?? course.academicContext}
diff --git a/src/features/courses/components/course-switcher.tsx b/src/features/courses/components/course-switcher.tsx index a59bbc4..a239a03 100644 --- a/src/features/courses/components/course-switcher.tsx +++ b/src/features/courses/components/course-switcher.tsx @@ -18,6 +18,26 @@ export interface CourseSwitcherItem { slug: string; title: string; languageLabel: string; + /** `null` si el curso no declara `curriculum`. */ + curriculumSummary: string | null; +} + +/** + * Subtítulo de cada curso en el menú: `lenguaje · curriculumSummary` + * cuando el curso declara `curriculum`; si no, sólo el lenguaje (más "· + * curso actual" si aplica) — el comportamiento de siempre. Exportada + * aparte porque el contenido del menú (Radix) no se monta cerrado, así + * que esto es lo único de esta pieza comprobable sin abrir el menú. + */ +export function courseSwitcherSubtitle( + course: Pick, + isActive: boolean, +): string { + return ( + course.languageLabel + + (course.curriculumSummary ? ` · ${course.curriculumSummary}` : "") + + (isActive ? " · curso actual" : "") + ); } /** @@ -118,8 +138,7 @@ export function CourseSwitcher({ {course.title} - {course.languageLabel} - {isActive ? " · curso actual" : ""} + {courseSwitcherSubtitle(course, isActive)} diff --git a/src/features/courses/queries.ts b/src/features/courses/queries.ts index b0f682b..c219111 100644 --- a/src/features/courses/queries.ts +++ b/src/features/courses/queries.ts @@ -1,5 +1,6 @@ import { cache } from "react"; +import { formatSemesterSummary } from "@/lib/curriculum"; import { db } from "@/lib/db"; export interface CourseChoice { @@ -12,6 +13,8 @@ export interface CourseChoice { language: string; unitCount: number; lessonCount: number; + /** `null` si el curso no declara `curriculum` (ver `prisma/content/types.ts`). */ + curriculumSummary: string | null; } /** @@ -37,6 +40,10 @@ export const getCourseChoices = cache(async (): Promise => { // puede abrir hoy, no lo que existe en la base. select: { _count: { select: { lessons: { where: { published: true } } } } }, }, + curriculumSections: { + orderBy: { order: "asc" }, + select: { semester: true }, + }, }, }); @@ -50,6 +57,9 @@ export const getCourseChoices = cache(async (): Promise => { language: course.language, unitCount: course.units.length, lessonCount: course.units.reduce((n, u) => n + u._count.lessons, 0), + curriculumSummary: formatSemesterSummary( + course.curriculumSections.map((s) => s.semester), + ), })); }); diff --git a/src/features/roadmap/components/roadmap-units.tsx b/src/features/roadmap/components/roadmap-units.tsx index c62d544..677ba81 100644 --- a/src/features/roadmap/components/roadmap-units.tsx +++ b/src/features/roadmap/components/roadmap-units.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { ArrowRight, Check, Lock } from "lucide-react"; import { BrickColumn } from "@/components/ui/bricks"; +import { formatSemester } from "@/lib/curriculum"; import { cn } from "@/lib/utils"; import type { RoadmapUnit } from "@/features/roadmap/types"; @@ -35,10 +36,23 @@ export function RoadmapUnits({ courseSlug, units }: RoadmapUnitsProps) { } const headIndex = findHeadIndex(units); + const sectionProgress = buildSectionProgress(units); + const renderItems = buildRenderItems(units, sectionProgress); return (
    - {units.map((unit, idx) => { + {renderItems.map((item) => { + if (item.kind === "section-header") { + return ( + + ); + } + + const { unit, index: idx } = item; const completed = unit.lessonCount > 0 && unit.completedCount === unit.lessonCount; const isHere = idx === headIndex && unit.published; @@ -180,6 +194,89 @@ function findHeadIndex(units: RoadmapUnit[]): number { return -1; } +type CurriculumSectionInfo = NonNullable; + +interface SectionProgress { + completedCount: number; + lessonCount: number; +} + +/** Progreso por sección curricular: suma completedCount/lessonCount de sus Units. */ +function buildSectionProgress( + units: RoadmapUnit[], +): Map { + const progress = new Map(); + for (const unit of units) { + if (!unit.curriculumSection) continue; + const key = unit.curriculumSection.key; + const current = progress.get(key) ?? { completedCount: 0, lessonCount: 0 }; + current.completedCount += unit.completedCount; + current.lessonCount += unit.lessonCount; + progress.set(key, current); + } + return progress; +} + +type RenderItem = + | { kind: "section-header"; key: string; section: CurriculumSectionInfo; progress: SectionProgress } + | { kind: "unit"; key: string; unit: RoadmapUnit; index: number }; + +/** + * Aplana `units` (ya en `Unit.order` global) a la secuencia a renderizar, + * insertando un header ANTES de la primera unidad de cada sección nueva — + * mismo flujo vertical, mismo orden, sin reordenar por semestre. Un curso + * sin `curriculum` (todo `curriculumSection: null`) no produce headers: + * renderiza exactamente como la lista plana de siempre. + */ +function buildRenderItems( + units: RoadmapUnit[], + sectionProgress: Map, +): RenderItem[] { + const items: RenderItem[] = []; + let previousSectionKey: string | null = null; + + units.forEach((unit, index) => { + const sectionKey = unit.curriculumSection?.key ?? null; + if (unit.curriculumSection && sectionKey !== previousSectionKey) { + items.push({ + kind: "section-header", + key: `section-${sectionKey}`, + section: unit.curriculumSection, + progress: sectionProgress.get(sectionKey!) ?? { + completedCount: 0, + lessonCount: 0, + }, + }); + } + items.push({ kind: "unit", key: unit.slug, unit, index }); + previousSectionKey = sectionKey; + }); + + return items; +} + +function CurriculumSectionHeader({ + section, + progress, +}: { + section: CurriculumSectionInfo; + progress: SectionProgress; +}) { + return ( +
  1. +

    + {formatSemester(section.semester)} +

    +

    + {section.subjectName} +

    +

    + {progress.completedCount} / {progress.lessonCount} lecciones +

    +
  2. + ); +} + /** * El nudo de la ruta: dónde empieza cada unidad. Palomita = terminada, * anillo = aquí estás, número = pendiente, candado = por publicar. diff --git a/src/features/roadmap/queries.ts b/src/features/roadmap/queries.ts index a0b9a3b..b736a76 100644 --- a/src/features/roadmap/queries.ts +++ b/src/features/roadmap/queries.ts @@ -51,6 +51,9 @@ export const getRoadmapUnits = cache(async ( }, }, }, + curriculumSection: { + select: { key: true, semester: true, subjectName: true, order: true }, + }, }, }); @@ -61,6 +64,7 @@ export const getRoadmapUnits = cache(async ( published: u.published, lessonCount: u.lessons.length, completedCount: u.lessons.filter((l) => l.progress.length > 0).length, + curriculumSection: u.curriculumSection, })); }); diff --git a/src/features/roadmap/types.ts b/src/features/roadmap/types.ts index 7091c99..93e7991 100644 --- a/src/features/roadmap/types.ts +++ b/src/features/roadmap/types.ts @@ -5,6 +5,17 @@ export interface RoadmapUnit { published: boolean; lessonCount: number; completedCount: number; + /** + * Sección curricular a la que pertenece esta unidad, si el curso + * declara `curriculum`. `null` en cursos sin agrupación curricular — + * ahí el roadmap se ve exactamente como antes: una lista plana. + */ + curriculumSection: { + key: string; + semester: number; + subjectName: string; + order: number; + } | null; } export type RoadmapLessonStatus = "completed" | "in_progress" | "not_started"; diff --git a/src/lib/app-shell.ts b/src/lib/app-shell.ts index 998faf7..00839dc 100644 --- a/src/lib/app-shell.ts +++ b/src/lib/app-shell.ts @@ -43,6 +43,7 @@ export async function loadAppShellBase() { languageLabel: isLanguageId(c.language) ? LANGUAGE_PROFILES[c.language].label : c.language, + curriculumSummary: c.curriculumSummary, })); return { diff --git a/src/lib/curriculum.ts b/src/lib/curriculum.ts new file mode 100644 index 0000000..a1506f6 --- /dev/null +++ b/src/lib/curriculum.ts @@ -0,0 +1,59 @@ +// ===================================================================== +// Helpers de formato para agrupaciones curriculares (CurriculumSection). +// +// Puramente de presentación: no consultan la base ni conocen el resto +// del dominio. `semester` no es identidad — sólo un número que se lee en +// español. +// ===================================================================== + +/** + * Formatea un número de semestre en español de México. + * + * 1 → "1.er semestre", 2 → "2.º semestre", 3 → "3.er semestre", + * 4 → "4.º semestre". Cualquier otro entero positivo usa el ordinal + * genérico `${n}.º semestre`. + */ +export function formatSemester(n: number): string { + switch (n) { + case 1: + return "1.er semestre"; + case 2: + return "2.º semestre"; + case 3: + return "3.er semestre"; + case 4: + return "4.º semestre"; + default: + return `${n}.º semestre`; + } +} + +/** + * Resume una lista de semestres en una sola línea legible. + * + * `[]` → `null` (nada que resumir). Un único semestre usa + * `formatSemester`. Dos o más se unen con la unión española natural: + * "Semestres 1 y 2", "Semestres 1, 2 y 3". + * + * Deduplica preservando la PRIMERA aparición de cada semestre — nunca + * ordena: el orden de entrada es el orden curricular declarado por el + * curso, y reordenarlo aquí lo falsearía. + */ +export function formatSemesterSummary(semesters: number[]): string | null { + const deduped: number[] = []; + const seen = new Set(); + for (const semester of semesters) { + if (!seen.has(semester)) { + seen.add(semester); + deduped.push(semester); + } + } + + if (deduped.length === 0) return null; + if (deduped.length === 1) return formatSemester(deduped[0]); + + const labels = deduped.map(String); + const last = labels[labels.length - 1]; + const rest = labels.slice(0, -1); + return `Semestres ${rest.join(", ")} y ${last}`; +} diff --git a/tests/components/layout/course-navigation.test.tsx b/tests/components/layout/course-navigation.test.tsx index 4cd4dc9..c360140 100644 --- a/tests/components/layout/course-navigation.test.tsx +++ b/tests/components/layout/course-navigation.test.tsx @@ -40,6 +40,7 @@ const CPP_COURSE = { language: "cpp", unitCount: 1, lessonCount: 3, + curriculumSummary: "Semestres 1 y 2", }; const CSHARP_COURSE = { @@ -52,6 +53,7 @@ const CSHARP_COURSE = { language: "csharp", unitCount: 1, lessonCount: 8, + curriculumSummary: "3.er semestre", }; const BASE_SHELL_DATA = { @@ -64,8 +66,18 @@ const BASE_SHELL_DATA = { }, courses: [CPP_COURSE, CSHARP_COURSE], courseOptions: [ - { slug: CPP_COURSE.slug, title: CPP_COURSE.title, languageLabel: "C++" }, - { slug: CSHARP_COURSE.slug, title: CSHARP_COURSE.title, languageLabel: "C#" }, + { + slug: CPP_COURSE.slug, + title: CPP_COURSE.title, + languageLabel: "C++", + curriculumSummary: CPP_COURSE.curriculumSummary, + }, + { + slug: CSHARP_COURSE.slug, + title: CSHARP_COURSE.title, + languageLabel: "C#", + curriculumSummary: CSHARP_COURSE.curriculumSummary, + }, ], stats: { totalXp: 120, currentStreak: 3, longestStreak: 5 }, pendingFriendsCount: 0, @@ -89,6 +101,7 @@ vi.mock("@/features/roadmap/queries", () => ({ published: true, lessonCount: 3, completedCount: 1, + curriculumSection: null, }, ]; } @@ -100,6 +113,7 @@ vi.mock("@/features/roadmap/queries", () => ({ published: true, lessonCount: 8, completedCount: 2, + curriculumSection: null, }, ]; }), diff --git a/tests/components/layout/course-switcher.test.tsx b/tests/components/layout/course-switcher.test.tsx index 0355815..176e4a9 100644 --- a/tests/components/layout/course-switcher.test.tsx +++ b/tests/components/layout/course-switcher.test.tsx @@ -6,16 +6,25 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), })); -import { CourseSwitcher } from "@/features/courses/components/course-switcher"; +import { + CourseSwitcher, + courseSwitcherSubtitle, +} from "@/features/courses/components/course-switcher"; import { Sidebar } from "@/components/layout/sidebar"; import { Topbar } from "@/components/layout/topbar"; const COURSES = [ - { slug: "cpp-desde-cero", title: "C++ desde cero", languageLabel: "C++" }, + { + slug: "cpp-desde-cero", + title: "C++ desde cero", + languageLabel: "C++", + curriculumSummary: "Semestres 1 y 2", + }, { slug: "csharp-poo-1", title: "Programación Orientada a Objetos I con C#", languageLabel: "C#", + curriculumSummary: null, }, ]; @@ -46,6 +55,20 @@ describe("CourseSwitcher", () => { ); expect(html).toContain("Elegir curso"); }); + + it("con curriculumSummary: el subtítulo del menú es lenguaje · resumen", () => { + expect(courseSwitcherSubtitle(COURSES[0], false)).toBe( + "C++ · Semestres 1 y 2", + ); + expect(courseSwitcherSubtitle(COURSES[0], true)).toBe( + "C++ · Semestres 1 y 2 · curso actual", + ); + }); + + it("sin curriculumSummary: conserva el comportamiento anterior (sólo lenguaje)", () => { + expect(courseSwitcherSubtitle(COURSES[1], false)).toBe("C#"); + expect(courseSwitcherSubtitle(COURSES[1], true)).toBe("C# · curso actual"); + }); }); describe("el shell expone el cambio de curso en desktop y en móvil", () => { diff --git a/tests/components/roadmap/curriculum-sections.test.tsx b/tests/components/roadmap/curriculum-sections.test.tsx new file mode 100644 index 0000000..d3098c1 --- /dev/null +++ b/tests/components/roadmap/curriculum-sections.test.tsx @@ -0,0 +1,162 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { RoadmapUnits } from "@/features/roadmap/components/roadmap-units"; +import type { RoadmapUnit } from "@/features/roadmap/types"; + +function unit(overrides: Partial & Pick): RoadmapUnit { + return { + title: `Unidad ${overrides.slug}`, + published: true, + lessonCount: 3, + completedCount: 0, + curriculumSection: null, + ...overrides, + }; +} + +/** + * Curso SIN curriculum: exactamente el flujo plano de siempre. Ninguna de + * las unidades trae `curriculumSection`. + */ +const FLAT_UNITS: RoadmapUnit[] = [ + unit({ slug: "u1", order: 1, title: "Unidad uno" }), + unit({ slug: "u2", order: 2, title: "Unidad dos" }), +]; + +const S1: RoadmapUnit["curriculumSection"] = { + key: "s1-fundamentos-desarrollo-software", + semester: 1, + subjectName: "Fundamentos de Desarrollo de Software", + order: 1, +}; + +const S2: RoadmapUnit["curriculumSection"] = { + key: "s2-programacion-estructurada", + semester: 2, + subjectName: "Programación Estructurada", + order: 2, +}; + +/** Réplica reducida del contrato de C++: printf-scanf (S1) antes de funciones (S2). */ +const CURRICULUM_UNITS: RoadmapUnit[] = [ + unit({ + slug: "loops", + order: 5, + title: "Loops", + curriculumSection: S1, + lessonCount: 6, + completedCount: 6, + }), + unit({ + slug: "printf-scanf", + order: 6, + title: "printf y scanf", + curriculumSection: S1, + lessonCount: 6, + completedCount: 3, + }), + unit({ + slug: "funciones", + order: 7, + title: "Funciones", + curriculumSection: S2, + lessonCount: 6, + completedCount: 0, + }), + unit({ + slug: "arreglos", + order: 8, + title: "Arreglos", + curriculumSection: S2, + lessonCount: 7, + completedCount: 0, + }), +]; + +describe("RoadmapUnits: cursos SIN curriculum", () => { + it("renderiza la lista plana de siempre — sin headers de sección", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Unidad uno"); + expect(html).toContain("Unidad dos"); + expect(html).not.toContain("semestre"); + // El formato de header ("X / Y lecciones") no aparece; sólo el de la + // card de unidad de siempre ("X de Y lecciones · Z%"). + expect(html).not.toMatch(/\d+ \/ \d+ lecciones/); + }); +}); + +describe("RoadmapUnits: curso CON curriculum", () => { + it("una sola sección: un header, con subjectName y semestre", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("1.er semestre"); + expect(html).toContain("Fundamentos de Desarrollo de Software"); + // Progreso derivado sumando completedCount/lessonCount de sus Units: 6+3=9 / 6+6=12. + expect(html).toContain("9 / 12 lecciones"); + }); + + it("múltiples secciones: un header por sección, con su subjectName", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Fundamentos de Desarrollo de Software"); + expect(html).toContain("Programación Estructurada"); + expect(html).toContain("1.er semestre"); + expect(html).toContain("2.º semestre"); + }); + + it("preserva Unit.order global: printf-scanf (S1) se renderiza antes que funciones (S2)", () => { + const html = renderToStaticMarkup( + , + ); + const printfIdx = html.indexOf("printf y scanf"); + const funcionesIdx = html.indexOf("Funciones"); + expect(printfIdx).toBeGreaterThan(-1); + expect(funcionesIdx).toBeGreaterThan(-1); + expect(printfIdx).toBeLessThan(funcionesIdx); + }); + + it("el header de una sección aparece ANTES de su primera unidad, no antes de la unidad previa de otra sección", () => { + const html = renderToStaticMarkup( + , + ); + const s2HeaderIdx = html.indexOf("Programación Estructurada"); + const loopsIdx = html.indexOf("Loops"); + const funcionesIdx = html.indexOf("Funciones"); + // El header S2 va después de "Loops" (última unidad de S1 antes del corte) + // y antes de "Funciones" (primera unidad de S2). + expect(loopsIdx).toBeLessThan(s2HeaderIdx); + expect(s2HeaderIdx).toBeLessThan(funcionesIdx); + }); + + it("progreso por sección: S2 (funciones + arreglos) suma 0 / 13", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("0 / 13 lecciones"); + }); +}); diff --git a/tests/content/authoring.test.ts b/tests/content/authoring.test.ts index 94d3eec..ac25d04 100644 --- a/tests/content/authoring.test.ts +++ b/tests/content/authoring.test.ts @@ -65,14 +65,17 @@ const legacyCursoCpp: CourseDefinition = { academicContext: "Curso introductorio CETI", language: "cpp", executionProfile: "cpp17-wandbox", + // Orden CANÓNICO actual (ver `prisma/content/courses/cpp-desde-cero/index.ts`): + // el único cambio respecto al histórico es que `printf-scanf` (unidad07) pasa + // ANTES de `funciones` (unidad06) — es la transición 1.er → 2.º semestre. units: [ unidad01, unidadVariables, unidadCin, unidad04, unidad05, - unidad06, unidad07, + unidad06, unidad08, unidad09, unidad10, @@ -407,7 +410,25 @@ describe("adaptLegacyUnits", () => { expect(authored).toHaveLength(1); expect(authored[0].practice).toBe(validSet.exercises); - const pkg = defineCourse({ ...baseCourse, units: authored }); + const { + slug, + title, + description, + subjectName, + academicContext, + language, + executionProfile, + } = baseCourse; + const pkg = defineCourse({ + slug, + title, + description, + subjectName, + academicContext, + language, + executionProfile, + 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. @@ -422,6 +443,347 @@ describe("adaptLegacyUnits", () => { }); }); +// ======================================================================= +// defineCourse: camino `curriculum` — agrupaciones curriculares +// ======================================================================= + +function curriculumToyUnit(slug: string): AuthoredUnitDefinition { + return defineUnit({ + slug, + title: `Unidad ${slug}`, + description: `desc ${slug}`, + lessons: [ + defineLesson({ + slug: `${slug}-l1`, + title: `Lección de ${slug}`, + description: "desc", + steps: [{ type: "theory", markdown: "hola" }], + }), + ], + }); +} + +function curriculumBaseMetadata(slug = "curso-curricular") { + return { + slug, + title: "Curso curricular", + description: "desc", + subjectName: "Materia", + academicContext: "Contexto", + language: "cpp" as const, + executionProfile: "cpp17-wandbox" as const, + }; +} + +describe("defineCourse: curriculum", () => { + it("curso SIN curriculum: units se conserva y curriculum queda undefined (comportamiento legacy intacto)", () => { + const pkg = defineCourse({ + ...curriculumBaseMetadata(), + units: [curriculumToyUnit("unidad-a")], + }); + expect(pkg.course.curriculum).toBeUndefined(); + expect(pkg.course.units.map((u) => u.slug)).toEqual(["unidad-a"]); + }); + + it("una sección: aplana sus unidades y deriva order=1 y unitSlugs", () => { + const unitA = curriculumToyUnit("unidad-a"); + const unitB = curriculumToyUnit("unidad-b"); + const pkg = defineCourse({ + ...curriculumBaseMetadata(), + curriculum: [ + { + key: "s1", + semester: 1, + subjectName: "Materia 1", + units: [unitA, unitB], + }, + ], + }); + + expect(pkg.course.units.map((u) => u.slug)).toEqual(["unidad-a", "unidad-b"]); + expect(pkg.course.curriculum).toEqual([ + { + key: "s1", + semester: 1, + subjectName: "Materia 1", + order: 1, + unitSlugs: ["unidad-a", "unidad-b"], + }, + ]); + }); + + it("varias secciones: recorre sections y units EN EL ORDEN recibido y deriva order = índice + 1", () => { + const unitA = curriculumToyUnit("unidad-a"); + const unitB = curriculumToyUnit("unidad-b"); + const unitC = curriculumToyUnit("unidad-c"); + const pkg = defineCourse({ + ...curriculumBaseMetadata(), + curriculum: [ + { key: "s1", semester: 1, subjectName: "Materia 1", units: [unitA] }, + { key: "s2", semester: 2, subjectName: "Materia 2", units: [unitB, unitC] }, + ], + }); + + // Flatten correcto: el orden GLOBAL de units es sección→unidad, en orden. + expect(pkg.course.units.map((u) => u.slug)).toEqual([ + "unidad-a", + "unidad-b", + "unidad-c", + ]); + expect(pkg.course.curriculum!.map((s) => s.order)).toEqual([1, 2]); + expect(pkg.course.curriculum!.map((s) => s.unitSlugs)).toEqual([ + ["unidad-a"], + ["unidad-b", "unidad-c"], + ]); + }); + + it("preserva el orden de la práctica colocalizada dentro de las unidades del curriculum", () => { + const unitA = defineUnit({ + slug: "unidad-a", + title: "Unidad A", + description: "desc", + lessons: [ + defineLesson({ + slug: "l1", + title: "L1", + description: "d", + steps: [{ type: "theory", markdown: "x" }], + }), + ], + practice: [ + { + slug: "ex1", + title: "E1", + description: "d", + prompt: "p", + starterCode: "", + solutionCode: "s", + difficulty: "easy", + testCases: [{ expectedStdout: "1" }], + }, + { + slug: "ex2", + title: "E2", + description: "d", + prompt: "p", + starterCode: "", + solutionCode: "s", + difficulty: "easy", + testCases: [{ expectedStdout: "2" }], + }, + ], + }); + const pkg = defineCourse({ + ...curriculumBaseMetadata(), + curriculum: [ + { key: "s1", semester: 1, subjectName: "Materia", units: [unitA] }, + ], + }); + expect(pkg.practiceSets).toHaveLength(1); + expect(pkg.practiceSets[0].exercises.map((e) => e.slug)).toEqual([ + "ex1", + "ex2", + ]); + }); + + it("no muta el arreglo curriculum recibido", () => { + const sections = [ + { + key: "s1", + semester: 1, + subjectName: "Materia 1", + units: [curriculumToyUnit("unidad-a")], + }, + ]; + const snapshot = JSON.parse(JSON.stringify(sections)); + defineCourse({ ...curriculumBaseMetadata(), curriculum: sections }); + expect(JSON.parse(JSON.stringify(sections))).toEqual(snapshot); + }); +}); + +// ======================================================================= +// validate: curriculum — issues acumulados vía buildContentRegistry +// ======================================================================= + +describe("validate: curriculum", () => { + function expectCurriculumIssue( + pkg: CoursePackageDefinition, + pathSubstring: string, + messagePattern: RegExp, + ) { + 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.includes(pathSubstring) && messagePattern.test(i.message), + ), + `issues: ${JSON.stringify(validationErr.issues)}`, + ).toBe(true); + } + } + + it("curriculum vacío ([]) es inválido", () => { + const pkg = defineCourse({ + ...curriculumBaseMetadata("curso-curriculum-vacio"), + curriculum: [], + }); + expectCurriculumIssue(pkg, "curriculum", /al menos 1 sección/); + }); + + it("key vacía es inválida", () => { + const pkg = defineCourse({ + ...curriculumBaseMetadata("curso-key-vacia"), + curriculum: [ + { + key: "", + semester: 1, + subjectName: "Materia", + units: [curriculumToyUnit("u1")], + }, + ], + }); + expectCurriculumIssue(pkg, "curriculum", /key vacía/); + }); + + it("key duplicada dentro del curso es inválida", () => { + const pkg = defineCourse({ + ...curriculumBaseMetadata("curso-key-duplicada"), + curriculum: [ + { + key: "s1", + semester: 1, + subjectName: "M1", + units: [curriculumToyUnit("u1")], + }, + { + key: "s1", + semester: 2, + subjectName: "M2", + units: [curriculumToyUnit("u2")], + }, + ], + }); + expectCurriculumIssue(pkg, "curriculum", /key duplicada/); + }); + + it("semester 0 es inválido", () => { + const pkg = defineCourse({ + ...curriculumBaseMetadata("curso-semester-0"), + curriculum: [ + { + key: "s1", + semester: 0, + subjectName: "M1", + units: [curriculumToyUnit("u1")], + }, + ], + }); + expectCurriculumIssue(pkg, "curriculum", /semester/); + }); + + it("semester decimal es inválido", () => { + const pkg = defineCourse({ + ...curriculumBaseMetadata("curso-semester-decimal"), + curriculum: [ + { + key: "s1", + semester: 1.5, + subjectName: "M1", + units: [curriculumToyUnit("u1")], + }, + ], + }); + expectCurriculumIssue(pkg, "curriculum", /semester/); + }); + + it("subjectName vacío es inválido", () => { + const pkg = defineCourse({ + ...curriculumBaseMetadata("curso-subjectname-vacio"), + curriculum: [ + { + key: "s1", + semester: 1, + subjectName: " ", + units: [curriculumToyUnit("u1")], + }, + ], + }); + expectCurriculumIssue(pkg, "curriculum", /subjectName vacío/); + }); + + it("una sección sin unidades es inválida", () => { + const pkg = defineCourse({ + ...curriculumBaseMetadata("curso-seccion-vacia"), + curriculum: [{ key: "s1", semester: 1, subjectName: "M1", units: [] }], + }); + expectCurriculumIssue(pkg, "curriculum", /al menos 1 unidad/); + }); + + it("una Unit que aparece en dos secciones es inválida", () => { + const shared = curriculumToyUnit("compartida"); + const pkg = defineCourse({ + ...curriculumBaseMetadata("curso-unidad-repetida"), + curriculum: [ + { key: "s1", semester: 1, subjectName: "M1", units: [shared] }, + { key: "s2", semester: 2, subjectName: "M2", units: [shared] }, + ], + }); + expectCurriculumIssue( + pkg, + "curriculum[s2].unitSlugs", + /ya pertenece a la sección "s1"/, + ); + }); + + it("un unitSlug que no existe en course.units es inválido", () => { + const course: CourseDefinition = { + ...curriculumBaseMetadata("curso-unitslug-inexistente"), + units: [ + { + slug: "u1", + title: "U1", + description: "d", + lessons: [ + { + slug: "l1", + title: "L", + description: "d", + steps: [{ type: "theory", markdown: "x" }], + }, + ], + }, + ], + curriculum: [ + { + key: "s1", + semester: 1, + subjectName: "M1", + order: 1, + unitSlugs: ["u1", "fantasma"], + }, + ], + }; + const pkg: CoursePackageDefinition = { course, practiceSets: [] }; + expectCurriculumIssue( + pkg, + "curriculum[s1].unitSlugs[1]", + /no existe en course\.units/, + ); + }); + + it("un curso sin curriculum sigue siendo válido", () => { + const pkg = defineCourse({ + ...curriculumBaseMetadata("curso-sin-curriculum-valido"), + units: [curriculumToyUnit("u1")], + }); + expect(() => buildContentRegistry([pkg])).not.toThrow(); + }); +}); + // ======================================================================= // Errores del validador incluyen un path preciso // ======================================================================= diff --git a/tests/content/course-contract.test.ts b/tests/content/course-contract.test.ts index d46e3eb..72177cc 100644 --- a/tests/content/course-contract.test.ts +++ b/tests/content/course-contract.test.ts @@ -220,3 +220,93 @@ describe("baseline de contenido C++", () => { expect(LANGUAGE_PROFILES[cpp.language].markdownFences).toContain("cpp"); }); }); + +describe("curriculum de C++: 2 secciones semestrales", () => { + const cpp = courseBySlug.get("cpp-desde-cero")!; + + it("declara exactamente 2 sections", () => { + expect(cpp.curriculum).toBeDefined(); + expect(cpp.curriculum).toHaveLength(2); + }); + + it("S1: semester 1, subjectName exacto, 6 units exactas", () => { + const s1 = cpp.curriculum!.find((s) => s.key === "s1-fundamentos-desarrollo-software"); + expect(s1).toBeDefined(); + expect(s1!.semester).toBe(1); + expect(s1!.order).toBe(1); + expect(s1!.subjectName).toBe("Fundamentos de Desarrollo de Software"); + expect(s1!.unitSlugs).toEqual([ + "primer-programa", + "variables-y-tipos", + "leer-datos", + "control-de-flujo", + "loops", + "printf-scanf", + ]); + }); + + it("S2: semester 2, subjectName exacto, 4 units exactas", () => { + const s2 = cpp.curriculum!.find((s) => s.key === "s2-programacion-estructurada"); + expect(s2).toBeDefined(); + expect(s2!.semester).toBe(2); + expect(s2!.order).toBe(2); + expect(s2!.subjectName).toBe("Programación Estructurada"); + expect(s2!.unitSlugs).toEqual(["funciones", "arreglos", "archivos", "matrices"]); + }); + + it("el orden GLOBAL aplanado (Unit.order) es exactamente el contrato", () => { + expect(cpp.units.map((u) => u.slug)).toEqual([ + "primer-programa", + "variables-y-tipos", + "leer-datos", + "control-de-flujo", + "loops", + "printf-scanf", + "funciones", + "arreglos", + "archivos", + "matrices", + ]); + }); + + it("los totales del curso siguen intactos: 10 units, 67 lessons, 314 steps, 80 practices", () => { + const lessons = cpp.units.flatMap((u) => u.lessons); + const steps = lessons.flatMap((l) => l.steps); + const cppSets = allPracticeSets.filter((s) => s.courseSlug === "cpp-desde-cero"); + const totalPractice = cppSets.reduce((n, s) => n + s.exercises.length, 0); + + expect(cpp.units.length).toBe(10); + expect(lessons.length).toBe(67); + expect(steps.length).toBe(314); + expect(totalPractice).toBe(80); + }); + + it("S1 tiene 41 lessons y S2 tiene 26 lessons", () => { + const unitBySlug = new Map(cpp.units.map((u) => [u.slug, u])); + const s1 = cpp.curriculum!.find((s) => s.key === "s1-fundamentos-desarrollo-software")!; + const s2 = cpp.curriculum!.find((s) => s.key === "s2-programacion-estructurada")!; + + const lessonsOf = (slugs: string[]) => + slugs.reduce((n, slug) => n + unitBySlug.get(slug)!.lessons.length, 0); + + expect(lessonsOf(s1.unitSlugs)).toBe(41); + expect(lessonsOf(s2.unitSlugs)).toBe(26); + }); +}); + +describe("curriculum de C#: 1 sección (demuestra que no está hardcodeado a C++)", () => { + const csharp = courseBySlug.get("csharp-poo-1")!; + + it("declara exactamente 1 section: semester 3, subjectName exacto, 8 Units existentes", () => { + expect(csharp.curriculum).toBeDefined(); + expect(csharp.curriculum).toHaveLength(1); + + const [s3] = csharp.curriculum!; + expect(s3.key).toBe("s3-programacion-orientada-objetos-1"); + expect(s3.semester).toBe(3); + expect(s3.order).toBe(1); + expect(s3.subjectName).toBe("Programación Orientada a Objetos I"); + expect(s3.unitSlugs).toEqual(csharp.units.map((u) => u.slug)); + expect(s3.unitSlugs).toHaveLength(8); + }); +}); diff --git a/tests/features/practice/practice-visibility.test.ts b/tests/features/practice/practice-visibility.test.ts index 0133f6b..8fe6fc7 100644 --- a/tests/features/practice/practice-visibility.test.ts +++ b/tests/features/practice/practice-visibility.test.ts @@ -108,3 +108,79 @@ describe("visibilidad de práctica bajo unidad no publicada", () => { expect(await getPracticeBySlug(COURSE, "u7-boton", "user_1")).not.toBeNull(); }); }); + +/** + * Refactor de curriculum: `getPracticeGroups` sigue derivando el orden de + * los grupos EXCLUSIVAMENTE de `Unit.order` — nunca de una noción de + * semestre. Con el swap `printf-scanf` (order 6) / `funciones` (order 7), + * los grupos deben salir en ese mismo orden. `PracticeExercise` no lleva + * (ni necesita) ningún campo de semestre/curriculum. + */ +describe("los grupos de práctica siguen Unit.order (curriculum es transparente)", () => { + const CPP_COURSE = "course_cpp"; + + beforeEach(() => { + fake.reset(); + fake.seed("unit", [ + { + id: "u-printf", + courseId: CPP_COURSE, + slug: "printf-scanf", + title: "printf y scanf", + icon: null, + order: 6, + published: true, + }, + { + id: "u-funciones", + courseId: CPP_COURSE, + slug: "funciones", + title: "Funciones", + icon: null, + order: 7, + published: true, + }, + ]); + fake.seed("practiceExercise", [ + { + id: "pex_funciones", + courseId: CPP_COURSE, + slug: "funciones-suma", + unitSlug: "funciones", + title: "Suma", + description: "d", + prompt: "p", + starterCode: "", + hints: [], + difficulty: "easy", + xpReward: 10, + position: 1, + published: true, + contentRevision: "r1", + testCases: [], + }, + { + id: "pex_printf", + courseId: CPP_COURSE, + slug: "printf-hola", + unitSlug: "printf-scanf", + title: "Hola", + description: "d", + prompt: "p", + starterCode: "", + hints: [], + difficulty: "easy", + xpReward: 10, + position: 1, + published: true, + contentRevision: "r1", + testCases: [], + }, + ]); + }); + + it("printf-scanf (order 6) sale antes que funciones (order 7)", async () => { + const groups = await getPracticeGroups(CPP_COURSE, "user_1"); + expect(groups.map((g) => g.unitSlug)).toEqual(["printf-scanf", "funciones"]); + }); +}); diff --git a/tests/integration/curriculum-seed-navigation.integration.test.ts b/tests/integration/curriculum-seed-navigation.integration.test.ts new file mode 100644 index 0000000..e43af6b --- /dev/null +++ b/tests/integration/curriculum-seed-navigation.integration.test.ts @@ -0,0 +1,333 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { db } from "@/lib/db"; +import { findNextLesson } from "@/features/roadmap/queries"; + +import { seedCourse } from "../../prisma/seed-content"; +import { seedPracticeExercises } from "../../prisma/seed-practice"; + +import { createTestUser } from "./helpers"; + +/** + * Contra PostgreSQL real: el refactor de `CurriculumSection` no debe mover + * ni una sola identidad (Course/Unit/Lesson/LessonStep/Exercise/ + * PracticeExercise), no debe tocar progreso/attempts/XP históricos, y el + * swap de orden `printf-scanf`↔`funciones` (la transición 1.er→2.º + * semestre) debe llegar por UPDATE, nunca por delete/recreate. + */ +describe("curriculum: el seed preserva identidad, progreso y XP; navegación S1→S2", () => { + let userId: string; + let cppCourseId: string; + let printfUnitId: string; + let funcionesUnitId: string; + let funcionesFirstLessonSlug: string; + + let sampleLessonId: string; + let sampleStepId: string; + let sampleExerciseId: string; + let samplePracticeId: string; + + let lessonProgressId: string; + let stepProgressId: string; + let exerciseAttemptId: string; + let practiceAttemptId: string; + let xpAwardId: string; + + let contentRevisionCountBefore: number; + + beforeAll(async () => { + // 1. seed baseline — idempotente, deja el contenido en su forma canónica. + await seedCourse(db); + await seedPracticeExercises(db); + + const cpp = await db.course.findUniqueOrThrow({ + where: { slug: "cpp-desde-cero" }, + }); + cppCourseId = cpp.id; + + const printfUnit = await db.unit.findUniqueOrThrow({ + where: { courseId_slug: { courseId: cppCourseId, slug: "printf-scanf" } }, + include: { + lessons: { where: { published: true }, orderBy: { order: "asc" } }, + }, + }); + const funcionesUnit = await db.unit.findUniqueOrThrow({ + where: { courseId_slug: { courseId: cppCourseId, slug: "funciones" } }, + include: { + lessons: { where: { published: true }, orderBy: { order: "asc" } }, + }, + }); + printfUnitId = printfUnit.id; + funcionesUnitId = funcionesUnit.id; + funcionesFirstLessonSlug = funcionesUnit.lessons[0].slug; + + // 2. capturar IDs de recursos representativos. + sampleLessonId = printfUnit.lessons[0].id; + + const step = await db.lessonStep.findFirstOrThrow({ + where: { lessonId: sampleLessonId }, + orderBy: { order: "asc" }, + }); + sampleStepId = step.id; + + const challengeStep = await db.lessonStep.findFirstOrThrow({ + where: { type: "code_challenge", lesson: { unitId: printfUnitId } }, + }); + const exercise = await db.exercise.findUniqueOrThrow({ + where: { stepId: challengeStep.id }, + }); + sampleExerciseId = exercise.id; + + const practice = await db.practiceExercise.findFirstOrThrow({ + where: { courseId: cppCourseId, unitSlug: "printf-scanf" }, + orderBy: { position: "asc" }, + }); + samplePracticeId = practice.id; + + const user = await createTestUser("curriculum"); + userId = user.id; + + // 3. crear/proteger fixtures históricos. + const lessonProgress = await db.userLessonProgress.create({ + data: { + userId, + lessonId: sampleLessonId, + status: "completed", + xpEarned: 20, + completedAt: new Date(), + }, + }); + lessonProgressId = lessonProgress.id; + + const stepProgress = await db.userStepProgress.create({ + data: { userId, stepId: sampleStepId }, + }); + stepProgressId = stepProgress.id; + + const exerciseAttempt = await db.userExerciseAttempt.create({ + data: { + userId, + exerciseId: sampleExerciseId, + code: "// fixture histórico", + passed: true, + testsPassed: 1, + testsTotal: 1, + awardedXp: true, + }, + }); + exerciseAttemptId = exerciseAttempt.id; + + const practiceAttempt = await db.userPracticeAttempt.create({ + data: { + userId, + exerciseId: samplePracticeId, + code: "// fixture histórico", + passed: true, + testsPassed: 1, + testsTotal: 1, + awardedXp: true, + }, + }); + practiceAttemptId = practiceAttempt.id; + + const xpAward = await db.xpAward.create({ + data: { + userId, + amount: 20, + reason: "lesson_completed", + dedupeKey: `curriculum-integration-${sampleLessonId}`, + lessonId: sampleLessonId, + }, + }); + xpAwardId = xpAward.id; + + contentRevisionCountBefore = await db.contentRevision.count(); + + // 4. simular el orden HISTÓRICO (antes de este refactor): funciones=6, + // printf-scanf=7 — el swap que el seed nuevo debe corregir. + await db.unit.update({ where: { id: funcionesUnitId }, data: { order: 6 } }); + await db.unit.update({ where: { id: printfUnitId }, data: { order: 7 } }); + + // 5. correr el seed nuevo. + await seedCourse(db); + await seedPracticeExercises(db); + }); + + afterAll(async () => { + // Limpia sólo el fixture del usuario de este test — el contenido del + // curso no se borra: es la fuente de verdad compartida por todo lo demás. + await db.xpAward.deleteMany({ where: { userId } }); + await db.userPracticeAttempt.deleteMany({ where: { userId } }); + await db.userExerciseAttempt.deleteMany({ where: { userId } }); + await db.userStepProgress.deleteMany({ where: { userId } }); + await db.userLessonProgress.deleteMany({ where: { userId } }); + await db.user.delete({ where: { id: userId } }); + await db.$disconnect(); + }); + + it("el curso, la unidad y el orden corregido conservan los mismos IDs", async () => { + const cpp = await db.course.findUniqueOrThrow({ + where: { slug: "cpp-desde-cero" }, + }); + expect(cpp.id).toBe(cppCourseId); + + const printfUnit = await db.unit.findUniqueOrThrow({ where: { id: printfUnitId } }); + const funcionesUnit = await db.unit.findUniqueOrThrow({ where: { id: funcionesUnitId } }); + + // El swap llegó por UPDATE (mismo id), no por delete/recreate. + expect(printfUnit.slug).toBe("printf-scanf"); + expect(funcionesUnit.slug).toBe("funciones"); + expect(printfUnit.order).toBe(6); + expect(funcionesUnit.order).toBe(7); + }); + + it("las filas históricas (progreso, attempts, XP) sobreviven intactas", async () => { + const lessonProgress = await db.userLessonProgress.findUnique({ + where: { id: lessonProgressId }, + }); + expect(lessonProgress).not.toBeNull(); + expect(lessonProgress!.lessonId).toBe(sampleLessonId); + expect(lessonProgress!.status).toBe("completed"); + + const stepProgress = await db.userStepProgress.findUnique({ + where: { id: stepProgressId }, + }); + expect(stepProgress).not.toBeNull(); + expect(stepProgress!.stepId).toBe(sampleStepId); + + const exerciseAttempt = await db.userExerciseAttempt.findUnique({ + where: { id: exerciseAttemptId }, + }); + expect(exerciseAttempt).not.toBeNull(); + expect(exerciseAttempt!.exerciseId).toBe(sampleExerciseId); + + const practiceAttempt = await db.userPracticeAttempt.findUnique({ + where: { id: practiceAttemptId }, + }); + expect(practiceAttempt).not.toBeNull(); + expect(practiceAttempt!.exerciseId).toBe(samplePracticeId); + + const xpAward = await db.xpAward.findUnique({ where: { id: xpAwardId } }); + expect(xpAward).not.toBeNull(); + expect(xpAward!.amount).toBe(20); + expect(xpAward!.lessonId).toBe(sampleLessonId); + }); + + it("2 CurriculumSections para C++ (membership 6 + 4); 1 sección S3 para C#", async () => { + const cppSections = await db.curriculumSection.findMany({ + where: { courseId: cppCourseId }, + include: { units: { select: { slug: true } } }, + orderBy: { order: "asc" }, + }); + expect(cppSections).toHaveLength(2); + expect(cppSections[0].key).toBe("s1-fundamentos-desarrollo-software"); + expect(cppSections[0].semester).toBe(1); + expect(cppSections[0].units.map((u) => u.slug).sort()).toEqual( + ["primer-programa", "variables-y-tipos", "leer-datos", "control-de-flujo", "loops", "printf-scanf"].sort(), + ); + expect(cppSections[1].key).toBe("s2-programacion-estructurada"); + expect(cppSections[1].semester).toBe(2); + expect(cppSections[1].units.map((u) => u.slug).sort()).toEqual( + ["funciones", "arreglos", "archivos", "matrices"].sort(), + ); + + const csharp = await db.course.findUniqueOrThrow({ + where: { slug: "csharp-poo-1" }, + }); + const csharpSections = await db.curriculumSection.findMany({ + where: { courseId: csharp.id }, + include: { units: true }, + }); + expect(csharpSections).toHaveLength(1); + expect(csharpSections[0].semester).toBe(3); + expect(csharpSections[0].units).toHaveLength(8); + }); + + it("un curso sin curriculum: sus Units quedan con curriculumSectionId null (FK nullable)", async () => { + const suffix = Date.now().toString(36); + const generalCourse = await db.course.create({ + data: { + slug: `curso-general-integration-${suffix}`, + title: "Curso general de prueba", + description: "test", + subjectName: "test", + academicContext: "Ruta general", + language: "cpp", + executionProfile: "cpp17-wandbox", + }, + }); + try { + const generalUnit = await db.unit.create({ + data: { + courseId: generalCourse.id, + slug: "unidad-1", + title: "Unidad 1", + description: "d", + }, + }); + expect(generalUnit.curriculumSectionId).toBeNull(); + + const sections = await db.curriculumSection.findMany({ + where: { courseId: generalCourse.id }, + }); + expect(sections).toHaveLength(0); + } finally { + // Cascade borra la unidad de prueba junto con el curso. + await db.course.delete({ where: { id: generalCourse.id } }); + } + }); + + it("no aparece una nueva ContentRevision de los recursos representativos por este cambio", async () => { + const countAfter = await db.contentRevision.count(); + expect(countAfter).toBe(contentRevisionCountBefore); + }); + + it("navegación: última lesson de printf-scanf → primera lesson de funciones", async () => { + const printfUnit = await db.unit.findUniqueOrThrow({ where: { id: printfUnitId } }); + + // `findNextLesson` busca la primera lección NO completada en orden + // (unit.order, lesson.order) — hay que completar TODO lo que va antes + // de printf-scanf (S1 completo), no sólo la unidad misma. + const lessonsThroughPrintf = await db.lesson.findMany({ + where: { + published: true, + unit: { courseId: cppCourseId, published: true, order: { lte: printfUnit.order } }, + }, + orderBy: [{ unit: { order: "asc" } }, { order: "asc" }], + }); + expect(lessonsThroughPrintf.length).toBeGreaterThan(0); + + for (const lesson of lessonsThroughPrintf) { + await db.userLessonProgress.upsert({ + where: { userId_lessonId: { userId, lessonId: lesson.id } }, + update: { status: "completed", completedAt: new Date() }, + create: { + userId, + lessonId: lesson.id, + status: "completed", + completedAt: new Date(), + }, + }); + } + + const next = await findNextLesson(userId, cppCourseId); + expect(next).not.toBeNull(); + expect(next!.unitSlug).toBe("funciones"); + expect(next!.lessonSlug).toBe(funcionesFirstLessonSlug); + expect(next!.status).toBe("not_started"); + }); + + it("PracticeExercise: mismo ID, mismo unitSlug, mismo attempt", async () => { + const practice = await db.practiceExercise.findUniqueOrThrow({ + where: { id: samplePracticeId }, + }); + expect(practice.unitSlug).toBe("printf-scanf"); + expect(practice.courseId).toBe(cppCourseId); + + const attempt = await db.userPracticeAttempt.findUnique({ + where: { id: practiceAttemptId }, + }); + expect(attempt).not.toBeNull(); + expect(attempt!.exerciseId).toBe(samplePracticeId); + }); +}); diff --git a/tests/lib/curriculum.test.ts b/tests/lib/curriculum.test.ts new file mode 100644 index 0000000..0b84551 --- /dev/null +++ b/tests/lib/curriculum.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { formatSemester, formatSemesterSummary } from "@/lib/curriculum"; + +describe("formatSemester", () => { + it("1 → 1.er semestre", () => { + expect(formatSemester(1)).toBe("1.er semestre"); + }); + + it("2 → 2.º semestre", () => { + expect(formatSemester(2)).toBe("2.º semestre"); + }); + + it("3 → 3.er semestre", () => { + expect(formatSemester(3)).toBe("3.er semestre"); + }); + + it("4 → 4.º semestre", () => { + expect(formatSemester(4)).toBe("4.º semestre"); + }); + + it("cualquier otro entero positivo usa el ordinal genérico", () => { + expect(formatSemester(5)).toBe("5.º semestre"); + expect(formatSemester(8)).toBe("8.º semestre"); + expect(formatSemester(12)).toBe("12.º semestre"); + }); +}); + +describe("formatSemesterSummary", () => { + it("[] → null", () => { + expect(formatSemesterSummary([])).toBeNull(); + }); + + it("[3] → 3.er semestre", () => { + expect(formatSemesterSummary([3])).toBe("3.er semestre"); + }); + + it("[1, 2] → Semestres 1 y 2", () => { + expect(formatSemesterSummary([1, 2])).toBe("Semestres 1 y 2"); + }); + + it("3+ semestres usan la unión española natural", () => { + expect(formatSemesterSummary([1, 2, 3])).toBe("Semestres 1, 2 y 3"); + expect(formatSemesterSummary([1, 2, 3, 4])).toBe("Semestres 1, 2, 3 y 4"); + }); + + it("deduplica preservando la PRIMERA aparición", () => { + expect(formatSemesterSummary([2, 1, 2])).toBe("Semestres 2 y 1"); + expect(formatSemesterSummary([1, 1, 1])).toBe("1.er semestre"); + }); + + it("no ordena: preserva el orden curricular declarado tal cual llega", () => { + expect(formatSemesterSummary([3, 1])).toBe("Semestres 3 y 1"); + }); +});