You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
leaflet-components/test/core/with-props.test.ts

482 lines
16 KiB
TypeScript

import { Marker, Popup, Tooltip } from 'leaflet';
import { describe, expect, it, vi } from 'vitest';
import { num, positional, str, type PropOptionValues } from '../../src/core/props.ts';
import { WithProps, type LeafletElementConstructor } from '../../src/core/with-props.ts';
import type { LeafletRegisterEvent } from '../../src/core/register.ts';
// A minimal stand-in for a Leaflet object: duck-typed setters/getters plus the
// on/off/fire trio WithProps needs to watch and forward events. None of the
// tests in this file need real Leaflet -- WithProps only calls methods by
// name, it never checks `instanceof` -- except the child-registration group
// at the bottom, which exercises #onChildRegister's real `instanceof
// Popup/Tooltip/Layer` checks and so needs real Leaflet objects.
class FakeObj {
radius = 0;
opacity = 1;
removed = false;
#listeners = new Map<string, Set<(data?: unknown) => void>>();
setRadius(v: number): void {
this.radius = v;
}
getRadius(): number {
return this.radius;
}
setOpacity(v: number): void {
this.opacity = v;
}
bindPopup(): void {}
unbindPopup(): void {}
bindTooltip(): void {}
unbindTooltip(): void {}
addLayer(): void {}
removeLayer(): void {}
on(type: string, handler: (data?: unknown) => void): this {
let set = this.#listeners.get(type);
if (!set) {
set = new Set();
this.#listeners.set(type, set);
}
set.add(handler);
return this;
}
off(type: string, handler: (data?: unknown) => void): this {
this.#listeners.get(type)?.delete(handler);
return this;
}
fire(type: string, data?: unknown): this {
for (const handler of this.#listeners.get(type) ?? []) handler(data);
return this;
}
remove(): void {
this.removed = true;
}
}
const BASE_PROPS = {
radius: num<FakeObj>(5, { get: (obj) => obj.getRadius() }),
opacity: num<FakeObj>(1),
} as const;
function define(
tag: string,
props: Parameters<typeof WithProps>[0] = BASE_PROPS,
elementOptions?: Parameters<typeof WithProps>[1],
): {
Ctor: LeafletElementConstructor<FakeObj, typeof props>;
create: ReturnType<typeof vi.fn<(options: PropOptionValues<typeof props>) => FakeObj>>;
} {
const create = vi.fn((options: PropOptionValues<typeof props>) => {
const obj = new FakeObj();
const opts = options as { radius?: number; opacity?: number };
if (opts.radius !== undefined) obj.setRadius(opts.radius);
if (opts.opacity !== undefined) obj.setOpacity(opts.opacity);
return obj;
});
class El extends WithProps<FakeObj, typeof props>(props, elementOptions) {
createLeafletObject(opts: PropOptionValues<typeof props>): FakeObj {
return create(opts);
}
}
customElements.define(tag, El);
return { Ctor: El as unknown as LeafletElementConstructor<FakeObj, typeof props>, create };
}
function childRegisterEvent(el: HTMLElement, leafletObject: unknown): LeafletRegisterEvent {
return new CustomEvent('leaflet-register', {
bubbles: true,
composed: true,
detail: { leafletObject, element: el },
}) as LeafletRegisterEvent;
}
describe('WithProps: observedAttributes', () => {
it('kebab-cases prop names and derives them from the PROPS table', () => {
const props = { fooBar: str<FakeObj>('x'), radius: num<FakeObj>(0) } as const;
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
}
customElements.define('wp-observed-attrs', El);
expect((El as unknown as { observedAttributes: string[] }).observedAttributes).toEqual([
'foo-bar',
'radius',
]);
});
});
describe('WithProps: creation and options', () => {
it('builds the options object only from present attributes, decoded', () => {
const { Ctor, create } = define('wp-create-options');
const el = document.createElement('wp-create-options') as InstanceType<typeof Ctor>;
el.setAttribute('radius', '20');
document.body.append(el);
expect(create).toHaveBeenCalledWith({ radius: 20 });
expect(el.leafletObject).toBeInstanceOf(FakeObj);
el.remove();
});
it('omits props with option:false (positional) from the options object', () => {
const props = { radius: positional(num<FakeObj>(5)), opacity: num<FakeObj>(1) } as const;
const create = vi.fn((_o: PropOptionValues<typeof props>) => new FakeObj());
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(opts: PropOptionValues<typeof props>): FakeObj {
return create(opts);
}
}
customElements.define('wp-positional', El);
const el = document.createElement('wp-positional');
el.setAttribute('radius', '50');
el.setAttribute('opacity', '0.5');
document.body.append(el);
expect(create).toHaveBeenCalledWith({ opacity: 0.5 });
el.remove();
});
it('calls leafletObjectCreated() after creation', () => {
const props = BASE_PROPS;
const created = vi.fn();
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
leafletObjectCreated(): void {
created();
}
}
customElements.define('wp-created-hook', El);
document.body.append(document.createElement('wp-created-hook'));
expect(created).toHaveBeenCalledOnce();
});
});
describe('WithProps: attribute changes update the Leaflet object', () => {
it('dispatches to the matching setNAME method by default', () => {
const tag = 'wp-attr-setter';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
node.setAttribute('radius', '42');
expect(node.leafletObject?.radius).toBe(42);
node.remove();
});
it('falls back to the default value when the attribute is removed', () => {
const tag = 'wp-attr-remove-falls-back';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
node.setAttribute('radius', '99');
document.body.append(node);
expect(node.leafletObject?.radius).toBe(99);
node.removeAttribute('radius');
// BASE_PROPS default.
expect(node.leafletObject?.radius).toBe(5);
node.remove();
});
it('uses a custom `set` function instead of the setter when provided', () => {
const props = {
radius: num<FakeObj>(5, {
set(obj, value) {
obj.setRadius(value * 2);
},
}),
} as const;
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
}
const tag = 'wp-custom-set';
customElements.define(tag, El);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
node.setAttribute('radius', '10');
expect(node.leafletObject?.radius).toBe(20);
node.remove();
});
it('is a silent no-op when there is neither a custom `set` nor a matching setter (constructor-only props)', () => {
const props = { unsettable: str<FakeObj>('x') } as const;
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
}
const tag = 'wp-constructor-only';
customElements.define(tag, El);
const node = document.createElement(tag);
document.body.append(node);
expect(() => node.setAttribute('unsettable', 'y')).not.toThrow();
node.remove();
});
it('ignores attribute churn before the element is connected', () => {
const tag = 'wp-not-connected';
const { create } = define(tag);
const node = document.createElement(tag);
node.setAttribute('radius', '1');
node.setAttribute('radius', '2');
expect(create).not.toHaveBeenCalled();
});
});
describe('WithProps: recreate option', () => {
it('rebuilds the whole object on any attribute change instead of calling a setter', () => {
const props = { radius: num<FakeObj>(5) } as const;
const created = vi.fn();
class El extends WithProps<FakeObj, typeof props>(props, { recreate: true }) {
createLeafletObject(opts: PropOptionValues<typeof props>): FakeObj {
const obj = new FakeObj();
if (opts.radius !== undefined) obj.setRadius(opts.radius);
return obj;
}
leafletObjectCreated(): void {
created();
}
}
const tag = 'wp-recreate';
customElements.define(tag, El);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const first = node.leafletObject;
expect(created).toHaveBeenCalledTimes(1);
node.setAttribute('radius', '7');
expect(node.leafletObject).not.toBe(first);
expect(node.leafletObject?.radius).toBe(7);
expect(created).toHaveBeenCalledTimes(2);
node.remove();
});
});
describe('WithProps: property accessors', () => {
it('get reads the live Leaflet value when `get` is defined', () => {
const tag = 'wp-get-live';
define(tag);
const node = document.createElement(tag) as HTMLElement & {
leafletObject?: FakeObj;
radius: number;
};
document.body.append(node);
// Bypass the attribute entirely.
node.leafletObject!.setRadius(123);
expect(node.radius).toBe(123);
node.remove();
});
it('get falls back to the attribute, then the default, when unconnected', () => {
const tag = 'wp-get-fallback';
define(tag);
const node = document.createElement(tag) as HTMLElement & { radius: number };
// Default, not connected yet.
expect(node.radius).toBe(5);
node.setAttribute('radius', '9');
// Attribute, still not connected.
expect(node.radius).toBe(9);
});
it('set encodes the value onto the attribute', () => {
const tag = 'wp-set-encodes';
define(tag);
const node = document.createElement(tag) as HTMLElement & { radius: number };
node.radius = 33;
expect(node.getAttribute('radius')).toBe('33');
});
});
describe('WithProps: event-driven attribute sync-back', () => {
it('re-syncs the attribute after the named Leaflet event fires', () => {
const props = {
radius: num<FakeObj>(5, { get: (obj) => obj.getRadius(), event: 'radiuschange' }),
} as const;
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
}
const tag = 'wp-event-sync';
customElements.define(tag, El);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
node.leafletObject!.setRadius(77);
// Not synced yet.
expect(node.getAttribute('radius')).toBeNull();
node.leafletObject!.fire('radiuschange');
expect(node.getAttribute('radius')).toBe('77');
node.remove();
});
});
describe('WithProps: generic leaflet: event forwarding', () => {
it('re-emits any fired Leaflet event as a non-bubbling leaflet:<type> CustomEvent', () => {
const tag = 'wp-forward-events';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
const wrapper = document.createElement('div');
wrapper.append(node);
document.body.append(wrapper);
const onNode = vi.fn();
const onWrapper = vi.fn();
node.addEventListener('leaflet:zoomend', onNode);
wrapper.addEventListener('leaflet:zoomend', onWrapper);
node.leafletObject!.fire('zoomend', { foo: 1 });
expect(onNode).toHaveBeenCalledOnce();
const event = onNode.mock.calls[0][0] as CustomEvent;
expect(event.detail).toEqual({ foo: 1 });
// Does not bubble.
expect(onWrapper).not.toHaveBeenCalled();
wrapper.remove();
});
it('dispatches the DOM event after native on() handlers have already run', () => {
const tag = 'wp-forward-order';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
let nativeRanFirst = false;
node.leafletObject!.on('customevent', () => {
nativeRanFirst = true;
});
node.addEventListener('leaflet:customevent', () => {
expect(nativeRanFirst).toBe(true);
});
node.leafletObject!.fire('customevent');
node.remove();
});
});
describe('WithProps: attach modes', () => {
it('attach "children" (default) registers with the parent and listens for children', () => {
const tag = 'wp-attach-children';
define(tag);
const parent = document.createElement('div');
document.body.append(parent);
const onRegister = vi.fn();
parent.addEventListener('leaflet-register', onRegister);
const node = document.createElement(tag);
parent.append(node);
expect(onRegister).toHaveBeenCalledOnce();
parent.remove();
});
it('attach "self" registers with the parent but does not bind child registrations', () => {
const tag = 'wp-attach-self';
define(tag, BASE_PROPS, { attach: 'self' });
const parent = document.createElement('div');
document.body.append(parent);
const onRegister = vi.fn();
parent.addEventListener('leaflet-register', onRegister);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
parent.append(node);
// It still registers itself.
expect(onRegister).toHaveBeenCalledOnce();
const bindPopup = vi.spyOn(node.leafletObject!, 'bindPopup');
const fakeChild = document.createElement('div');
node.append(fakeChild);
fakeChild.dispatchEvent(
new CustomEvent('leaflet-register', {
bubbles: true,
composed: true,
detail: { leafletObject: new Popup(), element: fakeChild },
}),
);
// "self" doesn't listen for children.
expect(bindPopup).not.toHaveBeenCalled();
parent.remove();
});
it('attach "none" does not register with the parent at all', () => {
const tag = 'wp-attach-none';
define(tag, BASE_PROPS, { attach: 'none' });
const parent = document.createElement('div');
document.body.append(parent);
const onRegister = vi.fn();
parent.addEventListener('leaflet-register', onRegister);
parent.append(document.createElement(tag));
expect(onRegister).not.toHaveBeenCalled();
parent.remove();
});
});
// These need real Leaflet Popup/Tooltip/Marker instances because
// #onChildRegister discriminates children by `instanceof Popup/Tooltip/Layer`,
// not duck typing.
describe('WithProps: child registration binds popups, tooltips and layers', () => {
it('binds a registering Popup child via bindPopup', () => {
const tag = 'wp-bind-popup';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const bindPopup = vi.spyOn(node.leafletObject!, 'bindPopup');
const child = document.createElement('div');
node.append(child);
child.dispatchEvent(childRegisterEvent(child, new Popup()));
expect(bindPopup).toHaveBeenCalledOnce();
node.remove();
});
it('binds a registering Tooltip child via bindTooltip', () => {
const tag = 'wp-bind-tooltip';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const bindTooltip = vi.spyOn(node.leafletObject!, 'bindTooltip');
const child = document.createElement('div');
node.append(child);
child.dispatchEvent(childRegisterEvent(child, new Tooltip()));
expect(bindTooltip).toHaveBeenCalledOnce();
node.remove();
});
it('adds a registering Layer child via addLayer', () => {
const tag = 'wp-bind-layer';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const addLayer = vi.spyOn(node.leafletObject!, 'addLayer');
const child = document.createElement('div');
node.append(child);
child.dispatchEvent(childRegisterEvent(child, new Marker([0, 0])));
expect(addLayer).toHaveBeenCalledOnce();
node.remove();
});
it('releases children (unbind/remove) on disconnect', () => {
const tag = 'wp-release-children';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const leafletObject = node.leafletObject!;
const child = document.createElement('div');
node.append(child);
child.dispatchEvent(childRegisterEvent(child, new Popup()));
const unbindPopup = vi.spyOn(leafletObject, 'unbindPopup');
node.remove();
expect(unbindPopup).toHaveBeenCalledOnce();
});
});