Files
STCourses/packages/feature-learner/test/web-shell.test.tsx
T
Lucas WintherandClaude Opus 5.5 89c51f013d feat(learner): web shell, library, lesson views (LessonView), decks, code files, exercises, notes, account and admin pages
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]>
2026-10-30 04:53:36 +01:00

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("/");
});