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
264 lines
7.8 KiB
JavaScript
264 lines
7.8 KiB
JavaScript
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
import { loadModules, getModuleById, loadModuleFromUrl, addCustomModule, loadLearningPaths } from "../../src/config/lessons.js";
|
|
|
|
describe("Lessons Config Module", () => {
|
|
describe("loadModules", () => {
|
|
test("should return an array of modules", async () => {
|
|
const modules = await loadModules();
|
|
|
|
expect(Array.isArray(modules)).toBe(true);
|
|
expect(modules.length).toBeGreaterThanOrEqual(6);
|
|
|
|
// Check if modules have the right structure
|
|
const moduleIds = modules.map((m) => m.id);
|
|
// HTML modules
|
|
expect(moduleIds).toContain("html-elements");
|
|
expect(moduleIds).toContain("html-forms-basic");
|
|
expect(moduleIds).toContain("html-forms-validation");
|
|
// CSS modules
|
|
expect(moduleIds).toContain("css-basic-selectors");
|
|
expect(moduleIds).toContain("box-model");
|
|
expect(moduleIds).toContain("flexbox");
|
|
});
|
|
|
|
test("should have mode set on each lesson", async () => {
|
|
const modules = await loadModules();
|
|
|
|
modules.forEach((module) => {
|
|
module.lessons.forEach((lesson) => {
|
|
expect(lesson.mode).toBeDefined();
|
|
expect(["html", "css", "tailwind"]).toContain(lesson.mode);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getModuleById", () => {
|
|
test("should return a module by ID", async () => {
|
|
// Load modules first to populate the module store
|
|
await loadModules();
|
|
|
|
const htmlModule = getModuleById("html-elements");
|
|
expect(htmlModule).not.toBeNull();
|
|
expect(htmlModule.id).toBe("html-elements");
|
|
expect(htmlModule.mode).toBe("html");
|
|
});
|
|
|
|
test("should return null for non-existent module ID", async () => {
|
|
// Load modules first
|
|
await loadModules();
|
|
|
|
const nonExistentModule = getModuleById("non-existent");
|
|
expect(nonExistentModule).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("loadModuleFromUrl", () => {
|
|
beforeEach(() => {
|
|
// Reset fetch mock
|
|
fetch.mockReset();
|
|
});
|
|
|
|
test("should load a module from a URL", async () => {
|
|
const mockModule = {
|
|
id: "remote-module",
|
|
title: "Remote Module",
|
|
lessons: [{ title: "Lesson 1", previewHTML: "<div>Preview</div>" }]
|
|
};
|
|
|
|
// Mock the fetch response
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => mockModule
|
|
});
|
|
|
|
const result = await loadModuleFromUrl("https://example.com/module.json");
|
|
|
|
expect(fetch).toHaveBeenCalledWith("https://example.com/module.json");
|
|
expect(result).toEqual(mockModule);
|
|
});
|
|
|
|
test("should throw an error for failed fetch", async () => {
|
|
fetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: "Not Found"
|
|
});
|
|
|
|
await expect(loadModuleFromUrl("https://example.com/not-found.json")).rejects.toThrow("Failed to load module: 404 Not Found");
|
|
});
|
|
|
|
test("should validate module structure", async () => {
|
|
// Missing required fields
|
|
const invalidModule = {
|
|
// Missing id
|
|
title: "Invalid Module"
|
|
// Missing lessons array
|
|
};
|
|
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => invalidModule
|
|
});
|
|
|
|
await expect(loadModuleFromUrl("https://example.com/invalid.json")).rejects.toThrow('Module config missing "id"');
|
|
|
|
// Invalid lessons structure
|
|
const moduleWithInvalidLessons = {
|
|
id: "invalid-lessons",
|
|
title: "Invalid Lessons",
|
|
lessons: [{ /* Missing title */ previewHTML: "<div>Preview</div>" }]
|
|
};
|
|
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => moduleWithInvalidLessons
|
|
});
|
|
|
|
await expect(loadModuleFromUrl("https://example.com/invalid-lessons.json")).rejects.toThrow('Lesson 0 missing "title"');
|
|
});
|
|
});
|
|
|
|
describe("addCustomModule", () => {
|
|
test("should add a new module to the store", async () => {
|
|
// Load modules first to get current count
|
|
const initialModules = await loadModules();
|
|
const initialCount = initialModules.length;
|
|
|
|
const customModule = {
|
|
id: "custom-module",
|
|
title: "Custom Module",
|
|
lessons: [{ title: "Custom Lesson", previewHTML: "<div>Preview</div>" }]
|
|
};
|
|
|
|
const result = addCustomModule(customModule);
|
|
expect(result).toBe(true);
|
|
|
|
// Check if module was added
|
|
const updatedModules = await loadModules();
|
|
expect(updatedModules.length).toBe(initialCount + 1);
|
|
|
|
const addedModule = getModuleById("custom-module");
|
|
expect(addedModule).not.toBeNull();
|
|
expect(addedModule.title).toBe("Custom Module");
|
|
});
|
|
|
|
test("should replace existing module with same ID", async () => {
|
|
// Add a module first
|
|
const customModule = {
|
|
id: "replace-test",
|
|
title: "Original Module",
|
|
lessons: [{ title: "Original Lesson", previewHTML: "<div>Preview</div>" }]
|
|
};
|
|
|
|
addCustomModule(customModule);
|
|
|
|
// Now replace it
|
|
const replacementModule = {
|
|
id: "replace-test",
|
|
title: "Replacement Module",
|
|
lessons: [{ title: "New Lesson", previewHTML: "<div>New Preview</div>" }]
|
|
};
|
|
|
|
const result = addCustomModule(replacementModule);
|
|
expect(result).toBe(true);
|
|
|
|
// Check if module was replaced
|
|
const updatedModule = getModuleById("replace-test");
|
|
expect(updatedModule.title).toBe("Replacement Module");
|
|
});
|
|
|
|
test("should validate module before adding", () => {
|
|
const invalidModule = {
|
|
// Missing required fields
|
|
title: "Invalid Module"
|
|
};
|
|
|
|
const result = addCustomModule(invalidModule);
|
|
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);
|
|
});
|
|
});
|
|
});
|