Files
kandev/apps/web/lib/sidebar/task-navigation.test.ts
2026-08-26 21:05:08 +00:00

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);
});
});