feat: add Vitest configuration and tests for lesson modules and rendering
This commit is contained in:
9
.prettierrc.json
Normal file
9
.prettierrc.json
Normal 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
1583
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
49
scaffold.sh
49
scaffold.sh
@@ -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
100
tests/setup.js
Normal 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">×</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
184
tests/unit/lessons.test.js
Normal 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
178
tests/unit/renderer.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
239
tests/unit/validator.test.js
Normal file
239
tests/unit/validator.test.js
Normal 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
20
vitest.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user