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]>
100 lines
5.2 KiB
TypeScript
100 lines
5.2 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { renderToString } from "react-dom/server";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { learnerWeb } from "../src/web";
|
|
import { filterCourses, LibraryView } from "../src/web/library/LibraryPage";
|
|
import { CourseFrame, moduleNumber } from "../src/web/shell/CourseFrame";
|
|
import { nextLesson } from "../src/web/course/pages";
|
|
import { safeNext } from "../src/web/account/AccountPage";
|
|
import type { CourseCard, CoursePayload, LibraryPayload } from "../src/shared/types";
|
|
|
|
const card = (id: string, over: Partial<CourseCard> = {}): CourseCard => ({
|
|
id, title: id.toUpperCase(), pitch: `${id} pitch`, authors: [], coverUrl: null, accent: null, formats: ["text"], lessonCount: 3,
|
|
minutes: 30, collections: [], tracks: [], source: "authored", addedAt: "2026-10-01T00:00:00Z", status: "ok", progress: 0, doneCount: 0, started: false, ...over,
|
|
});
|
|
|
|
const lib: LibraryPayload = {
|
|
signedIn: true,
|
|
featured: card("snake", { formats: ["text", "exercises"], minutes: 900 }),
|
|
continue: [{ courseId: "snake", lessonId: "m3", lessonTitle: "Pure game rules", moduleTitle: "Pure game rules", moduleIndex: 3, moduleDone: 1, moduleTotal: 5, progress: 0.2 }],
|
|
newest: ["docker", "snake"],
|
|
collections: [{ id: "devops", title: "DevOps", courseIds: ["docker"] }],
|
|
courses: [card("docker", { formats: ["video", "slides"], minutes: 300, started: true, progress: 0.5 }), card("snake", { formats: ["text", "exercises"], minutes: 900 })],
|
|
};
|
|
|
|
test("the learner web feature registers its routes", () => {
|
|
expect(learnerWeb.routes.map((r) => r.path)).toEqual(["/", "/c/:courseId", "/c/:courseId/notes", "/c/:courseId/:lessonId", "/account", "/account/admin"]);
|
|
});
|
|
|
|
test("every route module loads and has a default component", async () => {
|
|
for (const r of learnerWeb.routes) expect(typeof (await r.element()).default).toBe("function");
|
|
});
|
|
|
|
test("library: featured course, continue row, browsable rows and the course list", () => {
|
|
const out = renderToString(<MemoryRouter><LibraryView lib={lib} /></MemoryRouter>);
|
|
expect(out).toContain("Featured");
|
|
expect(out).toContain("SNAKE");
|
|
expect(out).toContain("Next up: <!-- -->Pure game rules");
|
|
expect(out).toContain("New in the library");
|
|
expect(out).toContain("DevOps");
|
|
expect(out).toContain("All courses");
|
|
expect(out).toContain('href="/c/snake/m3"');
|
|
expect(out).not.toMatch(/price|rating|bestseller/i);
|
|
});
|
|
|
|
test("library: search and filters switch to a results list", () => {
|
|
const out = renderToString(<MemoryRouter><LibraryView lib={lib} initialQuery="dock" /></MemoryRouter>);
|
|
expect(out).toContain("Courses matching “dock”");
|
|
expect(out).not.toContain("New in the library");
|
|
});
|
|
|
|
test("library filters by format, length and in-progress", () => {
|
|
const cs = lib.courses;
|
|
expect(filterCourses(cs, { format: "video", length: "any", inProgress: false }).map((c) => c.id)).toEqual(["docker"]);
|
|
expect(filterCourses(cs, { format: "all", length: "long", inProgress: false }).map((c) => c.id)).toEqual(["snake"]);
|
|
expect(filterCourses(cs, { format: "all", length: "medium", inProgress: false }).map((c) => c.id)).toEqual(["docker"]);
|
|
expect(filterCourses(cs, { format: "all", length: "any", inProgress: true }).map((c) => c.id)).toEqual(["docker"]);
|
|
});
|
|
|
|
const course: CoursePayload = {
|
|
id: "sd", title: "Systemic Design", pitch: null, authors: [], accent: "#86c7a6", coverUrl: null,
|
|
tracks: [{ id: "rust", label: "Rust" }, { id: "ts", label: "TypeScript" }], gating: "sequential", source: "authored", status: "ok",
|
|
modules: [
|
|
{ id: "m00", title: "Intro", index: 0, locked: false, hasCode: false, lessons: [{ id: "a", title: "A", type: "text", minutes: 5, hasDeck: false, browser: false }] },
|
|
{ id: "m01", title: "Next", index: 1, locked: true, hasCode: false, lessons: [{ id: "b", title: "Exercise 1.1 — `move`", type: "exercise", minutes: 15, hasDeck: false, browser: false }] },
|
|
],
|
|
progress: { a: { state: "done", positionSeconds: null, updatedAt: "" } }, canEdit: false, signedIn: true, diagnostics: [],
|
|
};
|
|
|
|
test("course frame: outline with progress, locks, current lesson and course accent", () => {
|
|
const out = renderToString(
|
|
<MemoryRouter initialEntries={["/c/sd/a"]}>
|
|
<CourseFrame course={course} currentLessonId="a" look="reading" lookMode="toggle"><p>body</p></CourseFrame>
|
|
</MemoryRouter>,
|
|
);
|
|
expect(out).toContain("--accent:#86c7a6");
|
|
expect(out).toContain('aria-current="page"');
|
|
expect(out).toContain("lr-les done");
|
|
expect(out).toContain("(locked)");
|
|
expect(out).toContain('aria-label="Course progress"');
|
|
expect(out).toContain("Your notes");
|
|
});
|
|
|
|
test("module numbers count from 0 when the first module id ends in 0", () => {
|
|
expect(moduleNumber(course, 1)).toBe(1);
|
|
expect(moduleNumber({ modules: [{ id: "m1" }] }, 0)).toBe(1);
|
|
});
|
|
|
|
test("next lesson skips done lessons and stops at a locked module", () => {
|
|
expect(nextLesson(course)).toEqual(course.modules[0]!.lessons[0]!);
|
|
const open = { ...course, modules: course.modules.map((m) => ({ ...m, locked: false })) };
|
|
expect(nextLesson(open)!.id).toBe("b");
|
|
});
|
|
|
|
test("sign-in only returns to same-site paths", () => {
|
|
expect(safeNext("/c/x")).toBe("/c/x");
|
|
expect(safeNext("//evil.example")).toBe("/");
|
|
expect(safeNext("https://evil.example")).toBe("/");
|
|
expect(safeNext(null)).toBe("/");
|
|
});
|