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 <[email protected]> * 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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user