diff --git a/src/base/strings.ts b/src/base/strings.ts index 59460cb..53e7ff8 100644 --- a/src/base/strings.ts +++ b/src/base/strings.ts @@ -15,6 +15,11 @@ export type StringFunction = (...args: any[]) => string; */ export declare interface StringLiterals extends Record { + BACK_BUTTON: string; + LOCATOR_ALL_LOCATIONS: string; + LOCATOR_LIST_HEADER: string; + LOCATOR_SEARCH_PROMPT: string; + LOCATOR_VIEW_DETAILS: string; PLACE_CLEAR_ARIA_LABEL: string; PLACE_CLOSED: string; PLACE_CLOSED_PERMANENTLY: string; @@ -51,6 +56,11 @@ export declare interface StringLiterals extends /** String literals in the `en-US` locale. */ export const STRING_LITERALS_EN_US: StringLiterals = Object.freeze({ + 'BACK_BUTTON': 'Back', + 'LOCATOR_ALL_LOCATIONS': 'All locations', + 'LOCATOR_LIST_HEADER': 'Find a location near you', + 'LOCATOR_SEARCH_PROMPT': 'Enter your address or zip code', + 'LOCATOR_VIEW_DETAILS': 'View details', 'PLACE_CLEAR_ARIA_LABEL': 'Clear', 'PLACE_CLOSED': 'Closed', 'PLACE_CLOSED_PERMANENTLY': 'Permanently closed', diff --git a/src/place_picker/place_picker_test.ts b/src/place_picker/place_picker_test.ts index 286fb04..c58dedc 100644 --- a/src/place_picker/place_picker_test.ts +++ b/src/place_picker/place_picker_test.ts @@ -42,7 +42,6 @@ const FAKE_PLACE_FROM_QUERY = makeFakePlace({ describe('PlacePicker', () => { const env = new Environment(); - let fakeAutocomplete: jasmine.SpyObj; beforeAll(() => { env.defineFakeMapElement(); @@ -57,13 +56,6 @@ describe('PlacePicker', () => { const fakeCircle = jasmine.createSpyObj('Circle', ['getBounds']); env.fakeGoogleMapsHarness!.libraries['maps'].Circle = jasmine.createSpy().and.returnValue(fakeCircle); - - // Create a fake Autocomplete class with test-specific logic. - fakeAutocomplete = jasmine.createSpyObj( - 'Autocomplete', - ['addListener', 'bindTo', 'getBounds', 'getPlace', 'setOptions']); - spyOn(env.fakeGoogleMapsHarness!, 'autocompleteConstructor') - .and.returnValue(fakeAutocomplete); }); async function prepareState(template?: TemplateResult) { @@ -101,6 +93,8 @@ describe('PlacePicker', () => { }); it('initializes Autocomplete widget with minimum configs', async () => { + spyOn(env.fakeGoogleMapsHarness!, 'autocompleteConstructor') + .and.callThrough(); const {picker, input, searchButton, clearButton} = await prepareState(); expect(env.fakeGoogleMapsHarness!.autocompleteConstructor) @@ -117,6 +111,9 @@ describe('PlacePicker', () => { }); it(`initializes Autocomplete widget based on attributes`, async () => { + spyOn(env.fakeGoogleMapsHarness!, 'autocompleteConstructor') + .and.callThrough(); + // The call to `.Circle.and.exec()` grabs a reference to the Circle // spy object without recording a call to the constructor spy (e.g. // `.Circle()`) @@ -160,13 +157,14 @@ describe('PlacePicker', () => { picker.type = 'restaurant'; await env.waitForStability(); - expect(fakeAutocomplete.setOptions).toHaveBeenCalledOnceWith({ - bounds: FAKE_BOUNDS, - componentRestrictions: {country: ['uk']}, - fields: [...PLACE_RESULT_DATA_FIELDS], - strictBounds: true, - types: ['restaurant'], - }); + expect(env.fakeGoogleMapsHarness!.autocompleteSpy.setOptions) + .toHaveBeenCalledOnceWith({ + bounds: FAKE_BOUNDS, + componentRestrictions: {country: ['uk']}, + fields: [...PLACE_RESULT_DATA_FIELDS], + strictBounds: true, + types: ['restaurant'], + }); }); it(`doesn't define bounds when only location bias is specified`, async () => { @@ -175,7 +173,7 @@ describe('PlacePicker', () => { picker.locationBias = {lat: 12, lng: 34}; await env.waitForStability(); - expect(fakeAutocomplete.setOptions) + expect(env.fakeGoogleMapsHarness!.autocompleteSpy.setOptions) .toHaveBeenCalledOnceWith(jasmine.objectContaining({ bounds: undefined, })); @@ -187,7 +185,7 @@ describe('PlacePicker', () => { picker.radius = 1000; await env.waitForStability(); - expect(fakeAutocomplete.setOptions) + expect(env.fakeGoogleMapsHarness!.autocompleteSpy.setOptions) .toHaveBeenCalledOnceWith(jasmine.objectContaining({ bounds: undefined, })); @@ -199,7 +197,8 @@ describe('PlacePicker', () => { picker.placeholder = 'Search nearby places'; await env.waitForStability(); - expect(fakeAutocomplete.setOptions).not.toHaveBeenCalled(); + expect(env.fakeGoogleMapsHarness!.autocompleteSpy.setOptions) + .not.toHaveBeenCalled(); }); it(`enables search & clear buttons on user input`, async () => { @@ -230,12 +229,13 @@ describe('PlacePicker', () => { it(`sets value based on user selection and fires event`, async () => { const dispatchEventSpy = spyOn(PlacePicker.prototype, 'dispatchEvent'); let autocompleteSelectionHandler: Function; - fakeAutocomplete.addListener.withArgs('place_changed', jasmine.anything()) + env.fakeGoogleMapsHarness!.autocompleteSpy.addListener + .withArgs('place_changed', jasmine.anything()) .and.callFake((eventName, handler) => { autocompleteSelectionHandler = handler; return {} as google.maps.MapsEventListener; }); - fakeAutocomplete.getPlace.and.returnValue( + env.fakeGoogleMapsHarness!.autocompleteSpy.getPlace.and.returnValue( FAKE_PLACE_RESULT_FROM_AUTOCOMPLETE); const {picker, input, searchButton, clearButton} = await prepareState(); @@ -256,7 +256,8 @@ describe('PlacePicker', () => { it(`sets value to undefined when place's cleared & fires event`, async () => { let autocompleteSelectionHandler: Function; - fakeAutocomplete.addListener.withArgs('place_changed', jasmine.anything()) + env.fakeGoogleMapsHarness!.autocompleteSpy.addListener + .withArgs('place_changed', jasmine.anything()) .and.callFake((eventName, handler) => { autocompleteSelectionHandler = handler; return {} as google.maps.MapsEventListener; @@ -280,7 +281,8 @@ describe('PlacePicker', () => { }); it(`sets value based on place returned by Find Place request`, async () => { - fakeAutocomplete.getBounds.and.returnValue(FAKE_BOUNDS); + env.fakeGoogleMapsHarness!.autocompleteSpy.getBounds.and.returnValue( + FAKE_BOUNDS); const {picker, input, searchButton, clearButton} = await prepareState(); await enterQueryText(input, '123 Main St'); @@ -308,7 +310,8 @@ describe('PlacePicker', () => { it('sets value from fallback GA API when Place.findPlaceFromQuery is not available', async () => { - fakeAutocomplete.getBounds.and.returnValue(FAKE_BOUNDS); + env.fakeGoogleMapsHarness!.autocompleteSpy.getBounds.and.returnValue( + FAKE_BOUNDS); const {picker, input, searchButton, clearButton} = await prepareState(); (env.fakeGoogleMapsHarness!.findPlaceFromQueryHandler as jasmine.Spy) .and.throwError(new Error( @@ -412,7 +415,8 @@ describe('PlacePicker', () => { await picker.bindTo(fakeMap); - expect(fakeAutocomplete.bindTo).toHaveBeenCalledOnceWith('bounds', fakeMap); + expect(env.fakeGoogleMapsHarness!.autocompleteSpy.bindTo) + .toHaveBeenCalledOnceWith('bounds', fakeMap); }); it(`binds to map bounds declaratively via attribute`, async () => { @@ -422,7 +426,7 @@ describe('PlacePicker', () => { `); const mapElement = root.querySelector('gmp-map')!; - expect(fakeAutocomplete.bindTo) + expect(env.fakeGoogleMapsHarness!.autocompleteSpy.bindTo) .toHaveBeenCalledOnceWith('bounds', mapElement.innerMap); }); @@ -431,7 +435,8 @@ describe('PlacePicker', () => { `); - expect(fakeAutocomplete.bindTo).not.toHaveBeenCalled(); + expect(env.fakeGoogleMapsHarness!.autocompleteSpy.bindTo) + .not.toHaveBeenCalled(); }); it(`doesn't bind to map bounds when id matches non-Map element`, async () => { @@ -440,6 +445,7 @@ describe('PlacePicker', () => {
`); - expect(fakeAutocomplete.bindTo).not.toHaveBeenCalled(); + expect(env.fakeGoogleMapsHarness!.autocompleteSpy.bindTo) + .not.toHaveBeenCalled(); }); }); diff --git a/src/route_building_blocks/route_marker/route_marker_test.ts b/src/route_building_blocks/route_marker/route_marker_test.ts index cfe2604..22c35fd 100644 --- a/src/route_building_blocks/route_marker/route_marker_test.ts +++ b/src/route_building_blocks/route_marker/route_marker_test.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import '../../testing/fake_gmp_components.js'; // import 'jasmine'; (google3-only) import {html, TemplateResult} from 'lit'; @@ -19,25 +18,6 @@ import {RouteMarker} from './route_marker.js'; type LatLng = google.maps.LatLng; -const FAKE_MARKER_LIBRARY = { - AdvancedMarkerElement: class { - position?: LatLng|null; - zIndex?: number|null; - title: string = ''; - map?: google.maps.Map|null; - - innerContent?: Element|null; - set content(content: Element|null|undefined) { - // Detach from the DOM as in the real AdvancedMarkerElement - content?.remove(); - this.innerContent = content; - } - get content(): Element|null|undefined { - return this.innerContent; - } - } -}; - function fakeRouteBetween( [startLat, startLng]: [number, number], [endLat, endLng]: [number, number]): google.maps.DirectionsRoute { @@ -54,15 +34,14 @@ describe('RouteMarker', () => { beforeAll(() => { env.defineFakeMapElement(); - }); - - beforeEach(() => { - env.importLibrarySpy!.and.returnValue(FAKE_MARKER_LIBRARY); + env.defineFakeAdvancedMarkerElement(); }); async function prepareState(template?: TemplateResult) { - const constructorSpy = - spyOn(FAKE_MARKER_LIBRARY, 'AdvancedMarkerElement').and.callThrough(); + const constructorSpy = spyOn( + env.fakeGoogleMapsHarness!.libraries['marker'], + 'AdvancedMarkerElement') + .and.callThrough(); const root = env.render(template ?? html``); await env.waitForStability(); diff --git a/src/route_building_blocks/route_polyline/route_polyline_test.ts b/src/route_building_blocks/route_polyline/route_polyline_test.ts index 6f1f8e7..3150016 100644 --- a/src/route_building_blocks/route_polyline/route_polyline_test.ts +++ b/src/route_building_blocks/route_polyline/route_polyline_test.ts @@ -15,16 +15,6 @@ import {makeFakeLeg, makeFakeRoute, makeFakeStep} from '../../testing/fake_route import {RoutePolyline} from './route_polyline.js'; -const FAKE_MAPS_LIBRARY = { - Polyline: class { - // clang-format off - setMap(map: google.maps.Map|null) {} - setPath(path: Array) {} - setOptions(options: google.maps.PolylineOptions) {} - // clang-format on - }, - LatLngBounds: FakeLatLngBounds, -}; describe('RoutePolyline', () => { const env = new Environment(); @@ -33,23 +23,25 @@ describe('RoutePolyline', () => { env.defineFakeMapElement(); }); - beforeEach(() => { - env.importLibrarySpy!.and.returnValue(FAKE_MAPS_LIBRARY); - }); - async function prepareState(template?: TemplateResult) { - const setMapSpy = spyOn(FAKE_MAPS_LIBRARY.Polyline.prototype, 'setMap'); - const setOptionsSpy = - spyOn(FAKE_MAPS_LIBRARY.Polyline.prototype, 'setOptions'); - const setPathSpy = spyOn(FAKE_MAPS_LIBRARY.Polyline.prototype, 'setPath'); + const setMapSpy = spyOn( + env.fakeGoogleMapsHarness!.libraries['maps'].Polyline.prototype, + 'setMap'); + const setOptionsSpy = spyOn( + env.fakeGoogleMapsHarness!.libraries['maps'].Polyline.prototype, + 'setOptions'); + const setPathSpy = spyOn( + env.fakeGoogleMapsHarness!.libraries['maps'].Polyline.prototype, + 'setPath'); const constructorSpy = - spyOn(FAKE_MAPS_LIBRARY, 'Polyline').and.callThrough(); + spyOn(env.fakeGoogleMapsHarness!.libraries['maps'], 'Polyline') + .and.callThrough(); const root = env.render( template ?? html``); const map = root.querySelector('gmp-map'); const polyline = root.querySelector('gmpx-route-polyline')!; - const fitBoundsSpy = map ? spyOn(map.innerMap, 'fitBounds') : undefined; + const fitBoundsSpy = map?.fitBoundsSpy; await env.waitForStability(); return { diff --git a/src/route_building_blocks/viewport_manager_test.ts b/src/route_building_blocks/viewport_manager_test.ts index 7693b18..902d3c5 100644 --- a/src/route_building_blocks/viewport_manager_test.ts +++ b/src/route_building_blocks/viewport_manager_test.ts @@ -12,15 +12,10 @@ import {FakeLatLngBounds} from '../testing/fake_lat_lng.js'; import {ViewportManager} from './viewport_manager.js'; -const FAKE_CORE_LIBRARY = { - LatLngBounds: FakeLatLngBounds, -}; - describe('ViewportManager', () => { const env = new Environment(); beforeEach(() => { - env.importLibrarySpy!.and.returnValue(FAKE_CORE_LIBRARY); env.defineFakeMapElement(); }); @@ -101,14 +96,13 @@ describe('ViewportManager', () => { it('fits the bounds of a registered component', async () => { const map = new FakeMapElement(); const manager = ViewportManager.getInstanceForMap(map); - const fitBoundsSpy = spyOn(map.innerMap, 'fitBounds'); const component = { getBounds: () => ({north: 1, south: 0, east: 1, west: 0}) }; await manager.register(component); - expect(fitBoundsSpy) + expect(map.fitBoundsSpy) .toHaveBeenCalledOnceWith( new FakeLatLngBounds({north: 1, south: 0, east: 1, west: 0})); }); @@ -124,10 +118,10 @@ describe('ViewportManager', () => { }; await manager.register(component1); - const fitBoundsSpy = spyOn(map.innerMap, 'fitBounds'); + map.fitBoundsSpy.calls.reset(); await manager.register(component2); - expect(fitBoundsSpy) + expect(map.fitBoundsSpy) .toHaveBeenCalledOnceWith( new FakeLatLngBounds({north: 2, south: 0, east: 2, west: 0})); }); @@ -144,10 +138,10 @@ describe('ViewportManager', () => { await manager.register(component1); await manager.register(component2); - const fitBoundsSpy = spyOn(map.innerMap, 'fitBounds'); + map.fitBoundsSpy.calls.reset(); await manager.unregister(component1); - expect(fitBoundsSpy) + expect(map.fitBoundsSpy) .toHaveBeenCalledOnceWith( new FakeLatLngBounds({north: 2, south: 1, east: 2, west: 1})); }); @@ -160,10 +154,10 @@ describe('ViewportManager', () => { }; await manager.register(component); - const fitBoundsSpy = spyOn(map.innerMap, 'fitBounds'); + map.fitBoundsSpy.calls.reset(); await manager.unregister(component); - expect(fitBoundsSpy).not.toHaveBeenCalled(); + expect(map.fitBoundsSpy).not.toHaveBeenCalled(); }); it('fits bounds when calling updateViewport() manually', async () => { @@ -174,10 +168,10 @@ describe('ViewportManager', () => { }; await manager.register(component); - const fitBoundsSpy = spyOn(map.innerMap, 'fitBounds'); + map.fitBoundsSpy.calls.reset(); await manager.updateViewport(); - expect(fitBoundsSpy) + expect(map.fitBoundsSpy) .toHaveBeenCalledOnceWith( new FakeLatLngBounds({north: 1, south: 0, east: 1, west: 0})); }); diff --git a/src/testing/environment.ts b/src/testing/environment.ts index 5914e63..e5f2c0d 100644 --- a/src/testing/environment.ts +++ b/src/testing/environment.ts @@ -10,7 +10,7 @@ import {ReactiveElement, render as litRender, TemplateResult} from 'lit'; import {APILoader} from '../api_loader/api_loader.js'; -import {FakeMapElement} from './fake_gmp_components.js'; +import {FakeAdvancedMarkerElement, FakeMapElement} from './fake_gmp_components.js'; import {FakeGoogleMapsHarness} from './fake_google_maps.js'; declare global { @@ -103,12 +103,25 @@ export class Environment { return root; } + /** + * Inserts a fake implementation of into the test environment. + */ defineFakeMapElement() { if (!customElements.get('gmp-map')) { customElements.define('gmp-map', FakeMapElement); } } + /** + * Inserts a fake implementation of into the test + * environment. + */ + defineFakeAdvancedMarkerElement() { + if (!customElements.get('gmp-advanced-marker')) { + customElements.define('gmp-advanced-marker', FakeAdvancedMarkerElement); + } + } + /** * Waits for all Lit `ReactiveElement` children of the given parent node to * finish rendering. diff --git a/src/testing/fake_autocomplete.ts b/src/testing/fake_autocomplete.ts new file mode 100644 index 0000000..41efda5 --- /dev/null +++ b/src/testing/fake_autocomplete.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +/** Creates a Jasmine spy to replace an Autocomplete object. */ +export const makeFakeAutocomplete = () => + jasmine.createSpyObj( + 'Autocomplete', + ['addListener', 'bindTo', 'getBounds', 'getPlace', 'setOptions']); \ No newline at end of file diff --git a/src/testing/fake_gmp_components.ts b/src/testing/fake_gmp_components.ts index ac56e2c..231f974 100644 --- a/src/testing/fake_gmp_components.ts +++ b/src/testing/fake_gmp_components.ts @@ -20,23 +20,46 @@ import {LitElement} from 'lit'; -import {LatLng, LatLngBounds, LatLngBoundsLiteral, LatLngLiteral} from '../utils/googlemaps_types.js'; +import {LatLng, LatLngLiteral} from '../utils/googlemaps_types.js'; declare global { interface HTMLElementTagNameMap { 'gmp-map': FakeMapElement; + 'gmp-advanced-marker': FakeAdvancedMarkerElement; } } -/** A fake google.maps.MapElement class for testing purposes. */ +/** A fake `google.maps.MapElement` class for testing purposes. */ export class FakeMapElement extends LitElement { + /** Test-only property to spy on calls to fitBounds(). */ + readonly fitBoundsSpy = jasmine.createSpy('fitBounds'); + center: LatLng|LatLngLiteral|null = null; // tslint:disable-next-line:prefer-type-annotation - readonly innerMap = { - fitBounds: (bounds: LatLngBounds|LatLngBoundsLiteral) => {} - } as google.maps.Map; + readonly innerMap = {fitBounds: this.fitBoundsSpy} as unknown as + google.maps.Map; mapId: string|null = null; zoom: number|null = null; } + +/** + * A fake `google.maps.AdvancedMarkerElement` class for testing purposes. + */ +export class FakeAdvancedMarkerElement extends LitElement { + position?: LatLng|null; + zIndex?: number|null; + override title = ''; + map?: google.maps.Map|null; + + innerContent?: Element|null; + set content(content: Element|null|undefined) { + // Detach from the DOM as in the real AdvancedMarkerElement + content?.remove(); + this.innerContent = content; + } + get content(): Element|null|undefined { + return this.innerContent; + } +} diff --git a/src/testing/fake_google_maps.ts b/src/testing/fake_google_maps.ts index 74be718..ae93cb5 100644 --- a/src/testing/fake_google_maps.ts +++ b/src/testing/fake_google_maps.ts @@ -6,7 +6,10 @@ import {LatLng, LatLngLiteral, Place, PlaceResult} from '../utils/googlemaps_types.js'; +import {makeFakeAutocomplete} from './fake_autocomplete.js'; import {makeFakeDistanceMatrixResponse} from './fake_distance_matrix.js'; +import {FakeAdvancedMarkerElement, FakeMapElement} from './fake_gmp_components.js'; +import {FakeLatLng, FakeLatLngBounds} from './fake_lat_lng.js'; import {makeFakePlace} from './fake_place.js'; import {makeFakeRoute} from './fake_route.js'; @@ -64,14 +67,19 @@ export class FakeGoogleMapsHarness { } = (request) => ({places: []}); /** - * Override this function to customize how `google.maps.places.Autocomplete` - * is instantiated. + * Spy for the fake Places Autocomplete. */ - autocompleteConstructor: + autocompleteSpy = makeFakeAutocomplete(); + + /** + * Override this function to control the constructor for + * `google.maps.places.Autocomplete`. + */ + autocompleteConstructor = (input: HTMLInputElement, options?: google.maps.places.AutocompleteOptions) => - google.maps.places.Autocomplete = () => - ({} as google.maps.places.Autocomplete); + this.autocompleteSpy; + /** * Collection of libraries that are dispatched via `importLibrary()`. @@ -87,9 +95,9 @@ export class FakeGoogleMapsHarness { constructor() { const harness = this; this.libraries = { - 'core': {}, + 'core': {LatLng: FakeLatLng, LatLngBounds: FakeLatLngBounds}, 'maps': { - Map: class {}, + Map: FakeMapElement, Polyline: class { setMap() {} setPath() {} @@ -97,7 +105,7 @@ export class FakeGoogleMapsHarness { } }, 'marker': { - AdvancedMarkerElement: class {}, + AdvancedMarkerElement: FakeAdvancedMarkerElement, }, 'places': { Autocomplete: class { diff --git a/src/testing/fake_lat_lng.ts b/src/testing/fake_lat_lng.ts index 864221b..4eec7d3 100644 --- a/src/testing/fake_lat_lng.ts +++ b/src/testing/fake_lat_lng.ts @@ -83,7 +83,9 @@ export class FakeLatLngBounds implements LatLngBounds { throw new Error('equals is not implemented'); } extend(point: LatLng|LatLngLiteral): LatLngBounds { - throw new Error('extend is not implemented'); + const lat = typeof point.lat === 'function' ? point.lat() : point.lat; + const lng = typeof point.lng === 'function' ? point.lng() : point.lng; + return this.union({north: lat, south: lat, east: lng, west: lng}); } getCenter(): LatLng { throw new Error('getCenter is not implemented'); diff --git a/src/utils/googlemaps_types.ts b/src/utils/googlemaps_types.ts index dd7fa6f..1a71daa 100644 --- a/src/utils/googlemaps_types.ts +++ b/src/utils/googlemaps_types.ts @@ -63,4 +63,5 @@ export type PriceLevel = google.maps.places.PriceLevel; /** HTML tag names for Maps JS web components. */ export interface HTMLElementTagNameMap { 'gmp-map': MapElement; + 'gmp-advanced-marker': AdvancedMarkerElement; }