diff --git a/eslint.config.js b/eslint.config.js index ee28012a..4662311d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,15 +16,15 @@ module.exports = [ ...config, rules: { ...config.rules, - "prettier/prettier": [ - "error", - { - singleQuote: false, - trailingComma: "es5", - }, - ], + quotes: ["warn", "double"], eqeqeq: ["error", "always"], }, })), + { + files: ["**/*.test.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, ]; diff --git a/src/algorithms/core.test.ts b/src/algorithms/core.test.ts index 5f60e393..9e9146cd 100644 --- a/src/algorithms/core.test.ts +++ b/src/algorithms/core.test.ts @@ -14,21 +14,14 @@ * limitations under the License. */ -import { initialize } from "@googlemaps/jest-mocks"; +import { initializeMocks, testMarkerTypes } from "../test-helpers"; import { noop } from "./core"; -initialize(); -const markerClasses = [ - google.maps.Marker, - google.maps.marker.AdvancedMarkerElement, -]; +initializeMocks(); -describe.each(markerClasses)( - "Core works with legacy and Advanced Markers", - (markerClass) => { - test("noop should return equivalent number of clusters", () => { - expect(noop([]).length).toBe(0); - expect(noop([new markerClass({}), new markerClass({})]).length).toBe(2); - }); - } -); +describe.each(testMarkerTypes)("Core works with %s", (_, markerClass) => { + test("noop should return equivalent number of clusters", () => { + expect(noop([]).length).toBe(0); + expect(noop([new markerClass({}), new markerClass({})]).length).toBe(2); + }); +}); diff --git a/src/algorithms/grid.test.ts b/src/algorithms/grid.test.ts index 37c29e4f..dc2128a3 100644 --- a/src/algorithms/grid.test.ts +++ b/src/algorithms/grid.test.ts @@ -15,17 +15,14 @@ */ import { GridAlgorithm } from "./grid"; -import { initialize, MapCanvasProjection } from "@googlemaps/jest-mocks"; +import { initializeMocks, testMarkerTypes } from "../test-helpers"; +import { MapCanvasProjection } from "@googlemaps/jest-mocks"; -initialize(); -const markers = [ - new google.maps.Marker(), - new google.maps.marker.AdvancedMarkerElement(), -]; +initializeMocks(); -describe.each(markers)( - "Grid works with legacy and Advanced Markers", - (marker) => { +describe.each(testMarkerTypes)( + "GridAlgorithm works with %s", + (_, markerClass) => { let map: google.maps.Map; beforeEach(() => { @@ -34,7 +31,7 @@ describe.each(markers)( test("calculate should return changed: true for first call when zoom > max zoom", () => { const mapCanvasProjection = new MapCanvasProjection(); - const markers = [marker]; + const markers = [new markerClass()]; const grid = new GridAlgorithm({ maxZoom: 16 }); grid["noop"] = jest.fn(); @@ -62,7 +59,7 @@ describe.each(markers)( test("calculate should return changed: false when zoom doesn't change", () => { const mapCanvasProjection = jest.fn() as unknown as google.maps.MapCanvasProjection; - const markers = [marker]; + const markers = [new markerClass()]; const grid = new GridAlgorithm({ maxZoom: 16 }); grid["noop"] = jest.fn(); @@ -89,7 +86,7 @@ describe.each(markers)( test("calculate should return changed: false for next calls at or above max zoom, even if zoom changed", () => { const mapCanvasProjection = jest.fn() as unknown as google.maps.MapCanvasProjection; - const markers = [marker]; + const markers = [new markerClass()]; const grid = new GridAlgorithm({ maxZoom: 16 }); grid["noop"] = jest.fn(); diff --git a/src/algorithms/supercluster.test.ts b/src/algorithms/supercluster.test.ts index 4b968cb9..4ee91a97 100644 --- a/src/algorithms/supercluster.test.ts +++ b/src/algorithms/supercluster.test.ts @@ -15,19 +15,19 @@ */ import { SuperClusterAlgorithm } from "./supercluster"; -import { initialize } from "@googlemaps/jest-mocks"; +import { + initializeMocks, + testMarkerTypes, + setupMapBounds, +} from "../test-helpers"; import { Marker } from "../marker-utils"; import { ClusterFeature } from "supercluster"; -initialize(); -const markerClasses = [ - google.maps.Marker, - google.maps.marker.AdvancedMarkerElement, -]; +initializeMocks(); -describe.each(markerClasses)( - "SuperCluster works with legacy and Advanced Markers", - (markerClass) => { +describe.each(testMarkerTypes)( + "SuperClusterAlgorithm works with %s", + (_, markerClass) => { let map: google.maps.Map; beforeEach(() => { @@ -62,14 +62,7 @@ describe.each(markerClasses)( const superCluster = new SuperClusterAlgorithm({}); map.getZoom = jest.fn().mockReturnValue(0); - map.getBounds = jest.fn().mockReturnValue({ - toJSON: () => ({ - west: -180, - south: -90, - east: 180, - north: 90, - }), - }); + setupMapBounds(map, { west: -180, south: -90, east: 180, north: 90 }); const { clusters } = superCluster.calculate({ markers, map, diff --git a/src/algorithms/supercluster.ts b/src/algorithms/supercluster.ts index 6eb24f9c..3a912b63 100644 --- a/src/algorithms/supercluster.ts +++ b/src/algorithms/supercluster.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { AbstractAlgorithm, AlgorithmInput, AlgorithmOutput } from "./core"; import SuperCluster, { ClusterFeature } from "supercluster"; +import { deepEqual } from "fast-equals"; +import { AbstractAlgorithm, AlgorithmInput, AlgorithmOutput } from "./core"; import { MarkerUtils, Marker } from "../marker-utils"; import { Cluster } from "../cluster"; -import { deepEqual } from "fast-equals"; import { assertNotNull } from "../utils"; +import { areClustersEqual, areMarkersEqual } from "./utils"; export type SuperClusterOptions = SuperCluster.Options< // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -50,7 +50,7 @@ export class SuperClusterAlgorithm extends AbstractAlgorithm { } public calculate(input: AlgorithmInput): AlgorithmOutput { - let changed = false; + let inputsChanged = false; let zoom = input.map.getZoom(); assertNotNull(zoom); @@ -59,14 +59,15 @@ export class SuperClusterAlgorithm extends AbstractAlgorithm { const state = { zoom: zoom }; - if (!deepEqual(input.markers, this.markers)) { - changed = true; - // TODO use proxy to avoid copy? + if (!areMarkersEqual(input.markers, this.markers)) { + inputsChanged = true; + this.markers = [...input.markers]; const points = this.markers.map((marker) => { const position = MarkerUtils.getPosition(marker); const coordinates = [position.lng(), position.lat()]; + return { type: "Feature", geometry: { type: "Point", coordinates }, @@ -76,9 +77,9 @@ export class SuperClusterAlgorithm extends AbstractAlgorithm { this.superCluster.load(points); } - if (!changed) { + if (!inputsChanged) { if (this.state.zoom <= this.maxZoom || state.zoom <= this.maxZoom) { - changed = !deepEqual(this.state, state); + inputsChanged = !deepEqual(this.state, state); } } @@ -88,14 +89,19 @@ export class SuperClusterAlgorithm extends AbstractAlgorithm { if (input.markers.length === 0) { this.clusters = []; - return { clusters: this.clusters, changed }; + return { clusters: this.clusters, changed: inputsChanged }; } - if (changed) { - this.clusters = this.cluster(input); + let clustersChanged = false; + if (inputsChanged) { + const newClusters = this.cluster(input); + + clustersChanged = !areClustersEqual(this.clusters, newClusters); + + this.clusters = newClusters; } - return { clusters: this.clusters, changed }; + return { clusters: this.clusters, changed: clustersChanged }; } public cluster({ map }: AlgorithmInput): Cluster[] { diff --git a/src/algorithms/superviewport.test.ts b/src/algorithms/superviewport.test.ts index d63e5772..3f678ee6 100644 --- a/src/algorithms/superviewport.test.ts +++ b/src/algorithms/superviewport.test.ts @@ -15,27 +15,44 @@ */ import { SuperClusterViewportAlgorithm } from "./superviewport"; -import { initialize, MapCanvasProjection } from "@googlemaps/jest-mocks"; +import { + initializeMocks, + testMarkerTypes, + setupMapBounds, +} from "../test-helpers"; +import { MapCanvasProjection } from "@googlemaps/jest-mocks"; import { Marker } from "../marker-utils"; import { ClusterFeature } from "supercluster"; +import { Cluster } from "../cluster"; -initialize(); -const markerClasses = [ - google.maps.Marker, - google.maps.marker.AdvancedMarkerElement, -]; +initializeMocks(); -describe.each(markerClasses)( - "SuperCluster works with legacy and Advanced Markers", - (markerClass) => { +describe.each(testMarkerTypes)( + "SuperClusterViewportAlgorithm works with %s", + (_, markerClass) => { let map: google.maps.Map; + let mapCanvasProjection: MapCanvasProjection; beforeEach(() => { map = new google.maps.Map(document.createElement("div")); + mapCanvasProjection = new MapCanvasProjection(); + + mapCanvasProjection.fromLatLngToDivPixel = jest + .fn() + .mockImplementation((latLng: google.maps.LatLng) => ({ + x: latLng.lng() * 100, + y: latLng.lat() * 100, + })); + + mapCanvasProjection.fromDivPixelToLatLng = jest + .fn() + .mockImplementation((point: google.maps.Point) => ({ + lat: () => point.y / 100, + lng: () => point.x / 100, + })); }); test("should only call load if markers change", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass()]; const superCluster = new SuperClusterViewportAlgorithm({}); @@ -63,25 +80,13 @@ describe.each(markerClasses)( }); test("should cluster markers", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass(), new markerClass()]; const superCluster = new SuperClusterViewportAlgorithm({}); map.getZoom = jest.fn().mockReturnValue(0); - map.getBounds = jest.fn().mockReturnValue({ - toJSON: () => ({ - west: -180, - south: -90, - east: 180, - north: 90, - }), - getNorthEast: jest - .fn() - .mockReturnValue({ getLat: () => -3, getLng: () => 34 }), - getSouthWest: jest - .fn() - .mockReturnValue({ getLat: () => 29, getLng: () => 103 }), - }); + + setupMapBounds(map, { west: 103, south: 29, east: 34, north: -3 }); + const { clusters } = superCluster.calculate({ markers, map, @@ -106,7 +111,6 @@ describe.each(markerClasses)( }, }; - // mock out the supercluster implementation jest .spyOn(superCluster["superCluster"], "getLeaves") .mockImplementation(() => [clusterFeature]); @@ -117,7 +121,6 @@ describe.each(markerClasses)( }); test("should not cluster if zoom didn't change", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass(), new markerClass()]; const superCluster = new SuperClusterViewportAlgorithm({}); @@ -134,12 +137,11 @@ describe.each(markerClasses)( mapCanvasProjection, }); - expect(changed).toBeTruthy(); + expect(changed).toBeFalsy(); expect(clusters).toBe(superCluster["clusters"]); }); test("should not cluster if zoom beyond maxZoom", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass(), new markerClass()]; const superCluster = new SuperClusterViewportAlgorithm({}); @@ -150,15 +152,18 @@ describe.each(markerClasses)( map.getZoom = jest.fn().mockReturnValue(superCluster["state"].zoom + 1); + setupMapBounds(map, { west: 0, south: 0, east: 0, north: 0 }); + const { clusters, changed } = superCluster.calculate({ markers, map, mapCanvasProjection, }); - expect(changed).toBeTruthy(); + expect(changed).toBeFalsy(); expect(clusters).toBe(superCluster["clusters"]); - expect(superCluster["state"]).toEqual({ zoom: 21, view: [0, 0, 0, 0] }); + expect(superCluster["state"].zoom).toBe(21); + expect(Array.isArray(superCluster["state"].view)).toBeTruthy(); }); test("should not cluster if markers array is empty", () => { @@ -180,29 +185,9 @@ describe.each(markerClasses)( }); test("should round fractional zoom", () => { - const mapCanvasProjection = new MapCanvasProjection(); const markers: Marker[] = [new markerClass(), new markerClass()]; - mapCanvasProjection.fromLatLngToDivPixel = jest - .fn() - .mockImplementation((b: google.maps.LatLng) => ({ - x: b.lat() * 100, - y: b.lng() * 100, - })); - mapCanvasProjection.fromDivPixelToLatLng = jest - .fn() - .mockImplementation( - (p: google.maps.Point) => - new google.maps.LatLng({ lat: p.x / 100, lng: p.y / 100 }) - ); - - map.getBounds = jest.fn().mockReturnValue({ - getNorthEast: jest - .fn() - .mockReturnValue({ lat: () => -3, lng: () => 34 }), - getSouthWest: jest - .fn() - .mockReturnValue({ lat: () => 29, lng: () => 103 }), - }); + + setupMapBounds(map, { west: 103, south: 29, east: 34, north: -3 }); const superCluster = new SuperClusterViewportAlgorithm({}); superCluster["superCluster"].getClusters = jest.fn().mockReturnValue([]); @@ -213,19 +198,148 @@ describe.each(markerClasses)( map.getZoom = jest.fn().mockReturnValue(1.534); expect( superCluster.calculate({ markers, map, mapCanvasProjection }) - ).toEqual({ changed: true, clusters: [] }); + ).toEqual({ changed: false, clusters: [] }); expect(superCluster["superCluster"].getClusters).toHaveBeenCalledWith( - [0, 0, 0, 0], + expect.any(Array), 2 ); map.getZoom = jest.fn().mockReturnValue(3.234); superCluster.calculate({ markers, map, mapCanvasProjection }); expect(superCluster["superCluster"].getClusters).toHaveBeenCalledWith( - [0, 0, 0, 0], + expect.any(Array), 3 ); }); + + test("should return changed=false when viewport changes but clusters remain the same", () => { + const markers: Marker[] = [new markerClass(), new markerClass()]; + + map.getZoom = jest.fn().mockReturnValue(10); + + setupMapBounds(map, { west: 0, south: 0, east: 10, north: 10 }); + + const algorithm = new SuperClusterViewportAlgorithm({ + viewportPadding: 60, + }); + + const sameCluster = [ + new Cluster({ + markers: markers, + position: { lat: 5, lng: 5 }, + }), + ]; + + algorithm.cluster = jest.fn().mockReturnValue(sameCluster); + + const firstResult = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + + expect(firstResult.changed).toBeTruthy(); + + setupMapBounds(map, { west: 5, south: 5, east: 15, north: 15 }); + + const secondResult = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + + expect(secondResult.changed).toBeFalsy(); + expect(secondResult.clusters).toEqual(sameCluster); + }); + + test("should detect cluster changes accurately with areClusterArraysEqual", () => { + const markers: Marker[] = [new markerClass(), new markerClass()]; + + map.getZoom = jest.fn().mockReturnValue(10); + setupMapBounds(map, { west: 0, south: 0, east: 10, north: 10 }); + + const algorithm = new SuperClusterViewportAlgorithm({}); + + const cluster1 = [ + new Cluster({ + markers: [markers[0]], + position: { lat: 2, lng: 2 }, + }), + new Cluster({ + markers: [markers[1]], + position: { lat: 8, lng: 8 }, + }), + ]; + + algorithm.cluster = jest.fn().mockReturnValueOnce(cluster1); + + const result1 = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + expect(result1.changed).toBeTruthy(); + + const cluster2 = [ + new Cluster({ + markers: [markers[0]], + position: { lat: 2, lng: 2 }, + }), + new Cluster({ + markers: [markers[1]], + position: { lat: 8, lng: 8 }, + }), + ]; + + algorithm.cluster = jest.fn().mockReturnValueOnce(cluster2); + + const result2 = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + expect(result2.changed).toBeFalsy(); + + const cluster3 = [ + new Cluster({ + markers: markers, + position: { lat: 5, lng: 5 }, + }), + ]; + + algorithm.cluster = jest.fn().mockReturnValueOnce(cluster3); + + const result3 = algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + expect(result3.changed).toBeTruthy(); + }); + + test("should correctly calculate viewport state with getPaddedViewport", () => { + const markers: Marker[] = [new markerClass()]; + + map.getZoom = jest.fn().mockReturnValue(10); + + setupMapBounds(map, { west: 10, south: 0, east: 20, north: 10 }); + + const algorithm = new SuperClusterViewportAlgorithm({ + viewportPadding: 60, + }); + algorithm.cluster = jest.fn().mockReturnValue([]); + + algorithm.calculate({ + markers, + map, + mapCanvasProjection, + }); + + const state = algorithm["state"]; + + expect(state.view).toEqual([0, 0, 0, 0]); + expect(state.zoom).toBe(10); + }); } ); diff --git a/src/algorithms/superviewport.ts b/src/algorithms/superviewport.ts index d7b3377b..00670c57 100644 --- a/src/algorithms/superviewport.ts +++ b/src/algorithms/superviewport.ts @@ -24,8 +24,7 @@ import { SuperClusterOptions } from "./supercluster"; import SuperCluster, { ClusterFeature } from "supercluster"; import { MarkerUtils, Marker } from "../marker-utils"; import { Cluster } from "../cluster"; -import { getPaddedViewport } from "./utils"; -import { deepEqual } from "fast-equals"; +import { areClustersEqual, areMarkersEqual, getPaddedViewport } from "./utils"; import { assertNotNull } from "../utils"; export interface SuperClusterViewportOptions @@ -46,8 +45,8 @@ export interface SuperClusterViewportState { */ export class SuperClusterViewportAlgorithm extends AbstractViewportAlgorithm { protected superCluster: SuperCluster; - protected markers: Marker[] = []; - protected clusters: Cluster[] = []; + protected markers?: Marker[]; + protected clusters?: Cluster[]; protected state: SuperClusterViewportState; constructor({ @@ -70,15 +69,17 @@ export class SuperClusterViewportAlgorithm extends AbstractViewportAlgorithm { public calculate(input: AlgorithmInput): AlgorithmOutput { const state = this.getViewportState(input); - let changed = !deepEqual(this.state, state); - if (!deepEqual(input.markers, this.markers)) { - changed = true; - // TODO use proxy to avoid copy? + let markersChanged = false; + + // recompute clusters when the marker inputs have changed + if (!areMarkersEqual(input.markers, this.markers)) { + markersChanged = true; this.markers = [...input.markers]; const points = this.markers.map((marker) => { const position = MarkerUtils.getPosition(marker); const coordinates = [position.lng(), position.lat()]; + return { type: "Feature" as const, geometry: { @@ -88,21 +89,24 @@ export class SuperClusterViewportAlgorithm extends AbstractViewportAlgorithm { properties: { marker }, }; }); + this.superCluster.load(points); } // when input is empty, return right away if (input.markers.length === 0) { this.clusters = []; - return { clusters: this.clusters, changed }; - } - if (changed) { - this.clusters = this.cluster(input); - this.state = state; + return { clusters: this.clusters, changed: markersChanged }; } - return { clusters: this.clusters, changed }; + const newClusters = this.cluster(input); + const clustersChanged = !areClustersEqual(this.clusters, newClusters); + + this.state = state; + this.clusters = newClusters; + + return { clusters: this.clusters, changed: clustersChanged }; } public cluster(input: AlgorithmInput): Cluster[] { diff --git a/src/algorithms/utils.ts b/src/algorithms/utils.ts index 5d20274b..a72fd401 100644 --- a/src/algorithms/utils.ts +++ b/src/algorithms/utils.ts @@ -16,6 +16,7 @@ import { MarkerUtils, Marker } from "../marker-utils"; import { assertNotNull } from "../utils"; +import { Cluster } from "../cluster"; /** * Returns the markers visible in a padded map viewport @@ -156,3 +157,41 @@ export const pixelBoundsToLatLngBounds = ( const ne = projection.fromDivPixelToLatLng(northEast); return new google.maps.LatLngBounds(sw, ne); }; + +export const areClustersEqual = ( + clustersA?: Cluster[], + clustersB?: Cluster[] +) => { + if (!clustersA || !clustersB) return clustersA === clustersB; + if (clustersA.length !== clustersB.length) return false; + + for (let i = 0; i < clustersA.length; i++) { + const a = clustersA[i]; + const b = clustersB[i]; + + if (a.markers.length !== b.markers.length) return false; + + const posA = a.position; + const posB = b.position; + + if (!posA.equals(posB)) return false; + } + + return true; +}; + +export const areMarkersEqual = (markersA?: Marker[], markersB?: Marker[]) => { + if (!markersA || !markersB) return markersA === markersB; + if (markersA.length !== markersB.length) return false; + + for (let i = 0; i < markersA.length; i++) { + if (markersA[i] !== markersB[i]) { + const posA = MarkerUtils.getPosition(markersA[i]); + const posB = MarkerUtils.getPosition(markersB[i]); + + if (!posA.equals(posB)) return false; + } + } + + return true; +}; diff --git a/src/cluster.test.ts b/src/cluster.test.ts index ea7fd356..2ef2fcad 100644 --- a/src/cluster.test.ts +++ b/src/cluster.test.ts @@ -15,62 +15,55 @@ */ import { Cluster } from "./cluster"; -import { initialize } from "@googlemaps/jest-mocks"; +import { initializeMocks, testMarkerTypes } from "./test-helpers"; import { MarkerUtils } from "./marker-utils"; -initialize(); -const markers = [ - new google.maps.Marker(), - new google.maps.marker.AdvancedMarkerElement(), -]; +initializeMocks(); -describe.each(markers)( - "Cluster works with legacy and Advanced Markers", - (marker) => { - test("bounds should be undefined if no markers or position", () => { - const cluster = new Cluster({ markers: [] }); - expect(cluster.bounds).toBeUndefined(); - }); +describe.each(testMarkerTypes)("Cluster works with %s", (name, markerClass) => { + test("bounds should be undefined if no markers or position", () => { + const cluster = new Cluster({ markers: [] }); + expect(cluster.bounds).toBeUndefined(); + }); - test("bounds should be undefined if position", () => { - const cluster = new Cluster({ - markers: [], - position: { lat: 0, lng: 0 }, - }); - expect(cluster.bounds).toBeDefined(); + test("bounds should be undefined if position", () => { + const cluster = new Cluster({ + markers: [], + position: { lat: 0, lng: 0 }, }); + expect(cluster.bounds).toBeDefined(); + }); - test("bounds should be undefined if markers", () => { - const cluster = new Cluster({ markers: [marker] }); - expect(cluster.bounds).toBeDefined(); - }); + test("bounds should be undefined if markers", () => { + const cluster = new Cluster({ markers: [new markerClass()] }); + expect(cluster.bounds).toBeDefined(); + }); - test("can push additional markers", () => { - const cluster = new Cluster({ markers: [] }); - cluster.push(marker); - expect(cluster["markers"]?.length).toBe(1); - }); + test("can push additional markers", () => { + const cluster = new Cluster({ markers: [] }); + cluster.push(new markerClass()); + expect(cluster["markers"]?.length).toBe(1); + }); - test("count visible markers", () => { - const cluster = new Cluster({ markers: [marker] }); - MarkerUtils.getVisible = jest.fn().mockReturnValue(true); - expect(cluster["markers"]?.length).toBe(1); - }); + test("count visible markers", () => { + const cluster = new Cluster({ markers: [new markerClass()] }); + MarkerUtils.getVisible = jest.fn().mockReturnValue(true); + expect(cluster["markers"]?.length).toBe(1); + }); - test("delete if marker set", () => { - const cluster = new Cluster({ markers: [marker] }); - MarkerUtils.getVisible = jest.fn().mockReturnValue(true); - cluster.marker = marker; - expect(cluster.count).toBe(1); - cluster.delete(); - expect(cluster.count).toBe(0); - expect(cluster.marker).toBeUndefined(); - }); + test("delete if marker set", () => { + const cluster = new Cluster({ markers: [new markerClass()] }); + MarkerUtils.getVisible = jest.fn().mockReturnValue(true); + cluster.marker = new markerClass(); + expect(cluster.count).toBe(1); + cluster.delete(); + expect(cluster.count).toBe(0); + expect(cluster.marker).toBeUndefined(); + }); - test("delete if marker not set", () => { - const cluster = new Cluster({ markers: [] }); - cluster.delete(); - expect(cluster.marker).toBeUndefined(); - }); - } -); + test("delete if marker not set", () => { + const cluster = new Cluster({ markers: [] }); + cluster.delete(); + expect(cluster.marker).toBeUndefined(); + }); +}); diff --git a/src/marker-utils.test.ts b/src/marker-utils.test.ts index ed1c7c20..6d014496 100644 --- a/src/marker-utils.test.ts +++ b/src/marker-utils.test.ts @@ -15,21 +15,17 @@ */ import { MarkerUtils } from "./marker-utils"; -import { initialize } from "@googlemaps/jest-mocks"; +import { initializeMocks, testMarkerTypes } from "./test-helpers"; -initialize(); -const markerClasses = [ - google.maps.Marker, - google.maps.marker.AdvancedMarkerElement, -]; +initializeMocks(); beforeEach(() => { - initialize(); + initializeMocks(); }); -describe.each(markerClasses)( - "MarkerUtils works with legacy and Advanced Markers", - (markerClass) => { +describe.each(testMarkerTypes)( + "MarkerUtils works with %s", + (_, markerClass) => { let map: google.maps.Map; beforeEach(() => { diff --git a/src/markerclusterer.test.ts b/src/markerclusterer.test.ts index 6db7230d..dd72d501 100644 --- a/src/markerclusterer.test.ts +++ b/src/markerclusterer.test.ts @@ -21,18 +21,17 @@ import { MarkerClusterer, } from "."; -import { initialize } from "@googlemaps/jest-mocks"; +import { + initializeMocks, + testMarkerTypes as markerClasses, +} from "./test-helpers"; import { Marker, MarkerUtils } from "./marker-utils"; -initialize(); -const markerClasses = [ - google.maps.Marker, - google.maps.marker.AdvancedMarkerElement, -]; +initializeMocks(); describe.each(markerClasses)( - "MarkerClusterer works with legacy and Advanced Markers", - (markerClass) => { + "MarkerClusterer works with %p", + (_, markerClass) => { const calculate = jest.fn().mockReturnValue({ clusters: [] }); const algorithm = { calculate }; @@ -215,8 +214,13 @@ describe.each(markerClasses)( MarkerUtils.setMap = jest.fn().mockImplementation(() => null); + jest.useFakeTimers(); + markerClusterer["render"](); + // removal happens delayed + jest.advanceTimersByTime(50); + expect(MarkerUtils.setMap).toHaveBeenCalledWith(cluster.marker, null); }); diff --git a/src/markerclusterer.ts b/src/markerclusterer.ts index 158c6f19..97c314d0 100644 --- a/src/markerclusterer.ts +++ b/src/markerclusterer.ts @@ -215,9 +215,11 @@ export class MarkerClusterer extends OverlayViewSafe { this.renderClusters(); // Delayed removal of the markers of the former groups. - requestAnimationFrame(() => - groupMarkers.forEach((marker) => MarkerUtils.setMap(marker, null)) - ); + setTimeout(() => { + groupMarkers.forEach((marker) => { + MarkerUtils.setMap(marker, null); + }); + }, 35); } google.maps.event.trigger( this, diff --git a/src/test-helpers.ts b/src/test-helpers.ts new file mode 100644 index 00000000..5e3a2434 --- /dev/null +++ b/src/test-helpers.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { initialize } from "@googlemaps/jest-mocks"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +initialize(); + +/** + * Initializes the `@googlemaps/jest-mocks` namespace and enhances it with + * basic functional behaviors (e.g. LatLng.equals value comparisons). + */ +export function initializeMocks() { + initialize(); + + // Replace the LatLng class with a functional mock! + const OriginalLatLng = google.maps.LatLng; + + class FunctionalLatLng extends OriginalLatLng { + private _lat: number; + private _lng: number; + + constructor( + latOrLiteral: number | google.maps.LatLngLiteral | any, + lng?: number, + noClampNoWrap?: boolean + ) { + super(latOrLiteral, lng, noClampNoWrap); + + let latVal = 0; + let lngVal = 0; + + if (typeof latOrLiteral === "object" && latOrLiteral !== null) { + if (typeof latOrLiteral.lat === "function") { + latVal = latOrLiteral.lat(); + } else if (typeof latOrLiteral.lat === "number") { + latVal = latOrLiteral.lat; + } + + if (typeof latOrLiteral.lng === "function") { + lngVal = latOrLiteral.lng(); + } else if (typeof latOrLiteral.lng === "number") { + lngVal = latOrLiteral.lng; + } + } else if (typeof latOrLiteral === "number") { + latVal = latOrLiteral; + lngVal = lng || 0; + } + + this._lat = latVal; + this._lng = lngVal; + + this.lat = jest.fn().mockImplementation(() => this._lat); + this.lng = jest.fn().mockImplementation(() => this._lng); + this.equals = jest + .fn() + .mockImplementation((other?: google.maps.LatLng) => { + if (!other) return false; + return this.lat() === other.lat() && this.lng() === other.lng(); + }); + } + } + + google.maps.LatLng = FunctionalLatLng as any; + + Object.defineProperty(FunctionalLatLng, Symbol.hasInstance, { + value: function (instance: any) { + return instance instanceof OriginalLatLng; + }, + }); +} + +/** + * Centralized list of marker classes for parameterized tests. + */ +export const testMarkerTypes = [ + ["Marker", google.maps.Marker], + ["AdvancedMarker", google.maps.marker.AdvancedMarkerElement], +] as Array<[string, any]>; + +/** + * Helper to quickly configure a mock Google Map to return specific bounds. + */ +export function setupMapBounds( + map: google.maps.Map, + bounds: { west: number; south: number; east: number; north: number } +) { + const northEast = new google.maps.LatLng(bounds.north, bounds.east); + const southWest = new google.maps.LatLng(bounds.south, bounds.west); + + const mockBounds = new google.maps.LatLngBounds(); + mockBounds.getNorthEast = jest.fn().mockReturnValue(northEast); + mockBounds.getSouthWest = jest.fn().mockReturnValue(southWest); + mockBounds.toJSON = jest.fn().mockReturnValue(bounds); + map.getBounds = jest.fn().mockReturnValue(mockBounds); + + return mockBounds; +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 4aad79a5..ac2b2b37 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -8,5 +8,5 @@ "rootDir": "src" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/test-helpers.ts"] }