feat: add HTML lessons mode and side-by-side comparison UI

- Add HTML mode support with new validation types (element_exists,
  element_count, attribute_value, element_text, parent_child, sibling)
- Create 3 HTML lesson modules: Elements, Forms Basic, Forms Validation
- Implement side-by-side preview comparison (Your Output vs Expected)
- Add merge animation with "Perfect Match!" overlay on validation success
- Render expected output from solutionCode field in lesson JSON
- Update schema to support HTML mode and solutionCode
- Reorder modules: HTML first, then CSS, then Tailwind
- Update tests for new functionality
This commit is contained in:
2025-12-21 22:12:00 +01:00
parent 94cdf368bc
commit b13c8ffea5
15 changed files with 1136 additions and 66 deletions

49
CLAUDE.md Normal file
View File

@@ -0,0 +1,49 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Code Crispies is an interactive CSS/Tailwind learning platform built with pure JavaScript (ES Modules) and Vite. Users complete lessons by writing CSS or Tailwind code that passes validation rules.
## Commands
```bash
npm start # Start dev server at http://localhost:1312
npm run build # Production build to dist/
npm run test # Run tests once
npm run test.watch # Run tests in watch mode
npm run test.coverage # Run tests with coverage report
npm run format # Format source files with Prettier
npm run format.lessons # Format lesson JSON files
```
## Architecture
### Core Components
- **LessonEngine** (`src/impl/LessonEngine.js`): Single source of truth for lesson state, progress tracking, and code validation. Manages user code application to preview iframes and persists progress to localStorage.
- **Validator** (`src/helpers/validator.js`): Validates user code against lesson requirements. Supports validation types: `contains`, `contains_class`, `not_contains`, `regex`, `property_value`, `syntax`, `custom`.
- **Lesson Config** (`src/config/lessons.js`): Loads and validates lesson modules from JSON. Modules are imported statically and stored in `moduleStore`.
### Data Flow
1. Lesson JSON files define modules with lessons, each containing `previewHTML`, `validations`, and other metadata
2. `LessonEngine` loads modules and tracks user progress per lesson
3. User code is applied to an iframe preview and validated against lesson rules
4. Progress is persisted to `localStorage` under `codeCrispies.progress` and `codeCrispies.userCode`
### Lesson Structure
Lessons are JSON files in `lessons/` following the schema in `schemas/code-crispies-module-schema.json`. Each module has:
- `mode`: "css" or "tailwind"
- `difficulty`: "beginner", "intermediate", or "advanced"
- `lessons[]`: Array of lessons with validations
For Tailwind mode, user classes are injected via `{{USER_CLASSES}}` placeholder in `previewHTML`.
### Testing
Tests use Vitest with jsdom environment. Setup in `tests/setup.js` includes DOM testing library matchers. Test files are in `tests/unit/`.

View File

@@ -0,0 +1,97 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "html-elements",
"title": "HTML Elements: Block vs Inline",
"description": "Understanding the fundamental difference between container (block) and inline elements",
"mode": "html",
"difficulty": "beginner",
"lessons": [
{
"id": "block-vs-inline-intro",
"title": "Block vs Inline Elements",
"description": "HTML elements fall into two main categories:<br><br><strong>Block elements</strong> (containers) start on a new line and take full width. Examples: <kbd>&lt;div&gt;</kbd>, <kbd>&lt;p&gt;</kbd>, <kbd>&lt;h1&gt;</kbd>, <kbd>&lt;section&gt;</kbd><br><br><strong>Inline elements</strong> flow within text and only take needed width. Examples: <kbd>&lt;span&gt;</kbd>, <kbd>&lt;a&gt;</kbd>, <kbd>&lt;strong&gt;</kbd>, <kbd>&lt;em&gt;</kbd>",
"task": "Create a paragraph with a <kbd>&lt;strong&gt;</kbd> word inside it. Notice how the paragraph is a block element (takes full width) while strong is inline (flows with text).",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 20px; } p { background: #e3f2fd; padding: 10px; } strong { background: #ffecb3; }",
"sandboxCSS": "",
"initialCode": "<p>This is a paragraph with an important word.</p>",
"solutionCode": "<p>This is a paragraph with a <strong>important</strong> word.</p>",
"previewContainer": "preview-area",
"validations": [
{
"type": "element_exists",
"value": "p",
"message": "Add a <p> paragraph element"
},
{
"type": "parent_child",
"value": { "parent": "p", "child": "strong" },
"message": "Place a <strong> element inside your paragraph"
}
]
},
{
"id": "semantic-containers",
"title": "Semantic Container Elements",
"description": "Modern HTML uses semantic containers that describe their content:<br><br><kbd>&lt;header&gt;</kbd> - Page or section header<br><kbd>&lt;nav&gt;</kbd> - Navigation links<br><kbd>&lt;main&gt;</kbd> - Main content area<br><kbd>&lt;section&gt;</kbd> - Thematic grouping<br><kbd>&lt;article&gt;</kbd> - Self-contained content<br><kbd>&lt;footer&gt;</kbd> - Page or section footer",
"task": "Create a basic page structure with <kbd>&lt;header&gt;</kbd>, <kbd>&lt;main&gt;</kbd>, and <kbd>&lt;footer&gt;</kbd> elements. Add a heading in the header.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui; margin: 0; } header { background: #1976d2; color: white; padding: 15px; } main { padding: 20px; min-height: 100px; } footer { background: #424242; color: white; padding: 10px; text-align: center; }",
"sandboxCSS": "",
"initialCode": "<!-- Create your page structure here -->",
"solutionCode": "<header>\n <h1>My Website</h1>\n</header>\n<main>\n <p>Welcome to my site!</p>\n</main>\n<footer>\n <p>Copyright 2025</p>\n</footer>",
"previewContainer": "preview-area",
"validations": [
{
"type": "element_exists",
"value": "header",
"message": "Add a <header> element"
},
{
"type": "element_exists",
"value": "main",
"message": "Add a <main> element"
},
{
"type": "element_exists",
"value": "footer",
"message": "Add a <footer> element"
},
{
"type": "parent_child",
"value": { "parent": "header", "child": "h1" },
"message": "Add an <h1> heading inside your header"
}
]
},
{
"id": "div-vs-span",
"title": "Generic Containers: div and span",
"description": "When you need a container without semantic meaning:<br><br><kbd>&lt;div&gt;</kbd> - Generic block container (for layout/grouping)<br><kbd>&lt;span&gt;</kbd> - Generic inline container (for styling text portions)<br><br>Use semantic elements when possible, div/span when no semantic element fits.",
"task": "Wrap the word 'highlighted' in a <kbd>&lt;span&gt;</kbd> to style it differently. Wrap the whole quote in a <kbd>&lt;div&gt;</kbd>.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: Georgia, serif; padding: 20px; } div { background: #f5f5f5; padding: 15px; border-left: 4px solid #1976d2; } span { background: #fff59d; padding: 2px 4px; }",
"sandboxCSS": "",
"initialCode": "The most highlighted moment was unforgettable.",
"solutionCode": "<div>The most <span>highlighted</span> moment was unforgettable.</div>",
"previewContainer": "preview-area",
"validations": [
{
"type": "element_exists",
"value": "div",
"message": "Wrap everything in a <div> element"
},
{
"type": "element_exists",
"value": "span",
"message": "Add a <span> around the word 'highlighted'"
},
{
"type": "element_text",
"value": { "selector": "span", "text": "highlighted" },
"message": "The <span> should contain the word 'highlighted'"
}
]
}
]
}

