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.
489 lines
17 KiB
TypeScript
489 lines
17 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;
|
|
// Mirrors Leaflet's own fire() merge: the original data plus
|
|
// type/target/sourceTarget, matching what a real .on() listener gets.
|
|
expect(event.detail).toEqual({
|
|
foo: 1,
|
|
type: 'zoomend',
|
|
target: node.leafletObject,
|
|
sourceTarget: node.leafletObject,
|
|
});
|
|
// 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();
|
|
});
|
|
});
|