127 lines
5.3 KiB
TypeScript
127 lines
5.3 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { renderHintsHtml, renderLessonHtml, resolveHref, sanitizeHtml } from "../src/web/markdown";
|
|
|
|
const tracks = [{ id: "js", label: "JavaScript" }, { id: "scala", label: "Scala 3" }, { id: "fsharp", label: "F#" }];
|
|
const opts = { courseId: "pure-snake", baseFile: "course/01-purity.md", tracks, lessonFiles: { "course/02-data.md": "m02-text" } };
|
|
|
|
const variants = `Intro
|
|
|
|
**JavaScript**
|
|
\`\`\`js
|
|
export const range = (n) => ...
|
|
\`\`\`
|
|
**Scala**
|
|
\`\`\`scala
|
|
def range(n: Int): List[Int]
|
|
\`\`\`
|
|
**F#**
|
|
\`\`\`fsharp
|
|
let range (n: int) : int list
|
|
\`\`\`
|
|
|
|
Tests: after.
|
|
`;
|
|
|
|
test("track switcher keeps only the chosen variant and its label", () => {
|
|
const scala = renderLessonHtml(variants, { ...opts, track: "scala" });
|
|
expect(scala).toContain("def range");
|
|
expect(scala).not.toContain("export const range");
|
|
expect(scala).not.toContain("let range");
|
|
expect(scala).toContain("<strong>Scala</strong>");
|
|
expect(scala).not.toContain("<strong>JavaScript</strong>");
|
|
expect(scala).toContain("Tests: after.");
|
|
const fs = renderLessonHtml(variants, { ...opts, track: "fsharp" });
|
|
expect(fs).toContain("let range");
|
|
expect(fs).not.toContain("def range");
|
|
});
|
|
|
|
test("a lone code block in one track stays visible for other tracks", () => {
|
|
const md = "Text\n\n```js\nexport const pipe = 1\n```\n\nMore";
|
|
expect(renderLessonHtml(md, { ...opts, track: "scala" })).toContain("export const pipe");
|
|
});
|
|
|
|
test("a variant group without the chosen track keeps every variant", () => {
|
|
const md = "```js\nA\n```\n\n```scala\nB\n```\n";
|
|
const html = renderLessonHtml(md, { ...opts, track: "fsharp" });
|
|
expect(html).toContain("A");
|
|
expect(html).toContain("B");
|
|
});
|
|
|
|
test("headings get GitHub ids and the first heading can be dropped", () => {
|
|
const html = renderLessonHtml("# Module 1\n\n## Why this module\n\n### Exercise 1.1 — `range`\n\n## Why this module\n", { ...opts, stripFirstHeading: true });
|
|
expect(html).not.toContain("Module 1");
|
|
expect(html).toContain('<h2 id="why-this-module">');
|
|
expect(html).toContain('<h3 id="exercise-11--range">');
|
|
expect(html).toContain('<h2 id="why-this-module-1">');
|
|
});
|
|
|
|
test("details become folds; consecutive hints become ordered tiers", () => {
|
|
const md = `<details><summary>Think about it first, then open the explanation</summary>
|
|
|
|
All segments move **at once**.
|
|
|
|
</details>
|
|
|
|
## Hints
|
|
|
|
<details><summary>Exercise 1.1 (range) — nudge</summary>
|
|
|
|
N1
|
|
|
|
</details>
|
|
|
|
<details><summary>Exercise 1.1 (range) — stronger hint</summary>
|
|
|
|
S1
|
|
|
|
</details>
|
|
|
|
<details><summary>Exercise 1.2 (dropLast) — nudge</summary>
|
|
|
|
N2
|
|
|
|
</details>
|
|
`;
|
|
const html = renderLessonHtml(md, opts);
|
|
expect(html).toContain('<details class="fold"><summary>');
|
|
expect(html).toContain("<strong>at once</strong>");
|
|
expect((html.match(/data-tier="1" data-tiers="2"/g) ?? []).length).toBe(1);
|
|
expect(html).toContain('data-tier="2" data-tiers="2"');
|
|
expect(html).toContain("1 of 2");
|
|
// the 1.2 hint starts a new group of its own
|
|
expect(html).toMatch(/<details class="fold" data-hint="1"><summary>[\s\S]*Exercise 1\.2 \(dropLast\)/);
|
|
expect((html.match(/<\/div><\/details>/g) ?? []).length).toBe(4);
|
|
});
|
|
|
|
test("systemic-design hint numbering forms one group", () => {
|
|
const md = "<details><summary>Hint 1 — nudge</summary>\n\na\n\n</details>\n<details><summary>Hint 2 — concept</summary>\n\nb\n\n</details>\n<details><summary>Hint 3 — pseudo-code</summary>\n\nc\n\n</details>\n";
|
|
const html = renderLessonHtml(md, opts);
|
|
expect(html).toContain('data-tier="3" data-tiers="3"');
|
|
});
|
|
|
|
test("raw HTML is reduced to safe tags", () => {
|
|
expect(sanitizeHtml('<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>')).toBe("<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>");
|
|
const html = renderLessonHtml('<div onclick="x()">hi</div>\n\n[x](javascript:alert(1)) <span style="color:red">s</span>', opts);
|
|
expect(html).not.toContain("<div");
|
|
expect(html).not.toContain("javascript:");
|
|
expect(html).not.toContain('<span style');
|
|
});
|
|
|
|
test("links and images resolve inside the course", () => {
|
|
expect(resolveHref("02-data.md#state", opts, "link")).toEqual({ url: "/c/pure-snake/m02-text#state", external: false });
|
|
expect(resolveHref("../fixtures/board 1.json", opts, "link")).toEqual({ url: "/api/media/pure-snake/fixtures/board%201.json", external: false });
|
|
expect(resolveHref("../../etc/passwd", opts, "link")).toBeNull();
|
|
expect(resolveHref("https://example.com", opts, "link")).toEqual({ url: "https://example.com", external: true });
|
|
expect(resolveHref("img/a.png", opts, "image")).toEqual({ url: "/api/media/pure-snake/course/img/a.png", external: false });
|
|
const html = renderLessonHtml(" and [ext](https://x.org)", opts);
|
|
expect(html).toContain('<img src="/api/media/pure-snake/course/img/board.png" alt="board" loading="lazy">');
|
|
expect(html).toContain('target="_blank" rel="noopener noreferrer"');
|
|
});
|
|
|
|
test("exercise hints render as one tier group", () => {
|
|
const html = renderHintsHtml([{ label: "nudge", body: "Which **operator**?" }, { label: "near-solution", body: "`a + b`" }], { courseId: "sample", baseFile: null });
|
|
expect(html).toContain("Nudge");
|
|
expect(html).toContain('data-tier="2" data-tiers="2"');
|
|
expect(html).toContain("<strong>operator</strong>");
|
|
});
|