/** * 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: '
Hello
', 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: '
Card
', 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: '
1
2
', 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: '
1
2
', 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: '
A
B
', 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(); }); }); });