Compare commits
8 Commits
feat/impl-
...
003-flexbo
| Author | SHA1 | Date | |
|---|---|---|---|
| 61acd692f4 | |||
| 672a2d28cb | |||
| 433379155b | |||
| 756841f8c2 | |||
| c97fce1f29 | |||
| 8b6a88ad59 | |||
| 4476d26140 | |||
| f28531fb4c |
@@ -1,15 +1,6 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git add:*)",
|
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(git push:*)",
|
|
||||||
"Bash(npm run build:*)",
|
|
||||||
"Bash(grep:*)",
|
|
||||||
"Bash(npm run format.lessons:*)",
|
|
||||||
"Bash(xargs:*)",
|
|
||||||
"Bash(cat:*)",
|
|
||||||
"Bash(prettier --write:*)"
|
|
||||||
],
|
],
|
||||||
"deny": ["Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)"]
|
"deny": ["Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)"]
|
||||||
},
|
},
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -8,3 +8,14 @@ coverage
|
|||||||
|
|
||||||
# Claude Code local settings (user-specific)
|
# Claude Code local settings (user-specific)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.claude_settings.json
|
||||||
|
|
||||||
|
# Auto-Claude
|
||||||
|
.auto-claude
|
||||||
|
.worktrees
|
||||||
|
|
||||||
|
# Wave ephemeral data
|
||||||
|
.wave/workspaces
|
||||||
|
.wave/traces
|
||||||
|
.wave/artifacts
|
||||||
|
.wave/output
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"id": "flexbox-1",
|
"id": "flexbox-1",
|
||||||
"title": "Container",
|
"title": "Container",
|
||||||
"description": "Before flexbox, creating even simple layouts required floats, positioning hacks, or table-based layouts. Flexbox (Flexible Box Layout) revolutionized CSS by providing a one-dimensional layout system designed specifically for distributing space and aligning content.<br><br><strong>How it works:</strong> When you set <kbd>display: flex</kbd> on an element, it becomes a <em>flex container</em>. Its direct children automatically become <em>flex items</em> that flow along a main axis (horizontal by default). This single property transforms stacked block elements into a horizontal row.<br><br><strong>The two axes:</strong><br>• <em>Main axis</em> – The primary direction items flow (row = left→right)<br>• <em>Cross axis</em> – Perpendicular to main (row = top→bottom)<br><br><pre>.nav {\n display: flex;\n}</pre>",
|
"description": "Before flexbox, creating even simple layouts required floats, positioning hacks, or table-based layouts. Flexbox (Flexible Box Layout) revolutionized CSS by providing a one-dimensional layout system designed specifically for distributing space and aligning content.<br><br><strong>How it works:</strong> When you set <kbd>display: flex</kbd> on an element, it becomes a <em>flex container</em>. Its direct children automatically become <em>flex items</em> that flow along a main axis (horizontal by default). This single property transforms stacked block elements into a horizontal row.<br><br><strong>The two axes:</strong><br>• <em>Main axis</em> – The primary direction items flow (row = left→right)<br>• <em>Cross axis</em> – Perpendicular to main (row = top→bottom)<br><br><pre>.nav {\n display: flex;\n}</pre>",
|
||||||
"task": "This navigation menu stacks vertically. Add <kbd>display: flex</kbd> to <kbd>.nav</kbd> to arrange the links horizontally.",
|
"task": "The navigation links are stacking vertically. Make them display side by side in a horizontal row.",
|
||||||
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
|
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
{
|
{
|
||||||
"type": "property_value",
|
"type": "property_value",
|
||||||
"value": { "property": "display", "expected": "flex" },
|
"value": { "property": "display", "expected": "flex" },
|
||||||
"message": "Set <kbd>display: flex</kbd>"
|
"message": "Try changing the display mode to create a flex container"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"id": "flexbox-2",
|
"id": "flexbox-2",
|
||||||
"title": "Gap",
|
"title": "Gap",
|
||||||
"description": "The <kbd>gap</kbd> property adds consistent spacing between flex items without needing margins. It only creates space between items, not around the edges.",
|
"description": "The <kbd>gap</kbd> property adds consistent spacing between flex items without needing margins. It only creates space between items, not around the edges.",
|
||||||
"task": "Add <kbd>gap: 1rem</kbd> to space out the navigation links evenly.",
|
"task": "The navigation links are crammed together with no breathing room. Add 1rem of spacing between them.",
|
||||||
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
|
"previewHTML": "<nav class=\"nav\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a><a href=\"#\">Contact</a></nav>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; background: rgba(255,255,255,0.1); }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; background: rgba(255,255,255,0.1); }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
{
|
{
|
||||||
"type": "property_value",
|
"type": "property_value",
|
||||||
"value": { "property": "gap", "expected": "1rem" },
|
"value": { "property": "gap", "expected": "1rem" },
|
||||||
"message": "Set <kbd>gap: 1rem</kbd>"
|
"message": "Use the property that adds spacing between flex items"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"id": "flexbox-3",
|
"id": "flexbox-3",
|
||||||
"title": "Justify Content",
|
"title": "Justify Content",
|
||||||
"description": "<kbd>justify-content</kbd> distributes items along the main axis. Common values:<br>• <kbd>flex-start</kbd> – pack items at the start<br>• <kbd>flex-end</kbd> – pack at the end<br>• <kbd>center</kbd> – center items<br>• <kbd>space-between</kbd> – equal space between items<br>• <kbd>space-around</kbd> – equal space around items",
|
"description": "<kbd>justify-content</kbd> distributes items along the main axis. Common values:<br>• <kbd>flex-start</kbd> – pack items at the start<br>• <kbd>flex-end</kbd> – pack at the end<br>• <kbd>center</kbd> – center items<br>• <kbd>space-between</kbd> – equal space between items<br>• <kbd>space-around</kbd> – equal space around items",
|
||||||
"task": "Push the \"Login\" button to the right by setting <kbd>justify-content: space-between</kbd> on the nav.",
|
"task": "The Login button should sit on the far right, with the other links staying on the left. Distribute the space between them.",
|
||||||
"previewHTML": "<nav class=\"nav\"><div class=\"links\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a></div><a href=\"#\" class=\"login\">Login</a></nav>",
|
"previewHTML": "<nav class=\"nav\"><div class=\"links\"><a href=\"#\">Home</a><a href=\"#\">Products</a><a href=\"#\">About</a></div><a href=\"#\" class=\"login\">Login</a></nav>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .links { display: flex; gap: 8px; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); } .login { background: steelblue; }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .nav { background: #1a1a2e; padding: 1rem; display: flex; } .links { display: flex; gap: 8px; } .nav a { color: white; text-decoration: none; padding: 8px 1rem; border-radius: 4px; } .nav a:hover { background: rgba(255,255,255,0.1); } .login { background: steelblue; }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
{
|
{
|
||||||
"type": "property_value",
|
"type": "property_value",
|
||||||
"value": { "property": "justify-content", "expected": "space-between" },
|
"value": { "property": "justify-content", "expected": "space-between" },
|
||||||
"message": "Set <kbd>justify-content: space-between</kbd>"
|
"message": "Use the property that distributes items along the main axis"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"id": "flexbox-4",
|
"id": "flexbox-4",
|
||||||
"title": "Align Items",
|
"title": "Align Items",
|
||||||
"description": "<kbd>align-items</kbd> controls alignment on the cross axis (vertical when flex-direction is row). Values include:<br>• <kbd>stretch</kbd> – stretch to fill (default)<br>• <kbd>flex-start</kbd> – align to top<br>• <kbd>flex-end</kbd> – align to bottom<br>• <kbd>center</kbd> – center vertically",
|
"description": "<kbd>align-items</kbd> controls alignment on the cross axis (vertical when flex-direction is row). Values include:<br>• <kbd>stretch</kbd> – stretch to fill (default)<br>• <kbd>flex-start</kbd> – align to top<br>• <kbd>flex-end</kbd> – align to bottom<br>• <kbd>center</kbd> – center vertically",
|
||||||
"task": "The logo and nav links have different heights. Center them vertically with <kbd>align-items: center</kbd>.",
|
"task": "The logo and nav links sit at different heights. Center them vertically so they line up.",
|
||||||
"previewHTML": "<header class=\"header\"><div class=\"logo\">ACME</div><nav><a href=\"#\">Products</a><a href=\"#\">Pricing</a><a href=\"#\">Docs</a></nav></header>",
|
"previewHTML": "<header class=\"header\"><div class=\"logo\">ACME</div><nav><a href=\"#\">Products</a><a href=\"#\">Pricing</a><a href=\"#\">Docs</a></nav></header>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .header { background: white; padding: 1rem 2rem; display: flex; justify-content: space-between; border-bottom: 1px solid #eee; } .logo { font-size: 1.5rem; font-weight: bold; color: steelblue; } nav { display: flex; gap: 1rem; } nav a { color: #333; text-decoration: none; font-size: 0.9rem; }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; margin: 0; } .header { background: white; padding: 1rem 2rem; display: flex; justify-content: space-between; border-bottom: 1px solid #eee; } .logo { font-size: 1.5rem; font-weight: bold; color: steelblue; } nav { display: flex; gap: 1rem; } nav a { color: #333; text-decoration: none; font-size: 0.9rem; }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
{
|
{
|
||||||
"type": "property_value",
|
"type": "property_value",
|
||||||
"value": { "property": "align-items", "expected": "center" },
|
"value": { "property": "align-items", "expected": "center" },
|
||||||
"message": "Set <kbd>align-items: center</kbd>"
|
"message": "Use the property that controls cross-axis alignment"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
"id": "flexbox-5",
|
"id": "flexbox-5",
|
||||||
"title": "Flex Wrap",
|
"title": "Flex Wrap",
|
||||||
"description": "By default, flex items squeeze onto one line. <kbd>flex-wrap: wrap</kbd> allows items to flow onto multiple lines when they run out of space.",
|
"description": "By default, flex items squeeze onto one line. <kbd>flex-wrap: wrap</kbd> allows items to flow onto multiple lines when they run out of space.",
|
||||||
"task": "These cards overflow the container. Add <kbd>flex-wrap: wrap</kbd> to allow them to wrap to new rows.",
|
"task": "The cards overflow the container instead of fitting within it. Allow the items to flow onto new rows when they run out of space.",
|
||||||
"previewHTML": "<div class=\"cards\"><article class=\"card\">Card 1</article><article class=\"card\">Card 2</article><article class=\"card\">Card 3</article><article class=\"card\">Card 4</article><article class=\"card\">Card 5</article><article class=\"card\">Card 6</article></div>",
|
"previewHTML": "<div class=\"cards\"><article class=\"card\">Card 1</article><article class=\"card\">Card 2</article><article class=\"card\">Card 3</article><article class=\"card\">Card 4</article><article class=\"card\">Card 5</article><article class=\"card\">Card 6</article></div>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .cards { display: flex; gap: 1rem; } .card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 120px; text-align: center; }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; background: #f5f5f5; } .cards { display: flex; gap: 1rem; } .card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); min-width: 120px; text-align: center; }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
{
|
{
|
||||||
"type": "property_value",
|
"type": "property_value",
|
||||||
"value": { "property": "flex-wrap", "expected": "wrap" },
|
"value": { "property": "flex-wrap", "expected": "wrap" },
|
||||||
"message": "Set <kbd>flex-wrap: wrap</kbd>"
|
"message": "Use the property that allows flex items to wrap onto new lines"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
"id": "flexbox-6",
|
"id": "flexbox-6",
|
||||||
"title": "Flex Grow",
|
"title": "Flex Grow",
|
||||||
"description": "The <kbd>flex</kbd> property on items controls how they grow and shrink. <kbd>flex: 1</kbd> makes an item grow to fill available space. Multiple items with <kbd>flex: 1</kbd> share space equally.",
|
"description": "The <kbd>flex</kbd> property on items controls how they grow and shrink. <kbd>flex: 1</kbd> makes an item grow to fill available space. Multiple items with <kbd>flex: 1</kbd> share space equally.",
|
||||||
"task": "Make the search input expand to fill available space by setting <kbd>flex: 1</kbd> on <kbd>.search</kbd>.",
|
"task": "The search input is too narrow. Make it stretch to fill all the remaining space in the toolbar.",
|
||||||
"previewHTML": "<div class=\"toolbar\"><input class=\"search\" type=\"text\" placeholder=\"Search...\"><button class=\"btn\">Search</button><button class=\"btn\">Filters</button></div>",
|
"previewHTML": "<div class=\"toolbar\"><input class=\"search\" type=\"text\" placeholder=\"Search...\"><button class=\"btn\">Search</button><button class=\"btn\">Filters</button></div>",
|
||||||
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .toolbar { display: flex; gap: 8px; padding: 1rem; background: #f5f5f5; border-radius: 8px; } .search { padding: 8px 1rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; } .btn { padding: 8px 1rem; background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; }",
|
"previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .toolbar { display: flex; gap: 8px; padding: 1rem; background: #f5f5f5; border-radius: 8px; } .search { padding: 8px 1rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; } .btn { padding: 8px 1rem; background: steelblue; color: white; border: none; border-radius: 4px; cursor: pointer; }",
|
||||||
"sandboxCSS": "",
|
"sandboxCSS": "",
|
||||||
@@ -125,9 +125,9 @@
|
|||||||
"previewContainer": "preview-area",
|
"previewContainer": "preview-area",
|
||||||
"validations": [
|
"validations": [
|
||||||
{
|
{
|
||||||
"type": "property_value",
|
"type": "regex",
|
||||||
"value": { "property": "flex", "expected": "1" },
|
"value": "(flex\\s*:\\s*1|flex-grow\\s*:\\s*1)",
|
||||||
"message": "Set <kbd>flex: 1</kbd>"
|
"message": "Use the property that makes a flex item grow to fill available space"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
76
specs/003-flexbox-task-wording/plan.md
Normal file
76
specs/003-flexbox-task-wording/plan.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Rewrite all 6 flexbox lesson task descriptions to describe the desired visual outcome instead of giving the exact CSS declaration. Update validation messages to hint without revealing answers, and accept alternative valid solutions where applicable.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
This is a content-only change to a single JSON file (`lessons/flexbox.json`). Each lesson needs three edits:
|
||||||
|
|
||||||
|
1. **Task text**: Replace copy-pasteable CSS declarations with outcome-oriented descriptions
|
||||||
|
2. **Validation messages**: Replace answer-revealing messages with pedagogical hints
|
||||||
|
3. **Validations array**: Add alternative accepted solutions where multiple CSS approaches achieve the same visual result
|
||||||
|
|
||||||
|
The lesson `description` fields (which teach concepts with code examples) remain unchanged — they are the learning material, not the exercise prompt.
|
||||||
|
|
||||||
|
## File Mapping
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `lessons/flexbox.json` | modify | Rewrite `task` and validation `message` fields for all 6 lessons; add alternative validations for flexbox-6 |
|
||||||
|
|
||||||
|
No new files need to be created. No validator code changes needed — the existing `property_value` and `regex` validation types already support everything required.
|
||||||
|
|
||||||
|
## Detailed Changes Per Lesson
|
||||||
|
|
||||||
|
### flexbox-1 (Container)
|
||||||
|
- **Task**: Describe that nav links stack vertically and should display side by side
|
||||||
|
- **Validation msg**: Hint at display property for flex layout
|
||||||
|
- **Alt solutions**: None — `display: flex` is the only correct answer (inline-flex changes block behavior)
|
||||||
|
|
||||||
|
### flexbox-2 (Gap)
|
||||||
|
- **Task**: Describe that links are crammed together and need 1rem of spacing between them
|
||||||
|
- **Validation msg**: Hint at the gap property
|
||||||
|
- **Alt solutions**: None — `gap: 1rem` is the specific expected value
|
||||||
|
|
||||||
|
### flexbox-3 (Justify Content)
|
||||||
|
- **Task**: Describe that Login button should be pushed to the far right, with nav links on the left
|
||||||
|
- **Validation msg**: Hint at main-axis distribution property
|
||||||
|
- **Alt solutions**: None — `justify-content: space-between` is the only property that works when targeting `.nav`
|
||||||
|
|
||||||
|
### flexbox-4 (Align Items)
|
||||||
|
- **Task**: Describe the visual misalignment and ask for vertical centering
|
||||||
|
- **Validation msg**: Hint at cross-axis alignment property
|
||||||
|
- **Alt solutions**: None — `align-items: center` is the correct answer
|
||||||
|
|
||||||
|
### flexbox-5 (Flex Wrap)
|
||||||
|
- **Task**: Describe cards overflowing and needing to flow onto new rows
|
||||||
|
- **Validation msg**: Hint at wrapping property
|
||||||
|
- **Alt solutions**: None — `flex-wrap: wrap` is the only answer
|
||||||
|
|
||||||
|
### flexbox-6 (Flex Grow)
|
||||||
|
- **Task**: Describe that the search input should stretch to fill remaining space
|
||||||
|
- **Validation msg**: Hint at flex growth property
|
||||||
|
- **Alt solutions**: Accept both `flex: 1` and `flex-grow: 1` via regex validation
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
1. **No validator code changes**: The existing `regex` validation type can handle alternative solutions for flexbox-6. No need to add a new validation type.
|
||||||
|
2. **Keep values in tasks where needed**: Some tasks mention target values like "1rem" since the validator checks exact values and students need to know the amount. The key change is removing the *property name* from the task.
|
||||||
|
3. **Solution field unchanged**: The `solution` field is used for the "show solution" feature and should remain as the canonical answer.
|
||||||
|
4. **codePrefix unchanged**: The existing codePrefix already shows the selector context (e.g., `.nav {`), which is enough guidance for students.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|------|-----------|------------|
|
||||||
|
| Tasks become too vague for beginners | Low | Descriptions still teach the property; tasks describe specific visual outcomes |
|
||||||
|
| Alternative regex validation too permissive | Low | Regex will be specific to `flex:\s*1` and `flex-grow:\s*1` patterns |
|
||||||
|
| Validation messages too cryptic | Low | Messages will hint at the property category without giving the exact declaration |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. **Run existing test suite**: `npm run test` — all tests should pass since no code or module structure changes
|
||||||
|
2. **Manual verification**: Validate that each rewritten task accurately describes the visual outcome shown in the preview
|
||||||
|
3. **JSON schema validation**: Ensure `lessons/flexbox.json` still conforms to the module schema
|
||||||
35
specs/003-flexbox-task-wording/spec.md
Normal file
35
specs/003-flexbox-task-wording/spec.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# fix: remove answers from flexbox task descriptions (copy-paste score 95%)
|
||||||
|
|
||||||
|
**Issue**: [libretech/code-crispies#3](https://git.librete.ch/libretech/code-crispies/issues/3)
|
||||||
|
**State**: open
|
||||||
|
**Author**: libretech
|
||||||
|
**Labels**: none
|
||||||
|
**Complexity**: simple
|
||||||
|
|
||||||
|
## Issue Body
|
||||||
|
|
||||||
|
Pedagogy audit: All 6 flexbox exercises give the exact CSS declaration in the task text. Students type without understanding. Rewrite tasks to describe the DESIRED OUTCOME instead of the exact code. Example: 'Add display: flex' → 'The navigation links stack vertically. Make them display side by side.' Accept multiple valid solutions in validations.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
All 6 lessons in `lessons/flexbox.json` have task descriptions that include the exact CSS declaration students need to type:
|
||||||
|
|
||||||
|
| Lesson | Current Task (gives away answer) |
|
||||||
|
|--------|----------------------------------|
|
||||||
|
| flexbox-1 | "Add `display: flex` to `.nav`" |
|
||||||
|
| flexbox-2 | "Add `gap: 1rem` to space out..." |
|
||||||
|
| flexbox-3 | "setting `justify-content: space-between` on the nav" |
|
||||||
|
| flexbox-4 | "Center them vertically with `align-items: center`" |
|
||||||
|
| flexbox-5 | "Add `flex-wrap: wrap` to allow them to wrap" |
|
||||||
|
| flexbox-6 | "setting `flex: 1` on `.search`" |
|
||||||
|
|
||||||
|
Validation error messages also give away answers (e.g., "Set `display: flex`").
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. All 6 flexbox task descriptions rewritten to describe the desired visual outcome, not the exact CSS code
|
||||||
|
2. Students cannot copy-paste from the task into the editor to pass
|
||||||
|
3. Validation error messages updated to provide hints without revealing the exact declaration
|
||||||
|
4. Where applicable, validations accept multiple valid CSS solutions (e.g., `flex: 1` and `flex-grow: 1`)
|
||||||
|
5. Existing tests continue to pass
|
||||||
|
6. Lesson descriptions (which teach the concepts) remain unchanged
|
||||||
13
specs/003-flexbox-task-wording/tasks.md
Normal file
13
specs/003-flexbox-task-wording/tasks.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## Phase 1: Core Content Changes
|
||||||
|
- [X] Task 1.1: Rewrite task text for all 6 flexbox lessons to describe visual outcomes [P]
|
||||||
|
- [X] Task 1.2: Rewrite validation error messages to hint without revealing answers [P]
|
||||||
|
|
||||||
|
## Phase 2: Alternative Validations
|
||||||
|
- [X] Task 2.1: Add regex validation for flexbox-6 to accept both `flex: 1` and `flex-grow: 1`
|
||||||
|
|
||||||
|
## Phase 3: Validation
|
||||||
|
- [X] Task 3.1: Run existing test suite to confirm no regressions
|
||||||
|
- [X] Task 3.2: Verify flexbox.json still conforms to module schema
|
||||||
|
- [X] Task 3.3: Run lesson format check (`npm run format.lessons`)
|
||||||
@@ -13,7 +13,7 @@ import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-pl
|
|||||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||||
import { tags } from "@lezer/highlight";
|
import { tags } from "@lezer/highlight";
|
||||||
|
|
||||||
// Custom theme with purple accent colors (matching app completed state)
|
// Custom theme with pink accent colors (matching app completed state)
|
||||||
const crispyTheme = EditorView.theme(
|
const crispyTheme = EditorView.theme(
|
||||||
{
|
{
|
||||||
"&": {
|
"&": {
|
||||||
@@ -21,10 +21,10 @@ const crispyTheme = EditorView.theme(
|
|||||||
color: "#c8c8d0"
|
color: "#c8c8d0"
|
||||||
},
|
},
|
||||||
".cm-content": {
|
".cm-content": {
|
||||||
caretColor: "#9b6dd4"
|
caretColor: "#d46d9b"
|
||||||
},
|
},
|
||||||
".cm-cursor, .cm-dropCursor": {
|
".cm-cursor, .cm-dropCursor": {
|
||||||
borderLeftColor: "#9b6dd4"
|
borderLeftColor: "#d46d9b"
|
||||||
},
|
},
|
||||||
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||||
backgroundColor: "#3e3e4a"
|
backgroundColor: "#3e3e4a"
|
||||||
@@ -35,10 +35,10 @@ const crispyTheme = EditorView.theme(
|
|||||||
},
|
},
|
||||||
".cm-searchMatch": {
|
".cm-searchMatch": {
|
||||||
backgroundColor: "#3e3e4a",
|
backgroundColor: "#3e3e4a",
|
||||||
outline: "1px solid #9b6dd4"
|
outline: "1px solid #d46d9b"
|
||||||
},
|
},
|
||||||
".cm-searchMatch.cm-searchMatch-selected": {
|
".cm-searchMatch.cm-searchMatch-selected": {
|
||||||
backgroundColor: "rgba(155, 109, 212, 0.3)"
|
backgroundColor: "rgba(212, 109, 155, 0.3)"
|
||||||
},
|
},
|
||||||
".cm-activeLine": {
|
".cm-activeLine": {
|
||||||
backgroundColor: "#2e2e3a"
|
backgroundColor: "#2e2e3a"
|
||||||
@@ -63,13 +63,13 @@ const crispyTheme = EditorView.theme(
|
|||||||
|
|
||||||
// Default syntax highlighting (blue accent)
|
// Default syntax highlighting (blue accent)
|
||||||
const defaultHighlight = HighlightStyle.define([
|
const defaultHighlight = HighlightStyle.define([
|
||||||
{ tag: tags.keyword, color: "#c9a6eb" },
|
{ tag: tags.keyword, color: "#eba6c9" },
|
||||||
{ tag: tags.operator, color: "#cdd6f4" },
|
{ tag: tags.operator, color: "#cdd6f4" },
|
||||||
{ tag: tags.variableName, color: "#89b4fa" },
|
{ tag: tags.variableName, color: "#89b4fa" },
|
||||||
{ tag: tags.propertyName, color: "#89b4fa" },
|
{ tag: tags.propertyName, color: "#89b4fa" },
|
||||||
{ tag: tags.attributeName, color: "#89b4fa" },
|
{ tag: tags.attributeName, color: "#89b4fa" },
|
||||||
{ tag: tags.className, color: "#89b4fa" },
|
{ tag: tags.className, color: "#89b4fa" },
|
||||||
{ tag: tags.tagName, color: "#c9a6eb" },
|
{ tag: tags.tagName, color: "#eba6c9" },
|
||||||
{ tag: tags.string, color: "#a6e3a1" },
|
{ tag: tags.string, color: "#a6e3a1" },
|
||||||
{ tag: tags.number, color: "#fab387" },
|
{ tag: tags.number, color: "#fab387" },
|
||||||
{ tag: tags.bool, color: "#fab387" },
|
{ tag: tags.bool, color: "#fab387" },
|
||||||
@@ -79,20 +79,20 @@ const defaultHighlight = HighlightStyle.define([
|
|||||||
{ tag: tags.punctuation, color: "#cdd6f4" },
|
{ tag: tags.punctuation, color: "#cdd6f4" },
|
||||||
{ tag: tags.definition(tags.variableName), color: "#89b4fa" },
|
{ tag: tags.definition(tags.variableName), color: "#89b4fa" },
|
||||||
{ tag: tags.function(tags.variableName), color: "#89b4fa" },
|
{ tag: tags.function(tags.variableName), color: "#89b4fa" },
|
||||||
{ tag: tags.atom, color: "#c9a6eb" },
|
{ tag: tags.atom, color: "#eba6c9" },
|
||||||
{ tag: tags.unit, color: "#a6e3a1" },
|
{ tag: tags.unit, color: "#a6e3a1" },
|
||||||
{ tag: tags.color, color: "#f9e2af" }
|
{ tag: tags.color, color: "#f9e2af" }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// CSS section highlighting (purple selectors)
|
// CSS section highlighting (pink selectors)
|
||||||
const cssHighlight = HighlightStyle.define([
|
const cssHighlight = HighlightStyle.define([
|
||||||
{ tag: tags.keyword, color: "#c9a6eb" },
|
{ tag: tags.keyword, color: "#eba6c9" },
|
||||||
{ tag: tags.operator, color: "#cdd6f4" },
|
{ tag: tags.operator, color: "#cdd6f4" },
|
||||||
{ tag: tags.variableName, color: "#c9a6eb" },
|
{ tag: tags.variableName, color: "#eba6c9" },
|
||||||
{ tag: tags.propertyName, color: "#89b4fa" },
|
{ tag: tags.propertyName, color: "#89b4fa" },
|
||||||
{ tag: tags.attributeName, color: "#89b4fa" },
|
{ tag: tags.attributeName, color: "#89b4fa" },
|
||||||
{ tag: tags.className, color: "#c9a6eb" },
|
{ tag: tags.className, color: "#eba6c9" },
|
||||||
{ tag: tags.tagName, color: "#c9a6eb" },
|
{ tag: tags.tagName, color: "#eba6c9" },
|
||||||
{ tag: tags.string, color: "#a6e3a1" },
|
{ tag: tags.string, color: "#a6e3a1" },
|
||||||
{ tag: tags.number, color: "#fab387" },
|
{ tag: tags.number, color: "#fab387" },
|
||||||
{ tag: tags.bool, color: "#fab387" },
|
{ tag: tags.bool, color: "#fab387" },
|
||||||
@@ -100,9 +100,9 @@ const cssHighlight = HighlightStyle.define([
|
|||||||
{ tag: tags.comment, color: "#6c7086", fontStyle: "italic" },
|
{ tag: tags.comment, color: "#6c7086", fontStyle: "italic" },
|
||||||
{ tag: tags.bracket, color: "#cdd6f4" },
|
{ tag: tags.bracket, color: "#cdd6f4" },
|
||||||
{ tag: tags.punctuation, color: "#cdd6f4" },
|
{ tag: tags.punctuation, color: "#cdd6f4" },
|
||||||
{ tag: tags.definition(tags.variableName), color: "#c9a6eb" },
|
{ tag: tags.definition(tags.variableName), color: "#eba6c9" },
|
||||||
{ tag: tags.function(tags.variableName), color: "#89b4fa" },
|
{ tag: tags.function(tags.variableName), color: "#89b4fa" },
|
||||||
{ tag: tags.atom, color: "#c9a6eb" },
|
{ tag: tags.atom, color: "#eba6c9" },
|
||||||
{ tag: tags.unit, color: "#a6e3a1" },
|
{ tag: tags.unit, color: "#a6e3a1" },
|
||||||
{ tag: tags.color, color: "#f9e2af" }
|
{ tag: tags.color, color: "#f9e2af" }
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -216,18 +216,18 @@ export class LessonEngine {
|
|||||||
iframe.style.height = "100%";
|
iframe.style.height = "100%";
|
||||||
iframe.style.border = "none";
|
iframe.style.border = "none";
|
||||||
iframe.title = "Preview";
|
iframe.title = "Preview";
|
||||||
|
iframe.setAttribute("sandbox", "allow-scripts");
|
||||||
|
|
||||||
const container = document.getElementById(previewContainer || "preview-area");
|
const container = document.getElementById(previewContainer || "preview-area");
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
container.appendChild(iframe);
|
container.appendChild(iframe);
|
||||||
|
|
||||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
let html;
|
||||||
iframeDoc.open();
|
|
||||||
|
|
||||||
if (mode === "html" || mode === "playground") {
|
if (mode === "html" || mode === "playground") {
|
||||||
// For HTML/playground mode, user code IS the HTML content (may include <style> blocks)
|
// For HTML/playground mode, user code IS the HTML content (may include <style> blocks)
|
||||||
const userHtml = this.userCode || "";
|
const userHtml = this.userCode || "";
|
||||||
iframeDoc.write(`
|
html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -239,11 +239,11 @@ export class LessonEngine {
|
|||||||
${userHtml}
|
${userHtml}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
} else if (mode === "tailwind") {
|
} else if (mode === "tailwind") {
|
||||||
// For Tailwind mode, user code goes directly in HTML classes
|
// For Tailwind mode, user code goes directly in HTML classes
|
||||||
const htmlWithClasses = this.injectTailwindClasses(previewHTML, this.userCode);
|
const htmlWithClasses = this.injectTailwindClasses(previewHTML, this.userCode);
|
||||||
iframeDoc.write(`
|
html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -256,11 +256,11 @@ export class LessonEngine {
|
|||||||
${htmlWithClasses}
|
${htmlWithClasses}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
} else if (mode === "markdown") {
|
} else if (mode === "markdown") {
|
||||||
// For Markdown mode, parse user code to HTML
|
// For Markdown mode, parse user code to HTML
|
||||||
const renderedHtml = marked.parse(this.userCode || "");
|
const renderedHtml = marked.parse(this.userCode || "");
|
||||||
iframeDoc.write(`
|
html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -288,11 +288,11 @@ export class LessonEngine {
|
|||||||
${renderedHtml}
|
${renderedHtml}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
} else {
|
} else {
|
||||||
// Original CSS mode
|
// Original CSS mode
|
||||||
const userCssWithWrapper = this.getCompleteCss();
|
const userCssWithWrapper = this.getCompleteCss();
|
||||||
iframeDoc.write(`
|
html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -305,10 +305,10 @@ export class LessonEngine {
|
|||||||
${previewHTML}
|
${previewHTML}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
iframeDoc.close();
|
iframe.srcdoc = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
injectTailwindClasses(html, userClasses) {
|
injectTailwindClasses(html, userClasses) {
|
||||||
@@ -341,6 +341,7 @@ export class LessonEngine {
|
|||||||
iframe.style.height = "100%";
|
iframe.style.height = "100%";
|
||||||
iframe.style.border = "none";
|
iframe.style.border = "none";
|
||||||
iframe.title = "Expected Result";
|
iframe.title = "Expected Result";
|
||||||
|
iframe.setAttribute("sandbox", "allow-scripts");
|
||||||
|
|
||||||
const container = document.getElementById("preview-expected");
|
const container = document.getElementById("preview-expected");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -348,12 +349,11 @@ export class LessonEngine {
|
|||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
container.appendChild(iframe);
|
container.appendChild(iframe);
|
||||||
|
|
||||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
let html;
|
||||||
iframeDoc.open();
|
|
||||||
|
|
||||||
if (mode === "html" || mode === "playground") {
|
if (mode === "html" || mode === "playground") {
|
||||||
// For HTML/playground mode, solution code IS the HTML content
|
// For HTML/playground mode, solution code IS the HTML content
|
||||||
iframeDoc.write(`
|
html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -365,11 +365,11 @@ export class LessonEngine {
|
|||||||
${solutionCode}
|
${solutionCode}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
} else if (mode === "tailwind") {
|
} else if (mode === "tailwind") {
|
||||||
// For Tailwind mode, inject solution classes into HTML
|
// For Tailwind mode, inject solution classes into HTML
|
||||||
const htmlWithClasses = this.injectTailwindClasses(previewHTML, solutionCode);
|
const htmlWithClasses = this.injectTailwindClasses(previewHTML, solutionCode);
|
||||||
iframeDoc.write(`
|
html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -382,11 +382,11 @@ export class LessonEngine {
|
|||||||
${htmlWithClasses}
|
${htmlWithClasses}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
} else if (mode === "markdown") {
|
} else if (mode === "markdown") {
|
||||||
// For Markdown mode, parse solution to HTML
|
// For Markdown mode, parse solution to HTML
|
||||||
const renderedHtml = marked.parse(solutionCode || "");
|
const renderedHtml = marked.parse(solutionCode || "");
|
||||||
iframeDoc.write(`
|
html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -414,12 +414,12 @@ export class LessonEngine {
|
|||||||
${renderedHtml}
|
${renderedHtml}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
} else {
|
} else {
|
||||||
// CSS mode - wrap solution with prefix/suffix
|
// CSS mode - wrap solution with prefix/suffix
|
||||||
const { codePrefix, codeSuffix } = this.currentLesson;
|
const { codePrefix, codeSuffix } = this.currentLesson;
|
||||||
const solutionCss = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
|
const solutionCss = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`;
|
||||||
iframeDoc.write(`
|
html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -432,10 +432,10 @@ export class LessonEngine {
|
|||||||
${previewHTML}
|
${previewHTML}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
iframeDoc.close();
|
iframe.srcdoc = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="./favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="./favicon.ico" type="image/x-icon" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://librete.ch https://liberapay.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.supabase.co wss://*.supabase.co; img-src 'self' https://liberapay.com data:; font-src 'self'; frame-src 'self' blob:" />
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<title>CODE CRISPIES - Learn HTML & CSS Interactively | Free Coding Practice</title>
|
<title>CODE CRISPIES - Learn HTML & CSS Interactively | Free Coding Practice</title>
|
||||||
|
|||||||
98
src/main.css
98
src/main.css
@@ -1,15 +1,15 @@
|
|||||||
/* ================= BASE THEME ================= */
|
/* ================= BASE THEME ================= */
|
||||||
:root {
|
:root {
|
||||||
/* Primary colors */
|
/* Primary colors */
|
||||||
--primary-color: #5e4b8b;
|
--primary-color: #c9507a;
|
||||||
--primary-light: #8a77b5;
|
--primary-light: #e077a0;
|
||||||
--primary-dark: #724a95;
|
--primary-dark: #a83d65;
|
||||||
|
|
||||||
/* Section colors (default to CSS purple) */
|
/* Section colors (default to CSS pink) */
|
||||||
--section-color: #9163b8;
|
--section-color: #d95a8a;
|
||||||
--section-color-light: #a87dc8;
|
--section-color-light: #e87da6;
|
||||||
--section-color-dark: #724a95;
|
--section-color-dark: #b84472;
|
||||||
--section-color-rgb: 145, 99, 184;
|
--section-color-rgb: 217, 90, 138;
|
||||||
|
|
||||||
/* Secondary colors */
|
/* Secondary colors */
|
||||||
--secondary-color: #444444;
|
--secondary-color: #444444;
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
--white-text: #ffffff;
|
--white-text: #ffffff;
|
||||||
|
|
||||||
/* Background colors */
|
/* Background colors */
|
||||||
--bg-color: #f8f7fc;
|
--bg-color: #fcf7f9;
|
||||||
--panel-bg: #ffffff;
|
--panel-bg: #ffffff;
|
||||||
--code-bg: #f7f5fa;
|
--code-bg: #faf5f7;
|
||||||
--editor-bg: #1e1e1e;
|
--editor-bg: #1e1e1e;
|
||||||
--editor-highlight: #303030;
|
--editor-highlight: #303030;
|
||||||
|
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
|
|
||||||
/* Status colors */
|
/* Status colors */
|
||||||
--info-color: #7a93fe;
|
--info-color: #7a93fe;
|
||||||
--success-color: #9b6dd4;
|
--success-color: #d46d9b;
|
||||||
--success-color-dark: #7c4dff;
|
--success-color-dark: #b84472;
|
||||||
--success-color-light: #c9b8e8;
|
--success-color-light: #e8b8d0;
|
||||||
--error-color: #cb6e75;
|
--error-color: #cb6e75;
|
||||||
--danger-color: #dc3545;
|
--danger-color: #dc3545;
|
||||||
|
|
||||||
@@ -252,11 +252,11 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo h1 .code-text {
|
.logo h1 .code-text {
|
||||||
color: #9163b8;
|
color: #d95a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo h1 .crispies-text {
|
.logo h1 .crispies-text {
|
||||||
background: #9163b8;
|
background: #d95a8a;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.15rem 0.35rem;
|
padding: 0.15rem 0.35rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -468,7 +468,7 @@ kbd {
|
|||||||
.completion-badge {
|
.completion-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff);
|
background: linear-gradient(135deg, #d95a8a, #d45aa0, #1aafb8, #ff4d88);
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -714,7 +714,7 @@ kbd {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: var(--spacing-md);
|
inset: var(--spacing-md);
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
background: conic-gradient(from var(--border-angle), #9163b8, #d45aa0, #1aafb8, #7c4dff, #9163b8);
|
background: conic-gradient(from var(--border-angle), #d95a8a, #d45aa0, #1aafb8, #ff4d88, #d95a8a);
|
||||||
filter: blur(30px);
|
filter: blur(30px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: spin-glow 3s ease-out forwards;
|
animation: spin-glow 3s ease-out forwards;
|
||||||
@@ -727,7 +727,7 @@ kbd {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: var(--spacing-md);
|
inset: var(--spacing-md);
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
background: conic-gradient(from 0deg, #9163b8, #d45aa0, #1aafb8, #7c4dff, #9163b8);
|
background: conic-gradient(from 0deg, #d95a8a, #d45aa0, #1aafb8, #ff4d88, #d95a8a);
|
||||||
filter: blur(30px);
|
filter: blur(30px);
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -816,7 +816,7 @@ kbd {
|
|||||||
border: 6px solid transparent;
|
border: 6px solid transparent;
|
||||||
background:
|
background:
|
||||||
linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box,
|
linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box,
|
||||||
conic-gradient(from 0deg, #9163b8, #d45aa0, #1aafb8, #7c4dff, #9163b8) border-box;
|
conic-gradient(from 0deg, #d95a8a, #d45aa0, #1aafb8, #ff4d88, #d95a8a) border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-wrapper.matched {
|
.preview-wrapper.matched {
|
||||||
@@ -824,7 +824,7 @@ kbd {
|
|||||||
border: 6px solid transparent;
|
border: 6px solid transparent;
|
||||||
background:
|
background:
|
||||||
linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box,
|
linear-gradient(var(--panel-bg), var(--panel-bg)) padding-box,
|
||||||
conic-gradient(from var(--border-angle), #9163b8, #d45aa0, #1aafb8, #7c4dff, #9163b8) border-box;
|
conic-gradient(from var(--border-angle), #d95a8a, #d45aa0, #1aafb8, #ff4d88, #d95a8a) border-box;
|
||||||
animation: spin-border 3s ease-out forwards;
|
animation: spin-border 3s ease-out forwards;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
@@ -844,7 +844,7 @@ kbd {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: white;
|
color: white;
|
||||||
background: linear-gradient(135deg, #9163b8 0%, #d45aa0 50%, #7c4dff 100%);
|
background: linear-gradient(135deg, #d95a8a 0%, #d45aa0 50%, #ff4d88 100%);
|
||||||
padding: 1.25rem 2rem 1.75rem;
|
padding: 1.25rem 2rem 1.75rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -1142,7 +1142,7 @@ nav.sidebar-section:not(.sidebar-nav-mobile) {
|
|||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #9163b8, #d45aa0, #1aafb8, #7c4dff);
|
background: linear-gradient(90deg, #d95a8a, #d45aa0, #1aafb8, #ff4d88);
|
||||||
background-size: calc(100% * 100 / var(--progress-percent, 100)) 100%;
|
background-size: calc(100% * 100 / var(--progress-percent, 100)) 100%;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
@@ -1206,7 +1206,7 @@ nav.sidebar-section:not(.sidebar-nav-mobile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Each milestone gets a color evenly distributed across the gradient
|
/* Each milestone gets a color evenly distributed across the gradient
|
||||||
Gradient: #9163b8 (0%) → #d45aa0 (33%) → #1aafb8 (67%) → #7c4dff (100%) */
|
Gradient: #d95a8a (0%) → #d45aa0 (33%) → #1aafb8 (67%) → #ff4d88 (100%) */
|
||||||
.milestone.reached:nth-child(1) { background: #a55eac; } /* ~14% */
|
.milestone.reached:nth-child(1) { background: #a55eac; } /* ~14% */
|
||||||
.milestone.reached:nth-child(2) { background: #c459a2; } /* ~28% */
|
.milestone.reached:nth-child(2) { background: #c459a2; } /* ~28% */
|
||||||
.milestone.reached:nth-child(3) { background: #d45aa0; } /* ~33% pink */
|
.milestone.reached:nth-child(3) { background: #d45aa0; } /* ~33% pink */
|
||||||
@@ -1214,12 +1214,12 @@ nav.sidebar-section:not(.sidebar-nav-mobile) {
|
|||||||
.milestone.reached:nth-child(5) { background: #7785ac; } /* ~50% */
|
.milestone.reached:nth-child(5) { background: #7785ac; } /* ~50% */
|
||||||
.milestone.reached:nth-child(6) { background: #33a3b6; } /* ~62% */
|
.milestone.reached:nth-child(6) { background: #33a3b6; } /* ~62% */
|
||||||
.milestone.reached:nth-child(7) { background: #4889d8; } /* ~80% */
|
.milestone.reached:nth-child(7) { background: #4889d8; } /* ~80% */
|
||||||
.milestone.reached:nth-child(8) { background: #7c4dff; } /* 100% */
|
.milestone.reached:nth-child(8) { background: #ff4d88; } /* 100% */
|
||||||
|
|
||||||
.milestone.current {
|
.milestone.current {
|
||||||
color: white;
|
color: white;
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
box-shadow: 0 2px 8px rgba(145, 99, 184, 0.4);
|
box-shadow: 0 2px 8px rgba(217, 90, 138, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.milestone.next {
|
.milestone.next {
|
||||||
@@ -2590,7 +2590,7 @@ input:checked + .toggle-slider::before {
|
|||||||
margin-top: var(--spacing-lg);
|
margin-top: var(--spacing-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: linear-gradient(135deg, rgba(145, 99, 184, 0.1), rgba(212, 90, 160, 0.1), rgba(26, 175, 184, 0.1));
|
background: linear-gradient(135deg, rgba(217, 90, 138, 0.1), rgba(212, 90, 160, 0.1), rgba(26, 175, 184, 0.1));
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
color: var(--light-text);
|
color: var(--light-text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -2840,7 +2840,7 @@ input:checked + .toggle-slider::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-overview code {
|
.section-overview code {
|
||||||
background: rgba(var(--section-color-rgb, 145, 99, 184), 0.1);
|
background: rgba(var(--section-color-rgb, 217, 90, 138), 0.1);
|
||||||
color: var(--section-color-dark, var(--primary-dark));
|
color: var(--section-color-dark, var(--primary-dark));
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -2950,7 +2950,7 @@ input:checked + .toggle-slider::before {
|
|||||||
|
|
||||||
/* Inline code in topic text */
|
/* Inline code in topic text */
|
||||||
.topic-text code {
|
.topic-text code {
|
||||||
background: rgba(var(--section-color-rgb, 145, 99, 184), 0.1);
|
background: rgba(var(--section-color-rgb, 217, 90, 138), 0.1);
|
||||||
color: var(--section-color-dark, var(--primary-dark));
|
color: var(--section-color-dark, var(--primary-dark));
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -3592,7 +3592,7 @@ input:checked + .toggle-slider::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ================= SECTION COLOR CODING ================= */
|
/* ================= SECTION COLOR CODING ================= */
|
||||||
/* CSS Section uses default purple from :root */
|
/* CSS Section uses default pink from :root */
|
||||||
|
|
||||||
/* HTML Section - Pink (balanced) */
|
/* HTML Section - Pink (balanced) */
|
||||||
[data-section="html"] {
|
[data-section="html"] {
|
||||||
@@ -3620,7 +3620,7 @@ input:checked + .toggle-slider::before {
|
|||||||
|
|
||||||
/* Apply section colors to nav links */
|
/* Apply section colors to nav links */
|
||||||
.nav-link[data-section="css"] {
|
.nav-link[data-section="css"] {
|
||||||
color: #9163b8;
|
color: #d95a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link[data-section="html"] {
|
.nav-link[data-section="html"] {
|
||||||
@@ -3637,8 +3637,8 @@ input:checked + .toggle-slider::before {
|
|||||||
|
|
||||||
.nav-link[data-section="css"]:hover,
|
.nav-link[data-section="css"]:hover,
|
||||||
.nav-link[data-section="css"].active {
|
.nav-link[data-section="css"].active {
|
||||||
background: rgba(145, 99, 184, 0.1);
|
background: rgba(217, 90, 138, 0.1);
|
||||||
color: #724a95;
|
color: #a83d65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link[data-section="html"]:hover,
|
.nav-link[data-section="html"]:hover,
|
||||||
@@ -3661,12 +3661,12 @@ input:checked + .toggle-slider::before {
|
|||||||
|
|
||||||
/* Hint section colors */
|
/* Hint section colors */
|
||||||
body[data-section="css"] .hint {
|
body[data-section="css"] .hint {
|
||||||
background: rgba(145, 99, 184, 0.3);
|
background: rgba(217, 90, 138, 0.3);
|
||||||
border-left-color: #a98cd6;
|
border-left-color: #a98cd6;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="css"] .hint-progress {
|
body[data-section="css"] .hint-progress {
|
||||||
background: #9163b8;
|
background: #d95a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="html"] .hint {
|
body[data-section="html"] .hint {
|
||||||
@@ -3718,7 +3718,7 @@ body[data-section="markdown"] .hint-progress {
|
|||||||
.ref-nav-link[data-ref="selectors"],
|
.ref-nav-link[data-ref="selectors"],
|
||||||
.ref-nav-link[data-ref="flexbox"],
|
.ref-nav-link[data-ref="flexbox"],
|
||||||
.ref-nav-link[data-ref="grid"] {
|
.ref-nav-link[data-ref="grid"] {
|
||||||
color: #9163b8;
|
color: #d95a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ref-nav-link[data-ref="css"]:hover,
|
.ref-nav-link[data-ref="css"]:hover,
|
||||||
@@ -3729,8 +3729,8 @@ body[data-section="markdown"] .hint-progress {
|
|||||||
.ref-nav-link[data-ref="flexbox"].active,
|
.ref-nav-link[data-ref="flexbox"].active,
|
||||||
.ref-nav-link[data-ref="grid"]:hover,
|
.ref-nav-link[data-ref="grid"]:hover,
|
||||||
.ref-nav-link[data-ref="grid"].active {
|
.ref-nav-link[data-ref="grid"].active {
|
||||||
background: rgba(145, 99, 184, 0.15);
|
background: rgba(217, 90, 138, 0.15);
|
||||||
color: #724a95;
|
color: #a83d65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ref-nav-link[data-ref="html"] {
|
.ref-nav-link[data-ref="html"] {
|
||||||
@@ -3745,21 +3745,21 @@ body[data-section="markdown"] .hint-progress {
|
|||||||
|
|
||||||
/* CodeMirror section color overrides */
|
/* CodeMirror section color overrides */
|
||||||
body[data-section="css"] .cm-editor .cm-content {
|
body[data-section="css"] .cm-editor .cm-content {
|
||||||
caret-color: #9163b8 !important;
|
caret-color: #d95a8a !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="css"] .cm-editor .cm-cursor,
|
body[data-section="css"] .cm-editor .cm-cursor,
|
||||||
body[data-section="css"] .cm-editor .cm-dropCursor {
|
body[data-section="css"] .cm-editor .cm-dropCursor {
|
||||||
border-left-color: #9163b8 !important;
|
border-left-color: #d95a8a !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="css"] .cm-editor .cm-selectionBackground,
|
body[data-section="css"] .cm-editor .cm-selectionBackground,
|
||||||
body[data-section="css"] .cm-editor .cm-content ::selection {
|
body[data-section="css"] .cm-editor .cm-content ::selection {
|
||||||
background-color: rgba(145, 99, 184, 0.25) !important;
|
background-color: rgba(217, 90, 138, 0.25) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="css"] .cm-editor .cm-activeLine {
|
body[data-section="css"] .cm-editor .cm-activeLine {
|
||||||
background-color: rgba(145, 99, 184, 0.08) !important;
|
background-color: rgba(217, 90, 138, 0.08) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="html"] .cm-editor .cm-content {
|
body[data-section="html"] .cm-editor .cm-content {
|
||||||
@@ -3818,12 +3818,12 @@ body[data-section="markdown"] .cm-editor .cm-activeLine {
|
|||||||
|
|
||||||
/* Module pill section colors */
|
/* Module pill section colors */
|
||||||
body[data-section="css"] .module-pill {
|
body[data-section="css"] .module-pill {
|
||||||
background: rgba(145, 99, 184, 0.1);
|
background: rgba(217, 90, 138, 0.1);
|
||||||
color: #9163b8;
|
color: #d95a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="css"] .module-pill .level-indicator {
|
body[data-section="css"] .module-pill .level-indicator {
|
||||||
color: #724a95;
|
color: #a83d65;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="html"] .module-pill {
|
body[data-section="html"] .module-pill {
|
||||||
@@ -3855,7 +3855,7 @@ body[data-section="markdown"] .module-pill .level-indicator {
|
|||||||
|
|
||||||
/* Code block border section colors */
|
/* Code block border section colors */
|
||||||
body[data-section="css"] .code-block {
|
body[data-section="css"] .code-block {
|
||||||
border-color: rgba(145, 99, 184, 0.4);
|
border-color: rgba(217, 90, 138, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="html"] .code-block {
|
body[data-section="html"] .code-block {
|
||||||
@@ -3889,7 +3889,7 @@ body[data-section="markdown"] .code-block .cm-editor .cm-line {
|
|||||||
|
|
||||||
/* Task instruction bubble section colors */
|
/* Task instruction bubble section colors */
|
||||||
[data-section="css"] .task-instruction {
|
[data-section="css"] .task-instruction {
|
||||||
background: rgba(145, 99, 184, 0.92);
|
background: rgba(217, 90, 138, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-section="html"] .task-instruction {
|
[data-section="html"] .task-instruction {
|
||||||
@@ -3906,7 +3906,7 @@ body[data-section="markdown"] .code-block .cm-editor .cm-line {
|
|||||||
|
|
||||||
/* Section page progress bar colors */
|
/* Section page progress bar colors */
|
||||||
body[data-section="css"] .section-progress-bar .progress-fill {
|
body[data-section="css"] .section-progress-bar .progress-fill {
|
||||||
background: #9163b8;
|
background: #d95a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="html"] .section-progress-bar .progress-fill {
|
body[data-section="html"] .section-progress-bar .progress-fill {
|
||||||
@@ -3923,7 +3923,7 @@ body[data-section="markdown"] .section-progress-bar .progress-fill {
|
|||||||
|
|
||||||
/* Section page header colors */
|
/* Section page header colors */
|
||||||
[data-section="css"] .section-hero h1 {
|
[data-section="css"] .section-hero h1 {
|
||||||
color: #9163b8;
|
color: #d95a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-section="html"] .section-hero h1 {
|
[data-section="html"] .section-hero h1 {
|
||||||
@@ -3940,7 +3940,7 @@ body[data-section="markdown"] .section-progress-bar .progress-fill {
|
|||||||
|
|
||||||
/* Lesson title h2 section colors */
|
/* Lesson title h2 section colors */
|
||||||
body[data-section="css"] #lesson-title {
|
body[data-section="css"] #lesson-title {
|
||||||
color: #9163b8;
|
color: #d95a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-section="html"] #lesson-title {
|
body[data-section="html"] #lesson-title {
|
||||||
|
|||||||
538
tests/unit/renderer-extended.test.js
Normal file
538
tests/unit/renderer-extended.test.js
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
renderModuleList,
|
||||||
|
renderLesson,
|
||||||
|
renderLevelIndicator,
|
||||||
|
renderDifficultyBadge,
|
||||||
|
showFeedback,
|
||||||
|
clearFeedback,
|
||||||
|
updateActiveLessonInSidebar,
|
||||||
|
computeLessonDifficulty
|
||||||
|
} from "../../src/helpers/renderer.js";
|
||||||
|
|
||||||
|
// Mock i18n
|
||||||
|
vi.mock("../../src/i18n.js", () => ({
|
||||||
|
t: (key, params = {}) => {
|
||||||
|
const translations = {
|
||||||
|
lessonLabel: "Lesson",
|
||||||
|
untitledLesson: "Untitled Lesson",
|
||||||
|
lessonFallback: `Lesson ${params.index || ""}`,
|
||||||
|
difficulty_easy_label: "Easy difficulty",
|
||||||
|
difficulty_medium_label: "Medium difficulty",
|
||||||
|
difficulty_hard_label: "Hard difficulty",
|
||||||
|
difficulty_easy: "Easy",
|
||||||
|
difficulty_medium: "Medium",
|
||||||
|
difficulty_hard: "Hard"
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Renderer Extended Coverage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="module-list"></div>
|
||||||
|
<div class="lesson-title-row">
|
||||||
|
<h2 id="title"></h2>
|
||||||
|
</div>
|
||||||
|
<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 class="editor-content"></div>
|
||||||
|
<input type="checkbox" id="disable-feedback-toggle" checked>
|
||||||
|
`;
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderModuleList - progress tracking", () => {
|
||||||
|
test("renderModuleList_CorruptedProgress_HandlesGracefully", () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
localStorage.setItem("codeCrispies.progress", "not-valid-json{{{");
|
||||||
|
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Error parsing progress"), expect.anything());
|
||||||
|
// Should still render modules despite parse error
|
||||||
|
expect(container.querySelectorAll(".module-header").length).toBe(1);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderModuleList_CompletedModule_AddedCompletedClass", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"codeCrispies.progress",
|
||||||
|
JSON.stringify({
|
||||||
|
mod1: { completed: [0, 1], current: 1 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }, { title: "L2" }] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
const header = container.querySelector(".module-header");
|
||||||
|
expect(header.classList.contains("completed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderModuleList_PartiallyCompleted_NoCompletedClass", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"codeCrispies.progress",
|
||||||
|
JSON.stringify({
|
||||||
|
mod1: { completed: [0], current: 1 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }, { title: "L2" }] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
const header = container.querySelector(".module-header");
|
||||||
|
expect(header.classList.contains("completed")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderModuleList_CompletedLesson_HasCompletedClass", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"codeCrispies.progress",
|
||||||
|
JSON.stringify({
|
||||||
|
mod1: { completed: [0], current: 1 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }, { title: "L2" }] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
const lessonItems = container.querySelectorAll(".lesson-list-item");
|
||||||
|
expect(lessonItems[0].classList.contains("completed")).toBe(true);
|
||||||
|
expect(lessonItems[1].classList.contains("completed")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderModuleList_CurrentLesson_HasCurrentClass", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"codeCrispies.progress",
|
||||||
|
JSON.stringify({
|
||||||
|
mod1: { completed: [0], current: 1 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "mod1", title: "Module 1", lessons: [{ title: "L1" }, { title: "L2" }] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
const lessonItems = container.querySelectorAll(".lesson-list-item");
|
||||||
|
expect(lessonItems[1].classList.contains("current")).toBe(true);
|
||||||
|
expect(lessonItems[0].classList.contains("current")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderModuleList - welcome/playground always expanded", () => {
|
||||||
|
test("renderModuleList_WelcomeModule_AlwaysExpanded", () => {
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "welcome", title: "Welcome", lessons: [{ title: "Intro" }] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
const details = container.querySelector("details.module-container");
|
||||||
|
expect(details.open).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderModuleList_PlaygroundModule_AlwaysExpanded", () => {
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "playground", title: "Playground", lessons: [{ title: "Play" }] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
const details = container.querySelector("details.module-container");
|
||||||
|
expect(details.open).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderModuleList_RegularModule_CollapsedByDefault", () => {
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "flexbox", title: "Flexbox", lessons: [{ title: "L1" }] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
const details = container.querySelector("details.module-container");
|
||||||
|
expect(details.open).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderModuleList - lesson fallback title", () => {
|
||||||
|
test("renderModuleList_NoLessonTitle_UsesFallback", () => {
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "mod1", title: "Module 1", lessons: [{}] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
const lessonItem = container.querySelector(".lesson-list-item");
|
||||||
|
expect(lessonItem.textContent).toContain("Lesson");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderModuleList - click behavior", () => {
|
||||||
|
test("renderModuleList_LessonClick_RemovesActiveFromOthers", () => {
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [
|
||||||
|
{
|
||||||
|
id: "mod1",
|
||||||
|
title: "Module 1",
|
||||||
|
lessons: [{ title: "L1" }, { title: "L2" }]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const onSelectLesson = vi.fn();
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), onSelectLesson);
|
||||||
|
|
||||||
|
const lessonItems = container.querySelectorAll(".lesson-list-item");
|
||||||
|
|
||||||
|
// Click first lesson
|
||||||
|
lessonItems[0].click();
|
||||||
|
expect(lessonItems[0].classList.contains("active")).toBe(true);
|
||||||
|
expect(onSelectLesson).toHaveBeenCalledWith("mod1", 0);
|
||||||
|
|
||||||
|
// Click second lesson
|
||||||
|
lessonItems[1].click();
|
||||||
|
expect(lessonItems[0].classList.contains("active")).toBe(false);
|
||||||
|
expect(lessonItems[1].classList.contains("active")).toBe(true);
|
||||||
|
expect(onSelectLesson).toHaveBeenCalledWith("mod1", 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderModuleList - module dataset", () => {
|
||||||
|
test("renderModuleList_DataAttributes_SetCorrectly", () => {
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "flex-mod", title: "Flex Module", lessons: [{ title: "L1" }] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
const details = container.querySelector("details.module-container");
|
||||||
|
expect(details.dataset.moduleId).toBe("flex-mod");
|
||||||
|
|
||||||
|
const header = container.querySelector(".module-header");
|
||||||
|
expect(header.dataset.moduleId).toBe("flex-mod");
|
||||||
|
|
||||||
|
const lesson = container.querySelector(".lesson-list-item");
|
||||||
|
expect(lesson.dataset.moduleId).toBe("flex-mod");
|
||||||
|
expect(lesson.dataset.lessonIndex).toBe("0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderModuleList - empty lessons", () => {
|
||||||
|
test("renderModuleList_EmptyLessonsArray_RendersModuleOnly", () => {
|
||||||
|
const container = document.getElementById("module-list");
|
||||||
|
const modules = [{ id: "mod1", title: "Module 1", lessons: [] }];
|
||||||
|
|
||||||
|
renderModuleList(container, modules, vi.fn(), vi.fn());
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".module-header").length).toBe(1);
|
||||||
|
expect(container.querySelectorAll(".lesson-list-item").length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderDifficultyBadge", () => {
|
||||||
|
test("renderDifficultyBadge_EasyLesson_CreatesEasyBadge", () => {
|
||||||
|
const container = document.querySelector(".lesson-title-row");
|
||||||
|
const lesson = { codePrefix: ".box {\n ", solution: "color: red;" };
|
||||||
|
|
||||||
|
renderDifficultyBadge(container, lesson);
|
||||||
|
|
||||||
|
const badge = container.querySelector(".difficulty-badge");
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge.classList.contains("difficulty-easy")).toBe(true);
|
||||||
|
expect(badge.querySelectorAll(".bar").length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderDifficultyBadge_MediumLesson_CreatesMediumBadge", () => {
|
||||||
|
const container = document.querySelector(".lesson-title-row");
|
||||||
|
const lesson = { codePrefix: "", solution: "p {\n color: red;\n}" };
|
||||||
|
|
||||||
|
renderDifficultyBadge(container, lesson);
|
||||||
|
|
||||||
|
const badge = container.querySelector(".difficulty-badge");
|
||||||
|
expect(badge.classList.contains("difficulty-medium")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderDifficultyBadge_HardLesson_CreatesHardBadge", () => {
|
||||||
|
const container = document.querySelector(".lesson-title-row");
|
||||||
|
const lesson = { codePrefix: "", solution: ".nav a {\n color: white;\n}" };
|
||||||
|
|
||||||
|
renderDifficultyBadge(container, lesson);
|
||||||
|
|
||||||
|
const badge = container.querySelector(".difficulty-badge");
|
||||||
|
expect(badge.classList.contains("difficulty-hard")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderDifficultyBadge_CalledTwice_RemovesPreviousBadge", () => {
|
||||||
|
const container = document.querySelector(".lesson-title-row");
|
||||||
|
const lesson1 = { codePrefix: ".box {\n ", solution: "color: red;" };
|
||||||
|
const lesson2 = { codePrefix: "", solution: ".nav a {\n color: white;\n}" };
|
||||||
|
|
||||||
|
renderDifficultyBadge(container, lesson1);
|
||||||
|
expect(container.querySelectorAll(".difficulty-wrapper").length).toBe(1);
|
||||||
|
|
||||||
|
renderDifficultyBadge(container, lesson2);
|
||||||
|
expect(container.querySelectorAll(".difficulty-wrapper").length).toBe(1);
|
||||||
|
|
||||||
|
const badge = container.querySelector(".difficulty-badge");
|
||||||
|
expect(badge.classList.contains("difficulty-hard")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderDifficultyBadge_HasAriaLabel", () => {
|
||||||
|
const container = document.querySelector(".lesson-title-row");
|
||||||
|
const lesson = { codePrefix: ".box {", solution: "color: red;" };
|
||||||
|
|
||||||
|
renderDifficultyBadge(container, lesson);
|
||||||
|
|
||||||
|
const badge = container.querySelector(".difficulty-badge");
|
||||||
|
expect(badge.getAttribute("aria-label")).toBeTruthy();
|
||||||
|
expect(badge.getAttribute("title")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("showFeedback", () => {
|
||||||
|
test("showFeedback_Success_CreatesSuccessElement", () => {
|
||||||
|
showFeedback(true, "Great job!");
|
||||||
|
|
||||||
|
const feedback = document.querySelector(".feedback-success");
|
||||||
|
expect(feedback).not.toBeNull();
|
||||||
|
expect(feedback.innerHTML).toBe("Great job!");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("showFeedback_Success_InsertedAfterEditorContent", () => {
|
||||||
|
showFeedback(true, "Good!");
|
||||||
|
|
||||||
|
const editorContent = document.querySelector(".editor-content");
|
||||||
|
const feedback = editorContent.nextSibling;
|
||||||
|
expect(feedback).not.toBeNull();
|
||||||
|
expect(feedback.classList.contains("feedback-success")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("showFeedback_Error_ToggleChecked_ShowsError", () => {
|
||||||
|
const toggle = document.getElementById("disable-feedback-toggle");
|
||||||
|
toggle.checked = true;
|
||||||
|
|
||||||
|
showFeedback(false, "Try again");
|
||||||
|
|
||||||
|
const feedback = document.querySelector(".feedback-error");
|
||||||
|
expect(feedback).not.toBeNull();
|
||||||
|
expect(feedback.innerHTML).toBe("Try again");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("showFeedback_Error_ToggleUnchecked_HidesError", () => {
|
||||||
|
const toggle = document.getElementById("disable-feedback-toggle");
|
||||||
|
toggle.checked = false;
|
||||||
|
|
||||||
|
showFeedback(false, "Try again");
|
||||||
|
|
||||||
|
const feedback = document.querySelector(".feedback-error");
|
||||||
|
expect(feedback).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("showFeedback_Error_AutoClearsAfterTimeout", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const toggle = document.getElementById("disable-feedback-toggle");
|
||||||
|
toggle.checked = true;
|
||||||
|
|
||||||
|
showFeedback(false, "Error!");
|
||||||
|
|
||||||
|
expect(document.querySelector(".feedback-error")).not.toBeNull();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
|
||||||
|
expect(document.querySelector(".feedback-error")).toBeNull();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("showFeedback_Success_DoesNotAutoCleanup", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
showFeedback(true, "Good!");
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
|
||||||
|
expect(document.querySelector(".feedback-success")).not.toBeNull();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("showFeedback_CalledTwice_ClearsPrevious", () => {
|
||||||
|
showFeedback(true, "First");
|
||||||
|
showFeedback(true, "Second");
|
||||||
|
|
||||||
|
const feedbacks = document.querySelectorAll(".feedback-success");
|
||||||
|
expect(feedbacks.length).toBe(1);
|
||||||
|
expect(feedbacks[0].innerHTML).toBe("Second");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearFeedback", () => {
|
||||||
|
test("clearFeedback_NoExistingFeedback_DoesNotThrow", () => {
|
||||||
|
expect(() => clearFeedback()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearFeedback_ExistingFeedback_RemovesIt", () => {
|
||||||
|
showFeedback(true, "Test");
|
||||||
|
expect(document.querySelector(".feedback-success")).not.toBeNull();
|
||||||
|
|
||||||
|
clearFeedback();
|
||||||
|
expect(document.querySelector(".feedback-success")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearFeedback_CalledMultipleTimes_Safe", () => {
|
||||||
|
showFeedback(true, "Test");
|
||||||
|
clearFeedback();
|
||||||
|
clearFeedback();
|
||||||
|
clearFeedback();
|
||||||
|
expect(document.querySelector(".feedback-success")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearFeedback_ClearsTimeout", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const toggle = document.getElementById("disable-feedback-toggle");
|
||||||
|
toggle.checked = true;
|
||||||
|
|
||||||
|
showFeedback(false, "Error");
|
||||||
|
clearFeedback();
|
||||||
|
|
||||||
|
// Advance past the timeout - should not throw
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateActiveLessonInSidebar", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<details class="module-container" data-module-id="mod1">
|
||||||
|
<summary class="module-header">Module 1</summary>
|
||||||
|
<div class="lessons-container">
|
||||||
|
<button class="lesson-list-item active" data-module-id="mod1" data-lesson-index="0">L1</button>
|
||||||
|
<button class="lesson-list-item" data-module-id="mod1" data-lesson-index="1">L2</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="module-container" data-module-id="mod2">
|
||||||
|
<summary class="module-header">Module 2</summary>
|
||||||
|
<div class="lessons-container">
|
||||||
|
<button class="lesson-list-item" data-module-id="mod2" data-lesson-index="0">L1</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
// Mock scrollIntoView on all lesson items (not available in jsdom)
|
||||||
|
document.querySelectorAll(".lesson-list-item").forEach((el) => {
|
||||||
|
el.scrollIntoView = vi.fn();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateActiveLessonInSidebar_ValidLesson_ActivatesCorrectItem", () => {
|
||||||
|
updateActiveLessonInSidebar("mod1", 1);
|
||||||
|
|
||||||
|
const items = document.querySelectorAll(".lesson-list-item");
|
||||||
|
expect(items[0].classList.contains("active")).toBe(false);
|
||||||
|
expect(items[1].classList.contains("active")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateActiveLessonInSidebar_DifferentModule_ExpandsParent", () => {
|
||||||
|
const details = document.querySelector('details[data-module-id="mod2"]');
|
||||||
|
expect(details.open).toBe(false);
|
||||||
|
|
||||||
|
updateActiveLessonInSidebar("mod2", 0);
|
||||||
|
|
||||||
|
expect(details.open).toBe(true);
|
||||||
|
const mod2Lesson = document.querySelector('.lesson-list-item[data-module-id="mod2"]');
|
||||||
|
expect(mod2Lesson.classList.contains("active")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateActiveLessonInSidebar_RemovesPreviousActive", () => {
|
||||||
|
const firstItem = document.querySelector('.lesson-list-item[data-module-id="mod1"][data-lesson-index="0"]');
|
||||||
|
expect(firstItem.classList.contains("active")).toBe(true);
|
||||||
|
|
||||||
|
updateActiveLessonInSidebar("mod2", 0);
|
||||||
|
|
||||||
|
expect(firstItem.classList.contains("active")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateActiveLessonInSidebar_NonExistentItem_DoesNotThrow", () => {
|
||||||
|
expect(() => {
|
||||||
|
updateActiveLessonInSidebar("nonexistent", 99);
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
// All active classes should still be removed
|
||||||
|
const activeItems = document.querySelectorAll(".lesson-list-item.active");
|
||||||
|
expect(activeItems.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateActiveLessonInSidebar_ScrollsToLesson", () => {
|
||||||
|
const targetItem = document.querySelector('.lesson-list-item[data-module-id="mod1"][data-lesson-index="1"]');
|
||||||
|
|
||||||
|
updateActiveLessonInSidebar("mod1", 1);
|
||||||
|
|
||||||
|
expect(targetItem.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "nearest" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeLessonDifficulty - additional edge cases", () => {
|
||||||
|
test("computeLessonDifficulty_NoSolution_ReturnsMedium", () => {
|
||||||
|
expect(computeLessonDifficulty({ codePrefix: "" })).toBe("medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("computeLessonDifficulty_SolutionNoBrace_ReturnsMedium", () => {
|
||||||
|
expect(
|
||||||
|
computeLessonDifficulty({
|
||||||
|
codePrefix: "",
|
||||||
|
solution: "color: red;"
|
||||||
|
})
|
||||||
|
).toBe("medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("computeLessonDifficulty_CodePrefixWithBrace_IgnoresSolution", () => {
|
||||||
|
expect(
|
||||||
|
computeLessonDifficulty({
|
||||||
|
codePrefix: ".nav a {",
|
||||||
|
solution: ".nav a {\n color: white;\n}"
|
||||||
|
})
|
||||||
|
).toBe("easy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("computeLessonDifficulty_NullCodePrefix_ReturnsMedium", () => {
|
||||||
|
expect(computeLessonDifficulty({ codePrefix: null, solution: null })).toBe("medium");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderLesson - edge cases", () => {
|
||||||
|
test("renderLesson_NullInputEl_DoesNotThrow", () => {
|
||||||
|
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 suffixEl = document.getElementById("suffix");
|
||||||
|
const lesson = { title: "Test", description: "Desc", task: "Task" };
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
renderLesson(titleEl, descriptionEl, taskEl, previewEl, prefixEl, null, suffixEl, lesson);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderLevelIndicator - formatting", () => {
|
||||||
|
test("renderLevelIndicator_ContainsLabelSpan", () => {
|
||||||
|
const element = document.getElementById("level-indicator");
|
||||||
|
renderLevelIndicator(element, 5, 12);
|
||||||
|
|
||||||
|
const label = element.querySelector(".level-label");
|
||||||
|
expect(label).not.toBeNull();
|
||||||
|
expect(label.textContent).toBe("Lesson");
|
||||||
|
expect(element.textContent).toContain("5 / 12");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
232
tests/unit/router.test.js
Normal file
232
tests/unit/router.test.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { parseHash, updateHash, navigateTo, replaceHash, replaceTo, getShareableUrl, getSectionIds, RouteType } from "../../src/helpers/router.js";
|
||||||
|
|
||||||
|
describe("Router", () => {
|
||||||
|
let pushStateSpy;
|
||||||
|
let replaceStateSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset hash
|
||||||
|
window.location.hash = "";
|
||||||
|
pushStateSpy = vi.spyOn(history, "pushState").mockImplementation(() => {});
|
||||||
|
replaceStateSpy = vi.spyOn(history, "replaceState").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
pushStateSpy.mockRestore();
|
||||||
|
replaceStateSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RouteType", () => {
|
||||||
|
test("RouteType_Constants_CorrectValues", () => {
|
||||||
|
expect(RouteType.HOME).toBe("home");
|
||||||
|
expect(RouteType.SECTION).toBe("section");
|
||||||
|
expect(RouteType.REFERENCE).toBe("reference");
|
||||||
|
expect(RouteType.LESSON).toBe("lesson");
|
||||||
|
expect(RouteType.LANGUAGE).toBe("language");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseHash", () => {
|
||||||
|
test("parseHash_EmptyHash_ReturnsHome", () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.HOME });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_HashOnly_ReturnsHome", () => {
|
||||||
|
window.location.hash = "#";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.HOME });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["de", "de"],
|
||||||
|
["pl", "pl"],
|
||||||
|
["ar", "ar"],
|
||||||
|
["es", "es"],
|
||||||
|
["en", "en"],
|
||||||
|
["uk", "uk"]
|
||||||
|
])("parseHash_LanguageCode_%s_ReturnsLanguageRoute", (code, expectedLang) => {
|
||||||
|
window.location.hash = `#${code}`;
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.LANGUAGE, lang: expectedLang });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["css", "css"],
|
||||||
|
["html", "html"],
|
||||||
|
["markdown", "markdown"]
|
||||||
|
])("parseHash_SectionId_%s_ReturnsSectionRoute", (sectionId, expectedId) => {
|
||||||
|
window.location.hash = `#${sectionId}`;
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.SECTION, sectionId: expectedId });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ReferenceWithoutSubpage_ReturnsReferenceRouteNullRefId", () => {
|
||||||
|
window.location.hash = "#reference";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.REFERENCE, refId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ReferenceWithSubpage_ReturnsReferenceRouteWithRefId", () => {
|
||||||
|
window.location.hash = "#reference/css";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.REFERENCE, refId: "css" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ReferenceWithFlexboxSubpage_ReturnsCorrectRefId", () => {
|
||||||
|
window.location.hash = "#reference/flexbox";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.REFERENCE, refId: "flexbox" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_SingleUnknownSegment_ReturnsLessonWithIndex0", () => {
|
||||||
|
window.location.hash = "#flexbox";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.LESSON, moduleId: "flexbox", lessonIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ModuleWithLessonIndex_ReturnsLessonRoute", () => {
|
||||||
|
window.location.hash = "#flexbox/2";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.LESSON, moduleId: "flexbox", lessonIndex: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ModuleWithIndex0_ReturnsLessonRoute", () => {
|
||||||
|
window.location.hash = "#box-model/0";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toEqual({ type: RouteType.LESSON, moduleId: "box-model", lessonIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_NegativeLessonIndex_ReturnsNull", () => {
|
||||||
|
window.location.hash = "#module/-1";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_NonNumericLessonIndex_ReturnsNull", () => {
|
||||||
|
window.location.hash = "#module/abc";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_ThreeOrMoreSegments_ReturnsNull", () => {
|
||||||
|
window.location.hash = "#a/b/c";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseHash_EmptyModuleIdWithIndex_ReturnsNull", () => {
|
||||||
|
// #/0 → parts = ["", "0"], moduleId is empty string (falsy)
|
||||||
|
window.location.hash = "#/0";
|
||||||
|
const result = parseHash();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateHash", () => {
|
||||||
|
test("updateHash_NewHash_CallsPushState", () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
updateHash("flexbox", 2);
|
||||||
|
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#flexbox/2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateHash_SameHash_DoesNotCallPushState", () => {
|
||||||
|
window.location.hash = "#flexbox/2";
|
||||||
|
updateHash("flexbox", 2);
|
||||||
|
expect(pushStateSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateHash_DifferentModule_CallsPushState", () => {
|
||||||
|
window.location.hash = "#flexbox/0";
|
||||||
|
updateHash("box-model", 0);
|
||||||
|
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#box-model/0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("navigateTo", () => {
|
||||||
|
test("navigateTo_SectionRoute_CallsPushState", () => {
|
||||||
|
window.location.hash = "";
|
||||||
|
navigateTo("css");
|
||||||
|
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigateTo_EmptyRoute_NavigatesToHash", () => {
|
||||||
|
window.location.hash = "#something";
|
||||||
|
navigateTo("");
|
||||||
|
expect(pushStateSpy).toHaveBeenCalledWith(null, "", "#");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigateTo_SameHash_DoesNotCallPushState", () => {
|
||||||
|
window.location.hash = "#css";
|
||||||
|
navigateTo("css");
|
||||||
|
expect(pushStateSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replaceHash", () => {
|
||||||
|
test("replaceHash_ValidArgs_CallsReplaceState", () => {
|
||||||
|
replaceHash("flexbox", 3);
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#flexbox/3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaceHash_Index0_FormatsCorrectly", () => {
|
||||||
|
replaceHash("box-model", 0);
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#box-model/0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replaceTo", () => {
|
||||||
|
test("replaceTo_Route_CallsReplaceState", () => {
|
||||||
|
replaceTo("css");
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaceTo_EmptyRoute_ReplacesToHash", () => {
|
||||||
|
replaceTo("");
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaceTo_ReferenceRoute_FormatsCorrectly", () => {
|
||||||
|
replaceTo("reference/flexbox");
|
||||||
|
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "#reference/flexbox");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getShareableUrl", () => {
|
||||||
|
test("getShareableUrl_ValidArgs_ReturnsFullUrl", () => {
|
||||||
|
const url = getShareableUrl("flexbox", 2);
|
||||||
|
expect(url).toContain("#flexbox/2");
|
||||||
|
expect(url).toMatch(/^https?:\/\/.+#flexbox\/2$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getShareableUrl_Index0_IncludesIndex", () => {
|
||||||
|
const url = getShareableUrl("box-model", 0);
|
||||||
|
expect(url).toContain("#box-model/0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSectionIds", () => {
|
||||||
|
test("getSectionIds_ReturnsCopy_NotOriginalArray", () => {
|
||||||
|
const ids1 = getSectionIds();
|
||||||
|
const ids2 = getSectionIds();
|
||||||
|
expect(ids1).toEqual(ids2);
|
||||||
|
expect(ids1).not.toBe(ids2); // Different references
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionIds_ContainsExpectedSections", () => {
|
||||||
|
const ids = getSectionIds();
|
||||||
|
expect(ids).toContain("css");
|
||||||
|
expect(ids).toContain("html");
|
||||||
|
expect(ids).toContain("markdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionIds_MutatingCopy_DoesNotAffectOriginal", () => {
|
||||||
|
const ids = getSectionIds();
|
||||||
|
ids.push("custom");
|
||||||
|
const freshIds = getSectionIds();
|
||||||
|
expect(freshIds).not.toContain("custom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
172
tests/unit/sections.test.js
Normal file
172
tests/unit/sections.test.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { sections, getSection, getSectionList, getModuleSection, getModulesBySection } from "../../src/config/sections.js";
|
||||||
|
|
||||||
|
describe("Sections Config", () => {
|
||||||
|
describe("sections constant", () => {
|
||||||
|
test("sections_AllDefined_HasFourSections", () => {
|
||||||
|
expect(Object.keys(sections)).toHaveLength(4);
|
||||||
|
expect(sections).toHaveProperty("css");
|
||||||
|
expect(sections).toHaveProperty("html");
|
||||||
|
expect(sections).toHaveProperty("tailwind");
|
||||||
|
expect(sections).toHaveProperty("markdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sections_EachSection_HasRequiredFields", () => {
|
||||||
|
for (const [key, section] of Object.entries(sections)) {
|
||||||
|
expect(section.id).toBe(key);
|
||||||
|
expect(section.title).toBeTruthy();
|
||||||
|
expect(section.description).toBeTruthy();
|
||||||
|
expect(section.color).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
expect(typeof section.order).toBe("number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSection", () => {
|
||||||
|
test.each([
|
||||||
|
["css", "CSS"],
|
||||||
|
["html", "HTML"],
|
||||||
|
["tailwind", "Tailwind CSS"],
|
||||||
|
["markdown", "Markdown"]
|
||||||
|
])("getSection_%s_ReturnsCorrectSection", (id, expectedTitle) => {
|
||||||
|
const section = getSection(id);
|
||||||
|
expect(section).not.toBeNull();
|
||||||
|
expect(section.id).toBe(id);
|
||||||
|
expect(section.title).toBe(expectedTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSection_NonExistentId_ReturnsNull", () => {
|
||||||
|
expect(getSection("nonexistent")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSection_Undefined_ReturnsNull", () => {
|
||||||
|
expect(getSection(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSection_EmptyString_ReturnsNull", () => {
|
||||||
|
expect(getSection("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSectionList", () => {
|
||||||
|
test("getSectionList_Default_ReturnsSortedByOrder", () => {
|
||||||
|
const list = getSectionList();
|
||||||
|
expect(list).toHaveLength(4);
|
||||||
|
|
||||||
|
// Verify sorted by order
|
||||||
|
for (let i = 1; i < list.length; i++) {
|
||||||
|
expect(list[i].order).toBeGreaterThan(list[i - 1].order);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionList_Default_CSSIsFirst", () => {
|
||||||
|
const list = getSectionList();
|
||||||
|
expect(list[0].id).toBe("css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionList_Default_MarkdownIsLast", () => {
|
||||||
|
const list = getSectionList();
|
||||||
|
expect(list[list.length - 1].id).toBe("markdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSectionList_Default_ContainsAllSections", () => {
|
||||||
|
const list = getSectionList();
|
||||||
|
const ids = list.map((s) => s.id);
|
||||||
|
expect(ids).toContain("css");
|
||||||
|
expect(ids).toContain("html");
|
||||||
|
expect(ids).toContain("tailwind");
|
||||||
|
expect(ids).toContain("markdown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getModuleSection", () => {
|
||||||
|
test("getModuleSection_ExplicitSection_UsesExplicitValue", () => {
|
||||||
|
const module = { mode: "css", section: "html" };
|
||||||
|
expect(getModuleSection(module)).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["css", "css"],
|
||||||
|
["html", "html"],
|
||||||
|
["tailwind", "tailwind"],
|
||||||
|
["markdown", "markdown"]
|
||||||
|
])("getModuleSection_Mode%s_InfersCorrectSection", (mode, expectedSection) => {
|
||||||
|
const module = { mode };
|
||||||
|
expect(getModuleSection(module)).toBe(expectedSection);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModuleSection_NoMode_DefaultsToCss", () => {
|
||||||
|
expect(getModuleSection({})).toBe("css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModuleSection_UndefinedMode_DefaultsToCss", () => {
|
||||||
|
expect(getModuleSection({ mode: undefined })).toBe("css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModuleSection_UnknownMode_DefaultsToCss", () => {
|
||||||
|
expect(getModuleSection({ mode: "javascript" })).toBe("css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModuleSection_ExplicitSectionOverridesMode_UsesSection", () => {
|
||||||
|
const module = { mode: "html", section: "tailwind" };
|
||||||
|
expect(getModuleSection(module)).toBe("tailwind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getModulesBySection", () => {
|
||||||
|
const testModules = [
|
||||||
|
{ id: "css-basics", mode: "css" },
|
||||||
|
{ id: "flexbox", mode: "css" },
|
||||||
|
{ id: "html-elements", mode: "html" },
|
||||||
|
{ id: "tailwind-intro", mode: "tailwind" },
|
||||||
|
{ id: "markdown-basics", mode: "markdown" },
|
||||||
|
{ id: "welcome", mode: "css", excludeFromProgress: true },
|
||||||
|
{ id: "playground", mode: "css", excludeFromProgress: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
test("getModulesBySection_Css_ReturnsCssModules", () => {
|
||||||
|
const result = getModulesBySection(testModules, "css");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((m) => m.id)).toEqual(["css-basics", "flexbox"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_Html_ReturnsHtmlModules", () => {
|
||||||
|
const result = getModulesBySection(testModules, "html");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("html-elements");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_Tailwind_ReturnsTailwindModules", () => {
|
||||||
|
const result = getModulesBySection(testModules, "tailwind");
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("tailwind-intro");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_ExcludesFromProgress_FiltersOut", () => {
|
||||||
|
const result = getModulesBySection(testModules, "css");
|
||||||
|
const ids = result.map((m) => m.id);
|
||||||
|
expect(ids).not.toContain("welcome");
|
||||||
|
expect(ids).not.toContain("playground");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_EmptyModules_ReturnsEmptyArray", () => {
|
||||||
|
const result = getModulesBySection([], "css");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_NonExistentSection_ReturnsEmptyArray", () => {
|
||||||
|
const result = getModulesBySection(testModules, "nonexistent");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getModulesBySection_ExplicitSectionOverride_IncludesModule", () => {
|
||||||
|
const modules = [
|
||||||
|
{ id: "special", mode: "css", section: "html" },
|
||||||
|
{ id: "normal-html", mode: "html" }
|
||||||
|
];
|
||||||
|
const result = getModulesBySection(modules, "html");
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((m) => m.id)).toContain("special");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
735
tests/unit/validator-extended.test.js
Normal file
735
tests/unit/validator-extended.test.js
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { validateUserCode, validateCssCode } from "../../src/helpers/validator.js";
|
||||||
|
|
||||||
|
describe("Validator Extended Coverage", () => {
|
||||||
|
describe("validateUserCode mode dispatch", () => {
|
||||||
|
test("validateUserCode_NoMode_DefaultsToCss", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [{ type: "contains", value: "color: red" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateUserCode_CssMode_UsesCssValidator", () => {
|
||||||
|
const result = validateUserCode("display: flex;", {
|
||||||
|
mode: "css",
|
||||||
|
validations: [{ type: "contains", value: "display: flex" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateUserCode_TailwindMode_UsesTailwindValidator", () => {
|
||||||
|
const result = validateUserCode("flex items-center", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_class", value: "flex" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateUserCode_HtmlMode_UsesHtmlValidator", () => {
|
||||||
|
const result = validateUserCode("<div>Hello</div>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "element_exists", value: "div" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateUserCode_UnknownMode_DefaultsToCss", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
mode: "javascript",
|
||||||
|
validations: [{ type: "contains", value: "color: red" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateUserCode_NullLesson_Throws", () => {
|
||||||
|
expect(() => validateUserCode("anything", null)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateUserCode_UndefinedLesson_Throws", () => {
|
||||||
|
expect(() => validateUserCode("anything", undefined)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Tailwind validation", () => {
|
||||||
|
test("tailwind_ContainsClass_Pass", () => {
|
||||||
|
const result = validateUserCode("flex items-center justify-between", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_class", value: "flex" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.validCases).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_ContainsClass_Fail_ReturnsMessage", () => {
|
||||||
|
const result = validateUserCode("items-center", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_class", value: "flex", message: "Add flex class" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toBe("Add flex class");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_ContainsClass_Fail_DefaultMessage", () => {
|
||||||
|
const result = validateUserCode("items-center", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_class", value: "flex" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toContain("flex");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_ContainsClass_PartialMatch_Fails", () => {
|
||||||
|
// "flex-1" contains "flex" as substring but split should not match
|
||||||
|
const result = validateUserCode("flex-1 items-center", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_class", value: "flex" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_ContainsPattern_Pass", () => {
|
||||||
|
const result = validateUserCode("text-lg font-bold", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_pattern", value: "text-\\w+" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_ContainsPattern_Fail_ReturnsMessage", () => {
|
||||||
|
const result = validateUserCode("font-bold", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_pattern", value: "text-\\w+", message: "Add text size" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toBe("Add text size");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_ContainsPattern_Fail_DefaultMessage", () => {
|
||||||
|
const result = validateUserCode("font-bold", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_pattern", value: "text-\\w+" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toContain("pattern");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_DefaultType_FallsBackToContains", () => {
|
||||||
|
const result = validateUserCode("flex items-center", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains", value: "items-center" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_NoValidations_ReturnsValid", () => {
|
||||||
|
const result = validateUserCode("flex", { mode: "tailwind" });
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.message).toContain("No validations specified");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_NullLesson_ReturnsValid", () => {
|
||||||
|
const result = validateUserCode("flex", { mode: "tailwind", validations: null });
|
||||||
|
// validateTailwindClasses checks !lesson.validations
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_MultipleValidations_AllPass", () => {
|
||||||
|
const result = validateUserCode("flex items-center gap-4", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [
|
||||||
|
{ type: "contains_class", value: "flex" },
|
||||||
|
{ type: "contains_class", value: "items-center" },
|
||||||
|
{ type: "contains_class", value: "gap-4" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.validCases).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_MultipleValidations_EarlyReturn", () => {
|
||||||
|
const result = validateUserCode("flex", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [
|
||||||
|
{ type: "contains_class", value: "flex" },
|
||||||
|
{ type: "contains_class", value: "items-center", message: "Missing items-center" },
|
||||||
|
{ type: "contains_class", value: "gap-4" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toBe("Missing items-center");
|
||||||
|
expect(result.validCases).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_WhitespaceHandling_LeadingTrailing", () => {
|
||||||
|
const result = validateUserCode(" flex items-center ", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_class", value: "flex" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tailwind_EmptyUserClasses_Fails", () => {
|
||||||
|
const result = validateUserCode("", {
|
||||||
|
mode: "tailwind",
|
||||||
|
validations: [{ type: "contains_class", value: "flex" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML validation - sibling type", () => {
|
||||||
|
test("sibling_ValidOrder_Passes", () => {
|
||||||
|
const result = validateUserCode("<h1>Title</h1><p>Content</p>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sibling_NonAdjacentButAfter_Passes", () => {
|
||||||
|
const result = validateUserCode("<h1>Title</h1><span>Middle</span><p>Content</p>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sibling_WrongOrder_Fails", () => {
|
||||||
|
const result = validateUserCode("<p>Content</p><h1>Title</h1>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
|
||||||
|
});
|
||||||
|
// h1 is after p, so p is not a sibling after h1 - but wait, h1 exists and p is before h1...
|
||||||
|
// Actually h1 exists. nextElementSibling of h1 is nothing. So it fails.
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sibling_FirstNotFound_Fails", () => {
|
||||||
|
const result = validateUserCode("<p>Content</p>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "sibling", value: { first: "h1", then: "p" }, message: "h1 not found" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toBe("h1 not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sibling_ThenNotFound_Fails", () => {
|
||||||
|
const result = validateUserCode("<h1>Title</h1><span>Only span</span>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sibling_DefaultMessage_ContainsBothSelectors", () => {
|
||||||
|
const result = validateUserCode("<div>Only div</div>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toContain("p");
|
||||||
|
expect(result.message).toContain("h1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sibling_NoFollowingSiblings_Fails", () => {
|
||||||
|
const result = validateUserCode("<div><h1>Title</h1></div>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML validation - not_contains type", () => {
|
||||||
|
test("htmlNotContains_AbsentText_Passes", () => {
|
||||||
|
const result = validateUserCode("<p>Hello</p>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "not_contains", value: "class=" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("htmlNotContains_PresentText_Fails", () => {
|
||||||
|
const result = validateUserCode('<p class="red">Hello</p>', {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "not_contains", value: "class=", message: "Remove classes" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toBe("Remove classes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("htmlNotContains_DefaultMessage", () => {
|
||||||
|
const result = validateUserCode('<p class="red">Hello</p>', {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "not_contains", value: "class=" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toContain("should not include");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML validation - regex type", () => {
|
||||||
|
test("htmlRegex_MatchingPattern_Passes", () => {
|
||||||
|
const result = validateUserCode('<img src="photo.jpg" alt="A photo">', {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "regex", value: 'alt="[^"]+"' }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("htmlRegex_NonMatchingPattern_Fails", () => {
|
||||||
|
const result = validateUserCode('<img src="photo.jpg">', {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "regex", value: 'alt="[^"]+"', message: "Add alt text" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toBe("Add alt text");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("htmlRegex_DefaultMessage", () => {
|
||||||
|
const result = validateUserCode("<p>Hello</p>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "regex", value: "<h1>" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toContain("pattern");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML validation - unknown type", () => {
|
||||||
|
test("htmlUnknownType_SkipsAndPasses", () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const result = validateUserCode("<p>Hello</p>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "unknown_type", value: "anything" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown HTML validation type"));
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML validation - element_count fallback (>0)", () => {
|
||||||
|
test("elementCount_NoCountNoMin_ChecksGreaterThanZero_Pass", () => {
|
||||||
|
const result = validateUserCode("<ul><li>Item</li></ul>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "element_count", value: { selector: "li" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("elementCount_NoCountNoMin_NoElements_Fails", () => {
|
||||||
|
const result = validateUserCode("<ul></ul>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "element_count", value: { selector: "li" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML validation - attribute_value edge cases", () => {
|
||||||
|
test("attributeValue_ElementNotFound_Fails", () => {
|
||||||
|
const result = validateUserCode("<p>Hello</p>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "attribute_value", value: { selector: "input", attr: "type", value: "email" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("attributeValue_NullValue_ChecksExists", () => {
|
||||||
|
const result = validateUserCode('<input data-test="anything">', {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "attribute_value", value: { selector: "input", attr: "data-test", value: null } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("attributeValue_NullValue_AttributeMissing_Fails", () => {
|
||||||
|
const result = validateUserCode("<input>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "attribute_value", value: { selector: "input", attr: "data-test", value: null } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML validation - element_text edge cases", () => {
|
||||||
|
test("elementText_ElementNotFound_Fails", () => {
|
||||||
|
const result = validateUserCode("<p>Hello</p>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "element_text", value: { selector: "button", text: "Submit" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("elementText_EmptyTextContent_FailsForNonEmptyExpected", () => {
|
||||||
|
const result = validateUserCode("<button></button>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "element_text", value: { selector: "button", text: "Submit" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("elementText_EmptyExpectedText_MatchesEmptyElement", () => {
|
||||||
|
const result = validateUserCode("<button></button>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "element_text", value: { selector: "button", text: "" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS validation - containsValidation wholeWord option", () => {
|
||||||
|
test("contains_WholeWord_ExactMatch_Passes", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [{ type: "contains", value: "red", options: { wholeWord: true } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains_WholeWord_PartialMatch_Fails", () => {
|
||||||
|
const result = validateUserCode("color: darkred;", {
|
||||||
|
validations: [{ type: "contains", value: "red", options: { wholeWord: true } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains_WholeWord_CaseInsensitive_Passes", () => {
|
||||||
|
const result = validateUserCode("COLOR: RED;", {
|
||||||
|
validations: [{ type: "contains", value: "red", options: { wholeWord: true, caseSensitive: false } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains_WholeWord_SpecialChars_Escaped", () => {
|
||||||
|
// \b doesn't match at non-word chars like ".", so use a word value with special chars around it
|
||||||
|
const result = validateUserCode("value: calc(100% - 20px);", {
|
||||||
|
validations: [{ type: "contains", value: "calc", options: { wholeWord: true } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
|
||||||
|
// "calc" should not match "recalculate"
|
||||||
|
const failResult = validateUserCode("/* recalculate */", {
|
||||||
|
validations: [{ type: "contains", value: "calc", options: { wholeWord: true } }]
|
||||||
|
});
|
||||||
|
expect(failResult.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS validation - regexValidation options", () => {
|
||||||
|
test("regex_CaseInsensitive_Passes", () => {
|
||||||
|
const result = validateUserCode("COLOR: RED;", {
|
||||||
|
validations: [{ type: "regex", value: "color:\\s*red", options: { caseSensitive: false } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regex_CaseSensitive_Default_FailsOnCaseMismatch", () => {
|
||||||
|
const result = validateUserCode("COLOR: RED;", {
|
||||||
|
validations: [{ type: "regex", value: "color:\\s*red" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regex_MultilineFalse_DoesNotMatchAcrossLines", () => {
|
||||||
|
const code = "body {\n color: red;\n}";
|
||||||
|
// With multiline=false, ^ should not match beginning of each line
|
||||||
|
const result = validateUserCode(code, {
|
||||||
|
validations: [{ type: "regex", value: "^\\s*color", options: { multiline: false } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regex_MultilineTrue_Default_MatchesEachLine", () => {
|
||||||
|
const code = "body {\n color: red;\n}";
|
||||||
|
const result = validateUserCode(code, {
|
||||||
|
validations: [{ type: "regex", value: "^\\s*color", options: { multiline: true } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regex_InvalidPattern_ReturnsFalse", () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [{ type: "regex", value: "[invalid(regex" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regex_EmptyPattern_MatchesEverything", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [{ type: "regex", value: "" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS validation - propertyValueValidation edge cases", () => {
|
||||||
|
test("propertyValue_PropertyNotFound_Fails", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [
|
||||||
|
{
|
||||||
|
type: "property_value",
|
||||||
|
value: { property: "display", expected: "flex" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propertyValue_ExactMatch_Passes", () => {
|
||||||
|
const result = validateUserCode("display: flex;", {
|
||||||
|
validations: [
|
||||||
|
{
|
||||||
|
type: "property_value",
|
||||||
|
value: { property: "display", expected: "flex" },
|
||||||
|
options: { exact: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propertyValue_ExactMatch_CaseMismatch_Fails", () => {
|
||||||
|
const result = validateUserCode("display: FLEX;", {
|
||||||
|
validations: [
|
||||||
|
{
|
||||||
|
type: "property_value",
|
||||||
|
value: { property: "display", expected: "flex" },
|
||||||
|
options: { exact: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propertyValue_FlexibleMatch_CaseInsensitive", () => {
|
||||||
|
const result = validateUserCode("display: FLEX;", {
|
||||||
|
validations: [
|
||||||
|
{
|
||||||
|
type: "property_value",
|
||||||
|
value: { property: "display", expected: "flex" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propertyValue_ShorthandProperty_Passes", () => {
|
||||||
|
const result = validateUserCode("margin: 10px 20px;", {
|
||||||
|
validations: [
|
||||||
|
{
|
||||||
|
type: "property_value",
|
||||||
|
value: { property: "margin", expected: "10px 20px" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propertyValue_DefaultMessage_IncludesPropertyAndExpected", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [
|
||||||
|
{
|
||||||
|
type: "property_value",
|
||||||
|
value: { property: "display", expected: "flex" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toContain("display");
|
||||||
|
expect(result.message).toContain("flex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS validation - syntaxValidation", () => {
|
||||||
|
test("syntax_ValidCss_Passes", () => {
|
||||||
|
const result = validateUserCode("div { color: red; }", {
|
||||||
|
validations: [{ type: "syntax" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS validation - custom edge cases", () => {
|
||||||
|
test("custom_NoValidatorFunction_ReturnsEarlyWithOriginalResult", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [{ type: "custom" }]
|
||||||
|
});
|
||||||
|
// When validator is falsy, validationPassed stays false, but result.isValid was never set to false
|
||||||
|
// The function returns early with the unmodified result (isValid: true)
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom_NonFunctionValidator_ReturnsEarlyWithOriginalResult", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [{ type: "custom", validator: "not-a-function" }]
|
||||||
|
});
|
||||||
|
// Same behavior: validator check fails, validationPassed stays false, returns unmodified result
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom_ValidatorReturnsNoMessage_UsesMessage", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [
|
||||||
|
{
|
||||||
|
type: "custom",
|
||||||
|
validator: () => ({ isValid: false }),
|
||||||
|
message: "Fallback message"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toBe("Fallback message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom_ValidatorReturnsNoMessage_NoLessonMessage_DefaultMessage", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [
|
||||||
|
{
|
||||||
|
type: "custom",
|
||||||
|
validator: () => ({ isValid: false })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.message).toContain("does not meet the requirements");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS validation - unknown type", () => {
|
||||||
|
test("unknownType_WarnsAndContinues", () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [
|
||||||
|
{ type: "invented_type", value: "anything" },
|
||||||
|
{ type: "contains", value: "color: red" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown validation type"));
|
||||||
|
// The unknown type is skipped (continue), then the next validation passes
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS validation - empty and whitespace input", () => {
|
||||||
|
test("emptyString_ContainsValidation_Fails", () => {
|
||||||
|
const result = validateUserCode("", {
|
||||||
|
validations: [{ type: "contains", value: "color" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("whitespaceOnly_ContainsValidation_Fails", () => {
|
||||||
|
const result = validateUserCode(" \n\t ", {
|
||||||
|
validations: [{ type: "contains", value: "color" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emptyString_NotContains_Passes", () => {
|
||||||
|
const result = validateUserCode("", {
|
||||||
|
validations: [{ type: "not_contains", value: "color" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS validation - validCases and totalCases tracking", () => {
|
||||||
|
test("allPassingValidations_ValidCasesEqualsTotalCases", () => {
|
||||||
|
const result = validateUserCode("display: flex; color: red;", {
|
||||||
|
validations: [
|
||||||
|
{ type: "contains", value: "display: flex" },
|
||||||
|
{ type: "contains", value: "color: red" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.validCases).toBe(2);
|
||||||
|
expect(result.totalCases).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("firstValidationFails_ValidCasesIs0", () => {
|
||||||
|
const result = validateUserCode("color: red;", {
|
||||||
|
validations: [
|
||||||
|
{ type: "contains", value: "display: flex" },
|
||||||
|
{ type: "contains", value: "color: red" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.validCases).toBe(0);
|
||||||
|
expect(result.totalCases).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("secondValidationFails_ValidCasesIs1", () => {
|
||||||
|
const result = validateUserCode("display: flex;", {
|
||||||
|
validations: [
|
||||||
|
{ type: "contains", value: "display: flex" },
|
||||||
|
{ type: "contains", value: "color: red" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.validCases).toBe(1);
|
||||||
|
expect(result.totalCases).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSS validation - special regex metacharacters in contains", () => {
|
||||||
|
test("contains_DotInValue_TreatedAsLiteral", () => {
|
||||||
|
// ".class" should match literally, not any char + "class"
|
||||||
|
const result = validateUserCode(".card { color: red; }", {
|
||||||
|
validations: [{ type: "contains", value: ".card" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains_BracketsInValue_TreatedAsLiteral", () => {
|
||||||
|
const result = validateUserCode("content: '[test]';", {
|
||||||
|
validations: [{ type: "contains", value: "[test]" }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML validation - deeply nested parent_child", () => {
|
||||||
|
test("parentChild_DeeplyNested_Passes", () => {
|
||||||
|
const html = "<div><section><article><p>Deep</p></article></section></div>";
|
||||||
|
const result = validateUserCode(html, {
|
||||||
|
mode: "html",
|
||||||
|
validations: [{ type: "parent_child", value: { parent: "div", child: "p" } }]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML validation - validCases tracking", () => {
|
||||||
|
test("htmlAllPass_ValidCasesEqualsTotal", () => {
|
||||||
|
const result = validateUserCode("<h1>Title</h1><p>Content</p>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [
|
||||||
|
{ type: "element_exists", value: "h1" },
|
||||||
|
{ type: "element_exists", value: "p" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.validCases).toBe(2);
|
||||||
|
expect(result.totalCases).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("htmlPartialPass_EarlyReturn", () => {
|
||||||
|
const result = validateUserCode("<h1>Title</h1>", {
|
||||||
|
mode: "html",
|
||||||
|
validations: [
|
||||||
|
{ type: "element_exists", value: "h1" },
|
||||||
|
{ type: "element_exists", value: "p", message: "Need paragraph" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.validCases).toBe(1);
|
||||||
|
expect(result.message).toBe("Need paragraph");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
376
wave.yaml
Normal file
376
wave.yaml
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
adapters:
|
||||||
|
claude:
|
||||||
|
binary: claude
|
||||||
|
default_permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Bash
|
||||||
|
deny: []
|
||||||
|
mode: headless
|
||||||
|
output_format: json
|
||||||
|
project_files:
|
||||||
|
- CLAUDE.md
|
||||||
|
- .claude/settings.json
|
||||||
|
apiVersion: v1
|
||||||
|
kind: WaveManifest
|
||||||
|
metadata:
|
||||||
|
description: An interactive platform for learning CSS through practical challenges
|
||||||
|
name: code-crispies
|
||||||
|
ontology:
|
||||||
|
telos: Interactive self-learning platform for web technologies (CSS, HTML, JavaScript, Markdown)
|
||||||
|
personas:
|
||||||
|
auditor:
|
||||||
|
adapter: claude
|
||||||
|
description: Security review and quality assurance
|
||||||
|
model: claude-haiku
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Grep
|
||||||
|
- Glob
|
||||||
|
- Bash
|
||||||
|
deny:
|
||||||
|
- Edit(*)
|
||||||
|
- Bash(rm -rf /*)
|
||||||
|
- Bash(git push*)
|
||||||
|
- Bash(git commit*)
|
||||||
|
system_prompt_file: .wave/personas/auditor.md
|
||||||
|
temperature: 0.1
|
||||||
|
craftsman:
|
||||||
|
adapter: claude
|
||||||
|
description: Code implementation and testing
|
||||||
|
model: claude-opus
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Bash
|
||||||
|
deny:
|
||||||
|
- Bash(rm -rf /*)
|
||||||
|
system_prompt_file: .wave/personas/craftsman.md
|
||||||
|
temperature: 0.7
|
||||||
|
debugger:
|
||||||
|
adapter: claude
|
||||||
|
description: Systematic debugging and root cause analysis
|
||||||
|
model: claude-opus
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- Bash
|
||||||
|
deny:
|
||||||
|
- Edit(*)
|
||||||
|
- Bash(rm -rf /*)
|
||||||
|
- Bash(git push*)
|
||||||
|
- Bash(git commit*)
|
||||||
|
system_prompt_file: .wave/personas/debugger.md
|
||||||
|
temperature: 0.1
|
||||||
|
gitea-analyst:
|
||||||
|
adapter: claude
|
||||||
|
description: Gitea issue analysis and scanning
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Bash(tea issues view*)
|
||||||
|
- Bash(tea issues list*)
|
||||||
|
- Bash(tea releases list*)
|
||||||
|
- Bash(tea pulls view*)
|
||||||
|
- Bash(tea pulls list*)
|
||||||
|
- Bash(tea --version)
|
||||||
|
- Bash(git log*)
|
||||||
|
- Bash(git status*)
|
||||||
|
- Bash(ls *)
|
||||||
|
deny:
|
||||||
|
- Bash(tea issues edit*)
|
||||||
|
- Bash(tea issues create*)
|
||||||
|
- Bash(tea issues close*)
|
||||||
|
- Bash(gh *)
|
||||||
|
- Bash(glab *)
|
||||||
|
- Edit(*)
|
||||||
|
system_prompt_file: .wave/personas/gitea-analyst.md
|
||||||
|
temperature: 0.1
|
||||||
|
gitea-commenter:
|
||||||
|
adapter: claude
|
||||||
|
description: Posts comments on Gitea issues and pull requests
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Bash(tea issues comment*)
|
||||||
|
- Bash(tea pulls create*)
|
||||||
|
- Bash(tea --version)
|
||||||
|
- Bash(git push*)
|
||||||
|
- Bash(git status*)
|
||||||
|
- Bash(git log*)
|
||||||
|
- Bash(git remote*)
|
||||||
|
- Bash(git diff*)
|
||||||
|
deny:
|
||||||
|
- Bash(tea issues edit*)
|
||||||
|
- Bash(tea issues close*)
|
||||||
|
- Bash(tea pulls merge*)
|
||||||
|
- Bash(tea pulls close*)
|
||||||
|
- Bash(gh *)
|
||||||
|
- Bash(glab *)
|
||||||
|
- Edit(*)
|
||||||
|
system_prompt_file: .wave/personas/gitea-commenter.md
|
||||||
|
temperature: 0.2
|
||||||
|
gitea-enhancer:
|
||||||
|
adapter: claude
|
||||||
|
description: Gitea issue enhancement and improvement
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Bash(tea issues edit*)
|
||||||
|
- Bash(tea issues view*)
|
||||||
|
- Bash(tea --version)
|
||||||
|
deny:
|
||||||
|
- Bash(tea issues create*)
|
||||||
|
- Bash(tea issues close*)
|
||||||
|
- Bash(gh *)
|
||||||
|
- Bash(glab *)
|
||||||
|
- Edit(*)
|
||||||
|
system_prompt_file: .wave/personas/gitea-enhancer.md
|
||||||
|
temperature: 0.2
|
||||||
|
gitea-scoper:
|
||||||
|
adapter: claude
|
||||||
|
description: Gitea epic analysis, decomposition, and sub-issue creation
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Bash(tea issues create*)
|
||||||
|
- Bash(tea issues view*)
|
||||||
|
- Bash(tea issues list*)
|
||||||
|
- Bash(tea --version)
|
||||||
|
deny:
|
||||||
|
- Bash(tea issues edit*)
|
||||||
|
- Bash(tea issues close*)
|
||||||
|
- Bash(gh *)
|
||||||
|
- Bash(glab *)
|
||||||
|
- Edit(*)
|
||||||
|
system_prompt_file: .wave/personas/gitea-scoper.md
|
||||||
|
temperature: 0.1
|
||||||
|
implementer:
|
||||||
|
adapter: claude
|
||||||
|
description: Execution specialist for code changes and structured output
|
||||||
|
model: claude-opus
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Bash
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
deny:
|
||||||
|
- Bash(rm -rf /*)
|
||||||
|
- Bash(sudo *)
|
||||||
|
system_prompt_file: .wave/personas/implementer.md
|
||||||
|
temperature: 0.3
|
||||||
|
navigator:
|
||||||
|
adapter: claude
|
||||||
|
description: Read-only codebase exploration and analysis
|
||||||
|
model: claude-haiku
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- Bash(git log*)
|
||||||
|
- Bash(git status*)
|
||||||
|
deny:
|
||||||
|
- Edit(*)
|
||||||
|
- Bash(git commit*)
|
||||||
|
- Bash(git push*)
|
||||||
|
system_prompt_file: .wave/personas/navigator.md
|
||||||
|
temperature: 0.1
|
||||||
|
philosopher:
|
||||||
|
adapter: claude
|
||||||
|
description: Architecture design and specification
|
||||||
|
model: claude-opus
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Bash
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
deny: []
|
||||||
|
system_prompt_file: .wave/personas/philosopher.md
|
||||||
|
temperature: 0.3
|
||||||
|
planner:
|
||||||
|
adapter: claude
|
||||||
|
description: Task breakdown and planning
|
||||||
|
model: claude-haiku
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Bash
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
deny: []
|
||||||
|
system_prompt_file: .wave/personas/planner.md
|
||||||
|
temperature: 0.2
|
||||||
|
provocateur:
|
||||||
|
adapter: claude
|
||||||
|
description: Creative challenger for divergent thinking and complexity hunting
|
||||||
|
model: claude-opus
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- Bash(wc *)
|
||||||
|
- Bash(git log*)
|
||||||
|
- Bash(git diff*)
|
||||||
|
- Bash(find*)
|
||||||
|
- Bash(ls*)
|
||||||
|
deny:
|
||||||
|
- Edit(*)
|
||||||
|
- Bash(git commit*)
|
||||||
|
- Bash(git push*)
|
||||||
|
- Bash(rm*)
|
||||||
|
system_prompt_file: .wave/personas/provocateur.md
|
||||||
|
temperature: 0.8
|
||||||
|
researcher:
|
||||||
|
adapter: claude
|
||||||
|
description: Deep codebase research and analysis
|
||||||
|
model: claude-opus
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Bash
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- WebSearch
|
||||||
|
- WebFetch
|
||||||
|
deny: []
|
||||||
|
system_prompt_file: .wave/personas/researcher.md
|
||||||
|
temperature: 0.1
|
||||||
|
reviewer:
|
||||||
|
adapter: claude
|
||||||
|
description: Code review and quality checks
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- Bash
|
||||||
|
deny:
|
||||||
|
- Write(*.go)
|
||||||
|
- Write(*.ts)
|
||||||
|
- Write(*.py)
|
||||||
|
- Write(*.rs)
|
||||||
|
- Edit(*)
|
||||||
|
- Bash(rm *)
|
||||||
|
- Bash(git push*)
|
||||||
|
- Bash(git commit*)
|
||||||
|
system_prompt_file: .wave/personas/reviewer.md
|
||||||
|
temperature: 0.1
|
||||||
|
summarizer:
|
||||||
|
adapter: claude
|
||||||
|
description: Context compaction for relay handoffs
|
||||||
|
model: claude-haiku
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Bash
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
deny: []
|
||||||
|
system_prompt_file: .wave/personas/summarizer.md
|
||||||
|
temperature: 0
|
||||||
|
supervisor:
|
||||||
|
adapter: claude
|
||||||
|
description: Work supervision and quality evaluation
|
||||||
|
model: claude-opus
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- Bash
|
||||||
|
deny:
|
||||||
|
- Edit(*)
|
||||||
|
- Bash(git push*)
|
||||||
|
- Bash(git commit*)
|
||||||
|
- Bash(rm*)
|
||||||
|
system_prompt_file: .wave/personas/supervisor.md
|
||||||
|
temperature: 0.2
|
||||||
|
synthesizer:
|
||||||
|
adapter: claude
|
||||||
|
description: Structured synthesis of analysis findings into actionable JSON proposals
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Edit
|
||||||
|
- Bash
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
deny: []
|
||||||
|
system_prompt_file: .wave/personas/synthesizer.md
|
||||||
|
temperature: 0.2
|
||||||
|
validator:
|
||||||
|
adapter: claude
|
||||||
|
description: Skeptical analysis and verification of findings against source code
|
||||||
|
permissions:
|
||||||
|
allowed_tools:
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Glob
|
||||||
|
- Grep
|
||||||
|
- Bash(wc *)
|
||||||
|
- Bash(git log*)
|
||||||
|
- Bash(git diff*)
|
||||||
|
deny:
|
||||||
|
- Edit(*)
|
||||||
|
- Bash(git commit*)
|
||||||
|
- Bash(git push*)
|
||||||
|
- Bash(rm*)
|
||||||
|
system_prompt_file: .wave/personas/validator.md
|
||||||
|
temperature: 0.1
|
||||||
|
project:
|
||||||
|
build_command: npm run build
|
||||||
|
flavour: node
|
||||||
|
format_command: npm run format
|
||||||
|
language: javascript
|
||||||
|
lint_command: ""
|
||||||
|
skill: javascript
|
||||||
|
source_glob: '*.{js,jsx,ts,tsx}'
|
||||||
|
test_command: npm test
|
||||||
|
runtime:
|
||||||
|
audit:
|
||||||
|
log_all_file_operations: false
|
||||||
|
log_all_tool_calls: true
|
||||||
|
log_dir: .wave/traces/
|
||||||
|
default_timeout_minutes: 30
|
||||||
|
max_concurrent_workers: 5
|
||||||
|
meta_pipeline:
|
||||||
|
max_depth: 2
|
||||||
|
max_total_steps: 20
|
||||||
|
max_total_tokens: 500000
|
||||||
|
timeout_minutes: 60
|
||||||
|
relay:
|
||||||
|
strategy: summarize_to_checkpoint
|
||||||
|
token_threshold_percent: 80
|
||||||
|
workspace_root: .wave/workspaces
|
||||||
Reference in New Issue
Block a user