View File

@@ -0,0 +1,102 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "html-forms-basic",
"title": "HTML Forms: Basic Inputs",
"description": "Learn to create forms with various input types",
"mode": "html",
"difficulty": "beginner",
"lessons": [
{
"id": "form-structure",
"title": "Form Structure",
"description": "Every form needs a <kbd>&lt;form&gt;</kbd> wrapper. Inside, use <kbd>&lt;label&gt;</kbd> to describe inputs and <kbd>&lt;input&gt;</kbd> for user data entry.<br><br>The <kbd>for</kbd> attribute on labels should match the <kbd>id</kbd> on inputs for accessibility.",
"task": "Create a form with a text input for 'Name'. Include a label connected to the input using for/id attributes.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 300px; } label { display: block; margin-bottom: 5px; font-weight: 500; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }",
"sandboxCSS": "",
"initialCode": "<!-- Create your form here -->",
"solutionCode": "<form>\n <label for=\"name\">Name:</label>\n <input type=\"text\" id=\"name\" name=\"name\">\n</form>",
"previewContainer": "preview-area",
"validations": [
{
"type": "element_exists",
"value": "form",
"message": "Wrap everything in a <form> element"
},
{
"type": "element_exists",
"value": "label",
"message": "Add a <label> for your input"
},
{
"type": "element_exists",
"value": "input",
"message": "Add an <input> element"
},
{
"type": "attribute_value",
"value": { "selector": "label", "attr": "for", "value": null },
"message": "Add a 'for' attribute to your label"
},
{
"type": "attribute_value",
"value": { "selector": "input", "attr": "id", "value": null },
"message": "Add an 'id' attribute to your input"
}
]
},
{
"id": "input-types",
"title": "Input Types",
"description": "Different input types provide appropriate keyboards and validation:<br><br><kbd>type=\"text\"</kbd> - General text<br><kbd>type=\"email\"</kbd> - Email with @ validation<br><kbd>type=\"password\"</kbd> - Hidden characters<br><kbd>type=\"number\"</kbd> - Numeric keyboard<br><kbd>type=\"tel\"</kbd> - Phone keyboard",
"task": "Create a login form with an email input and a password input, each with proper labels.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 300px; } .form-group { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }",
"sandboxCSS": "",
"initialCode": "<form>\n <!-- Add email and password inputs -->\n</form>",
"solutionCode": "<form>\n <div class=\"form-group\">\n <label for=\"email\">Email:</label>\n <input type=\"email\" id=\"email\" name=\"email\">\n </div>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\" name=\"password\">\n </div>\n</form>",
"previewContainer": "preview-area",
"validations": [
{
"type": "element_exists",
"value": "input[type='email']",
"message": "Add an input with type=\"email\""
},
{
"type": "element_exists",
"value": "input[type='password']",
"message": "Add an input with type=\"password\""
},
{
"type": "element_count",
"value": { "selector": "label", "min": 2 },
"message": "Add labels for both inputs"
}
]
},
{
"id": "submit-button",
"title": "Submit Button",
"description": "Forms need a way to submit data. Use:<br><br><kbd>&lt;button type=\"submit\"&gt;</kbd> - Preferred, flexible content<br><kbd>&lt;input type=\"submit\"&gt;</kbd> - Simple text-only button<br><br>The button text should be action-oriented (e.g., 'Sign In', 'Register', 'Send').",
"task": "Add a submit button to the form with the text 'Sign In'.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 300px; } .form-group { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; } button { width: 100%; padding: 10px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; } button:hover { background: #1565c0; }",
"sandboxCSS": "",
"initialCode": "<form>\n <div class=\"form-group\">\n <label for=\"email\">Email:</label>\n <input type=\"email\" id=\"email\">\n </div>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\">\n </div>\n <!-- Add submit button -->\n</form>",
"solutionCode": "<form>\n <div class=\"form-group\">\n <label for=\"email\">Email:</label>\n <input type=\"email\" id=\"email\">\n </div>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\">\n </div>\n <button type=\"submit\">Sign In</button>\n</form>",
"previewContainer": "preview-area",
"validations": [
{
"type": "element_exists",
"value": "button[type='submit'], input[type='submit']",
"message": "Add a submit button to your form"
},
{
"type": "element_text",
"value": { "selector": "button", "text": "Sign In" },
"message": "The button should say 'Sign In'"
}
]
}
]
}

