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 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(5, { get: (obj) => obj.getRadius() }), opacity: num(1), } as const; function define( tag: string, props: Parameters[0] = BASE_PROPS, elementOptions?: Parameters[1], ): { Ctor: LeafletElementConstructor; create: ReturnType) => FakeObj>>; } { const create = vi.fn((options: PropOptionValues) => { 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(props, elementOptions) { createLeafletObject(opts: PropOptionValues): FakeObj { return create(opts); } } customElements.define(tag, El); return { Ctor: El as unknown as LeafletElementConstructor, 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('x'), radius: num(0) } as const; class El extends WithProps(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; 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(5)), opacity: num(1) } as const; const create = vi.fn((_o: PropOptionValues) => new FakeObj()); class El extends WithProps(props) { createLeafletObject(opts: PropOptionValues): 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(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(5, { set(obj, value) { obj.setRadius(value * 2); }, }), } as const; class El extends WithProps(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('x') } as const; class El extends WithProps(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(5) } as const; const created = vi.fn(); class El extends WithProps(props, { recreate: true }) { createLeafletObject(opts: PropOptionValues): 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(); }); it('re-registers the new object with the parent (recreateLeafletObject is a public method)', () => { const props = { radius: num(5) } as const; class El extends WithProps(props) { createLeafletObject(): FakeObj { return new FakeObj(); } } const tag = 'wp-recreate-reregisters'; customElements.define(tag, El); const parent = document.createElement('div'); document.body.append(parent); const registrations: unknown[] = []; parent.addEventListener('leaflet-register', (e: LeafletRegisterEvent) => { registrations.push(e.detail.leafletObject); }); const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj; recreateLeafletObject(): void; }; parent.append(node); expect(registrations).toEqual([node.leafletObject]); const first = node.leafletObject; // recreateLeafletObject is declared on the public LeafletElement interface // but, until fixed, only a private implementation existed -- this call // would have thrown "is not a function" at runtime despite type-checking. node.recreateLeafletObject(); expect(node.leafletObject).not.toBe(first); expect(registrations).toEqual([first, node.leafletObject]); parent.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(5, { get: (obj) => obj.getRadius(), event: 'radiuschange' }), } as const; class El extends WithProps(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: 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(); }); });