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