Files
code-crispies/tests/unit/pathIntegration.test.js
Michael Czechowski 6c65381fcb 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
2026-01-12 20:30:09 +01:00

707 lines
22 KiB
JavaScript

/**
* 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();
});
});
});