test: add a Vitest + jsdom test suite
No test coverage existed before this. Adds vitest + jsdom (2 new devDependencies) and a suite that runs entirely without a browser: real Leaflet objects work fine under jsdom for everything this library needs to verify (option/attribute wiring, event forwarding), given a ResizeObserver stub in test/setup.ts (jsdom's only real gap here). - test/core/with-props.test.ts: the WithProps mixin itself, against a fake Leaflet-like class -- attribute<->setter dispatch, get/attribute/default fallback order, event-driven attribute sync-back, positional/recreate/ attach modes, and the generic fire()-patch event forwarding. Child registration (popup/tooltip/layer binding) uses real Popup/Tooltip/Marker instances since #onChildRegister discriminates by instanceof. - test/core/props.test.ts: the codec functions in isolation. - test/components/*.test.ts: grouped smoke tests across all components. - test/integration.test.ts: full tree wiring (map + tile-layer + feature-group + marker + popup). Caught and fixed one real bug along the way: urlProp's "live url getter" from the last refactor was dead code -- neither ImageOverlay nor VideoOverlay actually expose getUrl(). Removed it and the now-pointless getUrl?() from the Sourced interface in shared-props.ts. tsconfig.test.json keeps test/ out of the tsc build (dist/ stays test-free) while still typechecking it; oxlint.config.ts now covers test/ too, with max-classes-per-file relaxed there since testing a class factory means many small one-off element classes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>main
parent
c339ce1d06
commit
9b6c7ca598
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,101 @@
|
|||||||
|
import { Control, FeatureGroup, LayerGroup } from 'leaflet';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import '../../src/components/leaflet-control-zoom.ts';
|
||||||
|
import '../../src/components/leaflet-control-scale.ts';
|
||||||
|
import '../../src/components/leaflet-control-attribution.ts';
|
||||||
|
import '../../src/components/leaflet-control-layers.ts';
|
||||||
|
import '../../src/components/leaflet-tile-layer.ts';
|
||||||
|
import '../../src/components/leaflet-layer-group.ts';
|
||||||
|
import '../../src/components/leaflet-feature-group.ts';
|
||||||
|
import '../../src/components/leaflet-marker.ts';
|
||||||
|
|
||||||
|
describe('leaflet-control-zoom / scale / attribution', () => {
|
||||||
|
it('create their matching Control class with position and options', () => {
|
||||||
|
const zoom = document.createElement('leaflet-control-zoom') as HTMLElement & {
|
||||||
|
leafletObject?: Control.Zoom;
|
||||||
|
};
|
||||||
|
zoom.setAttribute('position', 'bottomleft');
|
||||||
|
document.body.append(zoom);
|
||||||
|
expect(zoom.leafletObject).toBeInstanceOf(Control.Zoom);
|
||||||
|
expect(zoom.leafletObject?.options.position).toBe('bottomleft');
|
||||||
|
zoom.remove();
|
||||||
|
|
||||||
|
const scale = document.createElement('leaflet-control-scale') as HTMLElement & {
|
||||||
|
leafletObject?: Control.Scale;
|
||||||
|
};
|
||||||
|
scale.setAttribute('max-width', '150');
|
||||||
|
document.body.append(scale);
|
||||||
|
expect(scale.leafletObject).toBeInstanceOf(Control.Scale);
|
||||||
|
expect(scale.leafletObject?.options.maxWidth).toBe(150);
|
||||||
|
scale.remove();
|
||||||
|
|
||||||
|
const attribution = document.createElement('leaflet-control-attribution') as HTMLElement & {
|
||||||
|
leafletObject?: Control.Attribution;
|
||||||
|
};
|
||||||
|
attribution.setAttribute('prefix', 'Test');
|
||||||
|
document.body.append(attribution);
|
||||||
|
expect(attribution.leafletObject).toBeInstanceOf(Control.Attribution);
|
||||||
|
expect(attribution.leafletObject?.options.prefix).toBe('Test');
|
||||||
|
attribution.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-control-layers', () => {
|
||||||
|
it('sorts existing children into base layers vs overlays by the type attribute', () => {
|
||||||
|
const el = document.createElement('leaflet-control-layers') as HTMLElement & {
|
||||||
|
leafletObject?: Control.Layers;
|
||||||
|
};
|
||||||
|
const base = document.createElement('leaflet-tile-layer');
|
||||||
|
base.setAttribute('url', 'https://a.example/{z}/{x}/{y}.png');
|
||||||
|
base.setAttribute('name', 'Base');
|
||||||
|
base.setAttribute('type', 'base');
|
||||||
|
base.setAttribute('active', '');
|
||||||
|
|
||||||
|
const overlay = document.createElement('leaflet-tile-layer');
|
||||||
|
overlay.setAttribute('url', 'https://b.example/{z}/{x}/{y}.png');
|
||||||
|
overlay.setAttribute('name', 'Overlay');
|
||||||
|
|
||||||
|
el.append(base, overlay);
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(Control.Layers);
|
||||||
|
// Control.Layers has no public getter for its layer list, so this reaches
|
||||||
|
// into Leaflet's own private `_layers` field -- brittle across Leaflet
|
||||||
|
// versions, but it's the only way to verify the base/overlay split.
|
||||||
|
const layersInternal = el.leafletObject as unknown as {
|
||||||
|
_layers: { name: string; overlay?: boolean }[];
|
||||||
|
};
|
||||||
|
const byName = Object.fromEntries(
|
||||||
|
// oxlint-disable-next-line no-underscore-dangle -- Leaflet's own field name
|
||||||
|
layersInternal._layers.map((l) => [l.name, Boolean(l.overlay)]),
|
||||||
|
);
|
||||||
|
expect(byName['Base']).toBe(false);
|
||||||
|
expect(byName['Overlay']).toBe(true);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-layer-group / leaflet-feature-group', () => {
|
||||||
|
it('are passthrough WithProps({}) containers that adopt registering children', () => {
|
||||||
|
const group = document.createElement('leaflet-layer-group') as HTMLElement & {
|
||||||
|
leafletObject?: LayerGroup;
|
||||||
|
};
|
||||||
|
document.body.append(group);
|
||||||
|
expect(group.leafletObject).toBeInstanceOf(LayerGroup);
|
||||||
|
|
||||||
|
const marker = document.createElement('leaflet-marker');
|
||||||
|
marker.setAttribute('lat', '1');
|
||||||
|
marker.setAttribute('lng', '2');
|
||||||
|
group.append(marker);
|
||||||
|
|
||||||
|
expect(group.leafletObject?.getLayers()).toHaveLength(1);
|
||||||
|
group.remove();
|
||||||
|
|
||||||
|
const feature = document.createElement('leaflet-feature-group') as HTMLElement & {
|
||||||
|
leafletObject?: FeatureGroup;
|
||||||
|
};
|
||||||
|
document.body.append(feature);
|
||||||
|
expect(feature.leafletObject).toBeInstanceOf(FeatureGroup);
|
||||||
|
feature.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { GeoJSON } from 'leaflet';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import '../../src/components/leaflet-geojson.ts';
|
||||||
|
|
||||||
|
const FEATURE = {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[-0.13, 51.5],
|
||||||
|
[-0.12, 51.5],
|
||||||
|
[-0.12, 51.51],
|
||||||
|
[-0.13, 51.51],
|
||||||
|
[-0.13, 51.5],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
properties: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('leaflet-geojson', () => {
|
||||||
|
it('builds a GeoJSON layer from the data attribute with pathProps nested as style', () => {
|
||||||
|
const el = document.createElement('leaflet-geojson') as HTMLElement & {
|
||||||
|
leafletObject?: GeoJSON;
|
||||||
|
};
|
||||||
|
el.setAttribute('data', JSON.stringify(FEATURE));
|
||||||
|
el.setAttribute('color', '#e67e22');
|
||||||
|
el.setAttribute('fill-opacity', '0.4');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(GeoJSON);
|
||||||
|
expect(el.leafletObject?.getLayers()).toHaveLength(1);
|
||||||
|
|
||||||
|
const style = el.leafletObject?.options.style as { color?: string; fillOpacity?: number };
|
||||||
|
expect(style.color).toBe('#e67e22');
|
||||||
|
expect(style.fillOpacity).toBe(0.4);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces its layers when the data attribute changes', () => {
|
||||||
|
const el = document.createElement('leaflet-geojson') as HTMLElement & {
|
||||||
|
leafletObject?: GeoJSON;
|
||||||
|
};
|
||||||
|
el.setAttribute('data', JSON.stringify(FEATURE));
|
||||||
|
document.body.append(el);
|
||||||
|
expect(el.leafletObject?.getLayers()).toHaveLength(1);
|
||||||
|
|
||||||
|
el.setAttribute(
|
||||||
|
'data',
|
||||||
|
JSON.stringify({ type: 'FeatureCollection', features: [FEATURE, FEATURE] }),
|
||||||
|
);
|
||||||
|
expect(el.leafletObject?.getLayers()).toHaveLength(2);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
import { Marker, Popup, Tooltip } from 'leaflet';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import '../../src/components/leaflet-map.ts';
|
||||||
|
import '../../src/components/leaflet-marker.ts';
|
||||||
|
import '../../src/components/leaflet-popup.ts';
|
||||||
|
import '../../src/components/leaflet-tooltip.ts';
|
||||||
|
import '../../src/components/leaflet-icon.ts';
|
||||||
|
|
||||||
|
describe('leaflet-marker', () => {
|
||||||
|
it('creates a Marker at lat/lng and keeps lat/lng synced while it moves', () => {
|
||||||
|
const el = document.createElement('leaflet-marker') as HTMLElement & {
|
||||||
|
leafletObject?: Marker;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
};
|
||||||
|
el.setAttribute('lat', '51.5');
|
||||||
|
el.setAttribute('lng', '-0.09');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(Marker);
|
||||||
|
expect(el.leafletObject?.getLatLng()).toMatchObject({ lat: 51.5, lng: -0.09 });
|
||||||
|
|
||||||
|
el.leafletObject?.setLatLng([1, 2]);
|
||||||
|
expect(el.lat).toBe(1);
|
||||||
|
expect(el.lng).toBe(2);
|
||||||
|
// event: 'move' syncs the attribute too
|
||||||
|
expect(el.getAttribute('lat')).toBe('1');
|
||||||
|
expect(el.getAttribute('lng')).toBe('2');
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draggable toggles the Leaflet dragging handler', () => {
|
||||||
|
// The dragging handler is only initialized in onAdd(), so the marker
|
||||||
|
// needs to actually be added to a map first.
|
||||||
|
const map = document.createElement('leaflet-map');
|
||||||
|
document.body.append(map);
|
||||||
|
|
||||||
|
const el = document.createElement('leaflet-marker') as HTMLElement & {
|
||||||
|
leafletObject?: Marker;
|
||||||
|
};
|
||||||
|
el.setAttribute('lat', '0');
|
||||||
|
el.setAttribute('lng', '0');
|
||||||
|
el.setAttribute('draggable', '');
|
||||||
|
map.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject?.dragging?.enabled()).toBe(true);
|
||||||
|
el.removeAttribute('draggable');
|
||||||
|
expect(el.leafletObject?.dragging?.enabled()).toBe(false);
|
||||||
|
map.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps in a <leaflet-icon> child via the icon-changed handshake', () => {
|
||||||
|
const el = document.createElement('leaflet-marker') as HTMLElement & {
|
||||||
|
leafletObject?: Marker;
|
||||||
|
};
|
||||||
|
el.setAttribute('lat', '0');
|
||||||
|
el.setAttribute('lng', '0');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
const icon = document.createElement('leaflet-icon');
|
||||||
|
icon.setAttribute('icon-url', 'https://example.com/icon.png');
|
||||||
|
el.append(icon);
|
||||||
|
|
||||||
|
expect(el.leafletObject?.options.icon).toBe(
|
||||||
|
(icon as HTMLElement & { leafletObject?: unknown }).leafletObject,
|
||||||
|
);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-popup', () => {
|
||||||
|
it('takes its content from innerHTML and stays in sync on mutation', async () => {
|
||||||
|
const el = document.createElement('leaflet-popup') as HTMLElement & {
|
||||||
|
leafletObject?: Popup;
|
||||||
|
};
|
||||||
|
el.innerHTML = '<b>hello</b>';
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(Popup);
|
||||||
|
expect(el.leafletObject?.getContent()).toBe('<b>hello</b>');
|
||||||
|
|
||||||
|
el.innerHTML = '<b>updated</b>';
|
||||||
|
// MutationObserver callbacks run as a microtask.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(el.leafletObject?.getContent()).toBe('<b>updated</b>');
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only sets its own latlng when lat/lng attributes are present', () => {
|
||||||
|
const withLatLng = document.createElement('leaflet-popup') as HTMLElement & {
|
||||||
|
leafletObject?: Popup;
|
||||||
|
};
|
||||||
|
withLatLng.setAttribute('lat', '1');
|
||||||
|
withLatLng.setAttribute('lng', '2');
|
||||||
|
document.body.append(withLatLng);
|
||||||
|
expect(withLatLng.leafletObject?.getLatLng()).toMatchObject({ lat: 1, lng: 2 });
|
||||||
|
withLatLng.remove();
|
||||||
|
|
||||||
|
const withoutLatLng = document.createElement('leaflet-popup') as HTMLElement & {
|
||||||
|
leafletObject?: Popup;
|
||||||
|
};
|
||||||
|
document.body.append(withoutLatLng);
|
||||||
|
expect(withoutLatLng.leafletObject?.getLatLng()).toBeUndefined();
|
||||||
|
withoutLatLng.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-tooltip', () => {
|
||||||
|
it('creates a Tooltip with content and direction/permanent/sticky options', () => {
|
||||||
|
const el = document.createElement('leaflet-tooltip') as HTMLElement & {
|
||||||
|
leafletObject?: Tooltip;
|
||||||
|
};
|
||||||
|
el.innerHTML = 'hover text';
|
||||||
|
el.setAttribute('direction', 'top');
|
||||||
|
el.setAttribute('permanent', '');
|
||||||
|
el.setAttribute('sticky', '');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(Tooltip);
|
||||||
|
expect(el.leafletObject?.getContent()).toBe('hover text');
|
||||||
|
expect(el.leafletObject?.options.direction).toBe('top');
|
||||||
|
expect(el.leafletObject?.options.permanent).toBe(true);
|
||||||
|
expect(el.leafletObject?.options.sticky).toBe(true);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
import { ImageOverlay, SVGOverlay, VideoOverlay, latLngBounds } from 'leaflet';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import '../../src/components/leaflet-map.ts';
|
||||||
|
import '../../src/components/leaflet-image-overlay.ts';
|
||||||
|
import '../../src/components/leaflet-video-overlay.ts';
|
||||||
|
import '../../src/components/leaflet-svg-overlay.ts';
|
||||||
|
|
||||||
|
const BOUNDS: [[number, number], [number, number]] = [
|
||||||
|
[40.71, -74.24],
|
||||||
|
[40.78, -74.12],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('leaflet-image-overlay', () => {
|
||||||
|
it('creates an ImageOverlay with a live bounds getter (url has no Leaflet getter to read back)', () => {
|
||||||
|
const el = document.createElement('leaflet-image-overlay') as HTMLElement & {
|
||||||
|
leafletObject?: ImageOverlay;
|
||||||
|
url: string;
|
||||||
|
bounds: unknown;
|
||||||
|
};
|
||||||
|
el.setAttribute('url', 'https://example.com/a.png');
|
||||||
|
el.setAttribute('bounds', JSON.stringify(BOUNDS));
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(ImageOverlay);
|
||||||
|
expect(el.url).toBe('https://example.com/a.png');
|
||||||
|
expect(el.bounds).toEqual(BOUNDS);
|
||||||
|
|
||||||
|
el.leafletObject?.setBounds(
|
||||||
|
latLngBounds([
|
||||||
|
[1, 2],
|
||||||
|
[3, 4],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// el.bounds reads live via getBounds()
|
||||||
|
expect(el.bounds).toEqual([
|
||||||
|
[1, 2],
|
||||||
|
[3, 4],
|
||||||
|
]);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-video-overlay', () => {
|
||||||
|
it('creates a VideoOverlay and applies the newly added options', () => {
|
||||||
|
const el = document.createElement('leaflet-video-overlay') as HTMLElement & {
|
||||||
|
leafletObject?: VideoOverlay;
|
||||||
|
};
|
||||||
|
el.setAttribute('url', 'https://example.com/a.mp4');
|
||||||
|
el.setAttribute('bounds', JSON.stringify(BOUNDS));
|
||||||
|
el.setAttribute('z-index', '5');
|
||||||
|
el.setAttribute('class-name', 'my-video');
|
||||||
|
el.setAttribute('keep-aspect-ratio', 'false');
|
||||||
|
el.setAttribute('error-overlay-url', 'https://example.com/error.png');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(VideoOverlay);
|
||||||
|
expect(el.leafletObject?.options.zIndex).toBe(5);
|
||||||
|
expect(el.leafletObject?.options.className).toBe('my-video');
|
||||||
|
expect(el.leafletObject?.options.keepAspectRatio).toBe(false);
|
||||||
|
expect(el.leafletObject?.options.errorOverlayUrl).toBe('https://example.com/error.png');
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies playback attributes directly to the underlying <video> element once it exists', () => {
|
||||||
|
// The <video> element is only created in onAdd(), so the overlay needs to
|
||||||
|
// actually be added to a map before getElement() returns anything.
|
||||||
|
const map = document.createElement('leaflet-map');
|
||||||
|
document.body.append(map);
|
||||||
|
|
||||||
|
const el = document.createElement('leaflet-video-overlay') as HTMLElement & {
|
||||||
|
leafletObject?: VideoOverlay;
|
||||||
|
getElement(): HTMLVideoElement | undefined;
|
||||||
|
};
|
||||||
|
el.setAttribute('url', 'https://example.com/a.mp4');
|
||||||
|
el.setAttribute('bounds', JSON.stringify(BOUNDS));
|
||||||
|
el.setAttribute('muted', '');
|
||||||
|
el.setAttribute('loop', '');
|
||||||
|
map.append(el);
|
||||||
|
|
||||||
|
const video = el.getElement();
|
||||||
|
expect(video?.muted).toBe(true);
|
||||||
|
expect(video?.loop).toBe(true);
|
||||||
|
map.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-svg-overlay', () => {
|
||||||
|
it('creates an SVGOverlay with a live bounds getter', () => {
|
||||||
|
const el = document.createElement('leaflet-svg-overlay') as HTMLElement & {
|
||||||
|
leafletObject?: SVGOverlay;
|
||||||
|
bounds: unknown;
|
||||||
|
};
|
||||||
|
el.setAttribute('bounds', JSON.stringify(BOUNDS));
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(SVGOverlay);
|
||||||
|
expect(el.bounds).toEqual(BOUNDS);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,147 @@
|
|||||||
|
import { Circle, CircleMarker, Polygon, Polyline, Rectangle } from 'leaflet';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import '../../src/components/leaflet-circle.ts';
|
||||||
|
import '../../src/components/leaflet-circle-marker.ts';
|
||||||
|
import '../../src/components/leaflet-rectangle.ts';
|
||||||
|
import '../../src/components/leaflet-polygon.ts';
|
||||||
|
import '../../src/components/leaflet-polyline.ts';
|
||||||
|
import '../../src/components/leaflet-line.ts';
|
||||||
|
|
||||||
|
describe('leaflet-circle', () => {
|
||||||
|
it('creates a Circle at lat/lng with radius and pathProps applied', () => {
|
||||||
|
const el = document.createElement('leaflet-circle') as HTMLElement & {
|
||||||
|
leafletObject?: Circle;
|
||||||
|
radius: number;
|
||||||
|
};
|
||||||
|
el.setAttribute('lat', '51.5');
|
||||||
|
el.setAttribute('lng', '-0.09');
|
||||||
|
el.setAttribute('radius', '500');
|
||||||
|
el.setAttribute('color', 'red');
|
||||||
|
el.setAttribute('interactive', 'false');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(Circle);
|
||||||
|
expect(el.leafletObject?.getLatLng()).toMatchObject({ lat: 51.5, lng: -0.09 });
|
||||||
|
expect(el.leafletObject?.getRadius()).toBe(500);
|
||||||
|
expect(el.leafletObject?.options.color).toBe('red');
|
||||||
|
expect(el.leafletObject?.options.interactive).toBe(false);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('radius property reads the live value and reacts to attribute changes', () => {
|
||||||
|
const el = document.createElement('leaflet-circle') as HTMLElement & {
|
||||||
|
leafletObject?: Circle;
|
||||||
|
radius: number;
|
||||||
|
};
|
||||||
|
document.body.append(el);
|
||||||
|
// Leaflet's own default, no attribute set
|
||||||
|
expect(el.radius).toBe(1000);
|
||||||
|
|
||||||
|
el.setAttribute('radius', '750');
|
||||||
|
expect(el.leafletObject?.getRadius()).toBe(750);
|
||||||
|
expect(el.radius).toBe(750);
|
||||||
|
|
||||||
|
// Change bypassing the attribute entirely.
|
||||||
|
el.leafletObject?.setRadius(42);
|
||||||
|
// Property getter still reflects Leaflet's live state.
|
||||||
|
expect(el.radius).toBe(42);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-circle-marker', () => {
|
||||||
|
it('creates a CircleMarker with a live radius getter', () => {
|
||||||
|
const el = document.createElement('leaflet-circle-marker') as HTMLElement & {
|
||||||
|
leafletObject?: CircleMarker;
|
||||||
|
radius: number;
|
||||||
|
};
|
||||||
|
el.setAttribute('lat', '1');
|
||||||
|
el.setAttribute('lng', '2');
|
||||||
|
el.setAttribute('radius', '15');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(CircleMarker);
|
||||||
|
expect(el.leafletObject?.getRadius()).toBe(15);
|
||||||
|
el.leafletObject?.setRadius(30);
|
||||||
|
expect(el.radius).toBe(30);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-rectangle', () => {
|
||||||
|
it('creates a Rectangle from the bounds attribute with a live bounds getter', () => {
|
||||||
|
const el = document.createElement('leaflet-rectangle') as HTMLElement & {
|
||||||
|
leafletObject?: Rectangle;
|
||||||
|
bounds: [[number, number], [number, number]];
|
||||||
|
};
|
||||||
|
el.setAttribute(
|
||||||
|
'bounds',
|
||||||
|
JSON.stringify([
|
||||||
|
[51.49, -0.08],
|
||||||
|
[51.5, -0.06],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(Rectangle);
|
||||||
|
expect(el.bounds).toEqual([
|
||||||
|
[51.49, -0.08],
|
||||||
|
[51.5, -0.06],
|
||||||
|
]);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-polygon', () => {
|
||||||
|
it('collects vertices from <leaflet-line> children and updates on change', () => {
|
||||||
|
const el = document.createElement('leaflet-polygon') as HTMLElement & {
|
||||||
|
leafletObject?: Polygon;
|
||||||
|
};
|
||||||
|
const line1 = document.createElement('leaflet-line');
|
||||||
|
line1.setAttribute('lat', '1');
|
||||||
|
line1.setAttribute('lng', '2');
|
||||||
|
const line2 = document.createElement('leaflet-line');
|
||||||
|
line2.setAttribute('lat', '3');
|
||||||
|
line2.setAttribute('lng', '4');
|
||||||
|
el.append(line1, line2);
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(Polygon);
|
||||||
|
expect(el.leafletObject?.getLatLngs()).toEqual([
|
||||||
|
[
|
||||||
|
{ lat: 1, lng: 2 },
|
||||||
|
{ lat: 3, lng: 4 },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
line1.setAttribute('lat', '9');
|
||||||
|
expect(el.leafletObject?.getLatLngs()).toEqual([
|
||||||
|
[
|
||||||
|
{ lat: 9, lng: 2 },
|
||||||
|
{ lat: 3, lng: 4 },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-polyline', () => {
|
||||||
|
it('applies smoothFactor/noClip and is unfilled by default', () => {
|
||||||
|
const el = document.createElement('leaflet-polyline') as HTMLElement & {
|
||||||
|
leafletObject?: Polyline;
|
||||||
|
};
|
||||||
|
el.setAttribute('smooth-factor', '2.5');
|
||||||
|
el.setAttribute('no-clip', '');
|
||||||
|
const line = document.createElement('leaflet-line');
|
||||||
|
line.setAttribute('lat', '1');
|
||||||
|
line.setAttribute('lng', '2');
|
||||||
|
el.append(line);
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(Polyline);
|
||||||
|
expect(el.leafletObject?.options.smoothFactor).toBe(2.5);
|
||||||
|
expect(el.leafletObject?.options.noClip).toBe(true);
|
||||||
|
expect(el.leafletObject?.options.fill).toBe(false);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { TileLayer } from 'leaflet';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import '../../src/components/leaflet-tile-layer.ts';
|
||||||
|
import '../../src/components/leaflet-tile-layer-wms.ts';
|
||||||
|
|
||||||
|
describe('leaflet-tile-layer', () => {
|
||||||
|
it('creates a TileLayer from the url attribute with tileLayerProps options', () => {
|
||||||
|
const el = document.createElement('leaflet-tile-layer') as HTMLElement & {
|
||||||
|
leafletObject?: TileLayer;
|
||||||
|
};
|
||||||
|
el.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
|
||||||
|
el.setAttribute('subdomains', 'abcd');
|
||||||
|
el.setAttribute('tms', '');
|
||||||
|
el.setAttribute('max-native-zoom', '17');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(TileLayer);
|
||||||
|
// Leaflet itself splits a string subdomains option into a character array.
|
||||||
|
expect(el.leafletObject?.options.subdomains).toEqual(['a', 'b', 'c', 'd']);
|
||||||
|
expect(el.leafletObject?.options.tms).toBe(true);
|
||||||
|
expect(el.leafletObject?.options.maxNativeZoom).toBe(17);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults zIndex to 1, matching Leaflet', () => {
|
||||||
|
const el = document.createElement('leaflet-tile-layer') as HTMLElement & {
|
||||||
|
leafletObject?: TileLayer;
|
||||||
|
zIndex: number;
|
||||||
|
};
|
||||||
|
el.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
|
||||||
|
document.body.append(el);
|
||||||
|
expect(el.leafletObject?.options.zIndex).toBe(1);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leaflet-tile-layer-wms', () => {
|
||||||
|
it('shares base tileLayerProps with leaflet-tile-layer (previously missing entirely)', () => {
|
||||||
|
const el = document.createElement('leaflet-tile-layer-wms') as HTMLElement & {
|
||||||
|
leafletObject?: TileLayer.WMS;
|
||||||
|
};
|
||||||
|
el.setAttribute('url', 'https://wms.example/service');
|
||||||
|
el.setAttribute('layers', 'basic');
|
||||||
|
el.setAttribute('attribution', 'Example');
|
||||||
|
el.setAttribute('min-zoom', '2');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject).toBeInstanceOf(TileLayer.WMS);
|
||||||
|
expect(el.leafletObject?.options.attribution).toBe('Example');
|
||||||
|
expect(el.leafletObject?.options.minZoom).toBe(2);
|
||||||
|
expect(el.leafletObject?.wmsParams.layers).toBe('basic');
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves crs by name to the matching Leaflet CRS instance', async () => {
|
||||||
|
const { CRS } = await import('leaflet');
|
||||||
|
const el = document.createElement('leaflet-tile-layer-wms') as HTMLElement & {
|
||||||
|
leafletObject?: TileLayer.WMS;
|
||||||
|
};
|
||||||
|
el.setAttribute('url', 'https://wms.example/service');
|
||||||
|
el.setAttribute('layers', 'basic');
|
||||||
|
el.setAttribute('crs', 'EPSG4326');
|
||||||
|
document.body.append(el);
|
||||||
|
|
||||||
|
expect(el.leafletObject?.options.crs).toBe(CRS.EPSG4326);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { bool, choice, disabled, json, kebab, num, positional, str } from '../../src/core/props.ts';
|
||||||
|
|
||||||
|
const noopGet = () => 1;
|
||||||
|
|
||||||
|
describe('kebab', () => {
|
||||||
|
it('converts camelCase to kebab-case', () => {
|
||||||
|
expect(kebab('fillColor')).toBe('fill-color');
|
||||||
|
expect(kebab('zIndex')).toBe('z-index');
|
||||||
|
expect(kebab('zIndexOffset')).toBe('z-index-offset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves single lowercase words unchanged', () => {
|
||||||
|
expect(kebab('opacity')).toBe('opacity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('num', () => {
|
||||||
|
it('defaults to 0 and decodes/encodes via Number/String', () => {
|
||||||
|
const def = num();
|
||||||
|
expect(def.default).toBe(0);
|
||||||
|
expect(def.decode('42')).toBe(42);
|
||||||
|
expect(def.encode(42)).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom default and passes through extra opts', () => {
|
||||||
|
const def = num(10, { get: noopGet });
|
||||||
|
expect(def.default).toBe(10);
|
||||||
|
expect(def.get).toBe(noopGet);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('str', () => {
|
||||||
|
it('defaults to empty string and decode/encode are identity', () => {
|
||||||
|
const def = str();
|
||||||
|
expect(def.default).toBe('');
|
||||||
|
expect(def.decode('hello')).toBe('hello');
|
||||||
|
expect(def.encode('hello')).toBe('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('choice', () => {
|
||||||
|
it('decode/encode are identity, typed as the literal union', () => {
|
||||||
|
const def = choice<'a' | 'b'>('a');
|
||||||
|
expect(def.default).toBe('a');
|
||||||
|
expect(def.decode('b')).toBe('b');
|
||||||
|
expect(def.encode('b')).toBe('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bool', () => {
|
||||||
|
it('decodes any value other than the literal string "false" as true', () => {
|
||||||
|
const def = bool();
|
||||||
|
expect(def.decode('')).toBe(true);
|
||||||
|
expect(def.decode('true')).toBe(true);
|
||||||
|
expect(def.decode('anything')).toBe(true);
|
||||||
|
expect(def.decode('false')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encode returns null when the value matches the default (removes the attribute)', () => {
|
||||||
|
const defFalse = bool(false);
|
||||||
|
expect(defFalse.encode(false)).toBeNull();
|
||||||
|
expect(defFalse.encode(true)).toBe('');
|
||||||
|
|
||||||
|
const defTrue = bool(true);
|
||||||
|
expect(defTrue.encode(true)).toBeNull();
|
||||||
|
expect(defTrue.encode(false)).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disabled', () => {
|
||||||
|
it('names its attribute disable-<kebab-name>', () => {
|
||||||
|
const def = disabled();
|
||||||
|
expect(def.attribute).toBeTypeOf('function');
|
||||||
|
expect((def.attribute as (name: string) => string)('dragging')).toBe('disable-dragging');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to true (enabled); presence of the attribute disables unless its value is "false"', () => {
|
||||||
|
const def = disabled();
|
||||||
|
expect(def.default).toBe(true);
|
||||||
|
// Bare `disable-foo` (raw === '') means "disable this" -> false.
|
||||||
|
expect(def.decode('')).toBe(false);
|
||||||
|
expect(def.decode('anything')).toBe(false);
|
||||||
|
// `disable-foo="false"` is a double-negative escape hatch -> stays enabled.
|
||||||
|
expect(def.decode('false')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes false as a present attribute, true as absent', () => {
|
||||||
|
const def = disabled();
|
||||||
|
expect(def.encode(true)).toBeNull();
|
||||||
|
expect(def.encode(false)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('json', () => {
|
||||||
|
it('decodes via JSON.parse and encodes via JSON.stringify', () => {
|
||||||
|
const def = json<{ a: number }>({ a: 0 });
|
||||||
|
expect(def.decode('{"a":5}')).toEqual({ a: 5 });
|
||||||
|
expect(def.encode({ a: 5 })).toBe('{"a":5}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('positional', () => {
|
||||||
|
it('adds option: false while preserving every other field', () => {
|
||||||
|
const base = num(5, { get: () => 1 });
|
||||||
|
const pos = positional(base);
|
||||||
|
expect(pos.option).toBe(false);
|
||||||
|
expect(pos.default).toBe(5);
|
||||||
|
expect(pos.get).toBe(base.get);
|
||||||
|
expect(pos.decode).toBe(base.decode);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,481 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import { FeatureGroup, Map as LMap, Marker, Popup, TileLayer } from 'leaflet';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import '../src/components/leaflet-map.ts';
|
||||||
|
import '../src/components/leaflet-tile-layer.ts';
|
||||||
|
import '../src/components/leaflet-feature-group.ts';
|
||||||
|
import '../src/components/leaflet-marker.ts';
|
||||||
|
import '../src/components/leaflet-popup.ts';
|
||||||
|
|
||||||
|
// Exercises the registration-bubbling protocol end to end across a small real
|
||||||
|
// tree, rather than one component at a time: <leaflet-register> events bubble
|
||||||
|
// from marker -> feature-group -> map, and popup binds to its parent marker
|
||||||
|
// instead of bubbling past it.
|
||||||
|
describe('component tree wiring', () => {
|
||||||
|
it('wires a map + tile-layer + feature-group + marker + popup tree together', () => {
|
||||||
|
const map = document.createElement('leaflet-map') as HTMLElement & {
|
||||||
|
leafletObject?: LMap;
|
||||||
|
};
|
||||||
|
map.setAttribute('lat', '51.5');
|
||||||
|
map.setAttribute('lng', '-0.09');
|
||||||
|
map.setAttribute('zoom', '13');
|
||||||
|
|
||||||
|
const tiles = document.createElement('leaflet-tile-layer') as HTMLElement & {
|
||||||
|
leafletObject?: TileLayer;
|
||||||
|
};
|
||||||
|
tiles.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
|
||||||
|
|
||||||
|
const group = document.createElement('leaflet-feature-group') as HTMLElement & {
|
||||||
|
leafletObject?: FeatureGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
const marker = document.createElement('leaflet-marker') as HTMLElement & {
|
||||||
|
leafletObject?: Marker;
|
||||||
|
};
|
||||||
|
marker.setAttribute('lat', '51.51');
|
||||||
|
marker.setAttribute('lng', '-0.1');
|
||||||
|
|
||||||
|
const popup = document.createElement('leaflet-popup') as HTMLElement & {
|
||||||
|
leafletObject?: Popup;
|
||||||
|
};
|
||||||
|
popup.innerHTML = 'hello';
|
||||||
|
|
||||||
|
marker.append(popup);
|
||||||
|
group.append(marker);
|
||||||
|
map.append(tiles, group);
|
||||||
|
document.body.append(map);
|
||||||
|
|
||||||
|
// tile-layer and feature-group both registered directly onto the map.
|
||||||
|
expect(map.leafletObject?.hasLayer(tiles.leafletObject!)).toBe(true);
|
||||||
|
expect(map.leafletObject?.hasLayer(group.leafletObject!)).toBe(true);
|
||||||
|
|
||||||
|
// marker registered onto the feature-group (which, per Leaflet's own
|
||||||
|
// LayerGroup.onAdd, also adds each child directly onto the map so it
|
||||||
|
// renders -- hasLayer is true on both, membership is what distinguishes them).
|
||||||
|
expect(group.leafletObject?.hasLayer(marker.leafletObject!)).toBe(true);
|
||||||
|
expect(map.leafletObject?.hasLayer(marker.leafletObject!)).toBe(true);
|
||||||
|
expect(group.leafletObject?.getLayers()).toEqual([marker.leafletObject]);
|
||||||
|
|
||||||
|
// popup bound to the marker, not registered as a map layer at all.
|
||||||
|
expect(marker.leafletObject?.getPopup()).toBe(popup.leafletObject);
|
||||||
|
|
||||||
|
// A marker added later goes through the same path.
|
||||||
|
const marker2 = document.createElement('leaflet-marker') as HTMLElement & {
|
||||||
|
leafletObject?: Marker;
|
||||||
|
};
|
||||||
|
marker2.setAttribute('lat', '1');
|
||||||
|
marker2.setAttribute('lng', '2');
|
||||||
|
group.append(marker2);
|
||||||
|
expect(group.leafletObject?.hasLayer(marker2.leafletObject!)).toBe(true);
|
||||||
|
|
||||||
|
// Disconnecting a child removes it from the map (WithProps calls the
|
||||||
|
// Leaflet object's own .remove(), which detaches from its _map).
|
||||||
|
// leafletObject is gone once disconnected, so capture the reference first.
|
||||||
|
// Note this is a pre-existing Leaflet quirk, not something WithProps
|
||||||
|
// introduces: Layer#remove() only detaches from the map, it does not tell
|
||||||
|
// an owning LayerGroup/FeatureGroup to forget it (that needs the group's
|
||||||
|
// own removeLayer()) -- so the group's hasLayer() stays stale/true here.
|
||||||
|
const removedMarkerObj = marker.leafletObject!;
|
||||||
|
marker.remove();
|
||||||
|
expect(map.leafletObject?.hasLayer(removedMarkerObj)).toBe(false);
|
||||||
|
expect(group.leafletObject?.hasLayer(removedMarkerObj)).toBe(true);
|
||||||
|
|
||||||
|
map.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
// jsdom has no ResizeObserver. leaflet-map only uses it to call
|
||||||
|
// invalidateSize() on resize, which no test here depends on incidentally --
|
||||||
|
// without a stub, just importing the component throws.
|
||||||
|
class ResizeObserverStub implements ResizeObserver {
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.ResizeObserver ??= ResizeObserverStub;
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"noEmit": true,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "test/**/*"]
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
setupFiles: ['./test/setup.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue