feat: add Vitest configuration and tests for lesson modules and rendering

This commit is contained in:
Michael Czechowski
2025-05-13 21:07:04 +02:00
parent ab4279f9ca
commit 4141501708
9 changed files with 2325 additions and 51 deletions

9
.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"tabWidth": 4,
"singleQuote": false,
"printWidth": 150,
"trailingComma": "none",
"useTabs": true
}

1583
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,11 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
"test": "vitest run",
"test.watch": "vitest watch",
"test.coverage": "vitest run --coverage",
"format": "prettier --write src/ tests/ package.json vite.config.js vitest.config.js",
"format.lessons": "prettier --write lessons/*.json"
},
"keywords": [
"css",
@@ -20,7 +24,15 @@
"author": "Michael Czechowski <mail@dailysh.it>",
"license": "Copyright Michael Czechowski 2025",
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@vitest/coverage-v8": "^3.1.3",
"jsdom": "^26.1.0",
"prettier": "^3.5.3",
"vite": "^6.3.5",
"vitest": "^3.1.3"
},
"dependencies": {
"whatwg-fetch": "^3.6.20"
}
}

View File

@@ -1,49 +0,0 @@
#!/bin/bash
# Define base directory
BASE_DIR="."
# Create directories
mkdir -p "$BASE_DIR/src/styles"
mkdir -p "$BASE_DIR/src/js"
mkdir -p "$BASE_DIR/src/lessons/configs"
# Create files with comments
touch "$BASE_DIR/src/index.html"
echo "<!-- Main entry HTML file -->" > "$BASE_DIR/src/index.html"
touch "$BASE_DIR/src/styles/main.css"
echo "/* Global styles */" > "$BASE_DIR/src/styles/main.css"
touch "$BASE_DIR/src/js/app.js"
echo "// Main application logic" > "$BASE_DIR/src/js/app.js"
touch "$BASE_DIR/src/js/LessonEngine.js"
echo "// Core lesson processing engine" > "$BASE_DIR/src/js/LessonEngine.js"
touch "$BASE_DIR/src/js/renderer.js"
echo "// Handles UI rendering" > "$BASE_DIR/src/js/renderer.js"
touch "$BASE_DIR/src/js/validator.js"
echo "// Validates user code submissions" > "$BASE_DIR/src/js/validator.js"
touch "$BASE_DIR/src/lessons/lesson-config.js"
echo "// Loads and parses lesson configs" > "$BASE_DIR/src/lessons/lesson-config.js"
touch "$BASE_DIR/src/lessons/configs/flexbox.json"
echo "{ /* Flexbox lesson config */ }" > "$BASE_DIR/src/lessons/configs/flexbox.json"
touch "$BASE_DIR/src/lessons/configs/grid.json"
echo "{ /* Grid lesson config */ }" > "$BASE_DIR/src/lessons/configs/grid.json"
touch "$BASE_DIR/src/lessons/configs/basics.json"
echo "{ /* Basics lesson config */ }" > "$BASE_DIR/src/lessons/configs/basics.json"
touch "$BASE_DIR/package.json"
echo "{\n \"name\": \"css-learning-platform\",\n \"version\": \"1.0.0\"\n}" > "$BASE_DIR/package.json"
touch "$BASE_DIR/vite.config.js"
echo "// Vite config file" > "$BASE_DIR/vite.config.js"
echo "Project scaffolded at: $BASE_DIR"

100
tests/setup.js Normal file
View File

