Tasks 10-12 of the WP-A plan in one commit (the route modules import each other): - header extras portalled into the Phase 0 header: course switcher, search, Reading/Work, account - course frame with outline sidebar (mobile overlay, Escape closes), progress, gating locks - library: featured course, continue row, New and collection rows, course list, filters, search - LessonView: text (track switcher, folds, ordered hint tiers), video (resume, done near the end, timestamp notes, transcript), companion decks rendered with pdf.js (thumbs, arrows, fullscreen, per-slide notes), code files (tree, tabs, copy, zip, command), exercises (Web Worker runner or local command with optional pasted evidence), floating action bar - notes list, account (email/password, providers, SSO by email) and owner admin pages - pdf.js worker served from /api/learner/pdf.worker.mjs - browser entry @stc/feature-learner/web (the package index also carries the server half) Co-Authored-By: Claude Opus 5.5 <[email protected]>
109 lines
5.4 KiB
TypeScript
109 lines
5.4 KiB
TypeScript
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<Record<string, LessonPayload>> {
|
|
const db = await connect("sqlite://:memory:");
|
|
await runMigrations(db);
|
|
const { call } = await makeApp(db);
|
|
const out: Record<string, LessonPayload> = {};
|
|
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(<MemoryRouter>{el}</MemoryRouter>);
|
|
|
|
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(<LessonView lesson={l["m1-intro"]!} />);
|
|
expect(text).toContain('data-type="text"');
|
|
expect(text).toContain('data-look="reading"');
|
|
expect(text).toContain("<h1 class=\"title\">Introduction</h1>");
|
|
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(<LessonView lesson={l["m1-video"]!} signedIn={false} />);
|
|
expect(video).toContain('data-type="video"');
|
|
expect(video).toMatch(/<video[^>]+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(<LessonView lesson={l["m2-ex-add"]!} look="reading" signedIn />);
|
|
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(<LessonView lesson={l["m1-video"]!} preview />);
|
|
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(<LessonView lesson={locked} signedIn />);
|
|
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(<LessonView lesson={lesson} track="js" />);
|
|
expect(js).toContain("const a = 1");
|
|
expect(js).not.toContain("val a = 1");
|
|
expect(js).toContain("<code>x</code>"); // inline code in the title
|
|
expect(js).toContain('aria-label="Track"');
|
|
expect(js).toContain("npm test");
|
|
const scala = html(<LessonView lesson={lesson} track="scala" />);
|
|
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");
|
|
});
|