Files
STCourses/packages/feature-learner/test/markdown.test.ts
T

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("&lt;script&gt;alert(1)&lt;/script&gt;<kbd>x</kbd>&lt;img src=x onerror=alert(1)&gt;<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("![board](img/board.png) 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>");
});