Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 132 additions & 7 deletions backend/src/gamification/course-progress-read.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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);
Expand All @@ -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({
Expand All @@ -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' },
Expand Down Expand Up @@ -88,12 +90,135 @@ 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({
where: activePrerequisiteEdgeWhereAcademy('academy-1'),
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' },
]);
});
});
});
32 changes: 26 additions & 6 deletions backend/src/gamification/course-progress-read.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand All @@ -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),
Expand All @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions backend/src/student-model/queries/student-state.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions backend/src/student-model/student-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
loadMasteryMapForCourse,
loadSectionState,
loadSectionStatesForAcademy,
loadSectionStatesForCourse,
} from './queries/student-state.queries';

const logger = getLogger('student-model');
Expand Down Expand Up @@ -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);
}
Expand Down
Loading