import { expect, test } from "bun:test"; import { renderToString } from "react-dom/server"; import { MemoryRouter } from "react-router-dom"; import { connect } from "@stc/db"; import { LessonView } from "../src/web"; import { LessonView as FromIndex } from "../src/index"; import type { LessonPayload } from "../src/shared/types"; import { localCommandFor } from "../src/web/lesson/Exercise"; import { firstStamp } from "../src/web/lesson/notes"; import { buildTree } from "../src/web/lesson/CodeFiles"; import { runMigrations } from "../src/server/migrate"; import { makeApp } from "./app"; async function sampleLessons(ids: string[]): Promise> { const db = await connect("sqlite://:memory:"); await runMigrations(db); const { call } = await makeApp(db); const out: Record = {}; for (const id of ids) out[id] = (await (await call("GET", `/api/courses/sample/lessons/${id}`)).json()) as LessonPayload; await db.close(); return out; } const html = (el: React.ReactElement) => renderToString({el}); test("LessonView is exported from the package index and the web entry", () => { expect(FromIndex).toBe(LessonView); }); test("LessonView renders every sample lesson type", async () => { const l = await sampleLessons(["m1-intro", "m1-video", "m2-ex-add"]); const text = html(); expect(text).toContain('data-type="text"'); expect(text).toContain('data-look="reading"'); expect(text).toContain("

Introduction

"); expect(text).toContain("Welcome to the sample course"); expect(text).toContain("Files for this section"); // module m1 has companion code expect(text).toContain("/api/courses/sample/code/m1/zip"); const video = html(); expect(video).toContain('data-type="video"'); expect(video).toMatch(/]+src="\/api\/media\/sample\/media\/clip\.mp4"/); expect(video).toContain('data-deck="d1"'); // companion deck expect(video).toContain("Transcript"); expect(video).toContain("Sign in"); // notes need an account const ex = html(); expect(ex).toContain('data-look="work"'); // exercises always use Work expect(ex).toContain("Run tests"); expect(ex).toContain("return a - b;"); // the starter is in the editor expect(ex).toContain('data-tier="1"'); expect(ex).toContain('data-tiers="2"'); expect(ex).toContain("node --test"); expect(ex).not.toContain("return a + b"); // no solution for learners }); test("preview mode renders without notes or saved-attempt UI", async () => { const l = await sampleLessons(["m1-video"]); const out = html(); expect(out).not.toContain("Your notes"); expect(out).not.toContain("Insert time"); }); test("a locked lesson shows the lock notice and no content", () => { const locked: LessonPayload = { courseId: "c", courseTitle: "C", lesson: { id: "l2", title: "Later", type: "text", minutes: null }, module: { id: "m2", title: "Two", index: 1 }, locked: true, markdown: null, baseFile: null, trackMarkdown: {}, tracks: [], video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: null, prev: { id: "l1", title: "Earlier" }, next: null, lessonFiles: {}, }; const out = html(); expect(out).toContain("This module is locked."); expect(out).toMatch(/Back to ()?Earlier/); }); test("track variants: the chosen track's markdown and code are shown", () => { const lesson: LessonPayload = { courseId: "c", courseTitle: "C", lesson: { id: "t", title: "Tracks `x`", type: "text", minutes: 5 }, module: { id: "m", title: "M", index: 0 }, locked: false, markdown: "# T\n\n**JavaScript**\n\n```js\nconst a = 1\n```\n\n**Scala 3**\n\n```scala\nval a = 1\n```\n", baseFile: "course/t.md", trackMarkdown: {}, tracks: [{ id: "js", label: "JavaScript" }, { id: "scala", label: "Scala 3" }], video: null, transcript: null, hints: [], decks: [], code: null, attachments: [], browser: null, localCommand: { js: "npm test", scala: "sbt test" }, prev: null, next: null, lessonFiles: {}, }; const js = html(); expect(js).toContain("const a = 1"); expect(js).not.toContain("val a = 1"); expect(js).toContain("x"); // inline code in the title expect(js).toContain('aria-label="Track"'); expect(js).toContain("npm test"); const scala = html(); expect(scala).toContain("val a = 1"); expect(scala).toContain("sbt test"); }); test("helpers: local command per track, note timestamps, code tree", () => { expect(localCommandFor({ js: "a", scala: "b" }, "scala")).toBe("b"); expect(localCommandFor({ js: "a" }, "fsharp")).toBe("a"); expect(localCommandFor("x", null)).toBe("x"); expect(firstStamp("see [1:05] and [0:10]")).toBe(65); expect(firstStamp("[1:02:03] x")).toBe(3723); expect(firstStamp("no stamp")).toBeNull(); const t = buildTree([{ path: "a.txt", size: 1 }, { path: "src/b.ts", size: 2 }, { path: "src/x/c.ts", size: 3 }]); expect(t.files.map((f) => f.name)).toEqual(["a.txt"]); expect(t.dirs.get("src")!.files.map((f) => f.path)).toEqual(["src/b.ts"]); expect(t.dirs.get("src")!.dirs.get("x")!.files[0]!.path).toBe("src/x/c.ts"); }); test("the exercise editor: no Tab capture, Enter keeps the indentation", async () => { const { newlineWithIndent } = await import("../src/web/lesson/Exercise"); expect(newlineWithIndent("function f() {\n return 1", 26)).toBe("\n "); expect(newlineWithIndent("x", 1)).toBe("\n"); expect(newlineWithIndent("\t\tx", 3)).toBe("\n\t\t"); const src = await Bun.file(new URL("../src/web/lesson/Exercise.tsx", import.meta.url)).text(); expect(src).not.toMatch(/e\.key === "Tab"/); });