Drops document-level View Transitions and the lesson hold: they dropped clicks for 120-180 ms, froze the screen while route code loaded, and let the dock act on a stale lesson. Navigation now swaps immediately; only the content region enters (opacity and a 6 px rise, 160 ms); a 2 px loading line appears when code or data takes over 150 ms; the dock works from the outline and the URL, so a second Next advances again; focus moves to the new h1 and a polite live region announces it. Route chunks of a feature preload together so the sidebar never drops. Scroll restore stops on user input; pdf.js never starts a load after cleanup. Co-Authored-By: Claude Opus 5.5 <[email protected]>
106 lines
5.7 KiB
TypeScript
106 lines
5.7 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 { lessonNeighbours, 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("/");
|
|
});
|
|
|
|
test("lessonNeighbours: the dock's previous and next come from the outline in flat course order", () => {
|
|
expect(lessonNeighbours(course, "a")).toMatchObject({ current: { id: "a" }, prev: null, next: { id: "b" } });
|
|
expect(lessonNeighbours(course, "b")).toMatchObject({ current: { id: "b" }, prev: { id: "a" }, next: null });
|
|
expect(lessonNeighbours(course, "zz")).toEqual({ current: null, prev: null, next: null });
|
|
});
|