feat(learner): hold the lesson until the next one loads, swap with a view transition
The sidebar highlight moves at once; the lesson column keeps the current lesson until the next has loaded, then swaps inside a view transition, and the route scroll (top, or the saved position on back) is applied then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3988addaed
commit
a953e35178
2 files changed
+65
-11
No files matched your search
@@ -2,7 +2,7 @@
|
||||
// production bundle) in a subprocess. A probe in the page counts document.startViewTransition calls and records
|
||||
// animationstart / transitionstart events, so each test can say exactly what moved. Skipped without Chrome.
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { appendFileSync, cpSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { Chrome, chromePath } from "../../../packages/feature-learner/test/chrome";
|
||||
@@ -21,6 +21,14 @@ const PROBE = `
|
||||
return cls + (e.pseudoElement || "");
|
||||
};
|
||||
document.addEventListener("animationstart", (e) => window.__m.anims.push(e.animationName + "@" + who(e)), true);
|
||||
// window.__delay (ms) holds lesson requests back, to see what the page shows while the next lesson loads.
|
||||
const f = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
const u = String((input && input.url) || input);
|
||||
const d = window.__delay || 0;
|
||||
if (d && u.includes("/lessons/")) return new Promise((r) => setTimeout(r, d)).then(() => f.call(this, input, init));
|
||||
return f.call(this, input, init);
|
||||
};
|
||||
document.addEventListener("transitionstart", (e) => window.__m.trans.push(e.propertyName + "@" + who(e)), true);
|
||||
`;
|
||||
|
||||
@@ -68,12 +76,17 @@ run("navigation motion (headless Chrome)", () => {
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
dataDir = mkdtempSync(join(tmpdir(), "stc-motion-"));
|
||||
// The sample course, with lessons long enough to scroll.
|
||||
const courses = join(dataDir, "courses");
|
||||
cpSync(join(ROOT, "fixtures/courses/sample"), join(courses, "sample"), { recursive: true });
|
||||
const filler = Array.from({ length: 40 }, (_, i) => `Paragraph ${i + 1}. ${"A line of reading text that wraps across the column. ".repeat(4)}`).join("\n\n");
|
||||
for (const f of ["01-intro.md", "03-code.md"]) appendFileSync(join(courses, "sample/lessons", f), `\n\n${filler}\n`);
|
||||
base = `http://localhost:${port}`;
|
||||
server = Bun.spawn([process.execPath, "apps/server/src/main.ts"], {
|
||||
cwd: ROOT, stdout: "pipe", stderr: "pipe",
|
||||
env: {
|
||||
...process.env, NODE_ENV: "production", STC_PORT: String(port), STC_SECRET: "motion-secret-0123456789abcdef0123456789",
|
||||
STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: join(ROOT, "fixtures/courses"),
|
||||
STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: courses,
|
||||
STC_PUBLIC_URL: base,
|
||||
},
|
||||
});
|
||||
@@ -105,4 +118,36 @@ run("navigation motion (headless Chrome)", () => {
|
||||
expect(r.anims).toContain("stc-enter@col");
|
||||
expect(r.anims.some((a) => a.startsWith("stc-fade-out@") && a.includes("::view-transition-old(root)"))).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
test("lesson to lesson: the old lesson stays until the next is ready, then swaps; sidebar and header do not move", async () => {
|
||||
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||
const side = await rect(".lr-side"), top = await rect(".stc-top");
|
||||
await chrome.eval("window.scrollTo(0, 900)");
|
||||
await Bun.sleep(150);
|
||||
expect(await chrome.eval<number>("scrollY")).toBe(900);
|
||||
await chrome.eval("window.__delay = 500");
|
||||
await reset();
|
||||
await click(".stc-dock .stc-pill--primary");
|
||||
expect(await chrome.eval<string>("location.pathname")).toBe("/c/sample/m1-video");
|
||||
await Bun.sleep(150);
|
||||
// The sidebar answers the click at once; the lesson column keeps the old lesson, where it was, until the next is in.
|
||||
expect(await text('.lr-les[aria-current="page"]')).toContain("Watch the clip");
|
||||
expect(await text(".lr-main .lr-lesson h1.title")).toBe("Introduction");
|
||||
expect(await chrome.eval<number>("scrollY")).toBe(900);
|
||||
await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Watch the clip", "next lesson");
|
||||
await chrome.eval("window.__delay = 0");
|
||||
await Bun.sleep(300);
|
||||
expect(await chrome.eval<number>("scrollY")).toBe(0);
|
||||
const r = await m();
|
||||
expect(r.vt).toBe(1);
|
||||
expect(r.anims).toContain("stc-enter@lr lr-lesson");
|
||||
expect(await rect(".lr-side")).toBe(side);
|
||||
expect(await rect(".stc-top")).toBe(top);
|
||||
|
||||
// Back: the intro returns at the position it was left.
|
||||
await chrome.eval("history.back()");
|
||||
await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Introduction", "previous lesson");
|
||||
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "restored scroll", 2000);
|
||||
expect(await rect(".lr-side")).toBe(side);
|
||||
}, 30_000);
|
||||
});
|
||||
Reference in new issue
Block a user