219 lines
7.5 KiB
TypeScript
219 lines
7.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
TASK_ROW_DOM_ATTR,
|
|
TASK_ROW_REVEAL_CLASS,
|
|
cancelSidebarTaskReveal,
|
|
revealSidebarTask,
|
|
taskRowSelector,
|
|
} from "./task-navigation";
|
|
|
|
type Rect = { x: number; y: number; width: number; height: number };
|
|
const TEST_TASK_ID = "task-1";
|
|
|
|
function setRect(element: HTMLElement, rect: Rect) {
|
|
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
top: rect.y,
|
|
right: rect.x + rect.width,
|
|
bottom: rect.y + rect.height,
|
|
left: rect.x,
|
|
toJSON: () => ({}),
|
|
} as DOMRect);
|
|
}
|
|
|
|
function mountViewport(visible = true) {
|
|
const viewport = document.createElement("div");
|
|
viewport.dataset.testid = "task-sidebar-scroll";
|
|
if (!visible) viewport.style.display = "none";
|
|
setRect(viewport, { x: 0, y: 0, width: 320, height: 100 });
|
|
document.body.appendChild(viewport);
|
|
return viewport;
|
|
}
|
|
|
|
function mountRow(viewport: HTMLElement, taskId: string, rect: Rect) {
|
|
const row = document.createElement("div");
|
|
row.setAttribute(TASK_ROW_DOM_ATTR, taskId);
|
|
row.scrollIntoView = vi.fn();
|
|
setRect(row, rect);
|
|
viewport.appendChild(row);
|
|
return row;
|
|
}
|
|
|
|
function setReducedMotion(reducedMotion: boolean) {
|
|
vi.stubGlobal(
|
|
"matchMedia",
|
|
vi.fn(() => ({ matches: reducedMotion })),
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("taskRowSelector", () => {
|
|
it("escapes task ids for use in a CSS selector", () => {
|
|
expect(taskRowSelector("task:a")).toBe(`[${TASK_ROW_DOM_ATTR}="task\\:a"]`);
|
|
});
|
|
});
|
|
|
|
describe("revealSidebarTask", () => {
|
|
it("smoothly scrolls and cues an off-screen rendered row", async () => {
|
|
setReducedMotion(false);
|
|
const viewport = mountViewport();
|
|
const row = mountRow(viewport, TEST_TASK_ID, { x: 0, y: 120, width: 320, height: 24 });
|
|
|
|
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
|
|
expect(row.scrollIntoView).toHaveBeenCalledWith({
|
|
behavior: "smooth",
|
|
block: "nearest",
|
|
inline: "nearest",
|
|
});
|
|
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
|
|
});
|
|
|
|
it("uses immediate nearest scrolling and a non-animated cue for reduced motion", async () => {
|
|
setReducedMotion(true);
|
|
const viewport = mountViewport();
|
|
const row = mountRow(viewport, TEST_TASK_ID, { x: 0, y: -24, width: 320, height: 24 });
|
|
|
|
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
|
|
expect(row.scrollIntoView).toHaveBeenCalledWith({
|
|
behavior: "auto",
|
|
block: "nearest",
|
|
inline: "nearest",
|
|
});
|
|
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
|
|
});
|
|
|
|
it("does not reposition a row that is already inside the viewport", async () => {
|
|
const viewport = mountViewport();
|
|
const row = mountRow(viewport, TEST_TASK_ID, { x: 0, y: 20, width: 320, height: 24 });
|
|
|
|
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
|
|
expect(row.scrollIntoView).not.toHaveBeenCalled();
|
|
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
|
|
});
|
|
|
|
it("restarts the cue without letting stale cleanup clear the newer cue", async () => {
|
|
vi.useFakeTimers();
|
|
const viewport = mountViewport();
|
|
const row = mountRow(viewport, TEST_TASK_ID, { x: 0, y: 120, width: 320, height: 24 });
|
|
|
|
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
|
|
vi.advanceTimersByTime(1_000);
|
|
|
|
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
|
|
vi.advanceTimersByTime(400);
|
|
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
|
|
|
|
vi.advanceTimersByTime(999);
|
|
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
|
|
vi.advanceTimersByTime(1);
|
|
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("revealSidebarTask edge cases", () => {
|
|
it("finds a row that renders after a later animation frame", async () => {
|
|
const viewport = mountViewport();
|
|
const callbacks: Array<() => void> = [];
|
|
const navigation = revealSidebarTask("late", (callback) => callbacks.push(callback));
|
|
|
|
expect(callbacks).toHaveLength(1);
|
|
const row = mountRow(viewport, "late", { x: 0, y: 120, width: 320, height: 24 });
|
|
callbacks.shift()!();
|
|
|
|
await expect(navigation).resolves.toBe(true);
|
|
expect(row.scrollIntoView).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not scroll a superseded task when its row renders late", async () => {
|
|
const viewport = mountViewport();
|
|
const firstCallbacks: Array<() => void> = [];
|
|
const firstNavigation = revealSidebarTask("task-a", (callback) =>
|
|
firstCallbacks.push(callback),
|
|
);
|
|
|
|
expect(firstCallbacks).toHaveLength(1);
|
|
|
|
const secondRow = mountRow(viewport, "task-b", { x: 0, y: 120, width: 320, height: 24 });
|
|
await expect(revealSidebarTask("task-b", (callback) => callback())).resolves.toBe(true);
|
|
expect(secondRow.scrollIntoView).toHaveBeenCalledTimes(1);
|
|
|
|
const firstRow = mountRow(viewport, "task-a", { x: 0, y: 120, width: 320, height: 24 });
|
|
firstCallbacks.shift()!();
|
|
|
|
await expect(firstNavigation).resolves.toBe(false);
|
|
expect(firstRow.scrollIntoView).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("cancels a pending reveal before a newer route is ready", async () => {
|
|
const viewport = mountViewport();
|
|
const callbacks: Array<() => void> = [];
|
|
const navigation = revealSidebarTask("pending", (callback) => callbacks.push(callback));
|
|
|
|
cancelSidebarTaskReveal();
|
|
const row = mountRow(viewport, "pending", { x: 0, y: 120, width: 320, height: 24 });
|
|
callbacks.shift()!();
|
|
|
|
await expect(navigation).resolves.toBe(false);
|
|
expect(row.scrollIntoView).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears an applied cue when navigation is canceled", async () => {
|
|
vi.useFakeTimers();
|
|
const viewport = mountViewport();
|
|
const row = mountRow(viewport, TEST_TASK_ID, { x: 0, y: 120, width: 320, height: 24 });
|
|
|
|
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
|
|
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(true);
|
|
|
|
cancelSidebarTaskReveal();
|
|
|
|
expect(row.classList.contains(TASK_ROW_REVEAL_CLASS)).toBe(false);
|
|
});
|
|
|
|
it("ignores a matching row inside a hidden sidebar viewport", async () => {
|
|
const hiddenViewport = mountViewport(false);
|
|
const hiddenRow = mountRow(hiddenViewport, TEST_TASK_ID, {
|
|
x: 0,
|
|
y: 120,
|
|
width: 320,
|
|
height: 24,
|
|
});
|
|
const visibleViewport = mountViewport();
|
|
const visibleRow = mountRow(visibleViewport, TEST_TASK_ID, {
|
|
x: 0,
|
|
y: 120,
|
|
width: 320,
|
|
height: 24,
|
|
});
|
|
|
|
await expect(revealSidebarTask(TEST_TASK_ID, (callback) => callback())).resolves.toBe(true);
|
|
expect(hiddenRow.scrollIntoView).not.toHaveBeenCalled();
|
|
expect(visibleRow.scrollIntoView).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("gives up after its bounded frame budget when no row renders", async () => {
|
|
mountViewport();
|
|
const callbacks: Array<() => void> = [];
|
|
const navigation = revealSidebarTask("missing", (callback) => callbacks.push(callback));
|
|
|
|
let frames = 0;
|
|
while (callbacks.length > 0 && frames < 100) {
|
|
callbacks.shift()!();
|
|
frames += 1;
|
|
}
|
|
|
|
await expect(navigation).resolves.toBe(false);
|
|
expect(frames).toBeLessThan(100);
|
|
expect(callbacks).toHaveLength(0);
|
|
});
|
|
});
|