219 lines
7.6 KiB
TypeScript
219 lines
7.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createChatScrollMotion } from "./chat-scroll-motion";
|
|
let frames: Map<number, FrameRequestCallback>;
|
|
let now: number;
|
|
function advance(ms = 16) {
|
|
now += ms;
|
|
const pending = [...frames.values()];
|
|
frames.clear();
|
|
pending.forEach((cb) => cb(now));
|
|
}
|
|
function fixture() {
|
|
const el = document.createElement("div");
|
|
let height = 1000;
|
|
Object.defineProperties(el, {
|
|
scrollHeight: { configurable: true, get: () => height },
|
|
clientHeight: { value: 200 },
|
|
});
|
|
return {
|
|
el,
|
|
grow: (next: number) => {
|
|
height = next;
|
|
},
|
|
};
|
|
}
|
|
beforeEach(() => {
|
|
frames = new Map();
|
|
now = 0;
|
|
let id = 0;
|
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
frames.set(++id, cb);
|
|
return id;
|
|
});
|
|
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
|
|
});
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
// @covers AC-UI-CHAT-MOTION-003.1, AC-UI-CHAT-MOTION-003.2, AC-UI-CHAT-MOTION-003.4
|
|
describe("interruptible chat following", () => {
|
|
it("defers geometry reads and coalesces requests into a single moving target", () => {
|
|
const { el, grow } = fixture();
|
|
const read = vi.spyOn(el, "scrollHeight", "get");
|
|
const motion = createChatScrollMotion(el, () => true, vi.fn());
|
|
motion.request();
|
|
motion.request();
|
|
expect(read).not.toHaveBeenCalled();
|
|
expect(frames.size).toBe(1);
|
|
advance();
|
|
advance();
|
|
expect(el.scrollTop).toBeGreaterThan(0);
|
|
expect(el.scrollTop).toBeLessThan(800);
|
|
grow(1400);
|
|
motion.request();
|
|
expect(frames.size).toBe(1);
|
|
for (let i = 0; i < 20; i++) advance();
|
|
expect(el.scrollTop).toBe(1200);
|
|
expect(frames.size).toBe(0);
|
|
expect(motion.isRunning()).toBe(false);
|
|
motion.dispose();
|
|
});
|
|
});
|
|
describe("continuous growth and content interactions", () => {
|
|
it("keeps moving when content grows on every frame", () => {
|
|
const { el, grow } = fixture();
|
|
const motion = createChatScrollMotion(el, () => true, vi.fn());
|
|
motion.request();
|
|
advance();
|
|
for (let i = 1; i <= 30; i++) {
|
|
const previous = el.scrollTop;
|
|
grow(1000 + i * 10);
|
|
motion.request();
|
|
advance();
|
|
expect(el.scrollTop).toBeGreaterThan(previous);
|
|
expect(frames.size).toBe(1);
|
|
}
|
|
advance(180);
|
|
expect(el.scrollTop).toBe(1100);
|
|
expect(frames.size).toBe(0);
|
|
motion.dispose();
|
|
});
|
|
it("keeps following after ordinary content clicks", () => {
|
|
const { el } = fixture();
|
|
const button = document.createElement("button");
|
|
el.append(button);
|
|
const interrupt = vi.fn();
|
|
const motion = createChatScrollMotion(el, () => true, interrupt);
|
|
motion.request();
|
|
button.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
|
|
el.dispatchEvent(new MouseEvent("pointerdown"));
|
|
expect(interrupt).not.toHaveBeenCalled();
|
|
advance();
|
|
advance();
|
|
expect(el.scrollTop).toBeGreaterThan(0);
|
|
motion.dispose();
|
|
});
|
|
it("yields to a scrollbar press and removes its listener", () => {
|
|
const { el } = fixture();
|
|
Object.defineProperties(el, { clientWidth: { value: 200 }, offsetWidth: { value: 216 } });
|
|
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ left: 10, right: 226 } as DOMRect);
|
|
const interrupt = vi.fn();
|
|
const motion = createChatScrollMotion(el, () => true, interrupt);
|
|
motion.request();
|
|
el.dispatchEvent(new MouseEvent("pointerdown", { clientX: 215 }));
|
|
expect(interrupt).toHaveBeenCalledOnce();
|
|
expect(frames.size).toBe(0);
|
|
motion.dispose();
|
|
el.dispatchEvent(new MouseEvent("pointerdown", { clientX: 215 }));
|
|
expect(interrupt).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
describe("scroll ownership", () => {
|
|
it.each(["wheel", "keydown"])("yields to %s and removes listeners on disposal", (type) => {
|
|
const { el } = fixture();
|
|
const interrupt = vi.fn();
|
|
const motion = createChatScrollMotion(el, () => true, interrupt);
|
|
motion.request();
|
|
advance();
|
|
advance();
|
|
const top = el.scrollTop;
|
|
el.dispatchEvent(
|
|
type === "keydown" ? new KeyboardEvent(type, { key: "PageUp" }) : new Event(type),
|
|
);
|
|
advance();
|
|
expect(el.scrollTop).toBe(top);
|
|
expect(frames.size).toBe(0);
|
|
expect(interrupt).toHaveBeenCalledTimes(1);
|
|
motion.dispose();
|
|
el.dispatchEvent(new Event("wheel"));
|
|
expect(interrupt).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("does not treat typing as a scroll gesture", () => {
|
|
const { el } = fixture();
|
|
const interrupt = vi.fn();
|
|
const motion = createChatScrollMotion(el, () => true, interrupt);
|
|
motion.request();
|
|
el.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
|
|
expect(interrupt).not.toHaveBeenCalled();
|
|
expect(frames.size).toBe(1);
|
|
motion.dispose();
|
|
});
|
|
it("cancels before writing when ownership changes", () => {
|
|
const { el } = fixture();
|
|
let allowed = true;
|
|
const motion = createChatScrollMotion(el, () => allowed, vi.fn());
|
|
motion.request();
|
|
advance();
|
|
allowed = false;
|
|
const top = el.scrollTop;
|
|
advance();
|
|
expect(el.scrollTop).toBe(top);
|
|
expect(frames.size).toBe(0);
|
|
motion.dispose();
|
|
});
|
|
});
|
|
|
|
describe("touch scroll intent", () => {
|
|
it("ignores taps and small finger jitter but yields to a drag", () => {
|
|
const { el } = fixture();
|
|
const interrupt = vi.fn();
|
|
const motion = createChatScrollMotion(el, () => true, interrupt);
|
|
const touch = (type: string, y: number) =>
|
|
el.dispatchEvent(new TouchEvent(type, { touches: [{ clientY: y } as Touch] }));
|
|
motion.request();
|
|
touch("touchstart", 100);
|
|
touch("touchmove", 102);
|
|
el.dispatchEvent(new TouchEvent("touchend"));
|
|
expect(interrupt).not.toHaveBeenCalled();
|
|
expect(frames.size).toBe(1);
|
|
touch("touchstart", 100);
|
|
touch("touchmove", 112);
|
|
expect(interrupt).toHaveBeenCalledOnce();
|
|
expect(frames.size).toBe(0);
|
|
touch("touchmove", 130);
|
|
expect(interrupt).toHaveBeenCalledOnce();
|
|
motion.dispose();
|
|
touch("touchstart", 100);
|
|
touch("touchmove", 130);
|
|
expect(interrupt).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("keyboard control ownership", () => {
|
|
it.each(["button", "input", "select", "textarea", "a", "editable", "role-button"])(
|
|
"keeps following when a %s handles a scroll key",
|
|
(kind) => {
|
|
const { el } = fixture();
|
|
const control = document.createElement(
|
|
kind === "editable" || kind === "role-button" ? "div" : kind,
|
|
);
|
|
if (kind === "editable") control.setAttribute("contenteditable", "true");
|
|
if (kind === "role-button") control.setAttribute("role", "button");
|
|
if (kind === "a") control.setAttribute("href", "#target");
|
|
const target = document.createElement("span");
|
|
control.append(target);
|
|
el.append(control);
|
|
const interrupt = vi.fn();
|
|
const motion = createChatScrollMotion(el, () => true, interrupt);
|
|
motion.request();
|
|
target.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
|
|
expect(interrupt).not.toHaveBeenCalled();
|
|
expect(motion.isRunning()).toBe(true);
|
|
el.dispatchEvent(new KeyboardEvent("keydown", { key: "PageUp" }));
|
|
expect(interrupt).toHaveBeenCalledOnce();
|
|
motion.dispose();
|
|
},
|
|
);
|
|
});
|
|
|
|
it("leaves a prevented scroll key with its existing handler", () => {
|
|
const { el } = fixture();
|
|
const interrupt = vi.fn();
|
|
const motion = createChatScrollMotion(el, () => true, interrupt);
|
|
motion.request();
|
|
const event = new KeyboardEvent("keydown", { key: "PageUp", cancelable: true });
|
|
event.preventDefault();
|
|
el.dispatchEvent(event);
|
|
expect(interrupt).not.toHaveBeenCalled();
|
|
expect(motion.isRunning()).toBe(true);
|
|
motion.dispose();
|
|
});
|