fix: sequential progression bug in course with ordered contents (#889)
* fix: sequential progression bug in course with ordered contents * test(utils): add course progression tests and unify navigation ordering Reuse flattenNavigableItems in getOrderedNavigableContent so next/previous navigation follows the same section-aware order as progression locks. Add vitest coverage for grouped multi-section flattening and sequential access rules. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix: address PR review comments for course progression Simplify grouped flatten ordering, only block progression for lessons and exercises explicitly, integrate flattenNavigableItems in the sequential access test, and include @cio/core watch in api:dev:full. ---------
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { ContentType } from '@cio/utils/constants';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||
import { AppError, ErrorCodes } from '@cio/utils/errors';
|
||||
import {
|
||||
computeProgressionAccess,
|
||||
flattenNavigableItems,
|
||||
type ProgressionLockReason
|
||||
} from '@cio/utils/functions/course-progression';
|
||||
import type { CourseContentItemRow } from '@cio/db/queries/course/content';
|
||||
import {
|
||||
getCompletedExerciseIdsForMember,
|
||||
@@ -14,8 +18,6 @@ import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group';
|
||||
|
||||
import { buildCourseContent, type CourseContent, type CourseContentItem } from './utils';
|
||||
|
||||
export type ProgressionLockReason = 'teacher_locked' | 'progression_blocked';
|
||||
|
||||
export type AnnotatedCourseContentItem = CourseContentItem & {
|
||||
accessible?: boolean;
|
||||
lockReason?: ProgressionLockReason | null;
|
||||
@@ -35,52 +37,6 @@ export type AnnotatedCourseContent = {
|
||||
items: AnnotatedCourseContentItem[];
|
||||
};
|
||||
|
||||
function sortNavigableItems(items: CourseContentItem[]): CourseContentItem[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const orderDiff = (left.order ?? 0) - (right.order ?? 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
|
||||
const typeDiff = (left.type === ContentType.Lesson ? 0 : 1) - (right.type === ContentType.Lesson ? 0 : 1);
|
||||
if (typeDiff !== 0) return typeDiff;
|
||||
|
||||
return new Date(left.createdAt || 0).getTime() - new Date(right.createdAt || 0).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function flattenNavigableItems(content: CourseContent): CourseContentItem[] {
|
||||
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
|
||||
|
||||
return sortNavigableItems(
|
||||
items.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
|
||||
);
|
||||
}
|
||||
|
||||
function lessonBlocksProgression(policy: string | undefined): boolean {
|
||||
return policy !== 'none';
|
||||
}
|
||||
|
||||
function exerciseBlocksProgression(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isItemProgressComplete(params: {
|
||||
item: CourseContentItem;
|
||||
completedLessonIds: Set<string>;
|
||||
completedExerciseIds: Set<string>;
|
||||
}): boolean {
|
||||
const { item, completedLessonIds, completedExerciseIds } = params;
|
||||
|
||||
if (item.type === ContentType.Lesson) {
|
||||
return completedLessonIds.has(item.id);
|
||||
}
|
||||
|
||||
if (item.type === ContentType.Exercise) {
|
||||
return completedExerciseIds.has(item.id) || Boolean(item.isComplete);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function annotateNavigableAccess(params: {
|
||||
navigableItems: CourseContentItem[];
|
||||
lessonPolicyById: Map<string, LessonProgressionPolicy>;
|
||||
@@ -88,44 +44,25 @@ function annotateNavigableAccess(params: {
|
||||
completedLessonIds: Set<string>;
|
||||
completedExerciseIds: Set<string>;
|
||||
}): Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }> {
|
||||
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
|
||||
let priorBlockingComplete = true;
|
||||
return computeProgressionAccess({
|
||||
navigableItems: params.navigableItems,
|
||||
progressionMode: params.progressionMode,
|
||||
isComplete: (item) => {
|
||||
if (item.type === ContentType.Lesson) {
|
||||
return params.completedLessonIds.has(item.id);
|
||||
}
|
||||
|
||||
for (const item of params.navigableItems) {
|
||||
const lessonPolicy = params.lessonPolicyById.get(item.id);
|
||||
const teacherLocked = !(item.isUnlocked ?? true);
|
||||
const progressionLocked = params.progressionMode === 'sequential' && !priorBlockingComplete;
|
||||
if (item.type === ContentType.Exercise) {
|
||||
return params.completedExerciseIds.has(item.id) || Boolean(item.isComplete);
|
||||
}
|
||||
|
||||
let accessible = !teacherLocked && !progressionLocked;
|
||||
let lockReason: ProgressionLockReason | null = null;
|
||||
|
||||
if (teacherLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'teacher_locked';
|
||||
} else if (progressionLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'progression_blocked';
|
||||
return true;
|
||||
},
|
||||
getCompletionPolicy: (item) => {
|
||||
const lessonPolicy = params.lessonPolicyById.get(item.id);
|
||||
return lessonPolicy?.completionPolicy ?? 'manual';
|
||||
}
|
||||
|
||||
accessById.set(item.id, { accessible, lockReason });
|
||||
|
||||
const complete = isItemProgressComplete({
|
||||
item,
|
||||
completedLessonIds: params.completedLessonIds,
|
||||
completedExerciseIds: params.completedExerciseIds
|
||||
});
|
||||
|
||||
const blocks =
|
||||
item.type === ContentType.Lesson
|
||||
? lessonBlocksProgression(lessonPolicy?.completionPolicy ?? 'manual')
|
||||
: exerciseBlocksProgression();
|
||||
|
||||
if (blocks && !complete) {
|
||||
priorBlockingComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
return accessById;
|
||||
});
|
||||
}
|
||||
|
||||
function applyAccessToContent(
|
||||
|
||||
@@ -73,7 +73,9 @@
|
||||
"scripts": {
|
||||
"build": "rimraf dist tsconfig.tsbuildinfo && tsc && tsc-alias",
|
||||
"dev": "tsc --watch",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.1.8"
|
||||
@@ -86,6 +88,7 @@
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "^1.2.2",
|
||||
"zod": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ContentType } from '../constants/content';
|
||||
|
||||
export type ProgressionLockReason = 'teacher_locked' | 'progression_blocked';
|
||||
|
||||
type NavigableCourseContent<T extends { order?: number | null }> = {
|
||||
grouped: boolean;
|
||||
sections: Array<{ items: T[] }>;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens sectioned or flat course content into a single ordered array of items based on section and item orders.
|
||||
*/
|
||||
export function flattenNavigableItems<T extends { order?: number | null }>(content: NavigableCourseContent<T>): T[] {
|
||||
if (content.grouped) {
|
||||
const flattenedListItems: T[] = [];
|
||||
|
||||
for (const section of content.sections) {
|
||||
const sortedSectionItems = section.items
|
||||
.map((item, index) => ({ item, sortOrder: item.order ?? index }))
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map(({ item }) => item);
|
||||
|
||||
flattenedListItems.push(...sortedSectionItems);
|
||||
}
|
||||
|
||||
return flattenedListItems;
|
||||
}
|
||||
|
||||
return [...content.items].sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates whether a lesson with a given completion policy blocks sequential progression.
|
||||
*/
|
||||
function lessonBlocksProgression(policy?: string | null): boolean {
|
||||
return policy !== 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates whether an exercise blocks sequential progression (always true).
|
||||
*/
|
||||
function exerciseBlocksProgression(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a course content item blocks sequential progression.
|
||||
*/
|
||||
function itemBlocksProgression(type?: ContentType, completionPolicy?: string | null): boolean {
|
||||
if (type === ContentType.Lesson) {
|
||||
return lessonBlocksProgression(completionPolicy ?? 'manual');
|
||||
}
|
||||
|
||||
if (type === ContentType.Exercise) {
|
||||
return exerciseBlocksProgression();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
type CourseProgressionItem = {
|
||||
id: string;
|
||||
type?: ContentType;
|
||||
order?: number | null;
|
||||
completionPolicy?: string | null;
|
||||
isUnlocked?: boolean | null;
|
||||
isComplete?: boolean | null;
|
||||
accessible?: boolean;
|
||||
lockReason?: ProgressionLockReason | null;
|
||||
};
|
||||
|
||||
type ComputeProgressionAccessParams<T extends CourseProgressionItem> = {
|
||||
navigableItems: T[];
|
||||
progressionMode: 'free' | 'sequential';
|
||||
isComplete?: (item: T) => boolean;
|
||||
getCompletionPolicy?: (item: T) => string | null | undefined;
|
||||
getIsUnlocked?: (item: T) => boolean;
|
||||
};
|
||||
|
||||
type AccessMap = Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>;
|
||||
|
||||
/**
|
||||
* Computes progression access (accessible flag and lockReason) across an ordered array of navigable items.
|
||||
*/
|
||||
export function computeProgressionAccess<T extends CourseProgressionItem>(
|
||||
params: ComputeProgressionAccessParams<T>
|
||||
): AccessMap {
|
||||
const { navigableItems, progressionMode, isComplete, getCompletionPolicy, getIsUnlocked } = params;
|
||||
const accessById: AccessMap = new Map();
|
||||
let priorBlockingComplete = true;
|
||||
|
||||
for (const item of navigableItems) {
|
||||
const isUnlocked = getIsUnlocked ? getIsUnlocked(item) : (item.isUnlocked ?? true);
|
||||
const teacherLocked = !isUnlocked;
|
||||
const progressionLocked = progressionMode === 'sequential' && !priorBlockingComplete;
|
||||
|
||||
let accessible = !teacherLocked && !progressionLocked;
|
||||
let lockReason: ProgressionLockReason | null = null;
|
||||
|
||||
if (teacherLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'teacher_locked';
|
||||
} else if (progressionLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'progression_blocked';
|
||||
}
|
||||
|
||||
accessById.set(item.id, { accessible, lockReason });
|
||||
|
||||
const itemComplete = isComplete ? isComplete(item) : Boolean(item.isComplete);
|
||||
const policy = getCompletionPolicy ? getCompletionPolicy(item) : item.completionPolicy;
|
||||
const blocks = itemBlocksProgression(item.type, policy);
|
||||
|
||||
if (blocks && !itemComplete) {
|
||||
priorBlockingComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
return accessById;
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotates a content item with its computed access and lock reason.
|
||||
*/
|
||||
export function annotateContentItem<T extends CourseProgressionItem>(item: T, accessById: AccessMap): T {
|
||||
const access = accessById.get(item.id);
|
||||
if (!access) return item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
accessible: access.accessible,
|
||||
lockReason: access.lockReason
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './array';
|
||||
export * from './course-progression';
|
||||
export * from './currency';
|
||||
export * from './fileValidation';
|
||||
export * from './number';
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ContentType } from '../src/constants/content';
|
||||
import {
|
||||
annotateContentItem,
|
||||
computeProgressionAccess,
|
||||
flattenNavigableItems,
|
||||
type ProgressionLockReason
|
||||
} from '../src/functions/course-progression';
|
||||
|
||||
type TestItem = {
|
||||
id: string;
|
||||
type: ContentType;
|
||||
order?: number | null;
|
||||
isComplete?: boolean | null;
|
||||
isUnlocked?: boolean | null;
|
||||
completionPolicy?: string | null;
|
||||
accessible?: boolean;
|
||||
lockReason?: ProgressionLockReason | null;
|
||||
};
|
||||
|
||||
function lesson(id: string, order: number, options: Partial<TestItem> = {}): TestItem {
|
||||
return {
|
||||
id,
|
||||
type: ContentType.Lesson,
|
||||
order,
|
||||
isComplete: false,
|
||||
isUnlocked: true,
|
||||
completionPolicy: 'manual',
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
function exercise(id: string, order: number, options: Partial<TestItem> = {}): TestItem {
|
||||
return {
|
||||
id,
|
||||
type: ContentType.Exercise,
|
||||
order,
|
||||
isComplete: false,
|
||||
isUnlocked: true,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
describe('flattenNavigableItems', () => {
|
||||
it('orders grouped content section-by-section instead of by global order values', () => {
|
||||
const content = {
|
||||
grouped: true,
|
||||
sections: [
|
||||
{
|
||||
items: [lesson('section-1-lesson-1', 1), lesson('section-1-lesson-2', 2)]
|
||||
},
|
||||
{
|
||||
items: [lesson('section-2-lesson-1', 1), lesson('section-2-lesson-2', 2)]
|
||||
}
|
||||
],
|
||||
items: []
|
||||
};
|
||||
|
||||
const flattenedIds = flattenNavigableItems(content).map((item) => item.id);
|
||||
|
||||
expect(flattenedIds).toEqual([
|
||||
'section-1-lesson-1',
|
||||
'section-1-lesson-2',
|
||||
'section-2-lesson-1',
|
||||
'section-2-lesson-2'
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts ungrouped content by order', () => {
|
||||
const content = {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
items: [lesson('lesson-3', 3), lesson('lesson-1', 1), lesson('lesson-2', 2)]
|
||||
};
|
||||
|
||||
const flattenedIds = flattenNavigableItems(content).map((item) => item.id);
|
||||
|
||||
expect(flattenedIds).toEqual(['lesson-1', 'lesson-2', 'lesson-3']);
|
||||
});
|
||||
|
||||
it('does not mutate the source content array for ungrouped courses', () => {
|
||||
const items = [lesson('lesson-2', 2), lesson('lesson-1', 1)];
|
||||
const content = {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
items
|
||||
};
|
||||
|
||||
flattenNavigableItems(content);
|
||||
|
||||
expect(content.items.map((item) => item.id)).toEqual(['lesson-2', 'lesson-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeProgressionAccess', () => {
|
||||
it('unlocks the next item in the same section after the previous item is complete', () => {
|
||||
const content = {
|
||||
grouped: true,
|
||||
sections: [
|
||||
{
|
||||
items: [lesson('section-1-lesson-1', 1, { isComplete: true }), lesson('section-1-lesson-2', 2)]
|
||||
},
|
||||
{
|
||||
items: [lesson('section-2-lesson-1', 1), lesson('section-2-lesson-2', 2)]
|
||||
}
|
||||
],
|
||||
items: []
|
||||
};
|
||||
const navigableItems = flattenNavigableItems(content);
|
||||
|
||||
const accessById = computeProgressionAccess({
|
||||
navigableItems,
|
||||
progressionMode: 'sequential'
|
||||
});
|
||||
|
||||
expect(accessById.get('section-1-lesson-1')).toEqual({ accessible: true, lockReason: null });
|
||||
expect(accessById.get('section-1-lesson-2')).toEqual({ accessible: true, lockReason: null });
|
||||
expect(accessById.get('section-2-lesson-1')).toEqual({
|
||||
accessible: false,
|
||||
lockReason: 'progression_blocked'
|
||||
});
|
||||
expect(accessById.get('section-2-lesson-2')).toEqual({
|
||||
accessible: false,
|
||||
lockReason: 'progression_blocked'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps all items accessible in free progression mode', () => {
|
||||
const navigableItems = [lesson('lesson-1', 1), lesson('lesson-2', 2, { isComplete: false })];
|
||||
|
||||
const accessById = computeProgressionAccess({
|
||||
navigableItems,
|
||||
progressionMode: 'free'
|
||||
});
|
||||
|
||||
expect(accessById.get('lesson-1')).toEqual({ accessible: true, lockReason: null });
|
||||
expect(accessById.get('lesson-2')).toEqual({ accessible: true, lockReason: null });
|
||||
});
|
||||
|
||||
it('blocks teacher-locked items before sequential progression rules', () => {
|
||||
const navigableItems = [lesson('lesson-1', 1, { isComplete: true }), lesson('lesson-2', 2, { isUnlocked: false })];
|
||||
|
||||
const accessById = computeProgressionAccess({
|
||||
navigableItems,
|
||||
progressionMode: 'sequential'
|
||||
});
|
||||
|
||||
expect(accessById.get('lesson-2')).toEqual({
|
||||
accessible: false,
|
||||
lockReason: 'teacher_locked'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not block progression on lessons with completion policy none', () => {
|
||||
const navigableItems = [
|
||||
lesson('lesson-1', 1, { isComplete: false, completionPolicy: 'none' }),
|
||||
lesson('lesson-2', 2)
|
||||
];
|
||||
|
||||
const accessById = computeProgressionAccess({
|
||||
navigableItems,
|
||||
progressionMode: 'sequential'
|
||||
});
|
||||
|
||||
expect(accessById.get('lesson-2')).toEqual({ accessible: true, lockReason: null });
|
||||
});
|
||||
|
||||
it('blocks progression until exercises are complete', () => {
|
||||
const navigableItems = [
|
||||
lesson('lesson-1', 1, { isComplete: true }),
|
||||
exercise('exercise-1', 2),
|
||||
lesson('lesson-2', 3)
|
||||
];
|
||||
|
||||
const accessById = computeProgressionAccess({
|
||||
navigableItems,
|
||||
progressionMode: 'sequential'
|
||||
});
|
||||
|
||||
expect(accessById.get('exercise-1')).toEqual({ accessible: true, lockReason: null });
|
||||
expect(accessById.get('lesson-2')).toEqual({
|
||||
accessible: false,
|
||||
lockReason: 'progression_blocked'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('annotateContentItem', () => {
|
||||
it('applies computed access metadata to a content item', () => {
|
||||
const item = lesson('lesson-1', 1);
|
||||
const accessById = new Map([['lesson-1', { accessible: false, lockReason: 'progression_blocked' as const }]]);
|
||||
|
||||
const annotated = annotateContentItem(item, accessById);
|
||||
|
||||
expect(annotated.accessible).toBe(false);
|
||||
expect(annotated.lockReason).toBe('progression_blocked');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts']
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user