Site: scroll tour with live clips, beat-reactive desk and speaker, road playground, night wave, film on an isometric screen
This commit is contained in:
46 files changed
+2708
-169
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const PAGE = "/";
|
||||
|
||||
/** Scrolls to the film and mutes it for autoplay. */
|
||||
async function reach(page: Page): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") errors.push(m.text());
|
||||
});
|
||||
await page.goto(PAGE);
|
||||
await page.locator("#film").scrollIntoViewIfNeeded();
|
||||
await expect(page.locator("#film")).toHaveClass(/seen/);
|
||||
await page.evaluate(() => {
|
||||
const video = document.querySelector<HTMLVideoElement>("#film video")!;
|
||||
video.muted = true;
|
||||
Object.assign(window, { filmVideo: video });
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
const box = (page: Page) => page.locator("#film video").evaluate((v) => v.getBoundingClientRect().toJSON());
|
||||
|
||||
test("the film flattens to play and returns to its face", async ({ page }) => {
|
||||
const errors = await reach(page);
|
||||
await expect(page.locator("#film video")).toHaveAttribute("preload", "metadata");
|
||||
const face = await box(page);
|
||||
const play = page.getByRole("button", { name: "Play the film" });
|
||||
await play.click();
|
||||
await expect.poll(() => page.locator("#film video").evaluate((v: HTMLVideoElement) => v.paused)).toBe(false);
|
||||
await expect.poll(() => page.locator("#film video").evaluate((v) => v.getAnimations().length)).toBe(0);
|
||||
const flat = await box(page);
|
||||
expect(flat.width / flat.height).toBeCloseTo(16 / 9, 1);
|
||||
expect(flat.width).toBeGreaterThan(page.viewportSize()!.width * 0.7);
|
||||
expect(flat.width).toBeGreaterThan(face.width * 1.5);
|
||||
await expect(page.locator("#film video")).toHaveJSProperty("controls", true);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(play).toBeFocused();
|
||||
await expect.poll(() => page.locator("#film video").evaluate((v) => v.getAnimations().length)).toBe(0);
|
||||
const back = await box(page);
|
||||
expect(back.width).toBeCloseTo(face.width, 0);
|
||||
expect(back.height).toBeCloseTo(face.height, 0);
|
||||
const state = await page.evaluate(() => {
|
||||
const video = document.querySelector<HTMLVideoElement>("#film video")!;
|
||||
return { same: video === (window as unknown as { filmVideo: HTMLVideoElement }).filmVideo, paused: video.paused, at: video.currentTime };
|
||||
});
|
||||
expect(state.same).toBe(true);
|
||||
expect(state.paused).toBe(true);
|
||||
expect(state.at).toBeGreaterThan(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("with reduced motion the film plays flat at once", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const errors = await reach(page);
|
||||
await page.getByRole("button", { name: "Play the film" }).click();
|
||||
const video = page.locator("#film video");
|
||||
expect(await video.evaluate((v) => [v.getAnimations().length, getComputedStyle(v).transform])).toEqual([0, "none"]);
|
||||
await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.paused)).toBe(false);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: "Play the film" })).toBeFocused();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const PAGE = "/";
|
||||
const DESK = { light: "rgb(230, 230, 226)", dark: "rgb(58, 58, 58)" };
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
});
|
||||
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
const open = async (page: Page) => {
|
||||
await page.goto(PAGE);
|
||||
await expect(page.locator(".model .block").first()).toBeAttached();
|
||||
};
|
||||
|
||||
/** The hero desk's top face colour. */
|
||||
const desk = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const top = document.querySelector(".model .block .face.top")!;
|
||||
return getComputedStyle(top).fill;
|
||||
});
|
||||
|
||||
const lamp = (page: Page) => page.getByRole("button", { name: "Switch to dark" });
|
||||
|
||||
test("the lamp turns the page dark in a wave", async ({ page }) => {
|
||||
await open(page);
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("light");
|
||||
expect(await desk(page)).toBe(DESK.light);
|
||||
|
||||
// Read the nearest and farthest hero blocks mid-wave.
|
||||
const mid = await page.evaluate(async () => {
|
||||
const tops = [...document.querySelectorAll<SVGGElement>(".model .block")];
|
||||
document.querySelector<HTMLButtonElement>(".night")!.click();
|
||||
const wave = (g: SVGGElement) => parseFloat(g.style.getPropertyValue("--wave"));
|
||||
tops.sort((a, b) => wave(a) - wave(b));
|
||||
const [near, far] = [tops[0], tops[tops.length - 1]];
|
||||
const fill = (g: SVGGElement) => getComputedStyle(g.querySelector(".face.top")!).fill;
|
||||
// Sample between the near landing and the far start.
|
||||
const gap = (wave(near) + 0.2 + wave(far)) / 2;
|
||||
await new Promise((r) => setTimeout(r, gap * 1000));
|
||||
return {
|
||||
steps: [wave(near), wave(far)],
|
||||
near: [fill(near), near.style.getPropertyValue("--tone-dark")],
|
||||
far: [fill(far), far.style.getPropertyValue("--tone")],
|
||||
};
|
||||
});
|
||||
expect(mid.steps[0] + 0.25).toBeLessThan(mid.steps[1]);
|
||||
expect(mid.steps[1]).toBeLessThanOrEqual(1);
|
||||
const rgb = (hex: string) => `rgb(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ")})`;
|
||||
expect(mid.near[0]).toBe(rgb(mid.near[1]));
|
||||
expect(mid.far[0]).toBe(rgb(mid.far[1]));
|
||||
|
||||
await expect.poll(() => desk(page), { timeout: 1200 }).toBe(DESK.dark);
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark");
|
||||
await expect(page.getByRole("button", { name: "Switch to light" })).toBeAttached();
|
||||
await expect(page.locator(".night-sky")).toHaveCount(0);
|
||||
await expect(page.locator("html")).toHaveCSS("background-color", "rgb(37, 37, 37)");
|
||||
});
|
||||
|
||||
test("dark text keeps 4.5:1 against the page", async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await open(page);
|
||||
const worst = await page.evaluate(() => {
|
||||
const parse = (c: string) => c.match(/[\d.]+/g)!.slice(0, 3).map(Number);
|
||||
const lum = (c: string) => {
|
||||
const [r, g, b] = parse(c).map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const page = lum(getComputedStyle(document.documentElement).backgroundColor);
|
||||
const ratios = [...document.querySelectorAll<HTMLElement>("h1, h2, h3, p, a, li, button")]
|
||||
.filter((el) => el.textContent?.trim() && getComputedStyle(el).color !== "rgba(0, 0, 0, 0)")
|
||||
.map((el) => {
|
||||
const text = lum(getComputedStyle(el).color);
|
||||
return { ratio: (Math.max(text, page) + 0.05) / (Math.min(text, page) + 0.05), text: el.textContent };
|
||||
});
|
||||
return ratios.sort((a, b) => a.ratio - b.ratio)[0];
|
||||
});
|
||||
expect(worst.ratio, String(worst.text)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
test("the system scheme sets the first theme; the lamp's choice stays", async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await open(page);
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark");
|
||||
expect(await desk(page)).toBe(DESK.dark);
|
||||
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe("light");
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await lamp(page).click();
|
||||
expect(await desk(page)).toBe(DESK.dark);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await page.reload();
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe("dark");
|
||||
});
|
||||
|
||||
test("reduced motion switches at once", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "light" });
|
||||
await open(page);
|
||||
await lamp(page).click();
|
||||
expect(await desk(page)).toBe(DESK.dark);
|
||||
await expect(page.locator(".night-sky")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const TREE = "Open a Markdown file's history in ElyTree.";
|
||||
|
||||
/** Opens the board and waits until it has grown. */
|
||||
async function open(page: Page): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
||||
await page.goto("/");
|
||||
await page.locator("#roads").scrollIntoViewIfNeeded();
|
||||
await expect(page.locator("#roads .stage")).not.toHaveClass(/grow/, { timeout: 10_000 });
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Screen centre of a road cell. */
|
||||
async function cell(page: Page, at: string) {
|
||||
const box = (await page.locator(`#roads [data-cell="${at}"]:not(.file)`).boundingBox())!;
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
const mark = (page: Page) => page.getByRole("button", { name: "ElyMark file" });
|
||||
|
||||
/** Screen point of a grid spot on the desk top. */
|
||||
const desk = (page: Page, x: number, y: number) =>
|
||||
page.evaluate(([x, y]) => {
|
||||
const svg = document.querySelector<SVGSVGElement>("#roads .stage")!;
|
||||
const p = new DOMPoint((x - y) * Math.cos(Math.PI / 6) * 28, ((x + y) / 2 - 0.6) * 28).matrixTransform(svg.getScreenCTM()!);
|
||||
return { x: p.x, y: p.y };
|
||||
}, [x, y]);
|
||||
|
||||
test("dragging Mark's file down its road reaches ElyTree", async ({ page }) => {
|
||||
const errors = await open(page);
|
||||
const from = (await mark(page).boundingBox())!;
|
||||
const to = await cell(page, "14,12");
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x, to.y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect(mark(page)).toHaveAttribute("data-cell", "14,12");
|
||||
await expect(page.locator("#roads .news")).toHaveText(TREE);
|
||||
await expect(page.locator('#roads [data-cell="14,12"]:not(.file)')).toHaveCSS("--tone", "#3d7fe5");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a file dragged onto empty desk stays on its road", async ({ page }) => {
|
||||
const errors = await open(page);
|
||||
const from = (await mark(page).boundingBox())!;
|
||||
const away = await desk(page, 10.5, 8.5);
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(away.x, away.y);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(mark(page)).toHaveAttribute("data-cell", "5,10");
|
||||
await expect(page.locator("#roads .news")).toHaveText("");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("arrow keys walk Mark's file to ElyTree, then reset", async ({ page }) => {
|
||||
const errors = await open(page);
|
||||
await mark(page).focus();
|
||||
for (const key of ["ArrowDown", "ArrowDown", ...Array(9).fill("ArrowRight")])
|
||||
await page.keyboard.press(key);
|
||||
await expect(mark(page)).toHaveAttribute("data-cell", "14,12");
|
||||
await expect(page.locator("#roads .news")).toHaveText(TREE);
|
||||
await page.getByRole("button", { name: "Put the files back" }).click();
|
||||
await expect(mark(page)).toHaveAttribute("data-cell", "5,10");
|
||||
await expect(page.locator("#roads .news")).toHaveText("");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test.describe("reduced motion", () => {
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
test("files stack at ElyPDF and the line says why", async ({ page }) => {
|
||||
const errors = await open(page);
|
||||
const doc = page.getByRole("button", { name: "ElyDoc file" });
|
||||
await doc.focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
for (let i = 0; i < 13; i++) await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(doc).toHaveAttribute("data-cell", "15,5");
|
||||
await expect(page.locator("#roads .news")).toHaveText("Export to PDF, open it in ElyPDF.");
|
||||
const show = page.getByRole("button", { name: "ElyShow file" });
|
||||
await show.focus();
|
||||
for (const key of ["ArrowDown", "ArrowDown", "ArrowRight", "ArrowRight", "ArrowRight", "ArrowRight", "ArrowRight", "ArrowUp"])
|
||||
await page.keyboard.press(key);
|
||||
await expect(show).toHaveAttribute("data-cell", "15,5");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto("/");
|
||||
await page.locator(".tour .model").scrollIntoViewIfNeeded();
|
||||
await button(page).hover();
|
||||
await page.evaluate(() =>
|
||||
Promise.all(document.querySelector(".tour .model")!.getAnimations({ subtree: true }).map((a) => a.finished)),
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
const button = (page: Page) => page.locator(".speaker-button");
|
||||
const paused = (page: Page) => page.evaluate(() => document.querySelector<HTMLAudioElement>(".tour audio")!.paused);
|
||||
|
||||
/** Screen top of each building's first top face. */
|
||||
const tops = (page: Page) =>
|
||||
page.evaluate(
|
||||
(apps) =>
|
||||
apps.map((n) => document.querySelector(`.tour .block[data-part="${n}"] .face.top`)!.getBoundingClientRect().top),
|
||||
APPS,
|
||||
);
|
||||
|
||||
/** Whether anything dances: hops, road light or a moving woofer. */
|
||||
const dancing = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const hops = [...document.querySelectorAll<SVGGElement>(".tour .block")].filter((g) =>
|
||||
g.style.getPropertyValue("--hop"),
|
||||
);
|
||||
const moved = [...document.querySelectorAll<SVGGElement>(".tour .woofer")].filter((g) => g.style.translate);
|
||||
return hops.length + document.querySelectorAll(".tour .pulse").length + moved.length;
|
||||
});
|
||||
|
||||
test("the speaker plays and pauses the music", async ({ page }) => {
|
||||
await expect(button(page)).toHaveAccessibleName("Play music");
|
||||
await button(page).click();
|
||||
await expect.poll(() => paused(page)).toBe(false);
|
||||
await expect(button(page)).toHaveAccessibleName("Pause music");
|
||||
await button(page).click();
|
||||
await expect.poll(() => paused(page)).toBe(true);
|
||||
await expect(button(page)).toHaveAccessibleName("Play music");
|
||||
});
|
||||
|
||||
test("buildings hop on the beat; all six on the bar", async ({ page }) => {
|
||||
const rest = await tops(page);
|
||||
await button(page).click();
|
||||
await page.waitForFunction(
|
||||
([apps, rest]) =>
|
||||
apps.every(
|
||||
(n, i) =>
|
||||
document.querySelector(`.tour .block[data-part="${n}"] .face.top`)!.getBoundingClientRect().top < rest[i] - 10,
|
||||
),
|
||||
[APPS, rest] as const,
|
||||
{ polling: "raf", timeout: 6000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
([apps, rest]) =>
|
||||
apps.filter(
|
||||
(n, i) =>
|
||||
document.querySelector(`.tour .block[data-part="${n}"] .face.top`)!.getBoundingClientRect().top < rest[i] - 10,
|
||||
).length === 1,
|
||||
[APPS, rest] as const,
|
||||
{ polling: "raf", timeout: 6000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("paused, nothing moves", async ({ page }) => {
|
||||
const rest = await tops(page);
|
||||
await button(page).click();
|
||||
await expect.poll(() => dancing(page)).toBeGreaterThan(0);
|
||||
await button(page).click();
|
||||
await expect.poll(() => paused(page)).toBe(true);
|
||||
await expect.poll(() => dancing(page)).toBe(0);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(await dancing(page)).toBe(0);
|
||||
expect(await tops(page)).toEqual(rest);
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("reduced motion", () => {
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
test("music plays, buildings stay, the woofer stays out", async ({ page }) => {
|
||||
const rest = await tops(page);
|
||||
await button(page).click();
|
||||
await expect.poll(() => paused(page)).toBe(false);
|
||||
await page.waitForTimeout(1200);
|
||||
expect(await dancing(page)).toBe(0);
|
||||
expect(await tops(page)).toEqual(rest);
|
||||
const push = await page.locator(".tour .woofer").first().evaluate((g) => getComputedStyle(g).translate);
|
||||
expect(push).not.toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
test("the desk cell under the pointer rises", async ({ page }) => {
|
||||
const at = await page.evaluate(() => {
|
||||
const svg = document.querySelector<SVGSVGElement>(".tour .stage")!;
|
||||
const [cos, sin, unit, top] = [Math.cos(Math.PI / 6), 0.5, 28, 0.6];
|
||||
const p = new DOMPoint((11.5 - 9.5) * cos * unit, ((11.5 + 9.5) * sin - top) * unit).matrixTransform(
|
||||
svg.getScreenCTM()!,
|
||||
);
|
||||
return { x: p.x, y: p.y };
|
||||
});
|
||||
await page.mouse.move(at.x, at.y);
|
||||
await expect(page.locator(".tour .tile")).toHaveCount(1);
|
||||
await page.mouse.move(at.x, at.y - 600);
|
||||
await expect(page.locator(".tour .tile")).toHaveCount(0);
|
||||
});
|
||||
+15
-12
@@ -61,6 +61,7 @@ test.describe("English locale", () => {
|
||||
await page.mouse.move(5, 500);
|
||||
await expect(lang).toHaveCSS("opacity", "0");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(lang).toBeFocused();
|
||||
await expect(lang).toHaveCSS("opacity", "1");
|
||||
});
|
||||
@@ -79,9 +80,9 @@ test.describe("English locale", () => {
|
||||
|
||||
test("links point at the release and the repository", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".region .get")).toHaveCount(APPS.length);
|
||||
for (const app of APPS) {
|
||||
await expect(page.locator(`#${app.toLowerCase()} .get`)).toHaveAttribute("href", dmg(app));
|
||||
await expect(page.locator(".tour .get")).toHaveCount(APPS.length);
|
||||
for (const [i, app] of APPS.entries()) {
|
||||
await expect(page.locator(".tour .get").nth(i)).toHaveAttribute("href", dmg(app));
|
||||
}
|
||||
await expect(page.locator("#download ul a")).toHaveText([...APPS, TEXT.en.source]);
|
||||
for (const [i, app] of APPS.entries()) {
|
||||
@@ -103,17 +104,19 @@ test.describe("English locale", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("region downloads show on hover or focus", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
for (const app of APPS) {
|
||||
const region = page.locator(`#${app.toLowerCase()}`);
|
||||
const get = region.locator(".get");
|
||||
await region.scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(0, 0);
|
||||
test("each app's copy arrives at its anchor; its download shows on intent", async ({ page }) => {
|
||||
for (const [i, app] of APPS.entries()) {
|
||||
await page.goto(`/#${app.toLowerCase()}`);
|
||||
const article = page.locator(".copy article").nth(i);
|
||||
await expect(article).toHaveClass(/current/);
|
||||
await expect(page.locator(".copy article.current")).toHaveCount(1);
|
||||
await expect(page.locator(".rail a").nth(i)).toHaveAttribute("aria-current", "step");
|
||||
const get = article.locator(".get");
|
||||
await page.mouse.move(1000, 5);
|
||||
await expect(get).toHaveCSS("opacity", "0");
|
||||
await region.locator("h2").hover();
|
||||
await article.locator("h2").hover();
|
||||
await expect(get).toHaveCSS("opacity", "1");
|
||||
await page.locator(".lang").hover();
|
||||
await page.mouse.move(1000, 5);
|
||||
await expect(get).toHaveCSS("opacity", "0");
|
||||
await get.focus();
|
||||
await expect(get).toHaveCSS("opacity", "1");
|
||||
|
||||
+11
-8
@@ -5,12 +5,13 @@ const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
|
||||
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
/** Screen top of the building's first block, in SVG pixels. */
|
||||
/** Building top over the desk top, in SVG pixels; camera-free. */
|
||||
const top = (page: Page, name: string) =>
|
||||
page.evaluate((n) => {
|
||||
const svg = document.querySelector<SVGSVGElement>(".hero .stage")!;
|
||||
const svg = document.querySelector<SVGSVGElement>(".model .stage")!;
|
||||
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
|
||||
return (document.querySelector(`.block[data-part="${n}"]`)!.getBoundingClientRect().top + scrollY) / scale;
|
||||
const at = (sel: string) => document.querySelector(sel)!.getBoundingClientRect().top;
|
||||
return (at(`.block[data-part="${n}"]`) - at('.block[data-part=""]')) / scale;
|
||||
}, name);
|
||||
|
||||
/** A point that hits the building; `low` picks the lowest. */
|
||||
@@ -38,7 +39,7 @@ const spot = (page: Page, name: string, low = false) =>
|
||||
[name, low] as const,
|
||||
);
|
||||
|
||||
const caption = (page: Page, name: string) => page.locator(".caption", { hasText: name });
|
||||
const caption = (page: Page, name: string) => page.locator(".tour .caption", { hasText: name });
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
@@ -46,7 +47,7 @@ test.beforeEach(async ({ page }) => {
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".hero .hit-face:not(.ground)").first()).toBeAttached();
|
||||
await expect(page.locator(".model .hit-face:not(.ground)").first()).toBeAttached();
|
||||
});
|
||||
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
@@ -59,7 +60,7 @@ test("six links in reading order; reduced motion skips growth", async ({ page })
|
||||
|
||||
test("focus lifts one cell, shows the name, survives the mouse", async ({ page }) => {
|
||||
const rest = await top(page, "ElyDoc");
|
||||
for (let i = 0; i < 3; i++) await page.keyboard.press("Tab");
|
||||
await page.locator(".doors a").first().focus();
|
||||
await expect(page.locator(".doors a").first()).toBeFocused();
|
||||
expect(rest - (await top(page, "ElyDoc"))).toBeCloseTo(CELL, 0);
|
||||
await expect(caption(page, "ElyDoc")).toHaveCSS("opacity", "1");
|
||||
@@ -89,7 +90,7 @@ test("hover at the bottom edge holds still", async ({ page }) => {
|
||||
|
||||
test("the hit owner is the block you see", async ({ page }) => {
|
||||
const wrong = await page.evaluate(() => {
|
||||
const r = document.querySelector(".hero .stage")!.getBoundingClientRect();
|
||||
const r = document.querySelector(".model .stage")!.getBoundingClientRect();
|
||||
const out: string[] = [];
|
||||
const owner = (x: number, y: number) => {
|
||||
const face = document.elementsFromPoint(x, y).find((e) => e.classList.contains("face"));
|
||||
@@ -120,6 +121,8 @@ test("a primary press sinks one cell; only a click starting on it opens", async
|
||||
|
||||
await page.evaluate(() => history.replaceState(null, "", "/"));
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
// The camera settles on the next frame.
|
||||
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const again = await spot(page, "ElyShow");
|
||||
await page.mouse.click(again.x, again.y, { button: "right" });
|
||||
await page.mouse.move(5, 5);
|
||||
@@ -131,7 +134,7 @@ test("a primary press sinks one cell; only a click starting on it opens", async
|
||||
|
||||
test("holding Enter sinks the focused building until release", async ({ page }) => {
|
||||
const rest = await top(page, "ElyDoc");
|
||||
for (let i = 0; i < 3; i++) await page.keyboard.press("Tab");
|
||||
await page.locator(".doors a").first().focus();
|
||||
await page.keyboard.down("Enter");
|
||||
expect(await top(page, "ElyDoc")).toBeCloseTo(rest, 0);
|
||||
await expect(page).toHaveURL(/#elydoc$/);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
});
|
||||
|
||||
test.afterEach(() => expect(errors).toEqual([]));
|
||||
|
||||
const frame = (page: Page) => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
/** Scrolls to `spans` stage heights into the tour. */
|
||||
async function at(page: Page, spans: number) {
|
||||
await page.evaluate((s) => {
|
||||
document.documentElement.style.scrollBehavior = "auto";
|
||||
scrollTo(0, s * document.querySelector<HTMLElement>(".pin")!.clientHeight);
|
||||
}, spans);
|
||||
await frame(page);
|
||||
}
|
||||
|
||||
/** Largest gap in px between clip and face corners. */
|
||||
const drift = (page: Page, i: number) =>
|
||||
page.evaluate((i) => {
|
||||
const video = document.querySelectorAll<HTMLVideoElement>(".clip")[i];
|
||||
const world = video.parentElement!;
|
||||
const host = world.parentElement!.getBoundingClientRect();
|
||||
const m = new DOMMatrix(getComputedStyle(world).transform).multiply(new DOMMatrix(getComputedStyle(video).transform));
|
||||
const [w, h] = [video.offsetWidth, video.offsetHeight];
|
||||
const clip = [[0, 0], [w, 0], [w, h], [0, h]].map(([x, y]) => m.transformPoint(new DOMPoint(x, y)));
|
||||
const row = (k: number) => document.querySelector<SVGGElement>(`.block.slab[data-app="${i}"][data-row="${k}"]`)!;
|
||||
const ctm = row(0).ownerSVGElement!.getScreenCTM()!;
|
||||
const corners = (g: SVGGElement) =>
|
||||
g.querySelector(".face.left")!.getAttribute("points")!.split(" ").map((p) => {
|
||||
const [x, y] = p.split(",").map(Number);
|
||||
return new DOMPoint(x, y).matrixTransform(ctm);
|
||||
});
|
||||
// Left face points: bottom-left, bottom-right, top-right, top-left.
|
||||
const [bl, br] = corners(row(0));
|
||||
const [, , tr, tl] = corners(row(3));
|
||||
const face = [tl, tr, br, bl];
|
||||
return Math.max(...clip.map((p, k) => Math.hypot(p.x + host.left - face[k].x, p.y + host.top - face[k].y)));
|
||||
}, i);
|
||||
|
||||
test("each clip stays on its screen face, held or gliding", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
for (const [i] of APPS.entries()) {
|
||||
for (const s of [i + 1, i + 1.5]) {
|
||||
await at(page, s);
|
||||
await page.waitForTimeout(800);
|
||||
expect(await drift(page, i), `${APPS[i]} at ${s}`).toBeLessThan(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("only the current app's clip loads and plays", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await at(page, 3);
|
||||
const clips = page.locator(".clip");
|
||||
await expect.poll(() => clips.nth(2).evaluate((v: HTMLVideoElement) => !v.paused && v.currentTime > 0)).toBe(true);
|
||||
for (const i of [0, 1, 3, 4, 5]) await expect(clips.nth(i).locator("source")).toHaveCount(0);
|
||||
await at(page, 4);
|
||||
await expect(clips.nth(2).locator("source")).toHaveCount(0);
|
||||
await expect.poll(() => clips.nth(3).evaluate((v: HTMLVideoElement) => !v.paused)).toBe(true);
|
||||
await at(page, 0);
|
||||
await expect(page.locator(".clip source")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the rail scrolls to an app and marks it", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await at(page, 1);
|
||||
await page.locator(".rail a").nth(4).click();
|
||||
await expect(page.locator(".rail a").nth(4)).toHaveAttribute("aria-current", "step");
|
||||
await expect(page.locator(".copy article.current h2")).toHaveText("ElyTree");
|
||||
});
|
||||
|
||||
test.describe("reduced motion", () => {
|
||||
test.use({ reducedMotion: "reduce" });
|
||||
|
||||
test("clips wait for a press; the camera jumps", async ({ page }) => {
|
||||
await page.goto("/#elyshow");
|
||||
const clip = page.locator(".clip").nth(2);
|
||||
await expect(page.locator(".copy article.current h2")).toHaveText("ElyShow");
|
||||
await expect(clip).toHaveAttribute("poster", "/clips/show.jpg");
|
||||
expect(await clip.evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
|
||||
const held = await page.evaluate(() => getComputedStyle(document.querySelector(".world")!).transform);
|
||||
await at(page, 3.4);
|
||||
expect(await page.evaluate(() => getComputedStyle(document.querySelector(".world")!).transform)).toBe(held);
|
||||
await page.locator(".copy article.current .toggle-clip").click();
|
||||
await expect.poll(() => clip.evaluate((v: HTMLVideoElement) => !v.paused)).toBe(true);
|
||||
await expect(page.locator(".copy article.current .toggle-clip")).toHaveText("Pause");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("phone", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("no sideways scroll; copy sits below the stage", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
for (const s of [0, 1, 2.5, 4, 6, 7]) {
|
||||
await at(page, s);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
|
||||
}
|
||||
await at(page, 2);
|
||||
const copy = await page.locator(".copy article.current").boundingBox();
|
||||
expect(copy!.y).toBeGreaterThan(844 * 0.55);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user