blob: e4dee32d37524590d5b4b4ee370402505cd43f07 [file] [edit]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {createContext, startDragGesture} from './mithril_utils';
// jsdom doesn't implement PointerEvent. Build a minimal stand-in by
// extending MouseEvent with a pointerId — sufficient for startDragGesture,
// which only reads clientX/clientY and pointerId.
function makePointerEvent(
type: string,
init: {clientX?: number; clientY?: number; pointerId?: number} = {},
): PointerEvent {
const ev = new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: init.clientX ?? 0,
clientY: init.clientY ?? 0,
}) as MouseEvent & {pointerId: number};
ev.pointerId = init.pointerId ?? 1;
return ev as unknown as PointerEvent;
}
function makeTarget(): HTMLElement {
const el = document.createElement('div');
document.body.appendChild(el);
// jsdom lacks pointer-capture; stub the methods startDragGesture calls.
el.setPointerCapture = () => {};
el.releasePointerCapture = () => {};
el.hasPointerCapture = () => false;
return el;
}
describe('startDragGesture', () => {
afterEach(() => {
document.body.innerHTML = '';
});
test('invokes onDragFailed when released inside deadzone', () => {
const el = makeTarget();
const onDrag = vi.fn();
const onDragEnd = vi.fn();
const onDragFailed = vi.fn();
const down = makePointerEvent('pointerdown', {clientX: 100, clientY: 100});
Object.defineProperty(down, 'currentTarget', {value: el});
startDragGesture({e: down, deadzonePx: 5, onDrag, onDragEnd, onDragFailed});
el.dispatchEvent(
makePointerEvent('pointerup', {clientX: 101, clientY: 101}),
);
expect(onDrag).not.toHaveBeenCalled();
expect(onDragEnd).not.toHaveBeenCalled();
expect(onDragFailed).toHaveBeenCalledTimes(1);
});
test('top-level onDrag/onDragEnd fire after deadzone is crossed', () => {
const el = makeTarget();
const onDrag = vi.fn();
const onDragEnd = vi.fn();
const onDragFailed = vi.fn();
const onDragStart = vi.fn();
const down = makePointerEvent('pointerdown', {clientX: 0, clientY: 0});
Object.defineProperty(down, 'currentTarget', {value: el});
startDragGesture({
e: down,
deadzonePx: 5,
onDragStart,
onDrag,
onDragEnd,
onDragFailed,
});
// Inside deadzone — nothing fires.
el.dispatchEvent(makePointerEvent('pointermove', {clientX: 2, clientY: 2}));
expect(onDragStart).not.toHaveBeenCalled();
expect(onDrag).not.toHaveBeenCalled();
// Crosses deadzone — onDragStart fires, but this move is consumed by it.
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 10, clientY: 0}),
);
expect(onDragStart).toHaveBeenCalledTimes(1);
expect(onDrag).not.toHaveBeenCalled();
// Subsequent moves go to the top-level onDrag.
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 20, clientY: 0}),
);
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 30, clientY: 0}),
);
expect(onDrag).toHaveBeenCalledTimes(2);
el.dispatchEvent(makePointerEvent('pointerup', {clientX: 30, clientY: 0}));
expect(onDragEnd).toHaveBeenCalledTimes(1);
expect(onDragFailed).not.toHaveBeenCalled();
});
test('works with no onDragStart at all (direct mode)', () => {
const el = makeTarget();
const onDrag = vi.fn();
const onDragEnd = vi.fn();
const down = makePointerEvent('pointerdown');
Object.defineProperty(down, 'currentTarget', {value: el});
startDragGesture({e: down, onDrag, onDragEnd});
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 50, clientY: 0}),
);
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 60, clientY: 0}),
);
el.dispatchEvent(makePointerEvent('pointerup', {clientX: 60, clientY: 0}));
expect(onDrag).toHaveBeenCalledTimes(2);
expect(onDragEnd).toHaveBeenCalledTimes(1);
});
test('per-gesture handlers from onDragStart override top-level ones', () => {
const el = makeTarget();
const topOnDrag = vi.fn();
const topOnDragEnd = vi.fn();
const perOnDrag = vi.fn();
const perOnDragEnd = vi.fn();
const down = makePointerEvent('pointerdown');
Object.defineProperty(down, 'currentTarget', {value: el});
startDragGesture({
e: down,
onDragStart: () => ({onDrag: perOnDrag, onDragEnd: perOnDragEnd}),
onDrag: topOnDrag,
onDragEnd: topOnDragEnd,
});
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 50, clientY: 0}),
);
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 60, clientY: 0}),
);
el.dispatchEvent(makePointerEvent('pointerup', {clientX: 60, clientY: 0}));
expect(perOnDrag).toHaveBeenCalledTimes(2);
expect(perOnDragEnd).toHaveBeenCalledTimes(1);
expect(topOnDrag).not.toHaveBeenCalled();
expect(topOnDragEnd).not.toHaveBeenCalled();
});
test('listeners are removed after gesture ends', () => {
const el = makeTarget();
const onDrag = vi.fn();
const onDragEnd = vi.fn();
const down = makePointerEvent('pointerdown');
Object.defineProperty(down, 'currentTarget', {value: el});
startDragGesture({e: down, onDrag, onDragEnd});
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 50, clientY: 0}),
);
el.dispatchEvent(makePointerEvent('pointerup', {clientX: 50, clientY: 0}));
onDrag.mockClear();
onDragEnd.mockClear();
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 100, clientY: 0}),
);
el.dispatchEvent(makePointerEvent('pointerup', {clientX: 100, clientY: 0}));
expect(onDrag).not.toHaveBeenCalled();
expect(onDragEnd).not.toHaveBeenCalled();
});
test('deadzonePx: 0 starts the drag immediately on pointerdown', () => {
const el = makeTarget();
const onDragStart = vi.fn();
const onDrag = vi.fn();
const onDragEnd = vi.fn();
const onDragFailed = vi.fn();
const down = makePointerEvent('pointerdown', {clientX: 10, clientY: 10});
Object.defineProperty(down, 'currentTarget', {value: el});
startDragGesture({
e: down,
deadzonePx: 0,
onDragStart,
onDrag,
onDragEnd,
onDragFailed,
});
// onDragStart fires synchronously, before any pointermove.
expect(onDragStart).toHaveBeenCalledTimes(1);
// A release without moving counts as drag end, not drag failed.
el.dispatchEvent(makePointerEvent('pointerup', {clientX: 10, clientY: 10}));
expect(onDragEnd).toHaveBeenCalledTimes(1);
expect(onDragFailed).not.toHaveBeenCalled();
});
test('pointercancel ends the gesture like pointerup', () => {
const el = makeTarget();
const onDragEnd = vi.fn();
const onDragFailed = vi.fn();
const down = makePointerEvent('pointerdown');
Object.defineProperty(down, 'currentTarget', {value: el});
startDragGesture({e: down, onDragEnd, onDragFailed});
el.dispatchEvent(
makePointerEvent('pointermove', {clientX: 50, clientY: 0}),
);
el.dispatchEvent(
makePointerEvent('pointercancel', {clientX: 50, clientY: 0}),
);
expect(onDragEnd).toHaveBeenCalledTimes(1);
expect(onDragFailed).not.toHaveBeenCalled();
});
});
describe('createContext', () => {
test('provides default value to consumers', () => {
const {Consumer} = createContext('default');
let receivedValue: string | undefined;
const TestComponent = {
view: () =>
m(Consumer, (value) => {
receivedValue = value;
}),
};
m.render(document.body, m(TestComponent));
expect(receivedValue).toBe('default');
});
test('provides undefined when no default value', () => {
const {Consumer} = createContext<string>();
let receivedValue: string | undefined = 'sentinel';
const TestComponent = {
view: () =>
m(Consumer, (value) => {
receivedValue = value;
}),
};
m.render(document.body, m(TestComponent));
expect(receivedValue).toBeUndefined();
});
test('provider overrides default value', () => {
const {Provider, Consumer} = createContext('default');
let receivedValue: string | undefined;
const TestComponent = {
view: () =>
m(
Provider,
{value: 'custom'},
m(Consumer, (value) => {
receivedValue = value;
}),
),
};
m.render(document.body, m(TestComponent));
expect(receivedValue).toBe('custom');
});
test('nested providers use innermost value', () => {
const {Provider, Consumer} = createContext('default');
const receivedValues: string[] = [];
const TestComponent = {
view: () =>
m(Provider, {value: 'outer'}, [
m(Consumer, (value) => {
receivedValues.push(value);
}),
m(
Provider,
{value: 'inner'},
m(Consumer, (value) => {
receivedValues.push(value);
}),
),
m(Consumer, (value) => {
receivedValues.push(value);
}),
]),
};
m.render(document.body, m(TestComponent));
expect(receivedValues).toEqual(['outer', 'inner', 'outer']);
});
test('multiple providers in parallel', () => {
const {Provider, Consumer} = createContext('default');
const receivedValues: string[] = [];
const TestComponent = {
view: () => [
m(Consumer, (value) => {
receivedValues.push(value);
}),
m(
Provider,
{value: 'foo'},
m(Consumer, (value) => {
receivedValues.push(value);
}),
),
m(
Provider,
{value: 'bar'},
m(Consumer, (value) => {
receivedValues.push(value);
}),
),
m(Consumer, (value) => {
receivedValues.push(value);
}),
],
};
m.render(document.body, m(TestComponent));
expect(receivedValues).toEqual(['default', 'foo', 'bar', 'default']);
});
test('different contexts are independent', () => {
const Context1 = createContext('default1');
const Context2 = createContext('default2');
const receivedValues: string[] = [];
const TestComponent = {
view: () =>
m(Context1.Provider, {value: 'value1'}, [
m(Context2.Provider, {value: 'value2'}, [
m(Context1.Consumer, (value) => {
receivedValues.push(value);
}),
m(Context2.Consumer, (value) => {
receivedValues.push(value);
}),
]),
]),
};
m.render(document.body, m(TestComponent));
expect(receivedValues).toEqual(['value1', 'value2']);
});
test('works with complex types', () => {
interface User {
name: string;
age: number;
}
const {Provider, Consumer} = createContext<User>({name: 'Default', age: 0});
let receivedValue: User | undefined;
const TestComponent = {
view: () =>
m(
Provider,
{value: {name: 'Alice', age: 30}},
m(Consumer, (value) => {
receivedValue = value;
}),
),
};
m.render(document.body, m(TestComponent));
expect(receivedValue).toEqual({name: 'Alice', age: 30});
});
test('handles null values', () => {
const {Provider, Consumer} = createContext<string | null>('default');
let receivedValue: string | null = 'sentinel';
const TestComponent = {
view: () =>
m(
Provider,
{value: null},
m(Consumer, (value) => {
receivedValue = value;
}),
),
};
m.render(document.body, m(TestComponent));
expect(receivedValue).toBeNull();
});
});