diff --git a/backend/src/gamification/course-progress-read.service.spec.ts b/backend/src/gamification/course-progress-read.service.spec.ts index 9357fcc..ec35ea9 100644 --- a/backend/src/gamification/course-progress-read.service.spec.ts +++ b/backend/src/gamification/course-progress-read.service.spec.ts @@ -14,8 +14,8 @@ describe('CourseProgressReadService', () => { mockPrisma = { concept: { findMany: jest.fn().mockResolvedValue([ - { id: 'concept-1', name: 'Grounding' }, - { id: 'concept-2', name: 'Bonding' }, + { id: 'concept-1', name: 'Grounding', sectionId: null }, + { id: 'concept-2', name: 'Bonding', sectionId: null }, ]), }, prerequisiteEdge: { @@ -29,6 +29,8 @@ describe('CourseProgressReadService', () => { getConceptMasteryForIds: jest.fn().mockResolvedValue( new Map([['concept-1', 'mastered']]), ), + getSectionStatesForAcademy: jest.fn().mockResolvedValue([]), + getSectionStatesForCourse: jest.fn().mockResolvedValue([]), }; service = new CourseProgressReadService(mockPrisma, mockStudentState); @@ -47,7 +49,7 @@ describe('CourseProgressReadService', () => { expect(mockPrisma.concept.findMany).toHaveBeenCalledWith({ where: activeConceptWhere({ courseId: 'course-1' }), - select: { id: true, name: true }, + select: { id: true, name: true, sectionId: true }, orderBy: { sortOrder: 'asc' }, }); expect(mockPrisma.prerequisiteEdge.findMany).toHaveBeenCalledWith({ @@ -58,9 +60,9 @@ describe('CourseProgressReadService', () => { it('returns academy graph with courseId attribution', async () => { mockPrisma.concept.findMany.mockResolvedValue([ - { id: 'concept-1', name: 'Grounding', courseId: 'course-a' }, - { id: 'concept-2', name: 'Bonding', courseId: 'course-a' }, - { id: 'concept-3', name: 'Wiring', courseId: 'course-b' }, + { id: 'concept-1', name: 'Grounding', courseId: 'course-a', sectionId: null }, + { id: 'concept-2', name: 'Bonding', courseId: 'course-a', sectionId: null }, + { id: 'concept-3', name: 'Wiring', courseId: 'course-b', sectionId: null }, ]); mockPrisma.prerequisiteEdge.findMany.mockResolvedValue([ { sourceConceptId: 'concept-1', targetConceptId: 'concept-3' }, @@ -88,7 +90,7 @@ describe('CourseProgressReadService', () => { expect(mockPrisma.concept.findMany).toHaveBeenCalledWith({ where: activeConceptWhere({ course: { academyId: 'academy-1' } }), - select: { id: true, name: true, courseId: true }, + select: { id: true, name: true, courseId: true, sectionId: true }, orderBy: { sortOrder: 'asc' }, }); expect(mockPrisma.prerequisiteEdge.findMany).toHaveBeenCalledWith({ @@ -96,4 +98,127 @@ describe('CourseProgressReadService', () => { select: { sourceConceptId: true, targetConceptId: true }, }); }); + + describe('locked section mastery override', () => { + it('getGraph returns "unstarted" for concepts in locked sections even when DB mastery is "in_progress"', async () => { + mockPrisma.concept.findMany.mockResolvedValue([ + { id: 'c1', name: 'Locked Concept', sectionId: 'sec-locked' }, + { id: 'c2', name: 'Unlocked Concept', sectionId: 'sec-open' }, + ]); + mockPrisma.prerequisiteEdge.findMany.mockResolvedValue([]); + mockStudentState.getSectionStatesForCourse.mockResolvedValue([ + { sectionId: 'sec-locked', status: 'locked' }, + { sectionId: 'sec-open', status: 'lesson_in_progress' }, + ]); + mockStudentState.getConceptMasteryForIds.mockResolvedValue( + new Map([ + ['c1', 'in_progress'], + ['c2', 'in_progress'], + ]), + ); + + const result = await service.getGraph('user-1', 'course-1'); + + expect(result.concepts).toEqual([ + { id: 'c1', name: 'Locked Concept', masteryState: 'unstarted' }, + { id: 'c2', name: 'Unlocked Concept', masteryState: 'in_progress' }, + ]); + }); + + it('getGraph returns true mastery for concepts in unlocked sections', async () => { + mockPrisma.concept.findMany.mockResolvedValue([ + { id: 'c1', name: 'In Progress', sectionId: 'sec-1' }, + { id: 'c2', name: 'Exam Ready', sectionId: 'sec-2' }, + { id: 'c3', name: 'Certified', sectionId: 'sec-3' }, + { id: 'c4', name: 'Needs Review', sectionId: 'sec-4' }, + ]); + mockPrisma.prerequisiteEdge.findMany.mockResolvedValue([]); + mockStudentState.getSectionStatesForCourse.mockResolvedValue([ + { sectionId: 'sec-1', status: 'lesson_in_progress' }, + { sectionId: 'sec-2', status: 'exam_ready' }, + { sectionId: 'sec-3', status: 'certified' }, + { sectionId: 'sec-4', status: 'needs_review' }, + ]); + mockStudentState.getConceptMasteryForIds.mockResolvedValue( + new Map([ + ['c1', 'in_progress'], + ['c2', 'in_progress'], + ['c3', 'mastered'], + ['c4', 'needs_review'], + ]), + ); + + const result = await service.getGraph('user-1', 'course-1'); + + expect(result.concepts).toEqual([ + { id: 'c1', name: 'In Progress', masteryState: 'in_progress' }, + { id: 'c2', name: 'Exam Ready', masteryState: 'in_progress' }, + { id: 'c3', name: 'Certified', masteryState: 'mastered' }, + { id: 'c4', name: 'Needs Review', masteryState: 'needs_review' }, + ]); + }); + + it('getGraph returns true mastery for concepts with no section (sectionId is null)', async () => { + mockPrisma.concept.findMany.mockResolvedValue([ + { id: 'c1', name: 'No Section', sectionId: null }, + ]); + mockPrisma.prerequisiteEdge.findMany.mockResolvedValue([]); + mockStudentState.getSectionStatesForCourse.mockResolvedValue([ + { sectionId: 'sec-locked', status: 'locked' }, + ]); + mockStudentState.getConceptMasteryForIds.mockResolvedValue( + new Map([['c1', 'mastered']]), + ); + + const result = await service.getGraph('user-1', 'course-1'); + + expect(result.concepts).toEqual([ + { id: 'c1', name: 'No Section', masteryState: 'mastered' }, + ]); + }); + + it('getAcademyGraph returns "unstarted" for concepts in locked sections', async () => { + mockPrisma.concept.findMany.mockResolvedValue([ + { id: 'c1', name: 'Locked', courseId: 'course-1', sectionId: 'sec-locked' }, + { id: 'c2', name: 'Open', courseId: 'course-1', sectionId: 'sec-open' }, + ]); + mockPrisma.prerequisiteEdge.findMany.mockResolvedValue([]); + mockStudentState.getSectionStatesForAcademy.mockResolvedValue([ + { sectionId: 'sec-locked', status: 'locked', courseId: 'course-1', section: { sortOrder: 0 } }, + { sectionId: 'sec-open', status: 'lesson_in_progress', courseId: 'course-1', section: { sortOrder: 1 } }, + ]); + mockStudentState.getConceptMasteryForIds.mockResolvedValue( + new Map([ + ['c1', 'mastered'], + ['c2', 'in_progress'], + ]), + ); + + const result = await service.getAcademyGraph('user-1', 'academy-1'); + + expect(result.concepts).toEqual([ + { id: 'c1', name: 'Locked', courseId: 'course-1', masteryState: 'unstarted' }, + { id: 'c2', name: 'Open', courseId: 'course-1', masteryState: 'in_progress' }, + ]); + }); + + it('getAcademyGraph returns true mastery for concepts with null sectionId', async () => { + mockPrisma.concept.findMany.mockResolvedValue([ + { id: 'c1', name: 'No Section', courseId: 'course-1', sectionId: null }, + ]); + mockPrisma.prerequisiteEdge.findMany.mockResolvedValue([]); + mockStudentState.getSectionStatesForAcademy.mockResolvedValue([ + { sectionId: 'sec-locked', status: 'locked', courseId: 'course-1', section: { sortOrder: 0 } }, + ]); + mockStudentState.getConceptMasteryForIds.mockResolvedValue( + new Map([['c1', 'mastered']]), + ); + + const result = await service.getAcademyGraph('user-1', 'academy-1'); + + expect(result.concepts).toEqual([ + { id: 'c1', name: 'No Section', courseId: 'course-1', masteryState: 'mastered' }, + ]); + }); + }); }); diff --git a/backend/src/gamification/course-progress-read.service.ts b/backend/src/gamification/course-progress-read.service.ts index 904f2dd..6b397e5 100644 --- a/backend/src/gamification/course-progress-read.service.ts +++ b/backend/src/gamification/course-progress-read.service.ts @@ -15,18 +15,25 @@ export class CourseProgressReadService { ) {} async getAcademyGraph(userId: string, academyId: string) { - const [concepts, edges] = await Promise.all([ + const [concepts, edges, sectionStates] = await Promise.all([ this.prisma.concept.findMany({ where: activeConceptWhere({ course: { academyId } }), - select: { id: true, name: true, courseId: true }, + select: { id: true, name: true, courseId: true, sectionId: true }, orderBy: { sortOrder: 'asc' }, }), this.prisma.prerequisiteEdge.findMany({ where: activePrerequisiteEdgeWhereAcademy(academyId), select: { sourceConceptId: true, targetConceptId: true }, }), + this.studentState.getSectionStatesForAcademy(userId, academyId), ]); + const lockedSectionIds = new Set( + sectionStates + .filter((s) => s.status === 'locked') + .map((s) => s.sectionId), + ); + const stateMap = await this.studentState.getConceptMasteryForIds( userId, concepts.map((concept) => concept.id), @@ -37,7 +44,10 @@ export class CourseProgressReadService { id: concept.id, name: concept.name, courseId: concept.courseId, - masteryState: stateMap.get(concept.id) ?? 'unstarted', + masteryState: + concept.sectionId && lockedSectionIds.has(concept.sectionId) + ? 'unstarted' + : (stateMap.get(concept.id) ?? 'unstarted'), })), edges: edges.map((edge) => ({ sourceConceptId: edge.sourceConceptId, @@ -47,18 +57,25 @@ export class CourseProgressReadService { } async getGraph(userId: string, courseId: string) { - const [concepts, edges] = await Promise.all([ + const [concepts, edges, sectionStates] = await Promise.all([ this.prisma.concept.findMany({ where: activeConceptWhere({ courseId }), - select: { id: true, name: true }, + select: { id: true, name: true, sectionId: true }, orderBy: { sortOrder: 'asc' }, }), this.prisma.prerequisiteEdge.findMany({ where: activePrerequisiteEdgeWhere(courseId), select: { sourceConceptId: true, targetConceptId: true }, }), + this.studentState.getSectionStatesForCourse(userId, courseId), ]); + const lockedSectionIds = new Set( + sectionStates + .filter((s) => s.status === 'locked') + .map((s) => s.sectionId), + ); + const stateMap = await this.studentState.getConceptMasteryForIds( userId, concepts.map((concept) => concept.id), @@ -68,7 +85,10 @@ export class CourseProgressReadService { concepts: concepts.map((concept) => ({ id: concept.id, name: concept.name, - masteryState: stateMap.get(concept.id) ?? 'unstarted', + masteryState: + concept.sectionId && lockedSectionIds.has(concept.sectionId) + ? 'unstarted' + : (stateMap.get(concept.id) ?? 'unstarted'), })), edges: edges.map((edge) => ({ sourceConceptId: edge.sourceConceptId, diff --git a/backend/src/student-model/queries/student-state.queries.ts b/backend/src/student-model/queries/student-state.queries.ts index 502c73b..3439dbf 100644 --- a/backend/src/student-model/queries/student-state.queries.ts +++ b/backend/src/student-model/queries/student-state.queries.ts @@ -237,6 +237,24 @@ export async function loadSectionState( }); } +export async function loadSectionStatesForCourse( + prisma: PrismaService, + userId: string, + courseId: string, +) { + return prisma.studentSectionState.findMany({ + where: { + userId, + courseId, + section: activeSectionWhere(), + }, + select: { + sectionId: true, + status: true, + }, + }); +} + export async function loadSectionStatesForAcademy( prisma: PrismaService, userId: string, diff --git a/backend/src/student-model/student-state.service.ts b/backend/src/student-model/student-state.service.ts index 005b0e5..2e3c320 100644 --- a/backend/src/student-model/student-state.service.ts +++ b/backend/src/student-model/student-state.service.ts @@ -22,6 +22,7 @@ import { loadMasteryMapForCourse, loadSectionState, loadSectionStatesForAcademy, + loadSectionStatesForCourse, } from './queries/student-state.queries'; const logger = getLogger('student-model'); @@ -227,6 +228,10 @@ export class StudentStateService { return loadSectionStatesForAcademy(this.prisma, userId, academyId); } + async getSectionStatesForCourse(userId: string, courseId: string) { + return loadSectionStatesForCourse(this.prisma, userId, courseId); + } + async getConceptStatesForFIRe(userId: string, academyId: string) { return loadConceptStatesForFIRe(this.prisma, userId, academyId); }