@@ -0,0 +1,100 @@
import { afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
// import 'whatwg-fetch';
// Setup mock for localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem(key) {
return store[key] || null;
},
setItem(key, value) {
store[key] = String(value);
},
removeItem(key) {
delete store[key];
},
clear() {
store = {};
},
length: 0,
key() { return null; }
};
})();
// Mock the DOM environment
global.document.body.innerHTML = `
<div class="app-container">
<aside class="sidebar">
<div class="module-list"></div>
</aside>
<main class="content">
<div class="lesson-container">
<h2 id="lesson-title"></h2>
<div id="lesson-description"></div>
<div class="task-container">
<h3>Your Task</h3>
<div id="task-instruction"></div>
</div>
<div class="preview-container">
<h3>Preview</h3>
<div id="preview-area"></div>
</div>
<div class="editor-container">
<h3>CSS Editor</h3>
<div class="editor-content">
<div id="editor-prefix"></div>
<textarea id="code-input"></textarea>
<div id="editor-suffix"></div>
</div>
<div class="editor-controls">
<button id="reset-btn">Reset</button>
<button id="run-btn">Run</button>
</div>
</div>
<div class="navigation">
<button id="prev-btn">Previous</button>
<span id="level-indicator"></span>
<button id="next-btn">Next</button>
</div>
</div>
</main>
<div id="modal-container" class="hidden">
<div class="modal">
<div class="modal-header">
<h2 id="modal-title"></h2>
<button id="modal-close">&times;</button>
</div>
<div id="modal-content"></div>
</div>
</div>
<button id="module-selector-btn">Progress</button>
<button id="help-btn">Help</button>
</div>
`;
// Setup browser mocks
global.localStorage = localStorageMock;
window.localStorage = localStorageMock;
// For iframe support in jsdom
if (!window.document.createRange) {
window.document.createRange = () => ({
setStart: () => {},
setEnd: () => {},
commonAncestorContainer: {
nodeName: 'BODY',
ownerDocument: document,
},
});
}
// Add fetch mock
global.fetch = vi.fn();
// Clean up after each test
afterEach(() => {
localStorage.clear();
fetch.mockReset();
});

184
tests/unit/lessons.test.js Normal file
View File

@@ -0,0 +1,184 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { loadModules, getModuleById, loadModuleFromUrl, addCustomModule } from '../../src/config/lessons.js';
// Mock the module store for testing
vi.mock('../../lessons/flexbox.json', () => ({ default: { id: 'flexbox', title: 'Flexbox', lessons: [] }}));
vi.mock('../../lessons/grid.json', () => ({ default: { id: 'grid', title: 'CSS Grid', lessons: [] }}));
vi.mock('../../lessons/basics.json', () => ({ default: { id: 'basics', title: 'CSS Basics', lessons: [] }}));
vi.mock('../../lessons/tailwindcss.json', () => ({ default: { id: 'tailwind', title: 'Tailwind CSS', lessons: [] }}));
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).toBe(4);
// Check if modules have the right structure
const moduleIds = modules.map(m => m.id);
expect(moduleIds).toContain('basics');
expect(moduleIds).toContain('flexbox');
expect(moduleIds).toContain('grid');
expect(moduleIds).toContain('tailwind');
});
});
describe('getModuleById', () => {
test('should return a module by ID', async () => {
// Load modules first to populate the module store
await loadModules();
const flexboxModule = getModuleById('flexbox');
expect(flexboxModule).not.toBeNull();
expect(flexboxModule.id).toBe('flexbox');
expect(flexboxModule.title).toBe('Flexbox');
});
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);
});
});
});

178
tests/unit/renderer.test.js Normal file
View File