View File

@@ -0,0 +1,112 @@
{
"$schema": "../schemas/code-crispies-module-schema.json",
"id": "html-forms-validation",
"title": "HTML Forms: Validation",
"description": "Learn HTML5 built-in form validation attributes",
"mode": "html",
"difficulty": "intermediate",
"lessons": [
{
"id": "required-fields",
"title": "Required Fields",
"description": "The <kbd>required</kbd> attribute prevents form submission if the field is empty.<br><br>Add it to any input that must be filled:<br><kbd>&lt;input type=\"text\" required&gt;</kbd><br><br>The browser shows a validation message automatically.",
"task": "Make both the name and email fields required.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 350px; } .form-group { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; } label .required { color: #d32f2f; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; } input:invalid { border-color: #d32f2f; } button { padding: 10px 20px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; }",
"sandboxCSS": "",
"initialCode": "<form>\n <div class=\"form-group\">\n <label for=\"name\">Name: <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"name\" name=\"name\">\n </div>\n <div class=\"form-group\">\n <label for=\"email\">Email: <span class=\"required\">*</span></label>\n <input type=\"email\" id=\"email\" name=\"email\">\n </div>\n <button type=\"submit\">Submit</button>\n</form>",
"solutionCode": "<form>\n <div class=\"form-group\">\n <label for=\"name\">Name: <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"name\" name=\"name\" required>\n </div>\n <div class=\"form-group\">\n <label for=\"email\">Email: <span class=\"required\">*</span></label>\n <input type=\"email\" id=\"email\" name=\"email\" required>\n </div>\n <button type=\"submit\">Submit</button>\n</form>",
"previewContainer": "preview-area",
"validations": [
{
"type": "attribute_value",
"value": { "selector": "input[name='name']", "attr": "required", "value": true },
"message": "Add the 'required' attribute to the name input"
},
{
"type": "attribute_value",
"value": { "selector": "input[name='email']", "attr": "required", "value": true },
"message": "Add the 'required' attribute to the email input"
}
]
},
{
"id": "input-constraints",
"title": "Input Constraints",
"description": "Control what users can enter:<br><br><kbd>minlength</kbd> / <kbd>maxlength</kbd> - Text length limits<br><kbd>min</kbd> / <kbd>max</kbd> - Number range<br><kbd>pattern</kbd> - Regex pattern matching<br><kbd>placeholder</kbd> - Hint text (not a label!)",
"task": "Add validation: password must be 8-20 characters. Add a helpful placeholder.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 350px; } .form-group { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; } input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; } input:invalid:not(:placeholder-shown) { border-color: #d32f2f; } .hint { font-size: 12px; color: #666; margin-top: 4px; } button { padding: 10px 20px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; }",
"sandboxCSS": "",
"initialCode": "<form>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\" name=\"password\" required>\n <div class=\"hint\">Must be 8-20 characters</div>\n </div>\n <button type=\"submit\">Create Account</button>\n</form>",
"solutionCode": "<form>\n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\" name=\"password\" required minlength=\"8\" maxlength=\"20\" placeholder=\"Enter password\">\n <div class=\"hint\">Must be 8-20 characters</div>\n </div>\n <button type=\"submit\">Create Account</button>\n</form>",
"previewContainer": "preview-area",
"validations": [
{
"type": "attribute_value",
"value": { "selector": "input[type='password']", "attr": "minlength", "value": "8" },
"message": "Add minlength=\"8\" to the password input"
},
{
"type": "attribute_value",
"value": { "selector": "input[type='password']", "attr": "maxlength", "value": "20" },
"message": "Add maxlength=\"20\" to the password input"
},
{
"type": "attribute_value",
"value": { "selector": "input[type='password']", "attr": "placeholder", "value": null },
"message": "Add a placeholder to hint what to enter"
}
]
},
{
"id": "complete-registration",
"title": "Complete Registration Form",
"description": "Build a complete registration form with all validation concepts:<br><br>- Required fields marked with *<br>- Email validation (use type=\"email\")<br>- Password with length constraints<br>- Terms checkbox (required)<br>- Submit button",
"task": "Complete the registration form. Add required attributes, proper input types, and validation constraints.",
"previewHTML": "",
"previewBaseCSS": "body { font-family: system-ui; padding: 20px; } form { max-width: 400px; background: #f5f5f5; padding: 25px; border-radius: 8px; } h2 { margin-top: 0; margin-bottom: 20px; } .form-group { margin-bottom: 18px; } label { display: block; margin-bottom: 5px; font-weight: 500; } .required { color: #d32f2f; } input[type='text'], input[type='email'], input[type='password'] { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; font-size: 14px; } input:focus { outline: 2px solid #1976d2; border-color: transparent; } .checkbox-group { display: flex; align-items: flex-start; gap: 8px; } .checkbox-group input { width: auto; margin-top: 3px; } .checkbox-group label { margin: 0; font-weight: normal; } button { width: 100%; padding: 12px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: 500; } button:hover { background: #1565c0; }",
"sandboxCSS": "",
"initialCode": "<form>\n <h2>Create Account</h2>\n \n <div class=\"form-group\">\n <label for=\"fullname\">Full Name <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"fullname\" name=\"fullname\">\n </div>\n \n <div class=\"form-group\">\n <label for=\"email\">Email <span class=\"required\">*</span></label>\n <input id=\"email\" name=\"email\">\n </div>\n \n <div class=\"form-group\">\n <label for=\"password\">Password <span class=\"required\">*</span></label>\n <input id=\"password\" name=\"password\">\n </div>\n \n <div class=\"form-group checkbox-group\">\n <input type=\"checkbox\" id=\"terms\" name=\"terms\">\n <label for=\"terms\">I agree to the Terms of Service <span class=\"required\">*</span></label>\n </div>\n \n <button type=\"submit\">Register</button>\n</form>",
"solutionCode": "<form>\n <h2>Create Account</h2>\n \n <div class=\"form-group\">\n <label for=\"fullname\">Full Name <span class=\"required\">*</span></label>\n <input type=\"text\" id=\"fullname\" name=\"fullname\" required>\n </div>\n \n <div class=\"form-group\">\n <label for=\"email\">Email <span class=\"required\">*</span></label>\n <input type=\"email\" id=\"email\" name=\"email\" required>\n </div>\n \n <div class=\"form-group\">\n <label for=\"password\">Password <span class=\"required\">*</span></label>\n <input type=\"password\" id=\"password\" name=\"password\" required minlength=\"8\">\n </div>\n \n <div class=\"form-group checkbox-group\">\n <input type=\"checkbox\" id=\"terms\" name=\"terms\" required>\n <label for=\"terms\">I agree to the Terms of Service <span class=\"required\">*</span></label>\n </div>\n \n <button type=\"submit\">Register</button>\n</form>",
"previewContainer": "preview-area",
"validations": [
{
"type": "attribute_value",
"value": { "selector": "#fullname", "attr": "required", "value": true },
"message": "Make the full name field required"
},
{
"type": "attribute_value",
"value": { "selector": "#email", "attr": "type", "value": "email" },
"message": "Set the email input type to 'email'"
},
{
"type": "attribute_value",
"value": { "selector": "#email", "attr": "required", "value": true },
"message": "Make the email field required"
},
{
"type": "attribute_value",
"value": { "selector": "#password", "attr": "type", "value": "password" },
"message": "Set the password input type to 'password'"
},
{
"type": "attribute_value",
"value": { "selector": "#password", "attr": "required", "value": true },
"message": "Make the password field required"
},
{
"type": "attribute_value",
"value": { "selector": "#password", "attr": "minlength", "value": "8" },
"message": "Add minlength=\"8\" to password"
},
{
"type": "attribute_value",
"value": { "selector": "#terms", "attr": "required", "value": true },
"message": "Make the terms checkbox required"
}
]
}
]
}

