feat: add Guided Learning Paths feature
Implement PathManager to orchestrate multi-module learning journeys: - Add PathManager class with start/pause/resume functionality - Create learning-paths.json config with CSS Fundamentals path - Integrate path progress tracking with LessonEngine - Add path selection UI to homepage and navigation - Include JSON schema for learning path validation - Add comprehensive test suite for PathManager
This commit is contained in:
322
tests/unit/lessonEngine-pathManager-integration.test.js
Normal file
322
tests/unit/lessonEngine-pathManager-integration.test.js
Normal file
@@ -0,0 +1,322 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { LessonEngine } from "../../src/impl/LessonEngine.js";
|
||||
import { PathManager } from "../../src/impl/PathManager.js";
|
||||
|
||||
describe("LessonEngine + PathManager Integration", () => {
|
||||
let lessonEngine;
|
||||
let pathManager;
|
||||
let mockModules;
|
||||
let mockPaths;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
|
||||
// Create mock modules
|
||||
mockModules = [
|
||||
{
|
||||
id: "module-1",
|
||||
title: "Module 1",
|
||||
mode: "css",
|
||||
lessons: [
|
||||
{
|
||||
id: "lesson-1-1",
|
||||
title: "Lesson 1.1",
|
||||
task: "Test task 1",
|
||||
initialCode: "",
|
||||
validations: [{ type: "contains", value: "color: red" }]
|
||||
},
|
||||
{
|
||||
id: "lesson-1-2",
|
||||
title: "Lesson 1.2",
|
||||
task: "Test task 2",
|
||||
initialCode: "",
|
||||
validations: [{ type: "contains", value: "color: blue" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "module-2",
|
||||
title: "Module 2",
|
||||
mode: "css",
|
||||
lessons: [
|
||||
{
|
||||
id: "lesson-2-1",
|
||||
title: "Lesson 2.1",
|
||||
task: "Test task 3",
|
||||
initialCode: "",
|
||||
validations: [{ type: "contains", value: "color: green" }]
|
||||
},
|
||||
{
|
||||
id: "lesson-2-2",
|
||||
title: "Lesson 2.2",
|
||||
task: "Test task 4",
|
||||
initialCode: "",
|
||||
validations: [{ type: "contains", value: "color: yellow" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "module-3",
|
||||
title: "Module 3",
|
||||
mode: "css",
|
||||
lessons: [
|
||||
{
|
||||
id: "lesson-3-1",
|
||||
title: "Lesson 3.1",
|
||||
task: "Test task 5",
|
||||
initialCode: "",
|
||||
validations: [{ type: "contains", value: "color: orange" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Create mock paths
|
||||
mockPaths = [
|
||||
{
|
||||
id: "path-1",
|
||||
title: "Test Path 1",
|
||||
goal: "Learn basics",
|
||||
estimatedTime: 60,
|
||||
difficulty: "beginner",
|
||||
modules: mockModules // Path includes all modules
|
||||
},
|
||||
{
|
||||
id: "path-2",
|
||||
title: "Test Path 2",
|
||||
goal: "Advanced concepts",
|
||||
estimatedTime: 90,
|
||||
difficulty: "intermediate",
|
||||
modules: [mockModules[1], mockModules[2]] // Only modules 2 and 3
|
||||
}
|
||||
];
|
||||
|
||||
// Initialize LessonEngine and PathManager
|
||||
lessonEngine = new LessonEngine();
|
||||
lessonEngine.setModules(mockModules);
|
||||
|
||||
pathManager = new PathManager();
|
||||
pathManager.setPaths(mockPaths);
|
||||
|
||||
// Connect PathManager to LessonEngine
|
||||
lessonEngine.setPathManager(pathManager);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("setPathManager()", () => {
|
||||
it("should set the PathManager instance", () => {
|
||||
const engine = new LessonEngine();
|
||||
const pm = new PathManager();
|
||||
|
||||
expect(engine.pathManager).toBeNull();
|
||||
engine.setPathManager(pm);
|
||||
expect(engine.pathManager).toBe(pm);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextLesson() with no active path", () => {
|
||||
it("should follow normal module order when no path is active", () => {
|
||||
lessonEngine.setModule(mockModules[0]);
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
expect(lessonEngine.currentModule.id).toBe("module-1");
|
||||
|
||||
// Move to next lesson in same module
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentLessonIndex).toBe(1);
|
||||
expect(lessonEngine.currentModule.id).toBe("module-1");
|
||||
|
||||
// Move to next module's first lesson
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
expect(lessonEngine.currentModule.id).toBe("module-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextLesson() with active path", () => {
|
||||
it("should follow path order when path is active", () => {
|
||||
// Start path-2 which includes only module-2 and module-3
|
||||
pathManager.startPath("path-2");
|
||||
|
||||
// Start at first lesson of path (module-2, lesson 0)
|
||||
const firstLesson = pathManager.getNextLesson("path-2");
|
||||
expect(firstLesson).toEqual({ moduleId: "module-2", lessonIndex: 0 });
|
||||
|
||||
lessonEngine.setModuleById(firstLesson.moduleId);
|
||||
lessonEngine.setLessonByIndex(firstLesson.lessonIndex);
|
||||
|
||||
expect(lessonEngine.currentModule.id).toBe("module-2");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
|
||||
// Mark first lesson complete
|
||||
pathManager.markLessonCompleted("module-2", 0);
|
||||
|
||||
// Next lesson should be module-2, lesson 1
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentModule.id).toBe("module-2");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("should navigate across modules within the path", () => {
|
||||
pathManager.startPath("path-2");
|
||||
|
||||
// Start at module-2, lesson 0
|
||||
lessonEngine.setModuleById("module-2");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Complete both lessons in module-2
|
||||
pathManager.markLessonCompleted("module-2", 0);
|
||||
pathManager.markLessonCompleted("module-2", 1);
|
||||
|
||||
// Next lesson should jump to module-3 (skipping module-1 which isn't in path)
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentModule.id).toBe("module-3");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should return false when path is complete", () => {
|
||||
pathManager.startPath("path-2");
|
||||
|
||||
// Complete all lessons in path-2
|
||||
pathManager.markLessonCompleted("module-2", 0);
|
||||
pathManager.markLessonCompleted("module-2", 1);
|
||||
pathManager.markLessonCompleted("module-3", 0);
|
||||
|
||||
// Set to last lesson
|
||||
lessonEngine.setModuleById("module-3");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Should return false as there's no next lesson in the path
|
||||
const result = lessonEngine.nextLesson();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCode() with active path", () => {
|
||||
it("should mark lesson complete in both LessonEngine and PathManager", () => {
|
||||
pathManager.startPath("path-1");
|
||||
|
||||
lessonEngine.setModule(mockModules[0]);
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Lesson should not be completed initially
|
||||
expect(lessonEngine.isCurrentLessonCompleted()).toBe(false);
|
||||
expect(pathManager.isLessonCompleted("module-1", 0)).toBe(false);
|
||||
|
||||
// Apply valid code
|
||||
lessonEngine.applyUserCode("color: red");
|
||||
const result = lessonEngine.validateCode();
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
// Both should mark it as completed
|
||||
expect(lessonEngine.isCurrentLessonCompleted()).toBe(true);
|
||||
expect(pathManager.isLessonCompleted("module-1", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not mark lesson complete in PathManager if validation fails", () => {
|
||||
pathManager.startPath("path-1");
|
||||
|
||||
lessonEngine.setModule(mockModules[0]);
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Apply invalid code
|
||||
lessonEngine.applyUserCode("color: wrong");
|
||||
const result = lessonEngine.validateCode();
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
|
||||
// Neither should mark it as completed
|
||||
expect(lessonEngine.isCurrentLessonCompleted()).toBe(false);
|
||||
expect(pathManager.isLessonCompleted("module-1", 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("should work normally without active path", () => {
|
||||
// No path started
|
||||
lessonEngine.setModule(mockModules[0]);
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
lessonEngine.applyUserCode("color: red");
|
||||
const result = lessonEngine.validateCode();
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
// Only LessonEngine should track completion
|
||||
expect(lessonEngine.isCurrentLessonCompleted()).toBe(true);
|
||||
// PathManager doesn't track without active path
|
||||
expect(pathManager.isLessonCompleted("module-1", 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path-aware navigation workflow", () => {
|
||||
it("should guide user through complete path workflow", () => {
|
||||
// Start a path
|
||||
pathManager.startPath("path-2");
|
||||
expect(pathManager.getActivePath().id).toBe("path-2");
|
||||
|
||||
// Get first lesson and navigate to it
|
||||
const firstLesson = pathManager.getNextLesson("path-2");
|
||||
lessonEngine.setModuleById(firstLesson.moduleId);
|
||||
lessonEngine.setLessonByIndex(firstLesson.lessonIndex);
|
||||
|
||||
expect(lessonEngine.currentModule.id).toBe("module-2");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
|
||||
// Complete first lesson
|
||||
lessonEngine.applyUserCode("color: green");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Navigate to next lesson using path order
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentModule.id).toBe("module-2");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(1);
|
||||
|
||||
// Complete second lesson
|
||||
lessonEngine.applyUserCode("color: yellow");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Navigate to next lesson (should cross to module-3)
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentModule.id).toBe("module-3");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
|
||||
// Complete final lesson
|
||||
lessonEngine.applyUserCode("color: orange");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Check path completion
|
||||
expect(pathManager.isPathComplete("path-2")).toBe(true);
|
||||
|
||||
// No more lessons
|
||||
const result = lessonEngine.nextLesson();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PathManager integration without setting PathManager", () => {
|
||||
it("should work normally when PathManager is not set", () => {
|
||||
const engine = new LessonEngine();
|
||||
engine.setModules(mockModules);
|
||||
|
||||
expect(engine.pathManager).toBeNull();
|
||||
|
||||
// Should navigate normally
|
||||
engine.setModule(mockModules[0]);
|
||||
engine.setLessonByIndex(0);
|
||||
|
||||
engine.nextLesson();
|
||||
expect(engine.currentModule.id).toBe("module-1");
|
||||
expect(engine.currentLessonIndex).toBe(1);
|
||||
|
||||
// Validation should work
|
||||
engine.applyUserCode("color: blue");
|
||||
const result = engine.validateCode();
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { loadModules, getModuleById, loadModuleFromUrl, addCustomModule } from "../../src/config/lessons.js";
|
||||
import { loadModules, getModuleById, loadModuleFromUrl, addCustomModule, loadLearningPaths } from "../../src/config/lessons.js";
|
||||
|
||||
describe("Lessons Config Module", () => {
|
||||
describe("loadModules", () => {
|
||||
@@ -178,4 +178,86 @@ describe("Lessons Config Module", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadLearningPaths", () => {
|
||||
test("should return an array of learning paths", () => {
|
||||
const paths = loadLearningPaths();
|
||||
|
||||
expect(Array.isArray(paths)).toBe(true);
|
||||
expect(paths.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Check if paths have the right structure
|
||||
const pathIds = paths.map((p) => p.id);
|
||||
expect(pathIds).toContain("css-fundamentals");
|
||||
expect(pathIds).toContain("flexbox-master");
|
||||
expect(pathIds).toContain("html-forms-expert");
|
||||
expect(pathIds).toContain("css-animations-pro");
|
||||
});
|
||||
|
||||
test("should validate learning paths on load", () => {
|
||||
// This should not throw as paths are valid
|
||||
expect(() => loadLearningPaths()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should resolve module references to actual module objects", () => {
|
||||
const paths = loadLearningPaths();
|
||||
|
||||
paths.forEach((path) => {
|
||||
expect(Array.isArray(path.modules)).toBe(true);
|
||||
expect(path.modules.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that modules are actual objects, not just IDs
|
||||
path.modules.forEach((module) => {
|
||||
expect(typeof module).toBe("object");
|
||||
expect(module).not.toBeNull();
|
||||
expect(module.id).toBeDefined();
|
||||
expect(module.title).toBeDefined();
|
||||
expect(Array.isArray(module.lessons)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should have required fields on each path", () => {
|
||||
const paths = loadLearningPaths();
|
||||
|
||||
paths.forEach((path) => {
|
||||
expect(path.id).toBeDefined();
|
||||
expect(path.title).toBeDefined();
|
||||
expect(path.goal).toBeDefined();
|
||||
expect(typeof path.estimatedTime).toBe("number");
|
||||
expect(path.estimatedTime).toBeGreaterThan(0);
|
||||
expect(["beginner", "intermediate", "advanced"]).toContain(path.difficulty);
|
||||
expect(Array.isArray(path.modules)).toBe(true);
|
||||
expect(path.modules.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("should support different languages", () => {
|
||||
const pathsEN = loadLearningPaths("en");
|
||||
const pathsDE = loadLearningPaths("de");
|
||||
|
||||
expect(Array.isArray(pathsEN)).toBe(true);
|
||||
expect(Array.isArray(pathsDE)).toBe(true);
|
||||
|
||||
// Both should have the same number of paths (structure is the same)
|
||||
expect(pathsEN.length).toBe(pathsDE.length);
|
||||
|
||||
// Modules should be resolved for each language
|
||||
pathsEN.forEach((path) => {
|
||||
expect(path.modules.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
pathsDE.forEach((path) => {
|
||||
expect(path.modules.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle missing modules gracefully", () => {
|
||||
const paths = loadLearningPaths();
|
||||
|
||||
// Should not throw even if some module references can't be resolved
|
||||
// (they are filtered out with a console warning)
|
||||
expect(Array.isArray(paths)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
706
tests/unit/pathIntegration.test.js
Normal file
706
tests/unit/pathIntegration.test.js
Normal file
@@ -0,0 +1,706 @@
|
||||
/**
|
||||
* Integration tests for LessonEngine + PathManager
|
||||
* Tests: path navigation across modules, progress sync, pause/resume, switching paths
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { LessonEngine } from "../../src/impl/LessonEngine.js";
|
||||
import { PathManager } from "../../src/impl/PathManager.js";
|
||||
|
||||
describe("PathManager + LessonEngine Integration", () => {
|
||||
let lessonEngine;
|
||||
let pathManager;
|
||||
let mockModules;
|
||||
let mockPaths;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
// Create comprehensive mock modules
|
||||
mockModules = [
|
||||
{
|
||||
id: "css-basics",
|
||||
title: "CSS Basics",
|
||||
mode: "css",
|
||||
lessons: [
|
||||
{
|
||||
id: "selectors-1",
|
||||
title: "Basic Selectors",
|
||||
task: "Style with color: steelblue",
|
||||
initialCode: "",
|
||||
codePrefix: ".box {\n ",
|
||||
codeSuffix: "\n}",
|
||||
previewHTML: '<div class="box">Hello</div>',
|
||||
previewBaseCSS: "",
|
||||
validations: [{ type: "contains", value: "color: steelblue" }]
|
||||
},
|
||||
{
|
||||
id: "selectors-2",
|
||||
title: "Class Selectors",
|
||||
task: "Style with color: coral",
|
||||
initialCode: "",
|
||||
codePrefix: ".card {\n ",
|
||||
codeSuffix: "\n}",
|
||||
previewHTML: '<div class="card">Card</div>',
|
||||
previewBaseCSS: "",
|
||||
validations: [{ type: "contains", value: "color: coral" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "flexbox-intro",
|
||||
title: "Flexbox Introduction",
|
||||
mode: "css",
|
||||
lessons: [
|
||||
{
|
||||
id: "flex-1",
|
||||
title: "Display Flex",
|
||||
task: "Set display: flex",
|
||||
initialCode: "",
|
||||
codePrefix: ".wrap {\n ",
|
||||
codeSuffix: "\n}",
|
||||
previewHTML: '<div class="wrap"><div>1</div><div>2</div></div>',
|
||||
previewBaseCSS: "",
|
||||
validations: [{ type: "property_value", property: "display", expected: "flex" }]
|
||||
},
|
||||
{
|
||||
id: "flex-2",
|
||||
title: "Justify Content",
|
||||
task: "Center items",
|
||||
initialCode: "",
|
||||
codePrefix: ".wrap {\n ",
|
||||
codeSuffix: "\n}",
|
||||
previewHTML: '<div class="wrap"><div>1</div><div>2</div></div>',
|
||||
previewBaseCSS: "",
|
||||
validations: [{ type: "contains", value: "justify-content: center" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "grid-basics",
|
||||
title: "CSS Grid Basics",
|
||||
mode: "css",
|
||||
lessons: [
|
||||
{
|
||||
id: "grid-1",
|
||||
title: "Display Grid",
|
||||
task: "Set display: grid",
|
||||
initialCode: "",
|
||||
codePrefix: ".grid {\n ",
|
||||
codeSuffix: "\n}",
|
||||
previewHTML: '<div class="grid"><div>A</div><div>B</div></div>',
|
||||
previewBaseCSS: "",
|
||||
validations: [{ type: "property_value", property: "display", expected: "grid" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Create mock learning paths
|
||||
mockPaths = [
|
||||
{
|
||||
id: "css-fundamentals",
|
||||
title: "CSS Fundamentals",
|
||||
goal: "Master CSS basics",
|
||||
difficulty: "beginner",
|
||||
estimatedTime: 60,
|
||||
modules: [mockModules[0], mockModules[1]] // css-basics + flexbox-intro
|
||||
},
|
||||
{
|
||||
id: "layout-master",
|
||||
title: "Layout Master",
|
||||
goal: "Master layouts",
|
||||
difficulty: "intermediate",
|
||||
estimatedTime: 90,
|
||||
modules: [mockModules[1], mockModules[2]] // flexbox-intro + grid-basics
|
||||
},
|
||||
{
|
||||
id: "complete-path",
|
||||
title: "Complete Journey",
|
||||
goal: "Learn everything",
|
||||
difficulty: "advanced",
|
||||
estimatedTime: 120,
|
||||
modules: mockModules // All modules
|
||||
}
|
||||
];
|
||||
|
||||
// Initialize
|
||||
lessonEngine = new LessonEngine();
|
||||
lessonEngine.setModules(mockModules);
|
||||
|
||||
pathManager = new PathManager();
|
||||
pathManager.setPaths(mockPaths);
|
||||
|
||||
// Connect integration
|
||||
lessonEngine.setPathManager(pathManager);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("Path Navigation Across Modules", () => {
|
||||
it("should navigate through lessons in multiple modules following path order", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
// Start at first lesson
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
expect(lessonEngine.currentModule.id).toBe("css-basics");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
|
||||
// Complete lesson 1
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Navigate to next lesson
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentModule.id).toBe("css-basics");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(1);
|
||||
|
||||
// Complete lesson 2
|
||||
lessonEngine.applyUserCode("color: coral");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Should cross to next module in path
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentModule.id).toBe("flexbox-intro");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should skip modules not in the active path", () => {
|
||||
// layout-master path includes flexbox-intro and grid-basics (skips css-basics)
|
||||
pathManager.startPath("layout-master");
|
||||
|
||||
// Navigate from flexbox-intro to grid-basics
|
||||
lessonEngine.setModuleById("flexbox-intro");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Complete flexbox lessons
|
||||
lessonEngine.applyUserCode("display: flex");
|
||||
lessonEngine.validateCode();
|
||||
lessonEngine.nextLesson();
|
||||
|
||||
lessonEngine.applyUserCode("justify-content: center");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Next should go to grid-basics (skipping css-basics which isn't in path)
|
||||
const result = lessonEngine.nextLesson();
|
||||
expect(result).toBe(true);
|
||||
expect(lessonEngine.currentModule.id).toBe("grid-basics");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle reaching end of path", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
// Navigate to last module and lesson
|
||||
lessonEngine.setModuleById("flexbox-intro");
|
||||
lessonEngine.setLessonByIndex(1);
|
||||
|
||||
// Mark all as complete
|
||||
pathManager.markLessonCompleted("css-basics", 0);
|
||||
pathManager.markLessonCompleted("css-basics", 1);
|
||||
pathManager.markLessonCompleted("flexbox-intro", 0);
|
||||
pathManager.markLessonCompleted("flexbox-intro", 1);
|
||||
|
||||
// Should return false as path is complete
|
||||
const result = lessonEngine.nextLesson();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should find next lesson even when starting mid-path", () => {
|
||||
pathManager.startPath("complete-path");
|
||||
|
||||
// Complete first module's lessons
|
||||
pathManager.markLessonCompleted("css-basics", 0);
|
||||
pathManager.markLessonCompleted("css-basics", 1);
|
||||
|
||||
// Start from second module
|
||||
lessonEngine.setModuleById("flexbox-intro");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Should navigate correctly
|
||||
lessonEngine.applyUserCode("display: flex");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentModule.id).toBe("flexbox-intro");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Progress Sync Between Path and Module Progress", () => {
|
||||
it("should sync lesson completion to both PathManager and LessonEngine", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Initially not completed in either system
|
||||
expect(lessonEngine.isLessonCompleted("css-basics", 0)).toBe(false);
|
||||
expect(pathManager.isLessonCompleted("css-basics", 0)).toBe(false);
|
||||
|
||||
// Apply valid code and validate
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
const result = lessonEngine.validateCode();
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
|
||||
// Both systems should show completion
|
||||
expect(lessonEngine.isLessonCompleted("css-basics", 0)).toBe(true);
|
||||
expect(pathManager.isLessonCompleted("css-basics", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not mark as complete in PathManager if validation fails", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Apply invalid code
|
||||
lessonEngine.applyUserCode("color: wrong");
|
||||
const result = lessonEngine.validateCode();
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
|
||||
// Neither system should mark as complete
|
||||
expect(lessonEngine.isLessonCompleted("css-basics", 0)).toBe(false);
|
||||
expect(pathManager.isLessonCompleted("css-basics", 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("should track progress independently for different modules", () => {
|
||||
pathManager.startPath("complete-path");
|
||||
|
||||
// Complete lesson in first module
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Complete lesson in second module
|
||||
lessonEngine.setModuleById("flexbox-intro");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("display: flex");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Both should be tracked independently
|
||||
expect(pathManager.isLessonCompleted("css-basics", 0)).toBe(true);
|
||||
expect(pathManager.isLessonCompleted("flexbox-intro", 0)).toBe(true);
|
||||
expect(pathManager.isLessonCompleted("css-basics", 1)).toBe(false);
|
||||
expect(pathManager.isLessonCompleted("flexbox-intro", 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("should calculate path progress accurately", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
// Path has 4 total lessons (2 in css-basics + 2 in flexbox-intro)
|
||||
let progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.totalLessons).toBe(4);
|
||||
expect(progress.completedCount).toBe(0);
|
||||
expect(progress.percentComplete).toBe(0);
|
||||
|
||||
// Complete first lesson
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.completedCount).toBe(1);
|
||||
expect(progress.percentComplete).toBe(25); // 1/4 = 25%
|
||||
|
||||
// Complete second lesson
|
||||
lessonEngine.setLessonByIndex(1);
|
||||
lessonEngine.applyUserCode("color: coral");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.completedCount).toBe(2);
|
||||
expect(progress.percentComplete).toBe(50); // 2/4 = 50%
|
||||
});
|
||||
|
||||
it("should not sync to PathManager when no path is active", () => {
|
||||
// No path started
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// LessonEngine should track
|
||||
expect(lessonEngine.isLessonCompleted("css-basics", 0)).toBe(true);
|
||||
|
||||
// PathManager should NOT track (no active path)
|
||||
expect(pathManager.isLessonCompleted("css-basics", 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path Pause and Resume", () => {
|
||||
it("should stop following path order after pausing", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Mark first lesson complete
|
||||
pathManager.markLessonCompleted("css-basics", 0);
|
||||
|
||||
// Pause the path
|
||||
pathManager.pausePath();
|
||||
|
||||
// nextLesson() should now follow default module order
|
||||
lessonEngine.nextLesson();
|
||||
|
||||
// Without path, it would go to next lesson in current module
|
||||
expect(lessonEngine.currentModule.id).toBe("css-basics");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("should resume path order after resuming path", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
// Complete first module
|
||||
pathManager.markLessonCompleted("css-basics", 0);
|
||||
pathManager.markLessonCompleted("css-basics", 1);
|
||||
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(1);
|
||||
|
||||
// Pause path
|
||||
pathManager.pausePath();
|
||||
expect(pathManager.getActivePath()).toBeNull();
|
||||
|
||||
// Resume path
|
||||
pathManager.resumePath("css-fundamentals");
|
||||
expect(pathManager.getActivePath().id).toBe("css-fundamentals");
|
||||
|
||||
// Should follow path order again (next lesson is flexbox-intro)
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentModule.id).toBe("flexbox-intro");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should preserve progress when pausing and resuming", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
// Complete some lessons
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
lessonEngine.setLessonByIndex(1);
|
||||
lessonEngine.applyUserCode("color: coral");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
let progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.completedCount).toBe(2);
|
||||
|
||||
// Pause
|
||||
pathManager.pausePath();
|
||||
|
||||
// Progress should be preserved
|
||||
progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.completedCount).toBe(2);
|
||||
|
||||
// Resume
|
||||
pathManager.resumePath("css-fundamentals");
|
||||
|
||||
// Progress still preserved
|
||||
progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.completedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should not sync completion to paused path", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.pausePath();
|
||||
|
||||
// Complete a lesson while paused
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// LessonEngine tracks it
|
||||
expect(lessonEngine.isLessonCompleted("css-basics", 0)).toBe(true);
|
||||
|
||||
// PathManager should NOT track (path is paused)
|
||||
expect(pathManager.isLessonCompleted("css-basics", 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Switching Between Paths", () => {
|
||||
it("should switch active path and follow new path order", () => {
|
||||
// Start first path
|
||||
pathManager.startPath("css-fundamentals");
|
||||
expect(pathManager.getActivePath().id).toBe("css-fundamentals");
|
||||
|
||||
// Complete a lesson
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Switch to different path
|
||||
pathManager.startPath("layout-master");
|
||||
expect(pathManager.getActivePath().id).toBe("layout-master");
|
||||
|
||||
// Navigate from layout-master start
|
||||
lessonEngine.setModuleById("flexbox-intro");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
|
||||
// Complete lessons in flexbox module
|
||||
pathManager.markLessonCompleted("flexbox-intro", 0);
|
||||
pathManager.markLessonCompleted("flexbox-intro", 1);
|
||||
|
||||
// Should navigate to grid-basics (part of layout-master path)
|
||||
lessonEngine.nextLesson();
|
||||
expect(lessonEngine.currentModule.id).toBe("grid-basics");
|
||||
});
|
||||
|
||||
it("should maintain separate progress for different paths", () => {
|
||||
// Progress in first path
|
||||
pathManager.startPath("css-fundamentals");
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
let progress1 = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress1.completedCount).toBe(1);
|
||||
|
||||
// Switch to second path
|
||||
pathManager.startPath("layout-master");
|
||||
lessonEngine.setModuleById("flexbox-intro");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("display: flex");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
let progress2 = pathManager.getPathProgress("layout-master");
|
||||
expect(progress2.completedCount).toBe(1);
|
||||
|
||||
// Original path progress should be unchanged
|
||||
progress1 = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress1.completedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should allow resuming previously started path", () => {
|
||||
// Start and make progress in path 1
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("css-basics", 0);
|
||||
|
||||
// Switch to path 2
|
||||
pathManager.startPath("layout-master");
|
||||
pathManager.markLessonCompleted("flexbox-intro", 0);
|
||||
|
||||
// Resume path 1
|
||||
pathManager.resumePath("css-fundamentals");
|
||||
expect(pathManager.getActivePath().id).toBe("css-fundamentals");
|
||||
|
||||
// Progress should be preserved
|
||||
const progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.completedCount).toBe(1);
|
||||
|
||||
// Should continue from where left off
|
||||
const nextLesson = pathManager.getNextLesson("css-fundamentals");
|
||||
expect(nextLesson).toEqual({ moduleId: "css-basics", lessonIndex: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalStorage Persistence", () => {
|
||||
it("should persist path progress across sessions", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Simulate new session
|
||||
const newLessonEngine = new LessonEngine();
|
||||
newLessonEngine.setModules(mockModules);
|
||||
|
||||
const newPathManager = new PathManager();
|
||||
newPathManager.setPaths(mockPaths);
|
||||
|
||||
newLessonEngine.setPathManager(newPathManager);
|
||||
|
||||
// Check persisted state
|
||||
expect(newPathManager.getActivePath().id).toBe("css-fundamentals");
|
||||
|
||||
const progress = newPathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.completedCount).toBe(1);
|
||||
expect(newPathManager.isLessonCompleted("css-basics", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("should persist module progress across sessions", () => {
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Simulate new session
|
||||
const newEngine = new LessonEngine();
|
||||
newEngine.setModules(mockModules);
|
||||
|
||||
// Module progress should be loaded
|
||||
expect(newEngine.isLessonCompleted("css-basics", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("should persist both systems independently", () => {
|
||||
// Complete lesson with path active
|
||||
pathManager.startPath("css-fundamentals");
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("color: steelblue");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Complete another lesson without path
|
||||
pathManager.pausePath();
|
||||
lessonEngine.setModuleById("flexbox-intro");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("display: flex");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Simulate new session
|
||||
const newEngine = new LessonEngine();
|
||||
newEngine.setModules(mockModules);
|
||||
|
||||
const newPathManager = new PathManager();
|
||||
newPathManager.setPaths(mockPaths);
|
||||
|
||||
newEngine.setPathManager(newPathManager);
|
||||
|
||||
// Both should be persisted correctly
|
||||
expect(newEngine.isLessonCompleted("css-basics", 0)).toBe(true);
|
||||
expect(newEngine.isLessonCompleted("flexbox-intro", 0)).toBe(true);
|
||||
expect(newPathManager.isLessonCompleted("css-basics", 0)).toBe(true);
|
||||
expect(newPathManager.isLessonCompleted("flexbox-intro", 0)).toBe(false); // Wasn't completed during active path
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
it("should handle missing PathManager gracefully", () => {
|
||||
const engine = new LessonEngine();
|
||||
engine.setModules(mockModules);
|
||||
|
||||
// No PathManager set
|
||||
expect(engine.pathManager).toBeNull();
|
||||
|
||||
// Should navigate normally
|
||||
engine.setModuleById("css-basics");
|
||||
engine.setLessonByIndex(0);
|
||||
|
||||
engine.nextLesson();
|
||||
expect(engine.currentModule.id).toBe("css-basics");
|
||||
expect(engine.currentLessonIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle getNextLesson returning null gracefully", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
// Complete all lessons
|
||||
pathManager.markLessonCompleted("css-basics", 0);
|
||||
pathManager.markLessonCompleted("css-basics", 1);
|
||||
pathManager.markLessonCompleted("flexbox-intro", 0);
|
||||
pathManager.markLessonCompleted("flexbox-intro", 1);
|
||||
|
||||
lessonEngine.setModuleById("flexbox-intro");
|
||||
lessonEngine.setLessonByIndex(1);
|
||||
|
||||
// Should return false when path complete
|
||||
const result = lessonEngine.nextLesson();
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Current position should remain unchanged
|
||||
expect(lessonEngine.currentModule.id).toBe("flexbox-intro");
|
||||
expect(lessonEngine.currentLessonIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle invalid module ID in path gracefully", () => {
|
||||
const invalidPath = {
|
||||
id: "invalid-path",
|
||||
title: "Invalid Path",
|
||||
goal: "Test",
|
||||
estimatedTime: 30,
|
||||
difficulty: "beginner",
|
||||
modules: [{ id: "nonexistent-module", lessons: [{}] }]
|
||||
};
|
||||
|
||||
pathManager.setPaths([...mockPaths, invalidPath]);
|
||||
pathManager.startPath("invalid-path");
|
||||
|
||||
// Try to navigate
|
||||
const nextLesson = pathManager.getNextLesson("invalid-path");
|
||||
expect(nextLesson).toEqual({ moduleId: "nonexistent-module", lessonIndex: 0 });
|
||||
|
||||
// setModuleById should return false for invalid module
|
||||
const result = lessonEngine.setModuleById("nonexistent-module");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle completing lessons in different order", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
|
||||
// Complete lessons out of order
|
||||
lessonEngine.setModuleById("css-basics");
|
||||
lessonEngine.setLessonByIndex(1);
|
||||
lessonEngine.applyUserCode("color: coral");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// First lesson should still be incomplete
|
||||
expect(pathManager.isLessonCompleted("css-basics", 0)).toBe(false);
|
||||
expect(pathManager.isLessonCompleted("css-basics", 1)).toBe(true);
|
||||
|
||||
// getNextLesson should return first incomplete (lesson 0)
|
||||
const nextLesson = pathManager.getNextLesson("css-fundamentals");
|
||||
expect(nextLesson).toEqual({ moduleId: "css-basics", lessonIndex: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path Completion Detection", () => {
|
||||
it("should detect when path is complete", () => {
|
||||
pathManager.startPath("layout-master");
|
||||
|
||||
// layout-master has 3 lessons total
|
||||
expect(pathManager.isPathComplete("layout-master")).toBe(false);
|
||||
|
||||
// Complete all lessons
|
||||
lessonEngine.setModuleById("flexbox-intro");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("display: flex");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
lessonEngine.setLessonByIndex(1);
|
||||
lessonEngine.applyUserCode("justify-content: center");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
lessonEngine.setModuleById("grid-basics");
|
||||
lessonEngine.setLessonByIndex(0);
|
||||
lessonEngine.applyUserCode("display: grid");
|
||||
lessonEngine.validateCode();
|
||||
|
||||
// Path should be complete
|
||||
expect(pathManager.isPathComplete("layout-master")).toBe(true);
|
||||
|
||||
// Progress should show 100%
|
||||
const progress = pathManager.getPathProgress("layout-master");
|
||||
expect(progress.percentComplete).toBe(100);
|
||||
expect(progress.isComplete).toBe(true);
|
||||
});
|
||||
|
||||
it("should return null for next lesson when path is complete", () => {
|
||||
pathManager.startPath("layout-master");
|
||||
|
||||
// Complete all lessons
|
||||
pathManager.markLessonCompleted("flexbox-intro", 0);
|
||||
pathManager.markLessonCompleted("flexbox-intro", 1);
|
||||
pathManager.markLessonCompleted("grid-basics", 0);
|
||||
|
||||
// No next lesson
|
||||
const nextLesson = pathManager.getNextLesson("layout-master");
|
||||
expect(nextLesson).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
197
tests/unit/pathManager-start-pause-resume.test.js
Normal file
197
tests/unit/pathManager-start-pause-resume.test.js
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Tests for PathManager start/pause/resume functionality
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { PathManager } from "../../src/impl/PathManager.js";
|
||||
|
||||
describe("PathManager - Start/Pause/Resume/GetActivePath", () => {
|
||||
let pathManager;
|
||||
let mockPaths;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
|
||||
// Create mock paths
|
||||
mockPaths = [
|
||||
{
|
||||
id: "css-fundamentals",
|
||||
title: "CSS Fundamentals",
|
||||
modules: [
|
||||
{ id: "basic-selectors", lessons: [{}, {}, {}] }
|
||||
],
|
||||
estimatedTime: 60
|
||||
},
|
||||
{
|
||||
id: "flexbox-master",
|
||||
title: "Flexbox Master",
|
||||
modules: [
|
||||
{ id: "flex-basics", lessons: [{}, {}] }
|
||||
],
|
||||
estimatedTime: 90
|
||||
}
|
||||
];
|
||||
|
||||
// Create fresh PathManager instance
|
||||
pathManager = new PathManager();
|
||||
pathManager.setPaths(mockPaths);
|
||||
});
|
||||
|
||||
describe("getActivePath()", () => {
|
||||
it("should return null when no path is active", () => {
|
||||
const result = pathManager.getActivePath();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the active path object after starting a path", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const result = pathManager.getActivePath();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.id).toBe("css-fundamentals");
|
||||
expect(result.title).toBe("CSS Fundamentals");
|
||||
});
|
||||
|
||||
it("should return null after pausing", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.pausePath();
|
||||
const result = pathManager.getActivePath();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("startPath(pathId)", () => {
|
||||
it("should activate a path and return true", () => {
|
||||
const result = pathManager.startPath("css-fundamentals");
|
||||
expect(result).toBe(true);
|
||||
expect(pathManager.getActivePath()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should return false for non-existent path", () => {
|
||||
const result = pathManager.startPath("non-existent");
|
||||
expect(result).toBe(false);
|
||||
expect(pathManager.getActivePath()).toBeNull();
|
||||
});
|
||||
|
||||
it("should initialize progress for new path", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.startTimestamp).not.toBeNull();
|
||||
expect(progress.isStarted).toBe(true);
|
||||
});
|
||||
|
||||
it("should switch active path when starting a different path", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
expect(pathManager.getActivePath().id).toBe("css-fundamentals");
|
||||
|
||||
pathManager.startPath("flexbox-master");
|
||||
expect(pathManager.getActivePath().id).toBe("flexbox-master");
|
||||
});
|
||||
|
||||
it("should persist active path to localStorage", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const saved = JSON.parse(localStorage.getItem("codeCrispies.pathProgress"));
|
||||
expect(saved.activePathId).toBe("css-fundamentals");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pausePath()", () => {
|
||||
it("should deactivate the current path and return true", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const result = pathManager.pausePath();
|
||||
expect(result).toBe(true);
|
||||
expect(pathManager.getActivePath()).toBeNull();
|
||||
});
|
||||
|
||||
it("should return false when no path is active", () => {
|
||||
const result = pathManager.pausePath();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should update lastActivityTimestamp before pausing", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const progressBefore = pathManager.getPathProgress("css-fundamentals");
|
||||
const timestampBefore = progressBefore.lastActivityTimestamp;
|
||||
|
||||
// Small delay to ensure timestamp changes
|
||||
const now = new Date().toISOString();
|
||||
pathManager.pausePath();
|
||||
|
||||
const progressAfter = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progressAfter.lastActivityTimestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should persist inactive state to localStorage", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.pausePath();
|
||||
const saved = JSON.parse(localStorage.getItem("codeCrispies.pathProgress"));
|
||||
expect(saved.activePathId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumePath(pathId)", () => {
|
||||
it("should reactivate a previously started path and return true", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.pausePath();
|
||||
const result = pathManager.resumePath("css-fundamentals");
|
||||
expect(result).toBe(true);
|
||||
expect(pathManager.getActivePath().id).toBe("css-fundamentals");
|
||||
});
|
||||
|
||||
it("should return false for a path that was never started", () => {
|
||||
const result = pathManager.resumePath("flexbox-master");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-existent path", () => {
|
||||
const result = pathManager.resumePath("non-existent");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should update lastActivityTimestamp when resuming", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const timestampBefore = pathManager.getPathProgress("css-fundamentals").lastActivityTimestamp;
|
||||
|
||||
pathManager.pausePath();
|
||||
pathManager.resumePath("css-fundamentals");
|
||||
|
||||
const timestampAfter = pathManager.getPathProgress("css-fundamentals").lastActivityTimestamp;
|
||||
expect(timestampAfter).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should persist resumed state to localStorage", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.pausePath();
|
||||
pathManager.resumePath("css-fundamentals");
|
||||
|
||||
const saved = JSON.parse(localStorage.getItem("codeCrispies.pathProgress"));
|
||||
expect(saved.activePathId).toBe("css-fundamentals");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Active path state - Only one path active at a time", () => {
|
||||
it("should only allow one active path at a time", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
expect(pathManager.getActivePath().id).toBe("css-fundamentals");
|
||||
|
||||
pathManager.startPath("flexbox-master");
|
||||
expect(pathManager.getActivePath().id).toBe("flexbox-master");
|
||||
|
||||
// Only flexbox-master should be active
|
||||
const activePath = pathManager.getActivePath();
|
||||
expect(activePath.id).toBe("flexbox-master");
|
||||
});
|
||||
|
||||
it("should store active path state separately from progress", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const saved = JSON.parse(localStorage.getItem("codeCrispies.pathProgress"));
|
||||
|
||||
// Active path ID stored separately
|
||||
expect(saved).toHaveProperty("activePathId");
|
||||
expect(saved.activePathId).toBe("css-fundamentals");
|
||||
|
||||
// Progress data stored separately
|
||||
expect(saved).toHaveProperty("pathProgress");
|
||||
expect(saved.pathProgress).toHaveProperty("css-fundamentals");
|
||||
});
|
||||
});
|
||||
});
|
||||
567
tests/unit/pathManager.test.js
Normal file
567
tests/unit/pathManager.test.js
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* Comprehensive unit tests for PathManager
|
||||
* Tests: path loading, progress tracking, next lesson calculation,
|
||||
* localStorage persistence, and edge cases
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { PathManager } from "../../src/impl/PathManager.js";
|
||||
|
||||
describe("PathManager", () => {
|
||||
let pathManager;
|
||||
let mockPaths;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
|
||||
// Create comprehensive mock paths
|
||||
mockPaths = [
|
||||
{
|
||||
id: "css-fundamentals",
|
||||
title: "CSS Fundamentals",
|
||||
goal: "Master CSS basics",
|
||||
difficulty: "beginner",
|
||||
estimatedTime: 60,
|
||||
modules: [
|
||||
{
|
||||
id: "basic-selectors",
|
||||
lessons: [{}, {}, {}] // 3 lessons
|
||||
},
|
||||
{
|
||||
id: "box-model",
|
||||
lessons: [{}, {}] // 2 lessons
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "flexbox-master",
|
||||
title: "Flexbox Master",
|
||||
goal: "Become a Flexbox expert",
|
||||
difficulty: "intermediate",
|
||||
estimatedTime: 90,
|
||||
modules: [
|
||||
{
|
||||
id: "flex-basics",
|
||||
lessons: [{}, {}] // 2 lessons
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "empty-path",
|
||||
title: "Empty Path",
|
||||
goal: "Path with no modules",
|
||||
difficulty: "beginner",
|
||||
estimatedTime: 0,
|
||||
modules: []
|
||||
}
|
||||
];
|
||||
|
||||
// Create fresh PathManager instance
|
||||
pathManager = new PathManager();
|
||||
pathManager.setPaths(mockPaths);
|
||||
});
|
||||
|
||||
describe("Path Loading", () => {
|
||||
it("should initialize with empty state", () => {
|
||||
const newPathManager = new PathManager();
|
||||
expect(newPathManager.paths).toEqual([]);
|
||||
expect(newPathManager.activePathId).toBeNull();
|
||||
expect(newPathManager.pathProgress).toEqual({});
|
||||
});
|
||||
|
||||
it("should set paths using setPaths()", () => {
|
||||
const newPathManager = new PathManager();
|
||||
newPathManager.setPaths(mockPaths);
|
||||
expect(newPathManager.paths).toEqual(mockPaths);
|
||||
expect(newPathManager.paths.length).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle empty paths array", () => {
|
||||
pathManager.setPaths([]);
|
||||
expect(pathManager.paths).toEqual([]);
|
||||
expect(pathManager.getActivePath()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Progress Tracking - getPathProgress()", () => {
|
||||
it("should return null for invalid path ID", () => {
|
||||
const progress = pathManager.getPathProgress("non-existent");
|
||||
expect(progress).toBeNull();
|
||||
});
|
||||
|
||||
it("should return default progress for path that hasn't been started", () => {
|
||||
const progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress).toEqual({
|
||||
pathId: "css-fundamentals",
|
||||
completedLessons: [],
|
||||
completedCount: 0,
|
||||
totalLessons: 5, // 3 + 2 from modules
|
||||
percentComplete: 0,
|
||||
startTimestamp: null,
|
||||
lastActivityTimestamp: null,
|
||||
isStarted: false,
|
||||
isComplete: false
|
||||
});
|
||||
});
|
||||
|
||||
it("should calculate total lessons correctly across multiple modules", () => {
|
||||
const progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.totalLessons).toBe(5); // 3 + 2
|
||||
});
|
||||
|
||||
it("should return accurate progress after starting a path", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const progress = pathManager.getPathProgress("css-fundamentals");
|
||||
|
||||
expect(progress.isStarted).toBe(true);
|
||||
expect(progress.startTimestamp).not.toBeNull();
|
||||
expect(progress.lastActivityTimestamp).not.toBeNull();
|
||||
expect(progress.completedCount).toBe(0);
|
||||
expect(progress.percentComplete).toBe(0);
|
||||
});
|
||||
|
||||
it("should update progress after marking lessons as completed", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
pathManager.markLessonCompleted("basic-selectors", 1);
|
||||
|
||||
const progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.completedCount).toBe(2);
|
||||
expect(progress.percentComplete).toBe(40); // 2/5 = 40%
|
||||
expect(progress.completedLessons).toContain("basic-selectors-0");
|
||||
expect(progress.completedLessons).toContain("basic-selectors-1");
|
||||
});
|
||||
|
||||
it("should calculate percentage correctly", () => {
|
||||
pathManager.startPath("flexbox-master");
|
||||
pathManager.markLessonCompleted("flex-basics", 0);
|
||||
|
||||
const progress = pathManager.getPathProgress("flexbox-master");
|
||||
expect(progress.percentComplete).toBe(50); // 1/2 = 50%
|
||||
});
|
||||
|
||||
it("should handle paths with no lessons (empty modules)", () => {
|
||||
const progress = pathManager.getPathProgress("empty-path");
|
||||
expect(progress.totalLessons).toBe(0);
|
||||
expect(progress.percentComplete).toBe(0);
|
||||
expect(progress.isComplete).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lesson Completion - markLessonCompleted() and isLessonCompleted()", () => {
|
||||
it("should mark a lesson as completed when path is active", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
|
||||
expect(pathManager.isLessonCompleted("basic-selectors", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not mark lesson as completed when no path is active", () => {
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
expect(pathManager.isLessonCompleted("basic-selectors", 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not mark the same lesson twice", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
|
||||
const progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.completedCount).toBe(1);
|
||||
expect(progress.completedLessons.filter((l) => l === "basic-selectors-0").length).toBe(1);
|
||||
});
|
||||
|
||||
it("should update lastActivityTimestamp when marking lesson completed", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const progressBefore = pathManager.getPathProgress("css-fundamentals");
|
||||
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
|
||||
const progressAfter = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progressAfter.lastActivityTimestamp).not.toBe(progressBefore.lastActivityTimestamp);
|
||||
});
|
||||
|
||||
it("should return false for non-completed lessons", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
expect(pathManager.isLessonCompleted("basic-selectors", 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when no path is active", () => {
|
||||
expect(pathManager.isLessonCompleted("basic-selectors", 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Next Lesson Calculation - getNextLesson()", () => {
|
||||
it("should return null for invalid path ID", () => {
|
||||
const nextLesson = pathManager.getNextLesson("non-existent");
|
||||
expect(nextLesson).toBeNull();
|
||||
});
|
||||
|
||||
it("should return first lesson of first module for unstarted path", () => {
|
||||
const nextLesson = pathManager.getNextLesson("css-fundamentals");
|
||||
expect(nextLesson).toEqual({
|
||||
moduleId: "basic-selectors",
|
||||
lessonIndex: 0
|
||||
});
|
||||
});
|
||||
|
||||
it("should return next incomplete lesson within same module", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
|
||||
const nextLesson = pathManager.getNextLesson("css-fundamentals");
|
||||
expect(nextLesson).toEqual({
|
||||
moduleId: "basic-selectors",
|
||||
lessonIndex: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("should move to next module when current module is completed", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
// Complete all lessons in basic-selectors
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
pathManager.markLessonCompleted("basic-selectors", 1);
|
||||
pathManager.markLessonCompleted("basic-selectors", 2);
|
||||
|
||||
const nextLesson = pathManager.getNextLesson("css-fundamentals");
|
||||
expect(nextLesson).toEqual({
|
||||
moduleId: "box-model",
|
||||
lessonIndex: 0
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when all lessons are completed", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
// Complete all lessons
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
pathManager.markLessonCompleted("basic-selectors", 1);
|
||||
pathManager.markLessonCompleted("basic-selectors", 2);
|
||||
pathManager.markLessonCompleted("box-model", 0);
|
||||
pathManager.markLessonCompleted("box-model", 1);
|
||||
|
||||
const nextLesson = pathManager.getNextLesson("css-fundamentals");
|
||||
expect(nextLesson).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle paths with no modules", () => {
|
||||
const nextLesson = pathManager.getNextLesson("empty-path");
|
||||
expect(nextLesson).toBeNull();
|
||||
});
|
||||
|
||||
it("should skip modules with no lessons", () => {
|
||||
const pathWithEmptyModule = [
|
||||
{
|
||||
id: "test-path",
|
||||
modules: [
|
||||
{ id: "empty-module" }, // No lessons array
|
||||
{ id: "valid-module", lessons: [{}] }
|
||||
]
|
||||
}
|
||||
];
|
||||
pathManager.setPaths(pathWithEmptyModule);
|
||||
|
||||
const nextLesson = pathManager.getNextLesson("test-path");
|
||||
expect(nextLesson).toEqual({
|
||||
moduleId: "valid-module",
|
||||
lessonIndex: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path Completion - isPathComplete()", () => {
|
||||
it("should return false for invalid path ID", () => {
|
||||
expect(pathManager.isPathComplete("non-existent")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for unstarted path", () => {
|
||||
expect(pathManager.isPathComplete("css-fundamentals")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for partially completed path", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
expect(pathManager.isPathComplete("css-fundamentals")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when all lessons are completed", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
// Complete all lessons
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
pathManager.markLessonCompleted("basic-selectors", 1);
|
||||
pathManager.markLessonCompleted("basic-selectors", 2);
|
||||
pathManager.markLessonCompleted("box-model", 0);
|
||||
pathManager.markLessonCompleted("box-model", 1);
|
||||
|
||||
expect(pathManager.isPathComplete("css-fundamentals")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for empty paths", () => {
|
||||
expect(pathManager.isPathComplete("empty-path")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Time Estimation - calculateEstimatedTimeRemaining()", () => {
|
||||
it("should return 0 for invalid path ID", () => {
|
||||
const remaining = pathManager.calculateEstimatedTimeRemaining("non-existent");
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("should return full estimated time for unstarted path", () => {
|
||||
const remaining = pathManager.calculateEstimatedTimeRemaining("css-fundamentals");
|
||||
expect(remaining).toBe(60);
|
||||
});
|
||||
|
||||
it("should calculate remaining time based on completion percentage", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
pathManager.markLessonCompleted("basic-selectors", 1);
|
||||
// 2 out of 5 lessons = 40% complete, so 60% remaining
|
||||
// 60 minutes * 0.6 = 36 minutes
|
||||
|
||||
const remaining = pathManager.calculateEstimatedTimeRemaining("css-fundamentals");
|
||||
expect(remaining).toBe(36);
|
||||
});
|
||||
|
||||
it("should return 0 when path is completed", () => {
|
||||
pathManager.startPath("flexbox-master");
|
||||
pathManager.markLessonCompleted("flex-basics", 0);
|
||||
pathManager.markLessonCompleted("flex-basics", 1);
|
||||
|
||||
const remaining = pathManager.calculateEstimatedTimeRemaining("flexbox-master");
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle 50% completion correctly", () => {
|
||||
pathManager.startPath("flexbox-master");
|
||||
pathManager.markLessonCompleted("flex-basics", 0);
|
||||
// 1 out of 2 lessons = 50% complete
|
||||
// 90 minutes * 0.5 = 45 minutes
|
||||
|
||||
const remaining = pathManager.calculateEstimatedTimeRemaining("flexbox-master");
|
||||
expect(remaining).toBe(45);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Get All Paths With Progress - getAllPathsWithProgress()", () => {
|
||||
it("should return all paths with their progress data", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
|
||||
const allPaths = pathManager.getAllPathsWithProgress();
|
||||
expect(allPaths.length).toBe(3);
|
||||
|
||||
const cssPath = allPaths.find((p) => p.id === "css-fundamentals");
|
||||
expect(cssPath.progress).toBeDefined();
|
||||
expect(cssPath.progress.completedCount).toBe(1);
|
||||
expect(cssPath.progress.isStarted).toBe(true);
|
||||
});
|
||||
|
||||
it("should include progress for all paths even if not started", () => {
|
||||
const allPaths = pathManager.getAllPathsWithProgress();
|
||||
allPaths.forEach((path) => {
|
||||
expect(path.progress).toBeDefined();
|
||||
expect(path.progress.isStarted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array when no paths are set", () => {
|
||||
pathManager.setPaths([]);
|
||||
const allPaths = pathManager.getAllPathsWithProgress();
|
||||
expect(allPaths).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalStorage Persistence", () => {
|
||||
it("should save progress to localStorage when marking lessons completed", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
|
||||
const saved = JSON.parse(localStorage.getItem("codeCrispies.pathProgress"));
|
||||
expect(saved).not.toBeNull();
|
||||
expect(saved.activePathId).toBe("css-fundamentals");
|
||||
expect(saved.pathProgress["css-fundamentals"].completedLessons).toContain("basic-selectors-0");
|
||||
});
|
||||
|
||||
it("should save timestamp with progress data", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
const saved = JSON.parse(localStorage.getItem("codeCrispies.pathProgress"));
|
||||
expect(saved.timestamp).toBeDefined();
|
||||
expect(saved.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO format
|
||||
});
|
||||
|
||||
it("should load progress from localStorage on initialization", () => {
|
||||
// Manually set localStorage data
|
||||
const progressData = {
|
||||
activePathId: "flexbox-master",
|
||||
pathProgress: {
|
||||
"flexbox-master": {
|
||||
completedLessons: ["flex-basics-0"],
|
||||
startTimestamp: "2024-01-01T00:00:00.000Z",
|
||||
lastActivityTimestamp: "2024-01-01T00:30:00.000Z"
|
||||
}
|
||||
},
|
||||
timestamp: "2024-01-01T00:30:00.000Z"
|
||||
};
|
||||
localStorage.setItem("codeCrispies.pathProgress", JSON.stringify(progressData));
|
||||
|
||||
// Create new PathManager (should load from localStorage)
|
||||
const newPathManager = new PathManager();
|
||||
newPathManager.setPaths(mockPaths);
|
||||
|
||||
expect(newPathManager.activePathId).toBe("flexbox-master");
|
||||
expect(newPathManager.isLessonCompleted("flex-basics", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return metadata when loading progress", () => {
|
||||
const progressData = {
|
||||
activePathId: "css-fundamentals",
|
||||
pathProgress: {},
|
||||
timestamp: "2024-01-01T00:00:00.000Z"
|
||||
};
|
||||
localStorage.setItem("codeCrispies.pathProgress", JSON.stringify(progressData));
|
||||
|
||||
const newPathManager = new PathManager();
|
||||
// loadPathProgress is called in constructor, but we can call it again
|
||||
const metadata = newPathManager.loadPathProgress();
|
||||
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata.activePathId).toBe("css-fundamentals");
|
||||
expect(metadata.timestamp).toBe("2024-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should handle corrupted localStorage data gracefully", () => {
|
||||
localStorage.setItem("codeCrispies.pathProgress", "invalid json {{{");
|
||||
|
||||
// Should not throw
|
||||
const newPathManager = new PathManager();
|
||||
expect(newPathManager.activePathId).toBeNull();
|
||||
expect(newPathManager.pathProgress).toEqual({});
|
||||
});
|
||||
|
||||
it("should handle missing localStorage data", () => {
|
||||
const newPathManager = new PathManager();
|
||||
expect(newPathManager.activePathId).toBeNull();
|
||||
expect(newPathManager.pathProgress).toEqual({});
|
||||
});
|
||||
|
||||
it("should persist multiple paths progress independently", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
pathManager.pausePath();
|
||||
|
||||
pathManager.startPath("flexbox-master");
|
||||
pathManager.markLessonCompleted("flex-basics", 0);
|
||||
|
||||
const saved = JSON.parse(localStorage.getItem("codeCrispies.pathProgress"));
|
||||
expect(saved.pathProgress["css-fundamentals"].completedLessons).toContain("basic-selectors-0");
|
||||
expect(saved.pathProgress["flexbox-master"].completedLessons).toContain("flex-basics-0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clear Progress - clearProgress()", () => {
|
||||
it("should clear all progress and active state", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
|
||||
pathManager.clearProgress();
|
||||
|
||||
expect(pathManager.activePathId).toBeNull();
|
||||
expect(pathManager.pathProgress).toEqual({});
|
||||
expect(localStorage.getItem("codeCrispies.pathProgress")).toBeNull();
|
||||
});
|
||||
|
||||
it("should remove data from localStorage", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
expect(localStorage.getItem("codeCrispies.pathProgress")).not.toBeNull();
|
||||
|
||||
pathManager.clearProgress();
|
||||
expect(localStorage.getItem("codeCrispies.pathProgress")).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow starting fresh after clearing", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
pathManager.clearProgress();
|
||||
|
||||
pathManager.startPath("flexbox-master");
|
||||
const progress = pathManager.getPathProgress("flexbox-master");
|
||||
expect(progress.isStarted).toBe(true);
|
||||
expect(progress.completedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle paths with null or undefined modules array", () => {
|
||||
const pathWithNullModules = [
|
||||
{
|
||||
id: "null-modules",
|
||||
modules: null,
|
||||
estimatedTime: 60
|
||||
}
|
||||
];
|
||||
pathManager.setPaths(pathWithNullModules);
|
||||
|
||||
// Should not throw
|
||||
expect(() => pathManager.getPathProgress("null-modules")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should handle lessons with special characters in module IDs", () => {
|
||||
const specialPath = [
|
||||
{
|
||||
id: "special-path",
|
||||
modules: [
|
||||
{ id: "module-with-dashes", lessons: [{}] }
|
||||
],
|
||||
estimatedTime: 30
|
||||
}
|
||||
];
|
||||
pathManager.setPaths(specialPath);
|
||||
pathManager.startPath("special-path");
|
||||
pathManager.markLessonCompleted("module-with-dashes", 0);
|
||||
|
||||
expect(pathManager.isLessonCompleted("module-with-dashes", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very large lesson indices", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 999);
|
||||
|
||||
expect(pathManager.isLessonCompleted("basic-selectors", 999)).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle zero estimated time", () => {
|
||||
const remaining = pathManager.calculateEstimatedTimeRemaining("empty-path");
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle path with completed lessons but never formally started", () => {
|
||||
// Manually add progress without starting path
|
||||
pathManager.pathProgress["css-fundamentals"] = {
|
||||
completedLessons: ["basic-selectors-0"],
|
||||
startTimestamp: null,
|
||||
lastActivityTimestamp: null
|
||||
};
|
||||
|
||||
const progress = pathManager.getPathProgress("css-fundamentals");
|
||||
expect(progress.isStarted).toBe(false);
|
||||
expect(progress.completedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle switching between paths multiple times", () => {
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 0);
|
||||
|
||||
pathManager.startPath("flexbox-master");
|
||||
pathManager.markLessonCompleted("flex-basics", 0);
|
||||
|
||||
pathManager.startPath("css-fundamentals");
|
||||
pathManager.markLessonCompleted("basic-selectors", 1);
|
||||
|
||||
const cssProgress = pathManager.getPathProgress("css-fundamentals");
|
||||
const flexProgress = pathManager.getPathProgress("flexbox-master");
|
||||
|
||||
expect(cssProgress.completedCount).toBe(2);
|
||||
expect(flexProgress.completedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user