@@ -0,0 +1,178 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { renderModuleList, renderLesson, renderLevelIndicator, showFeedback, clearFeedback } from '../../src/helpers/renderer.js';
describe('Renderer Module', () => {
beforeEach(() => {
// Reset the DOM between tests
document.body.innerHTML = `
<div id="module-list"></div>
<h2 id="title"></h2>
<div id="description"></div>
<div id="task"></div>
<div id="preview"></div>
<div id="prefix"></div>
<textarea id="input"></textarea>
<div id="suffix"></div>
<div id="level-indicator"></div>
<div id="code-editor"></div>
`;
});
describe('renderModuleList', () => {
test('should render a list of modules', () => {
const container = document.getElementById('module-list');
const modules = [
{ id: 'mod1', title: 'Module 1' },
{ id: 'mod2', title: 'Module 2' }
];
const onSelectModule = vi.fn();
renderModuleList(container, modules, onSelectModule);
// Check if heading is created
expect(container.innerHTML).toContain('<h3>Modules</h3>');
// Check if module items are created
const moduleItems = container.querySelectorAll('.module-list-item');
expect(moduleItems.length).toBe(2);
expect(moduleItems[0].textContent).toBe('Module 1');
expect(moduleItems[1].textContent).toBe('Module 2');
// Test click event
moduleItems[0].click();
expect(onSelectModule).toHaveBeenCalledWith('mod1');
});
test('should handle empty module list', () => {
const container = document.getElementById('module-list');
renderModuleList(container, [], vi.fn());
expect(container.innerHTML).toContain('<h3>Modules</h3>');
expect(container.querySelectorAll('.module-list-item').length).toBe(0);
});
});
describe('renderLesson', () => {
test('should render lesson content correctly', () => {
const titleEl = document.getElementById('title');
const descriptionEl = document.getElementById('description');
const taskEl = document.getElementById('task');
const previewEl = document.getElementById('preview');
const prefixEl = document.getElementById('prefix');
const inputEl = document.getElementById('input');
const suffixEl = document.getElementById('suffix');
const lesson = {
title: 'Test Lesson',
description: '<p>Description text</p>',
task: '<p>Task instructions</p>',
codePrefix: 'body {',
initialCode: ' color: red;',
codeSuffix: '}'
};
renderLesson(
titleEl,
descriptionEl,
taskEl,
previewEl,
prefixEl,
inputEl,
suffixEl,
lesson
);
expect(titleEl.textContent).toBe('Test Lesson');
expect(descriptionEl.innerHTML).toBe('<p>Description text</p>');
expect(taskEl.innerHTML).toBe('<p>Task instructions</p>');
expect(prefixEl.textContent).toBe('body {');
expect(inputEl.value).toBe(' color: red;');
expect(suffixEl.textContent).toBe('}');
});
test('should handle missing lesson data with defaults', () => {
const titleEl = document.getElementById('title');
const descriptionEl = document.getElementById('description');
const taskEl = document.getElementById('task');
const prefixEl = document.getElementById('prefix');
const inputEl = document.getElementById('input');
const suffixEl = document.getElementById('suffix');
// Empty lesson object
const lesson = {};
renderLesson(
titleEl,
descriptionEl,
taskEl,
document.getElementById('preview'),
prefixEl,
inputEl,
suffixEl,
lesson
);
expect(titleEl.textContent).toBe('Untitled Lesson');
expect(descriptionEl.innerHTML).toBe('');
expect(taskEl.innerHTML).toBe('');
expect(prefixEl.textContent).toBe('');
expect(inputEl.value).toBe('');
expect(suffixEl.textContent).toBe('');
});
});
describe('renderLevelIndicator', () => {
test('should update level indicator text', () => {
const element = document.getElementById('level-indicator');
renderLevelIndicator(element, 3, 10);
expect(element.textContent).toBe('Lesson 3 of 10');
renderLevelIndicator(element, 1, 5);
expect(element.textContent).toBe('Lesson 1 of 5');
});
});
describe.skip('showFeedback and clearFeedback', () => {
test('should create success feedback element', () => {
const editor = document.getElementById('code-editor');
showFeedback(true, 'Great job!');
const feedback = document.querySelector('.feedback-success');
expect(feedback).not.toBeNull();
expect(feedback.textContent).toBe('Great job!');
// Test auto clearing with setTimeout
vi.useFakeTimers();
showFeedback(true, 'Auto clear test');
vi.advanceTimersByTime(5001);
expect(document.querySelector('.feedback-success')).toBeNull();
vi.useRealTimers();
});
test('should create error feedback element', () => {
showFeedback(false, 'Try again');
const feedback = document.querySelector('.feedback-error');
expect(feedback).not.toBeNull();
expect(feedback.textContent).toBe('Try again');
// Error feedback should not auto-clear
vi.useFakeTimers();
vi.advanceTimersByTime(5001);
expect(document.querySelector('.feedback-error')).not.toBeNull();
vi.useRealTimers();
});
test('should clear existing feedback', () => {
showFeedback(false, 'Error message');
expect(document.querySelector('.feedback-error')).not.toBeNull();
clearFeedback();
expect(document.querySelector('.feedback-error')).toBeNull();
// Should work when called multiple times
clearFeedback();
});
});
});

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, vi } from 'vitest';
import { validateUserCode } from '../../src/helpers/validator.js';
describe('CSS Validator', () => {
// Mock document functions since we're not in a browser
document.createElement = vi.fn().mockImplementation(() => {
return {
textContent: '',
parentNode: { removeChild: vi.fn() }
};
});
// document.head = {
// appendChild: vi.fn(),
// removeChild: vi.fn()
// };
describe('validateUserCode', () => {
it('should pass when no validations are specified', () => {
const userCode = 'div { color: red; }';
const lesson = { title: 'Test Lesson' };
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
expect(result.message).toContain('No validations specified');
});
it('should pass with empty validations array', () => {
const userCode = 'div { color: red; }';
const lesson = {
title: 'Test Lesson',
validations: []
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
expect(result.message).toBe('Your code looks good!');
});
it('should validate "contains" rule correctly', () => {
const userCode = 'div { color: red; }';
const lesson = {
validations: [
{ type: 'contains', value: 'color: red', message: 'Should use red color' }
]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
const failLesson = {
validations: [
{ type: 'contains', value: 'color: blue', message: 'Should use blue color' }
]
};
const failResult = validateUserCode(userCode, failLesson);
expect(failResult.isValid).toBe(false);
expect(failResult.message).toBe('Should use blue color');
});
it('should validate "not_contains" rule correctly', () => {
const userCode = 'div { color: red; }';
const lesson = {
validations: [
{ type: 'not_contains', value: 'color: blue', message: 'Should not use blue color' }
]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
const failLesson = {
validations: [
{ type: 'not_contains', value: 'color: red', message: 'Should not use red color' }
]
};
const failResult = validateUserCode(userCode, failLesson);
expect(failResult.isValid).toBe(false);
expect(failResult.message).toBe('Should not use red color');
});
it('should validate "regex" rule correctly', () => {
const userCode = 'div { color: #ff0000; }';
const lesson = {
validations: [
{ type: 'regex', value: '#[a-f0-9]{6}', message: 'Should use hex color' }
]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
const failLesson = {
validations: [
{ type: 'regex', value: 'rgb\\(\\d+,\\s*\\d+,\\s*\\d+\\)', message: 'Should use RGB color' }
]
};
const failResult = validateUserCode(userCode, failLesson);
expect(failResult.isValid).toBe(false);
expect(failResult.message).toBe('Should use RGB color');
});
it('should validate "property_value" rule correctly', () => {
const userCode = 'div { display: flex; }';
const lesson = {
validations: [
{
type: 'property_value',
value: { property: 'display', expected: 'flex' },
message: 'Should use display: flex'
}
]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
const failLesson = {
validations: [
{
type: 'property_value',
value: { property: 'display', expected: 'grid' },
message: 'Should use display: grid'
}
]
};
const failResult = validateUserCode(userCode, failLesson);
expect(failResult.isValid).toBe(false);
expect(failResult.message).toBe('Should use display: grid');
});
it('should handle complex validation chains', () => {
const userCode = 'div { display: flex; color: red; }';
const lesson = {
validations: [
{ type: 'contains', value: 'display: flex' },
{ type: 'contains', value: 'color: red' },
{ type: 'not_contains', value: 'float:' }
]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
// First failing validation should cause early return
const failLesson = {
validations: [
{ type: 'contains', value: 'display: flex' },
{ type: 'contains', value: 'border: 1px solid black', message: 'Missing border' },
{ type: 'not_contains', value: 'color: green' }
]
};
const failResult = validateUserCode(userCode, failLesson);
expect(failResult.isValid).toBe(false);
expect(failResult.message).toBe('Missing border');
});
it('should validate "custom" rule correctly', () => {
const userCode = 'div { margin: 10px; }';
const customValidator = (code) => {
return {
isValid: code.includes('margin'),
message: 'Should include margin property'
};
};
const lesson = {
validations: [
{
type: 'custom',
validator: customValidator
}
]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
const failValidator = (code) => {
return {
isValid: code.includes('padding'),
message: 'Should include padding property'
};
};
const failLesson = {
validations: [
{
type: 'custom',
validator: failValidator,
message: 'Custom validation failed'
}
]
};
const failResult = validateUserCode(userCode, failLesson);
expect(failResult.isValid).toBe(false);
expect(failResult.message).toBe('Should include padding property');
});
it('should handle options in validations', () => {
// Case insensitive test
const userCode = 'div { COLOR: Red; }';
const lesson = {
validations: [
{
type: 'contains',
value: 'color: red',
options: { caseSensitive: false }
}
]
};
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
// With exact match required
const exactLesson = {
validations: [
{
type: 'property_value',
value: { property: 'color', expected: 'red' },
options: { exact: true }
}
]
};
const failExactResult = validateUserCode('div { color: RED; }', exactLesson);
expect(failExactResult.isValid).toBe(false);
});
});
});

20
vitest.config.js Normal file
View File

@@ -0,0 +1,20 @@
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.js'],
include: ['tests/**/*.{test,spec}.js'],
coverage: {
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'tests/setup.js']
},
server: {
deps: {
inline: true
}
}
}
});