diff --git a/.claude/settings.json b/.claude/settings.json
index 6b7569f..b147d79 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,15 +1,6 @@
{
"permissions": {
"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/**)"]
},
diff --git a/.gitignore b/.gitignore
index f6d7323..793b25a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,9 @@ coverage
# Auto-Claude
.auto-claude
.worktrees
+
+# Wave ephemeral data
+.wave/workspaces
+.wave/traces
+.wave/artifacts
+.wave/output
diff --git a/src/impl/CodeEditor.js b/src/impl/CodeEditor.js
index d34bd4f..8584fad 100644
--- a/src/impl/CodeEditor.js
+++ b/src/impl/CodeEditor.js
@@ -13,7 +13,7 @@ import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-pl
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
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(
{
"&": {
@@ -21,10 +21,10 @@ const crispyTheme = EditorView.theme(
color: "#c8c8d0"
},
".cm-content": {
- caretColor: "#9b6dd4"
+ caretColor: "#d46d9b"
},
".cm-cursor, .cm-dropCursor": {
- borderLeftColor: "#9b6dd4"
+ borderLeftColor: "#d46d9b"
},
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "#3e3e4a"
@@ -35,10 +35,10 @@ const crispyTheme = EditorView.theme(
},
".cm-searchMatch": {
backgroundColor: "#3e3e4a",
- outline: "1px solid #9b6dd4"
+ outline: "1px solid #d46d9b"
},
".cm-searchMatch.cm-searchMatch-selected": {
- backgroundColor: "rgba(155, 109, 212, 0.3)"
+ backgroundColor: "rgba(212, 109, 155, 0.3)"
},
".cm-activeLine": {
backgroundColor: "#2e2e3a"
@@ -63,13 +63,13 @@ const crispyTheme = EditorView.theme(
// Default syntax highlighting (blue accent)
const defaultHighlight = HighlightStyle.define([
- { tag: tags.keyword, color: "#c9a6eb" },
+ { tag: tags.keyword, color: "#eba6c9" },
{ tag: tags.operator, color: "#cdd6f4" },
{ tag: tags.variableName, color: "#89b4fa" },
{ tag: tags.propertyName, color: "#89b4fa" },
{ tag: tags.attributeName, color: "#89b4fa" },
{ tag: tags.className, color: "#89b4fa" },
- { tag: tags.tagName, color: "#c9a6eb" },
+ { tag: tags.tagName, color: "#eba6c9" },
{ tag: tags.string, color: "#a6e3a1" },
{ tag: tags.number, color: "#fab387" },
{ tag: tags.bool, color: "#fab387" },
@@ -79,20 +79,20 @@ const defaultHighlight = HighlightStyle.define([
{ tag: tags.punctuation, color: "#cdd6f4" },
{ tag: tags.definition(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.color, color: "#f9e2af" }
]);
-// CSS section highlighting (purple selectors)
+// CSS section highlighting (pink selectors)
const cssHighlight = HighlightStyle.define([
- { tag: tags.keyword, color: "#c9a6eb" },
+ { tag: tags.keyword, color: "#eba6c9" },
{ tag: tags.operator, color: "#cdd6f4" },
- { tag: tags.variableName, color: "#c9a6eb" },
+ { tag: tags.variableName, color: "#eba6c9" },
{ tag: tags.propertyName, color: "#89b4fa" },
{ tag: tags.attributeName, color: "#89b4fa" },
- { tag: tags.className, color: "#c9a6eb" },
- { tag: tags.tagName, color: "#c9a6eb" },
+ { tag: tags.className, color: "#eba6c9" },
+ { tag: tags.tagName, color: "#eba6c9" },
{ tag: tags.string, color: "#a6e3a1" },
{ tag: tags.number, color: "#fab387" },
{ tag: tags.bool, color: "#fab387" },
@@ -100,9 +100,9 @@ const cssHighlight = HighlightStyle.define([
{ tag: tags.comment, color: "#6c7086", fontStyle: "italic" },
{ tag: tags.bracket, 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.atom, color: "#c9a6eb" },
+ { tag: tags.atom, color: "#eba6c9" },
{ tag: tags.unit, color: "#a6e3a1" },
{ tag: tags.color, color: "#f9e2af" }
]);
diff --git a/src/main.css b/src/main.css
index fca0d98..93c119f 100644
--- a/src/main.css
+++ b/src/main.css
@@ -1,15 +1,15 @@
/* ================= BASE THEME ================= */
:root {
/* Primary colors */
- --primary-color: #5e4b8b;
- --primary-light: #8a77b5;
- --primary-dark: #724a95;
+ --primary-color: #c9507a;
+ --primary-light: #e077a0;
+ --primary-dark: #a83d65;
- /* Section colors (default to CSS purple) */
- --section-color: #9163b8;
- --section-color-light: #a87dc8;
- --section-color-dark: #724a95;
- --section-color-rgb: 145, 99, 184;
+ /* Section colors (default to CSS pink) */
+ --section-color: #d95a8a;
+ --section-color-light: #e87da6;
+ --section-color-dark: #b84472;
+ --section-color-rgb: 217, 90, 138;
/* Secondary colors */
--secondary-color: #444444;
@@ -23,9 +23,9 @@
--white-text: #ffffff;
/* Background colors */
- --bg-color: #f8f7fc;
+ --bg-color: #fcf7f9;
--panel-bg: #ffffff;
- --code-bg: #f7f5fa;
+ --code-bg: #faf5f7;
--editor-bg: #1e1e1e;
--editor-highlight: #303030;
@@ -34,9 +34,9 @@
/* Status colors */
--info-color: #7a93fe;
- --success-color: #9b6dd4;
- --success-color-dark: #7c4dff;
- --success-color-light: #c9b8e8;
+ --success-color: #d46d9b;
+ --success-color-dark: #b84472;
+ --success-color-light: #e8b8d0;
--error-color: #cb6e75;
--danger-color: #dc3545;
@@ -252,11 +252,11 @@ kbd {
}
.logo h1 .code-text {
- color: #9163b8;
+ color: #d95a8a;
}
.logo h1 .crispies-text {
- background: #9163b8;
+ background: #d95a8a;
color: white;
padding: 0.15rem 0.35rem;
border-radius: 4px;
@@ -468,7 +468,7 @@ kbd {
.completion-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
- background: linear-gradient(135deg, #9163b8, #d45aa0, #1aafb8, #7c4dff);
+ background: linear-gradient(135deg, #d95a8a, #d45aa0, #1aafb8, #ff4d88);
color: white;
font-size: 0.7rem;
font-weight: 600;
@@ -714,7 +714,7 @@ kbd {
position: absolute;
inset: var(--spacing-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);
opacity: 0;
animation: spin-glow 3s ease-out forwards;
@@ -727,7 +727,7 @@ kbd {
position: absolute;
inset: var(--spacing-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);
opacity: 0.35;
pointer-events: none;
@@ -816,7 +816,7 @@ kbd {
border: 6px solid transparent;
background:
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 {
@@ -824,7 +824,7 @@ kbd {
border: 6px solid transparent;
background:
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;
overflow: visible;
}
@@ -844,7 +844,7 @@ kbd {
font-weight: 800;
letter-spacing: 0.05em;
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;
z-index: 10;
pointer-events: none;
@@ -1142,7 +1142,7 @@ nav.sidebar-section:not(.sidebar-nav-mobile) {
.progress-fill {
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%;
border-radius: 4px;
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
- 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(2) { background: #c459a2; } /* ~28% */
.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(6) { background: #33a3b6; } /* ~62% */
.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 {
color: white;
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 {
@@ -2590,7 +2590,7 @@ input:checked + .toggle-slider::before {
margin-top: var(--spacing-lg);
text-align: center;
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);
color: var(--light-text);
font-size: 0.9rem;
@@ -2840,7 +2840,7 @@ input:checked + .toggle-slider::before {
}
.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));
padding: 0.1rem 0.35rem;
border-radius: 4px;
@@ -2950,7 +2950,7 @@ input:checked + .toggle-slider::before {
/* Inline code in topic text */
.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));
padding: 0.15rem 0.4rem;
border-radius: 4px;
@@ -3592,7 +3592,7 @@ input:checked + .toggle-slider::before {
}
/* ================= SECTION COLOR CODING ================= */
-/* CSS Section uses default purple from :root */
+/* CSS Section uses default pink from :root */
/* HTML Section - Pink (balanced) */
[data-section="html"] {
@@ -3620,7 +3620,7 @@ input:checked + .toggle-slider::before {
/* Apply section colors to nav links */
.nav-link[data-section="css"] {
- color: #9163b8;
+ color: #d95a8a;
}
.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"].active {
- background: rgba(145, 99, 184, 0.1);
- color: #724a95;
+ background: rgba(217, 90, 138, 0.1);
+ color: #a83d65;
}
.nav-link[data-section="html"]:hover,
@@ -3661,12 +3661,12 @@ input:checked + .toggle-slider::before {
/* Hint section colors */
body[data-section="css"] .hint {
- background: rgba(145, 99, 184, 0.3);
+ background: rgba(217, 90, 138, 0.3);
border-left-color: #a98cd6;
}
body[data-section="css"] .hint-progress {
- background: #9163b8;
+ background: #d95a8a;
}
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="flexbox"],
.ref-nav-link[data-ref="grid"] {
- color: #9163b8;
+ color: #d95a8a;
}
.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="grid"]:hover,
.ref-nav-link[data-ref="grid"].active {
- background: rgba(145, 99, 184, 0.15);
- color: #724a95;
+ background: rgba(217, 90, 138, 0.15);
+ color: #a83d65;
}
.ref-nav-link[data-ref="html"] {
@@ -3745,21 +3745,21 @@ body[data-section="markdown"] .hint-progress {
/* CodeMirror section color overrides */
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-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-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 {
- 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 {
@@ -3818,12 +3818,12 @@ body[data-section="markdown"] .cm-editor .cm-activeLine {
/* Module pill section colors */
body[data-section="css"] .module-pill {
- background: rgba(145, 99, 184, 0.1);
- color: #9163b8;
+ background: rgba(217, 90, 138, 0.1);
+ color: #d95a8a;
}
body[data-section="css"] .module-pill .level-indicator {
- color: #724a95;
+ color: #a83d65;
}
body[data-section="html"] .module-pill {
@@ -3855,7 +3855,7 @@ body[data-section="markdown"] .module-pill .level-indicator {
/* Code block border section colors */
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 {
@@ -3889,7 +3889,7 @@ body[data-section="markdown"] .code-block .cm-editor .cm-line {
/* Task instruction bubble section colors */
[data-section="css"] .task-instruction {
- background: rgba(145, 99, 184, 0.92);
+ background: rgba(217, 90, 138, 0.92);
}
[data-section="html"] .task-instruction {
@@ -3906,7 +3906,7 @@ body[data-section="markdown"] .code-block .cm-editor .cm-line {
/* Section page progress bar colors */
body[data-section="css"] .section-progress-bar .progress-fill {
- background: #9163b8;
+ background: #d95a8a;
}
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 */
[data-section="css"] .section-hero h1 {
- color: #9163b8;
+ color: #d95a8a;
}
[data-section="html"] .section-hero h1 {
@@ -3940,7 +3940,7 @@ body[data-section="markdown"] .section-progress-bar .progress-fill {
/* Lesson title h2 section colors */
body[data-section="css"] #lesson-title {
- color: #9163b8;
+ color: #d95a8a;
}
body[data-section="html"] #lesson-title {
diff --git a/tests/unit/renderer-extended.test.js b/tests/unit/renderer-extended.test.js
new file mode 100644
index 0000000..14d5084
--- /dev/null
+++ b/tests/unit/renderer-extended.test.js
@@ -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 = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ 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 = `
+
+
+
+ L1
+ L2
+
+
+
+
+
+ L1
+
+
+ `;
+ // 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");
+ });
+ });
+});
diff --git a/tests/unit/router.test.js b/tests/unit/router.test.js
new file mode 100644
index 0000000..61f835a
--- /dev/null
+++ b/tests/unit/router.test.js
@@ -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");
+ });
+ });
+});
diff --git a/tests/unit/sections.test.js b/tests/unit/sections.test.js
new file mode 100644
index 0000000..2d8a680
--- /dev/null
+++ b/tests/unit/sections.test.js
@@ -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");
+ });
+ });
+});
diff --git a/tests/unit/validator-extended.test.js b/tests/unit/validator-extended.test.js
new file mode 100644
index 0000000..0eb8398
--- /dev/null
+++ b/tests/unit/validator-extended.test.js
@@ -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("Hello
", {
+ 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("Title Content
", {
+ mode: "html",
+ validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
+ });
+ expect(result.isValid).toBe(true);
+ });
+
+ test("sibling_NonAdjacentButAfter_Passes", () => {
+ const result = validateUserCode("Title Middle Content
", {
+ mode: "html",
+ validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
+ });
+ expect(result.isValid).toBe(true);
+ });
+
+ test("sibling_WrongOrder_Fails", () => {
+ const result = validateUserCode("Content
Title ", {
+ 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("Content
", {
+ 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("Title Only span ", {
+ mode: "html",
+ validations: [{ type: "sibling", value: { first: "h1", then: "p" } }]
+ });
+ expect(result.isValid).toBe(false);
+ });
+
+ test("sibling_DefaultMessage_ContainsBothSelectors", () => {
+ const result = validateUserCode("Only 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("
Title ", {
+ 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("Hello
", {
+ mode: "html",
+ validations: [{ type: "not_contains", value: "class=" }]
+ });
+ expect(result.isValid).toBe(true);
+ });
+
+ test("htmlNotContains_PresentText_Fails", () => {
+ const result = validateUserCode('Hello
', {
+ 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('Hello
', {
+ 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(' ', {
+ mode: "html",
+ validations: [{ type: "regex", value: 'alt="[^"]+"' }]
+ });
+ expect(result.isValid).toBe(true);
+ });
+
+ test("htmlRegex_NonMatchingPattern_Fails", () => {
+ const result = validateUserCode(' ', {
+ 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("Hello
", {
+ mode: "html",
+ validations: [{ type: "regex", value: "" }]
+ });
+ 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(" Hello
", {
+ 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("", {
+ mode: "html",
+ validations: [{ type: "element_count", value: { selector: "li" } }]
+ });
+ expect(result.isValid).toBe(true);
+ });
+
+ test("elementCount_NoCountNoMin_NoElements_Fails", () => {
+ const result = validateUserCode("", {
+ 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("Hello
", {
+ 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(' ', {
+ 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(" ", {
+ 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("Hello
", {
+ mode: "html",
+ validations: [{ type: "element_text", value: { selector: "button", text: "Submit" } }]
+ });
+ expect(result.isValid).toBe(false);
+ });
+
+ test("elementText_EmptyTextContent_FailsForNonEmptyExpected", () => {
+ const result = validateUserCode(" ", {
+ mode: "html",
+ validations: [{ type: "element_text", value: { selector: "button", text: "Submit" } }]
+ });
+ expect(result.isValid).toBe(false);
+ });
+
+ test("elementText_EmptyExpectedText_MatchesEmptyElement", () => {
+ const result = validateUserCode(" ", {
+ 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 = "";
+ 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("Title Content
", {
+ 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("Title ", {
+ 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");
+ });
+ });
+});