View File

@@ -19,8 +19,8 @@
},
"mode": {
"type": "string",
"enum": ["css", "tailwind"],
"description": "Whether this module teaches CSS or Tailwind"
"enum": ["css", "tailwind", "html"],
"description": "Whether this module teaches CSS, Tailwind, or HTML"
},
"difficulty": {
"type": "string",
@@ -60,7 +60,7 @@
},
"mode": {
"type": "string",
"enum": ["css", "tailwind"],
"enum": ["css", "tailwind", "html"],
"description": "Override module mode for individual lessons"
},
"tailwindConfig": {
@@ -91,6 +91,10 @@
"type": "string",
"description": "Solution code for the lesson, if applicable"
},
"solutionCode": {
"type": "string",
"description": "Expected correct code used to render the expected preview for comparison"
},
"previewContainer": {
"type": "string",
"description": "ID of the container element for the preview"
@@ -105,26 +109,83 @@
"properties": {
"type": {
"type": "string",
"enum": ["contains", "contains_class", "not_contains", "regex", "property_value", "syntax", "custom"],
"enum": [
"contains",
"contains_class",
"contains_pattern",
"not_contains",
"regex",
"property_value",
"syntax",
"custom",
"element_exists",
"element_count",
"attribute_value",
"element_text",
"parent_child",
"sibling"
],
"description": "Type of validation to perform"
},
"value": {
"description": "Value to check against, format depends on validation type",
"description": "Value to check against, format depends on validation type. String for simple checks, object for complex validations.",
"oneOf": [
{
"type": "string"
},
{
"type": "boolean"
},
{
"type": "object",
"required": ["property", "expected"],
"description": "Object format for property_value, element_count, attribute_value, element_text, parent_child validations",
"properties": {
"property": {
"type": "string",
"description": "CSS property name to validate"
"description": "CSS property name (for property_value)"
},
"expected": {
"type": "string",
"description": "Expected value for the CSS property"
"description": "Expected value (for property_value)"
},
"selector": {
"type": "string",
"description": "CSS selector to target element (for HTML validations)"
},
"count": {
"type": "integer",
"description": "Expected count of elements (for element_count)"
},
"min": {
"type": "integer",
"description": "Minimum count of elements (for element_count)"
},
"attr": {
"type": "string",
"description": "Attribute name to check (for attribute_value)"
},
"value": {
"description": "Expected attribute value (for attribute_value). Use true to check existence only."
},
"text": {
"type": "string",
"description": "Expected text content (for element_text)"
},
"parent": {
"type": "string",
"description": "Parent selector (for parent_child)"
},
"child": {
"type": "string",
"description": "Child selector (for parent_child)"
},
"first": {
"type": "string",
"description": "First sibling selector (for sibling)"
},
"then": {
"type": "string",
"description": "Following sibling selector (for sibling)"
}
}
}

View File

@@ -181,13 +181,24 @@ function updateEditorForMode(mode) {
const codeInput = elements.codeInput;
const editorLabel = document.querySelector(".editor-label");
if (mode === "tailwind") {
codeInput.placeholder = "Enter Tailwind classes (e.g., bg-blue-500 text-white p-4)";
if (editorLabel) editorLabel.textContent = "Tailwind Classes:";
} else {
codeInput.placeholder = "Enter your CSS code here...";
if (editorLabel) editorLabel.textContent = "CSS Code:";
}
const modeConfig = {
html: {
placeholder: "Write your HTML here (e.g., <p>Hello World</p>)",
label: "HTML Editor"
},
tailwind: {
placeholder: "Enter Tailwind classes (e.g., bg-blue-500 text-white p-4)",
label: "Tailwind Classes"
},
css: {
placeholder: "Enter your CSS code here...",
label: "CSS Editor"
}
};
const config = modeConfig[mode] || modeConfig.css;
codeInput.placeholder = config.placeholder;
if (editorLabel) editorLabel.textContent = config.label;
}
// Configure editor layout based on display type
@@ -266,6 +277,9 @@ function loadCurrentLesson() {
// Focus on the code editor by default
elements.codeInput.focus();
// Render the expected/solution preview for comparison
lessonEngine.renderExpectedPreview();
// Track live changes and update preview when the user pauses typing
setupLivePreview();
}
@@ -379,6 +393,9 @@ function runCode() {
elements.nextBtn.classList.add("success");
elements.taskInstruction.classList.add("success-instruction");
// Show merge animation for side-by-side comparison
lessonEngine.showMatchAnimation();
// Update navigation buttons
updateNavigationButtons();
@@ -388,6 +405,9 @@ function runCode() {
// Reset any success indicators
resetSuccessIndicators();
// Hide merge animation if it was showing
lessonEngine.hideMatchAnimation();
// Show error feedback (with friendly message)
showFeedback(false, validationResult.message || "Not quite there yet! Let's try again.");
}

View File

@@ -6,9 +6,20 @@
import basicSelectorsConfig from "../../lessons/00-basic-selectors.json";
import advancedSelectorsConfig from "../../lessons/01-advanced-selectors.json";
import tailwindConfig from "../../lessons/10-tailwind-basics.json";
// HTML lessons
import htmlElementsConfig from "../../lessons/20-html-elements.json";
import htmlFormsBasicConfig from "../../lessons/21-html-forms-basic.json";
import htmlFormsValidationConfig from "../../lessons/22-html-forms-validation.json";
// Module store
const moduleStore = [basicSelectorsConfig, advancedSelectorsConfig, tailwindConfig];
const moduleStore = [
htmlElementsConfig,
htmlFormsBasicConfig,
htmlFormsValidationConfig,
basicSelectorsConfig,
advancedSelectorsConfig,
tailwindConfig
];
/**
* Load all available modules

View File

@@ -15,7 +15,7 @@ let feedbackTimeout = null;
*/
export function renderModuleList(container, modules, onSelectModule, onSelectLesson) {
// Clear the container
container.innerHTML = "<h3>CSS Lessons</h3>";
container.innerHTML = "<h3>Lessons</h3>";
// Get user progress from localStorage
const progressData = localStorage.getItem("codeCrispies.progress");

View File

@@ -5,13 +5,205 @@
export function validateUserCode(userCode, lesson) {
const mode = lesson.mode || "css";
if (mode === "tailwind") {
return validateTailwindClasses(userCode, lesson);
} else {
return validateCssCode(userCode, lesson);
switch (mode) {
case "html":
return validateHtmlCode(userCode, lesson);
case "tailwind":
return validateTailwindClasses(userCode, lesson);
case "css":
default:
return validateCssCode(userCode, lesson);
}
}
/**
* Validate user HTML code against the lesson requirements
* @param {string} userHtml - User submitted HTML code
* @param {Object} lesson - The current lesson object
* @returns {Object} Validation result with isValid and message properties
*/
function validateHtmlCode(userHtml, lesson) {
if (!lesson || !lesson.validations) {
return { isValid: true, message: "No validations specified for this lesson." };
}
// Parse the HTML using DOMParser
const parser = new DOMParser();
const doc = parser.parseFromString(userHtml, "text/html");
// Check for parse errors (DOMParser doesn't throw, but inserts error elements for XML)
// For HTML mode, it's more lenient, so we mainly check validations
const validations = lesson.validations;
let result = {
isValid: true,
validCases: 0,
totalCases: validations.length,
message: "Your HTML looks great!"
};
for (const validation of validations) {
const { type, value, message } = validation;
let validationPassed = false;
switch (type) {
case "element_exists":
// value is a CSS selector string
validationPassed = doc.querySelector(value) !== null;
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Missing element: ${value}`
};
}
break;
case "element_count":
// value is { selector: string, count?: number, min?: number }
const elements = doc.querySelectorAll(value.selector);
if (value.count !== undefined) {
validationPassed = elements.length === value.count;
} else if (value.min !== undefined) {
validationPassed = elements.length >= value.min;
} else {
validationPassed = elements.length > 0;
}
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Expected ${value.count || value.min + "+"} ${value.selector} element(s)`
};
}
break;
case "attribute_value":
// value is { selector: string, attr: string, value: any }
const el = doc.querySelector(value.selector);
if (!el) {
validationPassed = false;
} else if (value.value === true) {
// Check attribute exists (boolean attribute like "required")
validationPassed = el.hasAttribute(value.attr);
} else if (value.value === null) {
// Check attribute exists with any value
validationPassed = el.hasAttribute(value.attr);
} else {
// Check attribute has specific value
validationPassed = el.getAttribute(value.attr) === value.value;
}
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Element ${value.selector} should have ${value.attr} attribute`
};
}
break;
case "element_text":
// value is { selector: string, text: string }
const textEl = doc.querySelector(value.selector);
validationPassed = textEl && textEl.textContent.includes(value.text);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Element ${value.selector} should contain "${value.text}"`
};
}
break;
case "parent_child":
// value is { parent: string, child: string }
const parentEl = doc.querySelector(value.parent);
validationPassed = parentEl && parentEl.querySelector(value.child) !== null;
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `${value.child} should be inside ${value.parent}`
};
}
break;
case "sibling":
// value is { first: string, then: string }
const firstSibling = doc.querySelector(value.first);
if (!firstSibling) {
validationPassed = false;
} else {
// Check if "then" element comes after "first" element
let nextEl = firstSibling.nextElementSibling;
while (nextEl) {
if (nextEl.matches(value.then)) {
validationPassed = true;
break;
}
nextEl = nextEl.nextElementSibling;
}
}
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `${value.then} should follow ${value.first}`
};
}
break;
// Fall back to text-based validations for simple checks
case "contains":
validationPassed = containsValidation(userHtml, value);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Your HTML should include "${value}"`
};
}
break;
case "not_contains":
validationPassed = !containsValidation(userHtml, value);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Your HTML should not include "${value}"`
};
}
break;
case "regex":
validationPassed = regexValidation(userHtml, value);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || "Your HTML doesn't match the expected pattern"
};
}
break;
default:
console.warn(`Unknown HTML validation type: ${type}`);
validationPassed = true;
}
if (validationPassed) {
result.validCases++;
} else {
return result;
}
}
result.validCases = validations.length;
return result;
}
function validateTailwindClasses(userClasses, lesson) {
if (!lesson || !lesson.validations) {
return { isValid: true, message: "No validations specified for this lesson." };

View File

@@ -176,7 +176,22 @@ export class LessonEngine {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
if (mode === "tailwind") {
if (mode === "html") {
// For HTML mode, user code IS the HTML content
const userHtml = this.userCode || "";
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<style>${previewBaseCSS || ""}</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${userHtml}
</body>
</html>
`);
} else if (mode === "tailwind") {
// For Tailwind mode, user code goes directly in HTML classes
const htmlWithClasses = this.injectTailwindClasses(previewHTML, this.userCode);
iframeDoc.write(`
@@ -218,6 +233,122 @@ export class LessonEngine {
return html.replace(/{{USER_CLASSES}}/g, userClasses);
}
/**
* Render the expected/solution preview for comparison
*/
renderExpectedPreview() {
if (!this.currentLesson) return;
const solutionCode = this.currentLesson.solutionCode;
if (!solutionCode) {
// No solution code provided, hide the expected pane or show placeholder
const expectedContainer = document.getElementById("preview-expected");
if (expectedContainer) {
expectedContainer.innerHTML = '<div style="color: #999; font-size: 0.9rem; text-align: center;">No expected output available</div>';
}
return;
}
const mode = this.currentLesson.mode || this.currentModule?.mode || "css";
const { previewHTML, previewBaseCSS, sandboxCSS } = this.currentLesson;
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.title = "Expected Result";
const container = document.getElementById("preview-expected");
if (!container) return;
container.innerHTML = "";
container.appendChild(iframe);
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
if (mode === "html") {
// For HTML mode, solution code IS the HTML content
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<style>${previewBaseCSS || ""}</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${solutionCode}
</body>
</html>
`);
} else if (mode === "tailwind") {
// For Tailwind mode, inject solution classes into HTML
const htmlWithClasses = this.injectTailwindClasses(previewHTML, solutionCode);
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.tailwindcss.com"></script>
<style>${previewBaseCSS}</style>
<style>${sandboxCSS}</style>
</head>
<body>
${htmlWithClasses}
</body>
</html>
`);
} else {
// CSS mode - wrap solution with prefix/suffix
const { codePrefix, codeSuffix } = this.currentLesson;
const solutionCss = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<style>${previewBaseCSS}</style>
<style>${solutionCss}</style>
<style>${sandboxCSS}</style>
</head>
<body>
${previewHTML}
</body>
</html>
`);
}
iframeDoc.close();
}
/**
* Show merge animation when student's output matches expected
*/
showMatchAnimation() {
const overlay = document.getElementById("match-overlay");
const comparison = document.getElementById("preview-comparison");
if (overlay && comparison) {
overlay.classList.add("matched");
comparison.classList.add("matched");
// Remove animation classes after delay
setTimeout(() => {
overlay.classList.remove("matched");
comparison.classList.remove("matched");
}, 2500);
}
}
/**
* Hide match animation
*/
hideMatchAnimation() {
const overlay = document.getElementById("match-overlay");
const comparison = document.getElementById("preview-comparison");
if (overlay) overlay.classList.remove("matched");
if (comparison) comparison.classList.remove("matched");
}
/**
* Validate user code against the current lesson's requirements
* @returns {Object} Validation result

View File

@@ -46,8 +46,28 @@
<div class="lesson-description" id="lesson-description">Please select a lesson to begin.</div>
<div class="challenge-container">
<div class="preview-area" id="preview-area">
<!-- Preview of the challenge will be shown here -->
<div class="preview-comparison" id="preview-comparison">
<div class="preview-pane preview-student">
<div class="preview-header">
<span class="preview-label">Your Output</span>
</div>
<div class="preview-frame" id="preview-area">
<!-- Student's preview iframe will be shown here -->
</div>
</div>
<div class="preview-pane preview-expected">
<div class="preview-header">
<span class="preview-label">Expected Result</span>
</div>
<div class="preview-frame" id="preview-expected">
<!-- Expected result iframe will be shown here -->
</div>
</div>
<div class="preview-overlay" id="match-overlay">
<div class="match-celebration">Perfect Match!</div>
</div>
</div>
<div class="editor-container">

View File

@@ -367,13 +367,99 @@ footer a {
margin-bottom: var(--spacing-xl);
}
.preview-area {
background-color: var(--panel-bg);
/* ================= PREVIEW COMPARISON ================= */
.preview-comparison {
position: relative;
display: flex;
gap: var(--spacing-md);
min-height: 300px;
flex: 1;
}
.preview-pane {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
padding: var(--spacing-md);
overflow: hidden;
min-height: 300px;
background-color: var(--panel-bg);
}
.preview-header {
padding: var(--spacing-xs) var(--spacing-md);
background-color: var(--code-bg);
font-size: 0.85rem;
font-weight: 600;
color: var(--light-text);
border-bottom: 1px solid var(--border-color);
}
.preview-frame {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: var(--spacing-sm);
min-height: 200px;
}
/* Merge overlay when student matches expected */
.preview-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.5s ease;
z-index: 10;
border-radius: var(--border-radius-md);
}
.preview-overlay.matched {
opacity: 1;
background: rgba(88, 184, 144, 0.15);
}
.match-celebration {
background: var(--success-color);
color: var(--white-text);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--border-radius-lg);
font-weight: 700;
font-size: 1.2rem;
box-shadow: 0 4px 20px rgba(88, 184, 144, 0.3);
animation: pop-in 0.4s ease-out;
}
@keyframes pop-in {
0% {
transform: scale(0.8);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Both previews visually "merge" at 50% opacity */
.preview-comparison.matched .preview-pane {
opacity: 0.5;
transition: opacity 0.5s ease;
}
/* Legacy preview-area styles (now used inside preview-frame) */
.preview-area {
background-color: var(--panel-bg);
border: none;
border-radius: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
@@ -795,12 +881,23 @@ input:checked + .toggle-slider:before {
flex-direction: row;
}
.preview-area,
.preview-comparison,
.editor-container {
width: 50%;
}
}
/* Responsive: Stack preview panes on medium screens */
@media (max-width: 1200px) {
.preview-comparison {
flex-direction: column;
}
.preview-pane {
min-height: 150px;
}
}
@media (max-width: 1024px) {
.main-content {
flex-direction: column;
@@ -824,12 +921,12 @@ input:checked + .toggle-slider:before {
flex-direction: column;
}
.preview-area,
.preview-comparison,
.editor-container {
width: 100%;
}
.preview-area {
.preview-comparison {
min-height: 200px;
}

View File

@@ -1,26 +1,36 @@
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/00-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);
expect(modules.length).toBe(6);
// 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");
// HTML modules (first)
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("css-advanced-selectors");
// Tailwind
expect(moduleIds).toContain("tailwind-basics");
});
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);
});
});
});
});
@@ -29,10 +39,10 @@ describe("Lessons Config Module", () => {
// 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");
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 () => {

View File

@@ -22,33 +22,59 @@ describe("Renderer Module", () => {
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" }
{ id: "mod1", title: "Module 1", lessons: [{ title: "Lesson 1" }] },
{ id: "mod2", title: "Module 2", lessons: [{ title: "Lesson 2" }] }
];
const onSelectModule = vi.fn();
const onSelectLesson = vi.fn();
renderModuleList(container, modules, onSelectModule);
renderModuleList(container, modules, onSelectModule, onSelectLesson);
// Check if heading is created
expect(container.innerHTML).toContain("<h3>Modules</h3>");
expect(container.innerHTML).toContain("<h3>Lessons</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");
// Check if module headers are created
const moduleHeaders = container.querySelectorAll(".module-header");
expect(moduleHeaders.length).toBe(2);
// Test click event
moduleItems[0].click();
expect(onSelectModule).toHaveBeenCalledWith("mod1");
// Module titles should be in the headers
expect(moduleHeaders[0].textContent).toContain("Module 1");
expect(moduleHeaders[1].textContent).toContain("Module 2");
});
test("should handle empty module list", () => {
const container = document.getElementById("module-list");
renderModuleList(container, [], vi.fn());
renderModuleList(container, [], vi.fn(), vi.fn());
expect(container.innerHTML).toContain("<h3>Modules</h3>");
expect(container.querySelectorAll(".module-list-item").length).toBe(0);
expect(container.innerHTML).toContain("<h3>Lessons</h3>");
expect(container.querySelectorAll(".module-header").length).toBe(0);
});
test("should expand module and show lessons on click", () => {
const container = document.getElementById("module-list");
const modules = [
{ id: "mod1", title: "Module 1", lessons: [{ title: "Lesson A" }, { title: "Lesson B" }] }
];
const onSelectLesson = vi.fn();
renderModuleList(container, modules, vi.fn(), onSelectLesson);
// Lessons should be hidden initially
const lessonsContainer = container.querySelector(".lessons-container");
expect(lessonsContainer.style.display).toBe("none");
// Click module header to expand
const moduleHeader = container.querySelector(".module-header");
moduleHeader.click();
// Lessons should now be visible
expect(lessonsContainer.style.display).toBe("block");
// Click a lesson
const lessonItems = container.querySelectorAll(".lesson-list-item");
expect(lessonItems.length).toBe(2);
lessonItems[0].click();
expect(onSelectLesson).toHaveBeenCalledWith("mod1", 0);
});
});
@@ -76,9 +102,8 @@ describe("Renderer Module", () => {
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 {");
// Note: prefix/suffix elements are no longer populated by renderLesson (handled by LessonEngine)
expect(inputEl.value).toBe(" color: red;");
expect(suffixEl.textContent).toBe("}");
});
test("should handle missing lesson data with defaults", () => {
@@ -97,9 +122,7 @@ describe("Renderer Module", () => {
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("");
});
});

View File

@@ -36,7 +36,7 @@ describe("CSS Validator", () => {
const result = validateUserCode(userCode, lesson);
expect(result.isValid).toBe(true);
expect(result.message).toBe("Your code looks good!");
expect(result.message).toBe("Your CODE looks CRISPY!");
});
it('should validate "contains" rule correctly', () => {
@@ -225,3 +225,148 @@ describe("CSS Validator", () => {
});
});
});
describe("HTML Validator", () => {
describe("validateUserCode with mode: html", () => {
it("should validate element_exists correctly", () => {
const userHtml = "<p>Hello world</p>";
const lesson = {
mode: "html",
validations: [
{ type: "element_exists", value: "p", message: "Add a paragraph" }
]
};
const result = validateUserCode(userHtml, lesson);
expect(result.isValid).toBe(true);
const failResult = validateUserCode(userHtml, {
mode: "html",
validations: [
{ type: "element_exists", value: "div", message: "Add a div" }
]
});
expect(failResult.isValid).toBe(false);
expect(failResult.message).toBe("Add a div");
});
it("should validate element_count correctly", () => {
const userHtml = "<ul><li>One</li><li>Two</li><li>Three</li></ul>";
const lesson = {
mode: "html",
validations: [
{ type: "element_count", value: { selector: "li", count: 3 }, message: "Need 3 items" }
]
};
const result = validateUserCode(userHtml, lesson);
expect(result.isValid).toBe(true);
const failLesson = {
mode: "html",
validations: [
{ type: "element_count", value: { selector: "li", count: 5 }, message: "Need 5 items" }
]
};
const failResult = validateUserCode(userHtml, failLesson);
expect(failResult.isValid).toBe(false);
});
it("should validate element_count with min correctly", () => {
const userHtml = "<div><span>A</span><span>B</span></div>";
const lesson = {
mode: "html",
validations: [
{ type: "element_count", value: { selector: "span", min: 2 }, message: "Need at least 2 spans" }
]
};
const result = validateUserCode(userHtml, lesson);
expect(result.isValid).toBe(true);
});
it("should validate attribute_value correctly", () => {
const userHtml = '<input type="email" required>';
const lesson = {
mode: "html",
validations: [
{ type: "attribute_value", value: { selector: "input", attr: "type", value: "email" } }
]
};
const result = validateUserCode(userHtml, lesson);
expect(result.isValid).toBe(true);
// Test boolean attribute (required)
const boolLesson = {
mode: "html",
validations: [
{ type: "attribute_value", value: { selector: "input", attr: "required", value: true } }
]
};
const boolResult = validateUserCode(userHtml, boolLesson);
expect(boolResult.isValid).toBe(true);
});
it("should validate parent_child correctly", () => {
const userHtml = "<form><label>Name</label><input></form>";
const lesson = {
mode: "html",
validations: [
{ type: "parent_child", value: { parent: "form", child: "input" }, message: "Input should be inside form" }
]
};
const result = validateUserCode(userHtml, lesson);
expect(result.isValid).toBe(true);
const failHtml = "<label>Name</label><input>";
const failResult = validateUserCode(failHtml, lesson);
expect(failResult.isValid).toBe(false);
});
it("should validate element_text correctly", () => {
const userHtml = "<button>Submit</button>";
const lesson = {
mode: "html",
validations: [
{ type: "element_text", value: { selector: "button", text: "Submit" } }
]
};
const result = validateUserCode(userHtml, lesson);
expect(result.isValid).toBe(true);
const failLesson = {
mode: "html",
validations: [
{ type: "element_text", value: { selector: "button", text: "Cancel" }, message: "Button should say Cancel" }
]
};
const failResult = validateUserCode(userHtml, failLesson);
expect(failResult.isValid).toBe(false);
});
it("should validate contains for HTML mode", () => {
const userHtml = '<div class="container">Content</div>';
const lesson = {
mode: "html",
validations: [
{ type: "contains", value: "container" }
]
};
const result = validateUserCode(userHtml, lesson);
expect(result.isValid).toBe(true);
});
it("should pass with no validations in HTML mode", () => {
const userHtml = "<p>Hello</p>";
const lesson = { mode: "html" };
const result = validateUserCode(userHtml, lesson);
expect(result.isValid).toBe(true);
expect(result.message).toContain("No validations specified");
});
});
});