From 100b6ead5dbdeff249971629205225057fa165ff Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Thu, 6 Jan 2022 23:56:03 -0800 Subject: [PATCH] [v7] Tests (#1677) --- .eslintignore | 1 + aliases.js | 36 --- test/browser.js | 5 +- test/data/style.json | 8 +- test/node.js | 11 + test/render/golden-images/marker.png | Bin 0 -> 5077 bytes test/render/index.js | 43 +-- test/render/test-cases.js | 111 ++++---- test/src/components/controls.spec.js | 49 ++++ test/src/components/layer.spec.js | 79 ++++++ test/src/components/map.spec.js | 158 ++++++++++- test/src/components/marker.spec.js | 96 +++++++ test/src/components/popup.spec.js | 63 +++++ test/src/components/source.spec.js | 58 ++++ test/src/components/use-map.spec.js | 53 ++++ test/src/index.js | 7 + test/src/utils/mapbox-gl-mock/edge_insets.js | 72 +++++ test/src/utils/mapbox-gl-mock/evented.js | 128 +++++++++ test/src/utils/mapbox-gl-mock/globals.js | 26 ++ test/src/utils/mapbox-gl-mock/index.js | 34 +++ test/src/utils/mapbox-gl-mock/lng_lat.js | 79 ++++++ .../utils/mapbox-gl-mock/lng_lat_bounds.js | 139 ++++++++++ test/src/utils/mapbox-gl-mock/map.js | 249 ++++++++++++++++++ test/src/utils/mapbox-gl-mock/marker.js | 78 ++++++ test/src/utils/mapbox-gl-mock/popup.js | 55 ++++ test/src/utils/mapbox-gl-mock/style.js | 111 ++++++++ test/src/utils/mapbox-gl-mock/task_queue.js | 58 ++++ .../utils/{ => mapbox-gl-mock}/transform.js | 64 +---- test/src/utils/mapbox-gl-mock/util.js | 25 ++ test/src/utils/style-utils.spec.js | 213 +++++++++++++++ test/src/utils/test-utils.js | 17 ++ test/src/utils/transform.spec.js | 2 +- webpack.config.js | 6 +- yarn.lock | 6 +- 34 files changed, 1960 insertions(+), 180 deletions(-) delete mode 100644 aliases.js create mode 100644 test/render/golden-images/marker.png create mode 100644 test/src/components/controls.spec.js create mode 100644 test/src/components/layer.spec.js create mode 100644 test/src/components/marker.spec.js create mode 100644 test/src/components/popup.spec.js create mode 100644 test/src/components/source.spec.js create mode 100644 test/src/components/use-map.spec.js create mode 100644 test/src/utils/mapbox-gl-mock/edge_insets.js create mode 100644 test/src/utils/mapbox-gl-mock/evented.js create mode 100644 test/src/utils/mapbox-gl-mock/globals.js create mode 100644 test/src/utils/mapbox-gl-mock/index.js create mode 100644 test/src/utils/mapbox-gl-mock/lng_lat.js create mode 100644 test/src/utils/mapbox-gl-mock/lng_lat_bounds.js create mode 100644 test/src/utils/mapbox-gl-mock/map.js create mode 100644 test/src/utils/mapbox-gl-mock/marker.js create mode 100644 test/src/utils/mapbox-gl-mock/popup.js create mode 100644 test/src/utils/mapbox-gl-mock/style.js create mode 100644 test/src/utils/mapbox-gl-mock/task_queue.js rename test/src/utils/{ => mapbox-gl-mock}/transform.js (59%) create mode 100644 test/src/utils/mapbox-gl-mock/util.js create mode 100644 test/src/utils/style-utils.spec.js create mode 100644 test/src/utils/test-utils.js diff --git a/.eslintignore b/.eslintignore index 1eae0cf6..ec3d0791 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ dist/ node_modules/ +test/src/utils/mapbox-gl-mock/*.js diff --git a/aliases.js b/aliases.js deleted file mode 100644 index 17c6c175..00000000 --- a/aliases.js +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2015 - 2017 Uber Technologies, Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -// Registers an alias for this module -const {resolve} = require('path'); - -const ALIASES = { - 'react-map-gl/test': resolve('./test'), - 'react-map-gl': resolve('./src'), - 'mapbox-gl$': resolve('./node_modules/mapbox-gl/dist/mapbox-gl.js'), - webworkify: 'webworkify-webpack-dropin' -}; - -if (module.require) { - const moduleAlias = module.require('module-alias'); - moduleAlias.addAliases(ALIASES); -} - -module.exports = ALIASES; diff --git a/test/browser.js b/test/browser.js index 797c439a..c68f5652 100644 --- a/test/browser.js +++ b/test/browser.js @@ -1,6 +1,4 @@ /* global window */ -require('@babel/register'); - const test = require('tape'); const {_enableDOMLogging: enableDOMLogging} = require('@probe.gl/test-utils'); @@ -21,5 +19,4 @@ enableDOMLogging({ }) }); -require('./src'); -// require('./render'); +require('./render'); diff --git a/test/data/style.json b/test/data/style.json index 34bf331e..b4762b63 100644 --- a/test/data/style.json +++ b/test/data/style.json @@ -40,7 +40,7 @@ "prefetchable": true, "priority": 0, "tiles": [ - "http://localhost:5000/test/data/tile/v1/{z}/{x}/{y}/COMPOSITE" + "https://raw.githubusercontent.com/visgl/react-map-gl/master/test/data/tile/v1/{z}/{x}/{y}/COMPOSITE" ], "type": "vector" }, @@ -70,13 +70,13 @@ "minzoom": 10, "priority": 1, "tiles": [ - "http://localhost:5000/test/data/tile/v1/{z}/{x}/{y}/POI" + "https://raw.githubusercontent.com/visgl/react-map-gl/master/test/data/tile/v1/{z}/{x}/{y}/POI" ], "type": "vector" } }, - "sprite": "http://localhost:5000/test/data/sprite/tools/14/sprites", - "glyphs": "http://localhost:5000/test/data/glyph/{fontstack}/{range}", + "sprite": "https://raw.githubusercontent.com/visgl/react-map-gl/master/test/data/sprite/tools/14/sprites", + "glyphs": "https://raw.githubusercontent.com/visgl/react-map-gl/master/test/data/glyph/{fontstack}/{range}", "layers": [ { "id": "background", diff --git a/test/node.js b/test/node.js index 11d1ecc3..8c5e9522 100644 --- a/test/node.js +++ b/test/node.js @@ -1,5 +1,16 @@ const register = require('@babel/register').default; +const path = require('path'); +const {JSDOM} = require('jsdom'); + +const moduleAlias = require('module-alias'); +moduleAlias.addAliases({ + 'mapbox-gl': path.resolve(__dirname, './src/utils/mapbox-gl-mock') +}); register({extensions: ['.ts', '.tsx', '.js']}); +const dom = new JSDOM(`
`); +/* global global */ +global.document = dom.window.document; + require('./src'); diff --git a/test/render/golden-images/marker.png b/test/render/golden-images/marker.png new file mode 100644 index 0000000000000000000000000000000000000000..8c68cf7cc70ac11398ddd5af47a6424eb27a04e2 GIT binary patch literal 5077 zcmeHJi8~Zr`ySbf>>>L$CEM^?vSg_cV-I6&W3o<@Wk`sz){B>j#x7e~B3rU$FhY!d z3fYIZkgOwvX^iDJeZTAbFTQhK=Q`&+*SXGl&VAp{^PD6bD-#YjVKx8&zyUTjvIPJb z(&=6CJPX~j-MZRC9~gpdO>P6~#zfZv0KPP^(JlMO1>3|Zze|)t{4c`D&Tn^rPszZz z$hwjpS>nR0F2g&6xz_C4dUbqUKOsI6QU_=v!hepJ#T zmHH4l(G*_7p^lum#Q0>gio$EBBK6_k`I=6y#lklEwglOLp(n|Kl9J5n^n+}r2f=H*J+_#UncI!<^z`&$J4wYR@03J0nQwo%cG;PJBo`^6RuvDeO>~o zI86}Y#&-MI-7t^ugd%^EM(PYHC7vpE?!tx;15?N*~F>awAg)Y;$bGX{%cXV65D z^=KSb=K3^00MsfXF*mi2udVNQncI2b*s-GH+kJbYNq9_57dWWm)vwf-pKsawK6@bK zh92P=Jb2iEBklOdECm#`9Y=i%c!J%FL!DLjsvj%k|8qc7Q||+0hI{@R?74exvQw(+ z_o;JB4fO~9^RNgP%I9D3Gt$hJ%Ihl1g=Z*Eo6;%NtVf^MlE?MdeMfe|uuptSjlLlk zc$G`mO#{v!KJ@wptS1fPyic-~91b2_96`}>=Qy(nextZ~_}JG4q5CBws9VPCa9;Qj9Z$!9K5QY zKZxs7!dA##?ImZ1=(U~BPG3%t>rKgfzs-DTv}20ziu{rKVy%b_`X$2<4ATFD7;F3I zhaojz;1a%ymS1bj^a6ewn$JHX?}gR|Sl$;6g`KJ8gSYszbVu~pdAiv3sR{u^!4c@E937TT>HO0QP^ zFLc(|I$@eNf9JBTZnO8=)HKNXT)%RvpDjk>rAsLGFYZa^r30Oj$H!6q>M*E6z7nyc zEs_n%PR?ACDp`Zib)Y-kScW*0?$nw*<)kXQDdK)ho$HcbfZJ7 z?3mNg#^K)%t(s6>ME`dTQW3LYEC_*=+~K}yBN$1oO>l3DpU=p2!Q4NZ&&6B#3?V*w zs9tOw&G`~>H;efJ6Wy6^%IXnjv${w9?E|7AE^_c7sx4>h>POuk$s>Lb)1v~ZD2H*@ zr-qnA^Er}vyH2^p2yFXDk(7+gv=~Kf?XB6eq--#sPTtGUdo6kG4Ly-rzn{yr?R=1u zvCA@JB#SDg3u^+Sc1j)%q!+!|_qJr=VlM9TGhH&cCMJy0*VJq-Kvz_G7Wqn$w>6i7;z!A3 zSk5Sztr*|w4&htM_1kQTCfkcv4>P2r*{yjZA zHajbiIIgbC3yVnIx%?;>~v};SHOr|1uQ_H#wwc; z8r90y=pO~Q0xd^Sw-o9SXAz{QA_P5NE?24Zk{bOr-!0_rj*Oaz-tsqC{IVo~aQ6`X zX;|ty>j+|4X=QaqHIexJlk)O2Vw}7^Po)r@n`P_53Uq^aZMB0_vj_dK;EFZe${JaD zHm{)os^}W%gj*gMOi68T8zJMWb0`4|j_wfxfveK7lSL13`_%#|4+e3xE}a$aMLR2> z$TYp^IGEF|>8!%h<8C=E17ge>loQ%j@$vW$;1=wGU*qY|Sw4eiwe?;rkX?}ffW^XX zx6rFmGsOdX{`}9-%6B#5!czgZ=!7+-lg4V=S@c?NBss@T|ExnVG-5%?-VNrKgb-X! zNh=Huo`pZIJL6?stAx^@;hA~Z`qsgz{Vl{nY3|RZ?Rzm>K8^J=o(NZhHibKZ)b?J@ z)A&Zl;_8jXv~}!lJ(<|a%k{~=ns1#PYTpY8P$Hg_8D%r585u%3B*HG_M|lvJw&%Km zc3YA}9y^&81hw>nDhYkS8d2+TuG3 z8q`dPkvmm)^18gI_&3BD%2=q78T?R=c+DMH9iV>)wU7lmJ}6vEUZ=RdCv@agsW(q6 z2zb4*MzA5Qy6k^%?gqDa1x*Vz@N;jtuGS-ZI}UR*u~6r7_JRaoQvYKc3>E;F&s_;GTUNv)W!qzo6qd$+JGKQ)9Qc^=$1`>r-D2 zaC9IbBvHUL6d^n?Eo(BuiXGEL!QowZe~;twKBK z4pLUV@DN@4GOzXQxTl>ENixkf_|S6Y+Ow(i8=NoYrc~^X{)Xm*HRk)`SuNK2s>{@J z-n(f?R+OwtRcZfT@Om?mTH1B;wRkz{M`1(7r`wIMf|7vzR)t!5V$tZepQz%P39C0E zGTyoQe({en1;eMnkD_#;y@QPB88&iBGo^upz^NGFC#8Ss_H&hA6?R*qIxBv`e)^{` zAggTQi(!3HMAVw^a-uEYEq$cw{SWfY&tKEawi;a@n!}Q+7Q4AUHdP1Q;&*_fTFs

S6wz!m zC35c77=y?x>|0lQXL})|EXQ|T;La}{xGELv>MniDh$&4dPrg~FN5E9;Dlbd={b`5G zH$}6YCEd9%xJdFzKQ7^*5@}aa6=@(N5bZ#ORx6aX1_r=zqTMB_SJ()z=aj z34#Gid`4Q>eGAL@tL!&QVK5jx-~StZn&>-SchXrF1{C{{&gSMZ3_LM(nj4l+T?GJq zR9=E=Ao<3-@BC@Cdrca?Q^&sYSx1#+{Q1R1CwAZ<nsQBZbi}@NHR9TUw2BMu;^DLZ;Pxs#lMy}mh(&n!S z2@7jA9k;XA%Yv*kcbeF`m4lkSzjeNz9w}>YZ@+TQT%KN_%OE%sYH;--&Dq(x#r~RF z2PwBq>017g*Anf0{eU^vIW!oBBEhm)hR|p<$rhO& z`Lv9qw6t{obH_S~}wkMB)iE zSR!Np8{LH8-Q6wyvKK~LJQ?-7PGeu9EOBgdXLq3~JE%KkPA)Eke2c53YXnU#t>2&3 zsR0nV$9^-oDB5(?l0?di$U-NYqB&fABWGAoq#{0pvjw$u8Uk*GS9IL~(tanQx` zZSUU~R&R=JfR0ScUw4_Qt@}SU{+Q_*3K+}8;DM0OikJy1INdGZ(z1%7FFrUb?kIL4 zbP7+CNHkJH_#G3IR$~%m{q#Stq?o>$FwkC#{1%)*O-M|XAV(QM4kJZ+Znk*hpVN9` zd8^Jcvqb@}Npp}0Cxg485B4Ik9zl7Es&G54?l-ddDuUWZH#bS9aNd_bWXsMz7&UE#@lEjp#lUmLtD zT2)f6`jZwdKmO!gtU`}PNAl%^Q>^YsL!*)cLQhJteD1uH1~`>qOLR!-3w4HcF2oc-5j zhML4j6N|_q&*PT)01XHV@$rzxtpVTampD1O9ZPeWV8EWaQMQe+}; zNoWRE)z;RI+Y?* z^{yg2s;f5}yHnnWNdE(<b!yorJ9XLEpiJ0bFW=ZZnGkHW%YzV!*hJCq}XvL#+fei6z<-(>*i_R}2q#ZOL7 zxGXeNHFZ9UTM(jZ#?=>v_M%j4$cNkWUE+C4hjk=c)z5#?msO+*8r}{L=_~*Zk}LZK zC2;Z9=B7(Hsgt4~_)2rOBb?ky&xwzW#!>ddo0UzhUyGb7#B7k>6m3D?BX4#l1x284 z9otL2?5w<;$@*C(pzEtDmsgqf|8Pw>nw~HDG3uk%2f+krEu~&EL?3R2>*f;oD=Grm zd11{kTvU>MM0XllpXnYTB?%Kbo)U>m$V!l+euu)2xI%l?r9pLsdS zASu9moM(l-RrM~gOKgxG5aM1~;bgBh!LvTsw%~$=Zb-Lph)x3lW4(S|HXE^JVN*3u z77uR7qU8dG5*q&6!r4}7FaRns { - // Wait for mapbox's animation to finish - target.once('idle', () => - resolve({ - map: target, - boundingBox: getBoundingBoxInPage(container), - unmount - }) - ); + // Wait for animations to finish + target.once('idle', () => { + /* global setTimeout */ + setTimeout( + () => + resolve({ + map: target, + boundingBox: getBoundingBoxInPage(container), + unmount + }), + 500 + ); + }); }; const onError = evt => { @@ -49,15 +54,17 @@ async function runTestCase({Component = MapGL, props}) { reject(evt.error); }; - render( - , - container - ); + render(, container); }); } // CI does not have the same default fonts as local boxes. -async function loadFont() { +async function loadStyles() { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = './node_modules/mapbox-gl/dist/mapbox-gl.css'; + document.head.append(link); + const font = new FontFace( 'Roboto', 'url(https://fonts.gstatic.com/s/roboto/v20/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2)', @@ -74,7 +81,7 @@ test('Render test', async t => { // Default tape test timeout is 500ms - allow enough time for render and screenshot t.timeoutAfter(TEST_CASES.length * 4000); - await loadFont(); + await loadStyles(); for (const testCase of TEST_CASES) { t.comment(testCase.title); @@ -91,9 +98,9 @@ test('Render test', async t => { goldenImage, region: boundingBox, tolerance: 0.05, - includeEmpty: false + includeEmpty: false, // Uncomment to save screenshot - // , saveOnFail: true + saveOnFail: true }); error = result.error; diff --git a/test/render/test-cases.js b/test/render/test-cases.js index 6c71f7ef..0f1aeae1 100644 --- a/test/render/test-cases.js +++ b/test/render/test-cases.js @@ -1,12 +1,6 @@ /* global __MAPBOX_TOKEN__ */ import * as React from 'react'; -import {StaticMap, NavigationControl, GeolocateControl, Popup, Source, Layer} from 'react-map-gl'; - -const EMPTY_MAP_STYLE = { - version: 8, - sources: {}, - layers: [] -}; +import {NavigationControl, GeolocateControl, Marker, Popup, Source, Layer} from 'react-map-gl'; const ALT_EMPTY_MAP_STYLE = { version: 8, @@ -27,7 +21,7 @@ export default [ { title: 'Basic map', props: { - mapboxApiAccessToken: __MAPBOX_TOKEN__, + mapboxAccessToken: __MAPBOX_TOKEN__, mapStyle: 'mapbox://styles/mapbox/dark-v9', longitude: -122.4, latitude: 37.78, @@ -39,7 +33,7 @@ export default [ { title: 'Invalid map token', props: { - mapboxApiAccessToken: 'invalid_token', + mapboxAccessToken: 'invalid_token', mapStyle: 'mapbox://styles/mapbox/dark-v9', longitude: -122.4, latitude: 37.78, @@ -50,8 +44,8 @@ export default [ { title: 'Custom tile server', props: { - mapboxApiAccessToken: __MAPBOX_TOKEN__, - mapStyle: 'http://localhost:5000/test/data/style.json', + mapboxAccessToken: __MAPBOX_TOKEN__, + mapStyle: '/test/data/style.json', longitude: -122.4, latitude: 37.78, zoom: 12.5 @@ -61,29 +55,62 @@ export default [ }, { title: 'NavigationControl', - Component: StaticMap, props: { - mapboxApiAccessToken: __MAPBOX_TOKEN__, - mapStyle: EMPTY_MAP_STYLE, + mapboxAccessToken: __MAPBOX_TOKEN__, longitude: -122.4, latitude: 37.78, zoom: 12.5, - reuseMaps: true, bearing: 30, - children: ( -

- -
- ) + children: }, goldenImage: 'test/render/golden-images/navigation-control.png' }, { - title: 'Popup', - Component: StaticMap, + title: 'GeolocateControl', props: { - mapboxApiAccessToken: __MAPBOX_TOKEN__, - mapStyle: EMPTY_MAP_STYLE, + mapboxAccessToken: __MAPBOX_TOKEN__, + longitude: -122.4, + latitude: 37.78, + zoom: 12.5, + bearing: 30, + children: ( + + ) + }, + goldenImage: 'test/render/golden-images/geolocate-control.png' + }, + { + title: 'Marker', + props: { + mapboxAccessToken: __MAPBOX_TOKEN__, + longitude: -122.4, + latitude: 37.78, + reuseMaps: true, + zoom: 12.5, + children: [ + , + + + + + + ] + }, + threshold: 0.95, + goldenImage: 'test/render/golden-images/marker.png' + }, + { + title: 'Popup', + props: { + mapboxAccessToken: __MAPBOX_TOKEN__, longitude: -122.4, latitude: 37.78, reuseMaps: true, @@ -109,13 +136,10 @@ export default [ }, { title: 'JSX Source/Layer', - Component: StaticMap, props: { - mapboxApiAccessToken: __MAPBOX_TOKEN__, - mapStyle: EMPTY_MAP_STYLE, + mapboxAccessToken: __MAPBOX_TOKEN__, longitude: -122.4, latitude: 37.78, - reuseMaps: true, zoom: 12.5, children: [ - ) - }, - goldenImage: 'test/render/golden-images/geolocate-control.png' } -].filter(testCase => testCase.props.mapboxApiAccessToken); +].filter(testCase => testCase.props.mapboxAccessToken); diff --git a/test/src/components/controls.spec.js b/test/src/components/controls.spec.js new file mode 100644 index 00000000..2ce73461 --- /dev/null +++ b/test/src/components/controls.spec.js @@ -0,0 +1,49 @@ +import { + Map, + AttributionControl, + FullscreenControl, + GeolocateControl, + NavigationControl, + ScaleControl +} from 'react-map-gl'; +import * as React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import test from 'tape-promise/tape'; + +test('Controls', t => { + const renderer = ReactTestRenderer.create(); + renderer.update( + + + + ); + t.ok(renderer.root, 'Rendered '); + renderer.update( + + + + ); + t.ok(renderer.root, 'Rendered '); + renderer.update( + + + + ); + t.ok(renderer.root, 'Rendered '); + renderer.update( + + + + ); + t.ok(renderer.root, 'Rendered '); + renderer.update( + + + + ); + t.ok(renderer.root, 'Rendered '); + + renderer.unmount(); + + t.end(); +}); diff --git a/test/src/components/layer.spec.js b/test/src/components/layer.spec.js new file mode 100644 index 00000000..46082f25 --- /dev/null +++ b/test/src/components/layer.spec.js @@ -0,0 +1,79 @@ +import {Map, Source, Layer} from 'react-map-gl'; +import * as React from 'react'; +import {create, act} from 'react-test-renderer'; +import test from 'tape-promise/tape'; + +import {sleep} from '../utils/test-utils'; + +test('Source/Layer', async t => { + const mapRef = {current: null}; + + const mapStyle = {}; + const geoJSON = { + type: 'Point', + coordinates: [0, 0] + }; + const pointLayer = { + type: 'circle', + paint: { + 'circle-radius': 10, + 'circle-color': '#007cbf' + } + }; + const pointLayer2 = { + type: 'circle', + paint: { + 'circle-radius': 10, + 'circle-color': '#000000' + }, + layout: { + visibility: 'none' + } + }; + + let map; + act(() => { + map = create( + + + + + + ); + }); + await sleep(5); + t.ok(mapRef.current.getLayer('my-layer'), 'Layer is added'); + + act(() => + map.update( + + + + + + ) + ); + t.is(mapRef.current.getLayer('my-layer').paint['circle-color'], '#000000', 'Layer is updated'); + t.is(mapRef.current.getLayer('my-layer').layout.visibility, 'none', 'Layer is updated'); + + act(() => + map.update( + + + + + + ) + ); + await sleep(5); + t.ok(mapRef.current.getLayer('my-layer'), 'Layer is added after style change'); + + act(() => map.update()); + await sleep(5); + t.notOk(mapRef.current.getSource('my-data'), 'Source is removed'); + t.notOk(mapRef.current.getLayer('my-layer'), 'Layer is removed'); + + map.unmount(); + + t.end(); +}); diff --git a/test/src/components/map.spec.js b/test/src/components/map.spec.js index 4e4c719b..246afabb 100644 --- a/test/src/components/map.spec.js +++ b/test/src/components/map.spec.js @@ -1,15 +1,161 @@ +/* global setTimeout */ import {Map} from 'react-map-gl'; import * as React from 'react'; -import ReactTestUtils from 'react-test-renderer/shallow'; -// import ReactTestRenderer from 'react-test-renderer'; +import {create, act} from 'react-test-renderer'; import test from 'tape-promise/tape'; -test('InteractiveMap#default export', t => { +import {sleep} from '../utils/test-utils'; + +test('Map', async t => { t.ok(Map, 'Map is defined'); + const mapRef = {current: null}; - const map = ; - const result = ReactTestUtils.createRenderer().render(map); + let onloadCalled = 0; + const onLoad = () => onloadCalled++; + + let map; + act(() => { + map = create( + + ); + }); + + t.ok(map.root, 'Rendered '); + t.is(mapRef.current.getCenter().lng, -100, 'longitude is set'); + t.is(mapRef.current.getCenter().lat, 40, 'latitude is set'); + t.is(mapRef.current.getZoom(), 4, 'zoom is set'); + + act(() => { + map.update(); + }); + + t.is(mapRef.current.getCenter().lng, -122, 'longitude is updated'); + t.is(mapRef.current.getCenter().lat, 38, 'latitude is updated'); + t.is(mapRef.current.getZoom(), 14, 'zoom is updated'); + + await sleep(1); + t.is(onloadCalled, 1, 'onLoad is called'); + + map.unmount(); - t.ok(result, 'Map rendered'); t.end(); }); + +test('Map#uncontrolled', async t => { + t.plan(5); + + const lastLat = 40; + function onRender(e) { + const {lat} = e.target.getCenter(); + t.ok(lastLat > lat, 'animating'); + } + + act(() => { + create( + { + e.target.easeTo({center: [-122, 38], zoom: 14}); + }} + onRender={onRender} + /> + ); + }); +}); + +test('Map#controlled#no-update', async t => { + t.plan(5); + + function onRender(e) { + const {lat} = e.target.getCenter(); + t.is(lat, 40, `latitude should match props: ${lat}`); + } + + act(() => { + create( + { + e.target.easeTo({center: [-122, 38], zoom: 14}); + }} + onRender={onRender} + /> + ); + }); +}); + +test('Map#controlled#mirrow-back', async t => { + t.plan(5); + + let lastLat; + function onRender(e) { + const {lat} = e.target.getCenter(); + t.is(lat, lastLat, `latitude should match state: ${lat}`); + } + + function App() { + const [viewState, setViewState] = React.useState({ + longitude: -100, + latitude: 40, + zoom: 4 + }); + + lastLat = viewState.latitude; + + return ( + { + e.target.easeTo({center: [-122, 38], zoom: 14}); + }} + onMove={e => setViewState(e.viewState)} + onRender={onRender} + /> + ); + } + + act(() => { + create(); + }); +}); + +test('Map#controlled#delayed-update', async t => { + t.plan(6); + + let lastLat; + function onRender(e) { + const {lat} = e.target.getCenter(); + t.is(lat, lastLat, `latitude should match state: ${lat}`); + } + + function App() { + const [viewState, setViewState] = React.useState({ + longitude: -100, + latitude: 40, + zoom: 4 + }); + + lastLat = viewState.latitude; + + return ( + { + e.target.easeTo({center: [-122, 38], zoom: 14}); + }} + onMove={e => setTimeout(() => setViewState(e.viewState))} + onRender={onRender} + /> + ); + } + + act(() => { + create(); + }); +}); diff --git a/test/src/components/marker.spec.js b/test/src/components/marker.spec.js new file mode 100644 index 00000000..af3ab048 --- /dev/null +++ b/test/src/components/marker.spec.js @@ -0,0 +1,96 @@ +import {Map, Marker} from 'react-map-gl'; +import * as React from 'react'; +import {create, act} from 'react-test-renderer'; +import test from 'tape-promise/tape'; + +import {createPortalMock} from '../utils/test-utils'; + +test('Marker', t => { + const restoreMock = createPortalMock(); + const mapRef = {current: null}; + + let map; + act(() => { + map = create( + + + + ); + }); + + const marker = mapRef.current.getMap()._markers[0]; + t.ok(marker, 'Marker is created'); + + const offset = marker.getOffset(); + const draggable = marker.isDraggable(); + const rotation = marker.getRotation(); + const pitchAlignment = marker.getPitchAlignment(); + const rotationAlignment = marker.getRotationAlignment(); + + act(() => { + map.update( + + + + ); + }); + + t.is(offset, marker.getOffset(), 'offset did not change deeply'); + + let callbackType = ''; + act(() => { + map.update( + + (callbackType = 'dragstart')} + onDrag={() => (callbackType = 'drag')} + onDragEnd={() => (callbackType = 'dragend')} + /> + + ); + }); + + t.not(offset, marker.getOffset(), 'offset is updated'); + t.not(draggable, marker.isDraggable(), 'draggable is updated'); + t.not(rotation, marker.getRotation(), 'rotation is updated'); + t.not(pitchAlignment, marker.getPitchAlignment(), 'pitchAlignment is updated'); + t.not(rotationAlignment, marker.getRotationAlignment(), 'rotationAlignment is updated'); + + marker.fire('dragstart'); + t.is(callbackType, 'dragstart', 'onDragStart called'); + marker.fire('drag'); + t.is(callbackType, 'drag', 'onDrag called'); + marker.fire('dragend'); + t.is(callbackType, 'dragend', 'onDragEnd called'); + + act(() => { + map.update(); + }); + + t.is(mapRef.current.getMap()._markers.length, 0, 'marker is removed'); + + act(() => { + map.update( + + +
+ + + ); + }); + + t.ok(map.root.findByProps({id: 'marker-content'}), 'content is rendered'); + + map.unmount(); + + restoreMock(); + + t.end(); +}); diff --git a/test/src/components/popup.spec.js b/test/src/components/popup.spec.js new file mode 100644 index 00000000..50c59b38 --- /dev/null +++ b/test/src/components/popup.spec.js @@ -0,0 +1,63 @@ +import {Map, Popup} from 'react-map-gl'; +import * as React from 'react'; +import {create, act} from 'react-test-renderer'; +import test from 'tape-promise/tape'; + +import {createPortalMock} from '../utils/test-utils'; + +test('Popup', t => { + const restoreMock = createPortalMock(); + const mapRef = {current: null}; + + let map; + act(() => { + map = create( + + + You are here + + + ); + }); + + const popup = mapRef.current.getMap()._markers[0]; + t.ok(popup, 'Popup is created'); + + const {anchor, offset, maxWidth} = popup.options; + + act(() => { + map.update( + + + You are here + + + ); + }); + + t.is(offset, popup.options.offset, 'offset did not change deeply'); + + act(() => { + map.update( + + + You are here + + + ); + }); + + t.not(offset, popup.options.offset, 'offset is updated'); + t.not(anchor, popup.options.anchor, 'anchor is updated'); + t.not(maxWidth, popup.options.maxWidth, 'maxWidth is updated'); + + restoreMock(); + + t.end(); +}); diff --git a/test/src/components/source.spec.js b/test/src/components/source.spec.js new file mode 100644 index 00000000..8242472c --- /dev/null +++ b/test/src/components/source.spec.js @@ -0,0 +1,58 @@ +import {Map, Source} from 'react-map-gl'; +import * as React from 'react'; +import {create, act} from 'react-test-renderer'; +import test from 'tape-promise/tape'; + +import {sleep} from '../utils/test-utils'; + +test('Source/Layer', async t => { + const mapRef = {current: null}; + + const mapStyle = {}; + const geoJSON = { + type: 'Point', + coordinates: [0, 0] + }; + const geoJSON2 = { + type: 'Point', + coordinates: [1, 1] + }; + + let map; + act(() => { + map = create( + + + + ); + }); + await sleep(5); + t.ok(mapRef.current.getSource('my-data'), 'Source is added'); + + act(() => + map.update( + + + + ) + ); + await sleep(5); + t.ok(mapRef.current.getSource('my-data'), 'Source is added after style change'); + + act(() => + map.update( + + + + ) + ); + t.is(mapRef.current.getSource('my-data').getData(), geoJSON2, 'Source is updated'); + + act(() => map.update()); + await sleep(5); + t.notOk(mapRef.current.getSource('my-data'), 'Source is removed'); + + map.unmount(); + + t.end(); +}); diff --git a/test/src/components/use-map.spec.js b/test/src/components/use-map.spec.js new file mode 100644 index 00000000..92684988 --- /dev/null +++ b/test/src/components/use-map.spec.js @@ -0,0 +1,53 @@ +import {Map, MapProvider, useMap} from 'react-map-gl'; +import * as React from 'react'; +import {create, act} from 'react-test-renderer'; +import test from 'tape-promise/tape'; + +test('useMap', t => { + let app; + let maps; + + function TestControl() { + maps = useMap(); + return null; + } + + act(() => { + app = create( + + + + + + ); + }); + + t.ok(maps.mapA, 'Context has mapA'); + t.ok(maps.mapB, 'Context has mapB'); + + act(() => { + app = create( + + + + + ); + }); + + t.ok(maps.mapA, 'Context has mapA'); + t.notOk(maps.mapB, 'mapB is removed'); + + act(() => { + app = create( + + + + ); + }); + + t.notOk(maps.mapA, 'mapA is removed'); + + app.unmount(); + + t.end(); +}); diff --git a/test/src/index.js b/test/src/index.js index f6bf0b34..f340721b 100644 --- a/test/src/index.js +++ b/test/src/index.js @@ -1,4 +1,11 @@ import './components/map.spec'; +import './components/controls.spec'; +import './components/source.spec'; +import './components/layer.spec'; +import './components/marker.spec'; +import './components/popup.spec'; +import './components/use-map.spec'; import './utils/deep-equal.spec'; import './utils/transform.spec'; +import './utils/style-utils.spec'; diff --git a/test/src/utils/mapbox-gl-mock/edge_insets.js b/test/src/utils/mapbox-gl-mock/edge_insets.js new file mode 100644 index 00000000..fb8381ec --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/edge_insets.js @@ -0,0 +1,72 @@ +// Generated with +// flow-remove-types ./node_modules/mapbox-gl/src/geo/edge_insets.js + +import Point from '@mapbox/point-geometry'; +import {clamp, number} from './util.js'; + +class EdgeInsets { + constructor(top = 0, bottom = 0, left = 0, right = 0) { + if ( + isNaN(top) || + top < 0 || + isNaN(bottom) || + bottom < 0 || + isNaN(left) || + left < 0 || + isNaN(right) || + right < 0 + ) { + throw new Error( + 'Invalid value for edge-insets, top, bottom, left and right must all be numbers' + ); + } + + this.top = top; + this.bottom = bottom; + this.left = left; + this.right = right; + } + + interpolate(start, target, t) { + if (target.top != null && start.top != null) this.top = number(start.top, target.top, t); + if (target.bottom != null && start.bottom != null) + this.bottom = number(start.bottom, target.bottom, t); + if (target.left != null && start.left != null) this.left = number(start.left, target.left, t); + if (target.right != null && start.right != null) + this.right = number(start.right, target.right, t); + + return this; + } + + getCenter(width, height) { + // Clamp insets so they never overflow width/height and always calculate a valid center + const x = clamp((this.left + width - this.right) / 2, 0, width); + const y = clamp((this.top + height - this.bottom) / 2, 0, height); + + return new Point(x, y); + } + + equals(other) { + return ( + this.top === other.top && + this.bottom === other.bottom && + this.left === other.left && + this.right === other.right + ); + } + + clone() { + return new EdgeInsets(this.top, this.bottom, this.left, this.right); + } + + toJSON() { + return { + top: this.top, + bottom: this.bottom, + left: this.left, + right: this.right + }; + } +} + +export default EdgeInsets; diff --git a/test/src/utils/mapbox-gl-mock/evented.js b/test/src/utils/mapbox-gl-mock/evented.js new file mode 100644 index 00000000..abc2403c --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/evented.js @@ -0,0 +1,128 @@ +// Generated with +// flow-remove-types ./node_modules/mapbox-gl/src/util/evented.js + +import {extend} from './util.js'; + +function _addEventListener(type, listener, listenerList) { + const listenerExists = listenerList[type] && listenerList[type].indexOf(listener) !== -1; + if (!listenerExists) { + listenerList[type] = listenerList[type] || []; + listenerList[type].push(listener); + } +} + +function _removeEventListener(type, listener, listenerList) { + if (listenerList && listenerList[type]) { + const index = listenerList[type].indexOf(listener); + if (index !== -1) { + listenerList[type].splice(index, 1); + } + } +} + +export class Event { + constructor(type, data = {}) { + extend(this, data); + this.type = type; + } +} + +export class ErrorEvent extends Event { + constructor(error, data = {}) { + super('error', extend({error}, data)); + } +} + +export class Evented { + on(type, listener) { + this._listeners = this._listeners || {}; + _addEventListener(type, listener, this._listeners); + + return this; + } + + off(type, listener) { + _removeEventListener(type, listener, this._listeners); + _removeEventListener(type, listener, this._oneTimeListeners); + + return this; + } + + once(type, listener) { + if (!listener) { + return new Promise(resolve => this.once(type, resolve)); + } + + this._oneTimeListeners = this._oneTimeListeners || {}; + _addEventListener(type, listener, this._oneTimeListeners); + + return this; + } + + fire(event, properties) { + // Compatibility with (type: string, properties: Object) signature from previous versions. + // See https://github.com/mapbox/mapbox-gl-js/issues/6522, + // https://github.com/mapbox/mapbox-gl-draw/issues/766 + if (typeof event === 'string') { + event = new Event(event, properties || {}); + } + + const type = event.type; + + if (this.listens(type)) { + event.target = this; + + // make sure adding or removing listeners inside other listeners won't cause an infinite loop + const listeners = + this._listeners && this._listeners[type] ? this._listeners[type].slice() : []; + + for (const listener of listeners) { + listener.call(this, event); + } + + const oneTimeListeners = + this._oneTimeListeners && this._oneTimeListeners[type] + ? this._oneTimeListeners[type].slice() + : []; + for (const listener of oneTimeListeners) { + _removeEventListener(type, listener, this._oneTimeListeners); + listener.call(this, event); + } + + const parent = this._eventedParent; + if (parent) { + extend( + event, + typeof this._eventedParentData === 'function' + ? this._eventedParentData() + : this._eventedParentData + ); + parent.fire(event); + } + + // To ensure that no error events are dropped, print them to the + // console if they have no listeners. + } else if (event instanceof ErrorEvent) { + console.error(event.error); + } + + return this; + } + + listens(type) { + return Boolean( + (this._listeners && this._listeners[type] && this._listeners[type].length > 0) || + (this._oneTimeListeners && + this._oneTimeListeners[type] && + this._oneTimeListeners[type].length > 0) || + (this._eventedParent && this._eventedParent.listens(type)) + ); + } + + setEventedParent(parent, data) { + this._eventedParent = parent; + this._eventedParentData = data; + + return this; + } +} diff --git a/test/src/utils/mapbox-gl-mock/globals.js b/test/src/utils/mapbox-gl-mock/globals.js new file mode 100644 index 00000000..265f00d0 --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/globals.js @@ -0,0 +1,26 @@ +export function supported() { + return true; +} + +let rtlTextPlugin = ''; +let baseApiUrl = 'https://api.mapbox.com'; + +export function getRTLTextPluginStatus() { + return rtlTextPlugin ? 'deferred' : 'unavailable'; +} + +export function setRTLTextPlugin(url) { + rtlTextPlugin = url; +} + +export default { + supported, + getRTLTextPluginStatus, + setRTLTextPlugin, + get baseApiUrl() { + return baseApiUrl; + }, + set baseApiUrl(value) { + baseApiUrl = value; + } +}; diff --git a/test/src/utils/mapbox-gl-mock/index.js b/test/src/utils/mapbox-gl-mock/index.js new file mode 100644 index 00000000..a36432ad --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/index.js @@ -0,0 +1,34 @@ +// Adapted from https://github.com/mapbox/mapbox-gl-js-mock/ +// BSD 3-Clause License +// Copyright (c) 2017, Mapbox + +class FakeControl { + constructor(opts) { + this.options = opts; + } + addTo() {} + onAdd() {} + onRemove() {} + on() {} +} + +import Map from './map'; +import LngLat from './lng_lat'; +import LngLatBounds from './lng_lat_bounds'; +import globals from './globals'; +import Marker from './marker'; +import Popup from './popup'; + +export default { + ...globals, + Map, + LngLat, + LngLatBounds, + Marker, + Popup, + NavigationControl: FakeControl, + ScaleControl: FakeControl, + AttributionControl: FakeControl, + GeolocateControl: FakeControl, + FullscreenControl: FakeControl +}; diff --git a/test/src/utils/mapbox-gl-mock/lng_lat.js b/test/src/utils/mapbox-gl-mock/lng_lat.js new file mode 100644 index 00000000..7c37b2e6 --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/lng_lat.js @@ -0,0 +1,79 @@ +// Generated with +// flow-remove-types ./node_modules/mapbox-gl/src/geo/lng_lat.js + +import {wrap} from './util.js'; +import LngLatBounds from './lng_lat_bounds.js'; + +export const earthRadius = 6371008.8; + +class LngLat { + lng; + lat; + + constructor(lng, lat) { + if (isNaN(lng) || isNaN(lat)) { + throw new Error(`Invalid LngLat object: (${lng}, ${lat})`); + } + this.lng = Number(lng); + this.lat = Number(lat); + if (this.lat > 90 || this.lat < -90) { + throw new Error('Invalid LngLat latitude value: must be between -90 and 90'); + } + } + + wrap() { + return new LngLat(wrap(this.lng, -180, 180), this.lat); + } + + toArray() { + return [this.lng, this.lat]; + } + + toString() { + return `LngLat(${this.lng}, ${this.lat})`; + } + + distanceTo(lngLat) { + const rad = Math.PI / 180; + const lat1 = this.lat * rad; + const lat2 = lngLat.lat * rad; + const a = + Math.sin(lat1) * Math.sin(lat2) + + Math.cos(lat1) * Math.cos(lat2) * Math.cos((lngLat.lng - this.lng) * rad); + + const maxMeters = earthRadius * Math.acos(Math.min(a, 1)); + return maxMeters; + } + + toBounds(radius = 0) { + const earthCircumferenceInMetersAtEquator = 40075017; + const latAccuracy = (360 * radius) / earthCircumferenceInMetersAtEquator; + const lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat); + + return new LngLatBounds( + new LngLat(this.lng - lngAccuracy, this.lat - latAccuracy), + new LngLat(this.lng + lngAccuracy, this.lat + latAccuracy) + ); + } + + static convert(input) { + if (input instanceof LngLat) { + return input; + } + if (Array.isArray(input) && (input.length === 2 || input.length === 3)) { + return new LngLat(Number(input[0]), Number(input[1])); + } + if (!Array.isArray(input) && typeof input === 'object' && input !== null) { + return new LngLat( + // flow can't refine this to have one of lng or lat, so we have to cast to any + Number('lng' in input ? input.lng : input.lon), + Number(input.lat) + ); + } + throw new Error( + '`LngLatLike` argument must be specified as a LngLat instance, an object {lng: , lat: }, an object {lon: , lat: }, or an array of [, ]' + ); + } +} + +export default LngLat; diff --git a/test/src/utils/mapbox-gl-mock/lng_lat_bounds.js b/test/src/utils/mapbox-gl-mock/lng_lat_bounds.js new file mode 100644 index 00000000..a99b8ff3 --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/lng_lat_bounds.js @@ -0,0 +1,139 @@ +// Generated with +// flow-remove-types ./node_modules/mapbox-gl/src/geo/lng_lat_bounds.js + +import LngLat from './lng_lat.js'; + +class LngLatBounds { + _ne; + _sw; + + // This constructor is too flexible to type. It should not be so flexible. + constructor(sw, ne) { + if (!sw) { + // noop + } else if (ne) { + this.setSouthWest(sw).setNorthEast(ne); + } else if (sw.length === 4) { + this.setSouthWest([sw[0], sw[1]]).setNorthEast([sw[2], sw[3]]); + } else { + this.setSouthWest(sw[0]).setNorthEast(sw[1]); + } + } + + setNorthEast(ne) { + this._ne = ne instanceof LngLat ? new LngLat(ne.lng, ne.lat) : LngLat.convert(ne); + return this; + } + + setSouthWest(sw) { + this._sw = sw instanceof LngLat ? new LngLat(sw.lng, sw.lat) : LngLat.convert(sw); + return this; + } + + extend(obj) { + const sw = this._sw; + const ne = this._ne; + let ne2; + let sw2; + + if (obj instanceof LngLat) { + sw2 = obj; + ne2 = obj; + } else if (obj instanceof LngLatBounds) { + sw2 = obj._sw; + ne2 = obj._ne; + + if (!sw2 || !ne2) return this; + } else { + if (Array.isArray(obj)) { + if (obj.length === 4 || obj.every(Array.isArray)) { + const lngLatBoundsObj = obj; + return this.extend(LngLatBounds.convert(lngLatBoundsObj)); + } + const lngLatObj = obj; + return this.extend(LngLat.convert(lngLatObj)); + } + return this; + } + + if (!sw && !ne) { + this._sw = new LngLat(sw2.lng, sw2.lat); + this._ne = new LngLat(ne2.lng, ne2.lat); + } else { + sw.lng = Math.min(sw2.lng, sw.lng); + sw.lat = Math.min(sw2.lat, sw.lat); + ne.lng = Math.max(ne2.lng, ne.lng); + ne.lat = Math.max(ne2.lat, ne.lat); + } + + return this; + } + + getCenter() { + return new LngLat((this._sw.lng + this._ne.lng) / 2, (this._sw.lat + this._ne.lat) / 2); + } + + getSouthWest() { + return this._sw; + } + + getNorthEast() { + return this._ne; + } + + getNorthWest() { + return new LngLat(this.getWest(), this.getNorth()); + } + + getSouthEast() { + return new LngLat(this.getEast(), this.getSouth()); + } + + getWest() { + return this._sw.lng; + } + + getSouth() { + return this._sw.lat; + } + + getEast() { + return this._ne.lng; + } + + getNorth() { + return this._ne.lat; + } + + toArray() { + return [this._sw.toArray(), this._ne.toArray()]; + } + + toString() { + return `LngLatBounds(${this._sw.toString()}, ${this._ne.toString()})`; + } + + isEmpty() { + return !(this._sw && this._ne); + } + + contains(lnglat) { + const {lng, lat} = LngLat.convert(lnglat); + + const containsLatitude = this._sw.lat <= lat && lat <= this._ne.lat; + let containsLongitude = this._sw.lng <= lng && lng <= this._ne.lng; + if (this._sw.lng > this._ne.lng) { + // wrapped coordinates + containsLongitude = this._sw.lng >= lng && lng >= this._ne.lng; + } + + return containsLatitude && containsLongitude; + } + + static convert(input) { + if (!input || input instanceof LngLatBounds) return input; + return new LngLatBounds(input); + } +} + +export default LngLatBounds; diff --git a/test/src/utils/mapbox-gl-mock/map.js b/test/src/utils/mapbox-gl-mock/map.js new file mode 100644 index 00000000..544be3d3 --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/map.js @@ -0,0 +1,249 @@ +// Adapted from https://github.com/mapbox/mapbox-gl-js-mock/ +// BSD 3-Clause License +// Copyright (c) 2017, Mapbox +import LngLat from './lng_lat'; +import {Evented, Event} from './evented'; +import TaskQueue from './task_queue'; +import Transform from './transform'; +import {extend, number} from './util'; +import Style from './style'; + +const defaultOptions = { + doubleClickZoom: true +}; + +function functor(x) { + return function () { + return x; + }; +} + +export default class Map extends Evented { + constructor(options) { + super(); + + this.options = extend(options || {}, defaultOptions); + this._events = {}; + this.style = new Style(); + this._isMoving = false; + this._controls = []; + this._markers = []; + this._renderTaskQueue = new TaskQueue(); + + this.transform = new Transform(); + this.transform.center = LngLat.convert(this.options.center || [0, 0]); + this.transform.zoom = this.options.zoom || 0; + this.transform.pitch = this.options.pitch || 0; + this.transform.bearing = this.options.bearing || 0; + + setTimeout(() => { + this.style._loaded = true; + this.fire(new Event('styledata')); + this.fire(new Event('load')); + }, 0); + } + + addControl(control) { + this._controls.push(control); + control.onAdd(this); + } + removeControl(control) { + const i = this._controls.indexOf(control); + if (i >= 0) { + this._controls.splice(i, 1); + control.onRemove(this); + } + } + + _addMarker(marker) { + this._markers.push(marker); + } + _removeMarker(marker) { + const i = this._markers.indexOf(marker); + if (i >= 0) { + this._markers.splice(i, 1); + } + } + + getContainer() { + const container = { + parentNode: container, + appendChild() {}, + removeChild() {}, + getElementsByClassName() { + return [container]; + }, + addEventListener(name, handle) {}, + removeEventListener() {}, + classList: { + add() {}, + remove() {} + } + }; + + return container; + } + + loaded() { + return true; + } + + addSource(name, source) { + this.style.addSource(name, source); + if (source.type === 'geojson') { + const e = { + type: 'data', + sourceDataType: 'metadata', + sourceId: name, + isSourceLoaded: true, + dataType: 'source', + source + }; + this.fire(new Event('data', e)); + } + } + + getSource(name) { + const source = this.style.getSource(name); + if (source) { + return { + getData: () => source.data, + setData: data => { + source.data = data; + if (source.type === 'geojson') { + const e = { + type: 'data', + sourceDataType: 'content', + sourceId: name, + isSourceLoaded: true, + dataType: 'source', + source + }; + this.fire(new Event('data', e)); + } + }, + loadTile() {} + }; + } + return null; + } + + removeSource(name) { + this.style.removeSource(name); + } + + setStyle(style) { + this.style = new Style(); + setTimeout(() => { + this.style._loaded = true; + this.fire(new Event('styledata')); + }, 0); + } + + addLayer(layer, before) { + this.style.addLayer(layer, before); + } + moveLayer(layerId, beforeId) {} + removeLayer(layerId) { + this.style.removeLayer(layerId); + } + getLayer(layerId) { + return this.style.getLayer(layerId); + } + setLayoutProperty(layerId, name, value) { + this.style.setLayoutProperty(layerId, name, value); + } + setPaintProperty(layerId, name, value) { + this.style.setPaintProperty(layerId, name, value); + } + + doubleClickZoom = { + disable() {}, + enable() {} + }; + + boxZoom = { + disable() {}, + enable() {} + }; + + dragPan = { + disable() {}, + enable() {} + }; + + project() {} + + queryRenderedFeatures(pointOrBox, queryParams) { + return []; + } + + getCenter() { + return this.transform.center; + } + getZoom() { + return this.transform.zoom; + } + getBearing() { + return this.transform.bearing; + } + getPitch() { + return this.transform.pitch; + } + getPadding() { + return this.transform.padding; + } + + easeTo({center: [lng, lat], zoom}) { + const FRAMES = 5; + let f = 0; + + this._isMoving = true; + this.fire(new Event('movestart')); + + const startLng = this.getCenter().lng; + const startLat = this.getCenter().lat; + const startZoom = this.getZoom(); + + const onFrame = () => { + f++; + + this.transform.center = LngLat.convert([ + number(startLng, lng, f / FRAMES), + number(startLat, lat, f / FRAMES) + ]); + this.transform.zoom = number(startZoom, zoom, f / FRAMES); + + this.fire(new Event('move')); + + if (f < FRAMES) { + this._renderTaskQueue.add(onFrame); + this.triggerRepaint(); + } else { + this.fire(new Event('moveend')); + this._isMoving = false; + } + }; + + this._renderTaskQueue.add(onFrame); + this.triggerRepaint(); + } + + isMoving() { + return this._isMoving; + } + + triggerRepaint() { + setTimeout(() => this._render(), 0); + } + + _render() { + this._renderTaskQueue.run(); + this.fire(new Event('render')); + } + + remove() { + this._events = []; + this.sources = []; + } +} diff --git a/test/src/utils/mapbox-gl-mock/marker.js b/test/src/utils/mapbox-gl-mock/marker.js new file mode 100644 index 00000000..349eaae1 --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/marker.js @@ -0,0 +1,78 @@ +import {Evented} from './evented'; +import LngLat from './lng_lat'; + +export default class Marker extends Evented { + constructor(opts) { + super(); + this.options = opts; + + this._map = null; + this._lngLat = null; + this._popup = null; + this._element = opts.element || {}; + } + + addTo(map) { + this._map = map; + map._addMarker(this); + return this; + } + remove() { + this._map._removeMarker(this); + this._map = null; + } + + getElement() { + return this._element; + } + + getLngLat() { + return this._lngLat; + } + setLngLat(value) { + this._lngLat = LngLat.convert(value); + return this; + } + getOffset() { + return this.options.offset; + } + setOffset(value) { + this.options.offset = value; + return this; + } + isDraggable() { + return this.options.draggable; + } + setDraggable(value) { + this.options.draggable = value; + return this; + } + getRotation() { + return this.options.rotation; + } + setRotation(value) { + this.options.rotation = value; + return this; + } + getRotationAlignment() { + return this.options.rotationAlignment; + } + setRotationAlignment(value) { + this.options.rotationAlignment = value; + return this; + } + getPitchAlignment() { + return this.options.pitchAlignment; + } + setPitchAlignment(value) { + this.options.pitchAlignment = value; + return this; + } + getPopup() { + return this._popup; + } + setPopup(value) { + this._popup = value; + return this; + } +} diff --git a/test/src/utils/mapbox-gl-mock/popup.js b/test/src/utils/mapbox-gl-mock/popup.js new file mode 100644 index 00000000..4efd2377 --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/popup.js @@ -0,0 +1,55 @@ +import {Evented} from './evented'; +import LngLat from './lng_lat'; + +export default class Popup extends Evented { + constructor(opts) { + super(); + this.options = opts; + + this._lngLat = null; + this._content = null; + this._classList = new Set(opts.className ? opts.className.trim().split(/\s+/) : []); + } + + addTo(map) { + this._map = map; + map._addMarker(this); + return this; + } + remove() { + this._map._removeMarker(this); + this._map = null; + } + + getLngLat() { + return this._lngLat; + } + setLngLat(value) { + this._lngLat = LngLat.convert(value); + return this; + } + + setText(text) { + this._content = text; + return this; + } + setHTML(html) { + this._content = html; + return this; + } + setDOMContent(htmlNode) { + this._content = htmlNode; + return this; + } + getMaxWidth() { + return this.options.maxWidth; + } + setMaxWidth(value) { + this.options.maxWidth = value; + return this; + } + setOffset(value) { + this.options.offset = value; + return this; + } +} diff --git a/test/src/utils/mapbox-gl-mock/style.js b/test/src/utils/mapbox-gl-mock/style.js new file mode 100644 index 00000000..dcb844f6 --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/style.js @@ -0,0 +1,111 @@ +// Adapted from https://github.com/mapbox/mapbox-gl-js-mock/ +// BSD 3-Clause License +// Copyright (c) 2017, Mapbox + +export default class Style { + constructor() { + this.stylesheet = { + owner: 'mapbox', + id: 'testmap' + }; + + this._loaded = false; + this._light = null; + this._fog = null; + this._terrain = null; + + this._sources = {}; + this._layers = {}; + } + + loaded() { + return this._loaded; + } + + _checkLoaded() { + if (!this._loaded) { + throw new Error('style is not done loading'); + } + } + + setLight(value) { + this._checkLoaded(); + this._light = value; + } + setFog(value) { + this._checkLoaded(); + this._fog = value; + } + setTerrain(value) { + this._checkLoaded(); + this._terrain = value; + } + + addSource(name, source) { + this._checkLoaded(); + if (name in this._sources) { + throw new Error('Source with the same id already exists'); + } + this._sources[name] = source; + } + + getSource(name) { + return this._sources[name]; + } + + removeSource(name) { + this._checkLoaded(); + if (!(name in this._sources)) { + throw new Error('No source with this id'); + } + for (const layerId in this._layers) { + if (this._layers[layerId].source === name) { + throw new Error('Source cannot be removed while layer is using it.'); + } + } + delete this._sources[name]; + } + + addLayer(layer, before) { + this._checkLoaded(); + if (layer.id in this._layers) { + throw new Error('Layer with the same id already exists'); + } + if (!(layer.source in this._sources)) { + throw new Error('Layer source does not exist'); + } + this._layers[layer.id] = layer; + } + + removeLayer(layerId) { + this._checkLoaded(); + if (!(layerId in this._layers)) { + throw new Error('No layer with this id'); + } + delete this._layers[layerId]; + } + + getLayer(layerId) { + return this._layers[layerId]; + } + + setLayoutProperty(layerId, name, value) { + this._checkLoaded(); + const layer = this.getLayer(layerId); + if (!layer) { + throw new Error('No layer with this id'); + } + layer.layout = layer.layout || {}; + layer.layout[name] = value; + } + + setPaintProperty(layerId, name, value) { + this._checkLoaded(); + const layer = this.getLayer(layerId); + if (!layer) { + throw new Error('No layer with this id'); + } + layer.paint = layer.paint || {}; + layer.paint[name] = value; + } +} diff --git a/test/src/utils/mapbox-gl-mock/task_queue.js b/test/src/utils/mapbox-gl-mock/task_queue.js new file mode 100644 index 00000000..a0621032 --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/task_queue.js @@ -0,0 +1,58 @@ +// Generated with +// flow-remove-types ./node_modules/mapbox-gl/src/util/task_queue.js + +import assert from 'assert'; + +class TaskQueue { + constructor() { + this._queue = []; + this._id = 0; + this._cleared = false; + this._currentlyRunning = false; + } + + add(callback) { + const id = ++this._id; + const queue = this._queue; + queue.push({callback, id, cancelled: false}); + return id; + } + + remove(id) { + const running = this._currentlyRunning; + const queue = running ? this._queue.concat(running) : this._queue; + for (const task of queue) { + if (task.id === id) { + task.cancelled = true; + return; + } + } + } + + run(timeStamp = 0) { + assert(!this._currentlyRunning); + const queue = (this._currentlyRunning = this._queue); + + // Tasks queued by callbacks in the current queue should be executed + // on the next run, not the current run. + this._queue = []; + + for (const task of queue) { + if (task.cancelled) continue; + task.callback(timeStamp); + if (this._cleared) break; + } + + this._cleared = false; + this._currentlyRunning = false; + } + + clear() { + if (this._currentlyRunning) { + this._cleared = true; + } + this._queue = []; + } +} + +export default TaskQueue; diff --git a/test/src/utils/transform.js b/test/src/utils/mapbox-gl-mock/transform.js similarity index 59% rename from test/src/utils/transform.js rename to test/src/utils/mapbox-gl-mock/transform.js index c02b23d5..368819f8 100644 --- a/test/src/utils/transform.js +++ b/test/src/utils/mapbox-gl-mock/transform.js @@ -1,55 +1,7 @@ -function wrap(n, min, max) { - const d = max - min; - const w = ((((n - min) % d) + d) % d) + min; - return w === min ? max : w; -} +import {wrap, clamp} from './util'; -function clamp(n, min, max) { - return Math.min(max, Math.max(min, n)); -} +import EdgeInsets from './edge_insets'; -/** - * A dummy class that simulates mapbox's EdgeInsets - */ -class EdgeInsets { - constructor(top = 0, bottom = 0, left = 0, right = 0) { - this.top = top; - this.bottom = bottom; - this.left = left; - this.right = right; - } - - set(target) { - if (target.top !== null) this.top = target.top; - if (target.bottom !== null) this.bottom = target.bottom; - if (target.left !== null) this.left = target.left; - if (target.right !== null) this.right = target.right; - - return this; - } - - equals(other) { - return ( - this.top === other.top && - this.bottom === other.bottom && - this.left === other.left && - this.right === other.right - ); - } - - toJSON() { - return { - top: this.top, - bottom: this.bottom, - left: this.left, - right: this.right - }; - } -} - -/** - * A dummy class that simulates mapbox's Transform - */ export default class Transform { constructor() { this.minZoom = 0; @@ -119,7 +71,17 @@ export default class Transform { set padding(padding) { if (this._edgeInsets.equals(padding)) return; // Update edge-insets inplace - this._edgeInsets.set(padding); + this._edgeInsets.interpolate(this._edgeInsets, padding, 1); + } + + clone() { + const that = new Transform(); + that.center = this.center; + that.zoom = this.zoom; + that.bearing = this.bearing; + that.pitch = this.pitch; + that.padding = this.padding; + return that; } isPaddingEqual(padding) { diff --git a/test/src/utils/mapbox-gl-mock/util.js b/test/src/utils/mapbox-gl-mock/util.js new file mode 100644 index 00000000..54ac8e65 --- /dev/null +++ b/test/src/utils/mapbox-gl-mock/util.js @@ -0,0 +1,25 @@ +// Generated with +// flow-remove-types ./node_modules/mapbox-gl/src/util/util.js + +export function clamp(n, min, max) { + return Math.min(max, Math.max(min, n)); +} + +export function wrap(n, min, max) { + const d = max - min; + const w = ((((n - min) % d) + d) % d) + min; + return w === min ? max : w; +} + +export function extend(dest, ...sources) { + for (const src of sources) { + for (const k in src) { + dest[k] = src[k]; + } + } + return dest; +} + +export function number(a, b, t) { + return a * (1 - t) + b * t; +} diff --git a/test/src/utils/style-utils.spec.js b/test/src/utils/style-utils.spec.js new file mode 100644 index 00000000..2706536c --- /dev/null +++ b/test/src/utils/style-utils.spec.js @@ -0,0 +1,213 @@ +import test from 'tape-promise/tape'; + +import {normalizeStyle} from 'react-map-gl/utils/style-utils'; + +const testStyle = { + version: 8, + name: 'Test', + sources: { + mapbox: { + url: 'mapbox://mapbox.mapbox-streets-v7', + type: 'vector' + } + }, + sprite: 'mapbox://sprites/mapbox/basic-v8', + glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf', + layers: [ + { + id: 'background', + type: 'background', + paint: { + 'background-color': '#dedede' + } + }, + { + id: 'park', + type: 'fill', + source: 'mapbox', + 'source-layer': 'landuse_overlay', + filter: ['==', 'class', 'park'], + paint: { + 'fill-color': '#d2edae', + 'fill-opacity': 0.75 + }, + interactive: true + }, + { + id: 'road', + source: 'mapbox', + 'source-layer': 'road', + layout: { + 'line-cap': 'butt', + 'line-join': 'miter' + }, + filter: ['all', ['==', '$type', 'LineString']], + type: 'line', + paint: { + 'line-color': '#efefef', + 'line-width': { + base: 1.55, + stops: [ + [4, 0.25], + [20, 30] + ] + } + }, + minzoom: 5, + maxzoom: 20, + interactive: true + }, + { + id: 'park-2', + ref: 'park', + paint: { + 'fill-color': '#00f080', + 'fill-opacity': 0.5 + } + }, + { + id: 'road-outline', + ref: 'road', + minzoom: 10, + maxzoom: 12, + paint: { + 'line-color': '#efefef', + 'line-width': { + base: 2, + stops: [ + [4, 0.5], + [20, 40] + ] + } + } + } + ] +}; + +const expectedStyle = { + version: 8, + name: 'Test', + sources: { + mapbox: { + url: 'mapbox://mapbox.mapbox-streets-v7', + type: 'vector' + } + }, + sprite: 'mapbox://sprites/mapbox/basic-v8', + glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf', + layers: [ + { + id: 'background', + type: 'background', + paint: { + 'background-color': '#dedede' + } + }, + { + id: 'park', + type: 'fill', + source: 'mapbox', + 'source-layer': 'landuse_overlay', + filter: ['==', 'class', 'park'], + paint: { + 'fill-color': '#d2edae', + 'fill-opacity': 0.75 + } + }, + { + id: 'road', + source: 'mapbox', + 'source-layer': 'road', + layout: { + 'line-cap': 'butt', + 'line-join': 'miter' + }, + filter: ['all', ['==', '$type', 'LineString']], + type: 'line', + paint: { + 'line-color': '#efefef', + 'line-width': { + base: 1.55, + stops: [ + [4, 0.25], + [20, 30] + ] + } + }, + minzoom: 5, + maxzoom: 20 + }, + { + id: 'park-2', + type: 'fill', + source: 'mapbox', + 'source-layer': 'landuse_overlay', + filter: ['==', 'class', 'park'], + paint: { + 'fill-color': '#00f080', + 'fill-opacity': 0.5 + } + }, + { + id: 'road-outline', + source: 'mapbox', + 'source-layer': 'road', + layout: { + 'line-cap': 'butt', + 'line-join': 'miter' + }, + filter: ['all', ['==', '$type', 'LineString']], + type: 'line', + minzoom: 5, + maxzoom: 20, + paint: { + 'line-color': '#efefef', + 'line-width': { + base: 2, + stops: [ + [4, 0.5], + [20, 40] + ] + } + } + } + ] +}; + +test('normalizeStyle', t => { + // Make sure the style is not mutated + freezeRecursive(testStyle); + + t.is(normalizeStyle(null), null, 'Handles null'); + t.is( + normalizeStyle('mapbox://styles/mapbox/light-v9'), + 'mapbox://styles/mapbox/light-v9', + 'Handles url string' + ); + + let result = normalizeStyle(testStyle); + t.notEqual(result, testStyle, 'style is not mutated'); + t.deepEqual(result, expectedStyle, 'plain object style is normalized'); + + // Immutable-like object + result = normalizeStyle({toJS: () => testStyle}); + t.deepEqual(result, expectedStyle, 'immutable style is normalized'); + + t.end(); +}); + +function freezeRecursive(obj) { + if (!obj) return; + if (typeof obj === 'object') { + if (Array.isArray(obj)) { + for (const el of obj) { + freezeRecursive(el); + } + } else { + for (const key in obj) { + freezeRecursive(obj[key]); + } + } + Object.freeze(obj); + } +} diff --git a/test/src/utils/test-utils.js b/test/src/utils/test-utils.js new file mode 100644 index 00000000..fc8e0416 --- /dev/null +++ b/test/src/utils/test-utils.js @@ -0,0 +1,17 @@ +import * as React from 'react'; + +/* global setTimeout */ +export function sleep(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +export function createPortalMock() { + const reactDom = require('react-dom'); + const createPortal = reactDom.createPortal; + reactDom.createPortal = function mockCreatePortal(content, container) { + return
{content}
; + }; + return () => { + reactDom.createPortal = createPortal; + }; +} diff --git a/test/src/utils/transform.spec.js b/test/src/utils/transform.spec.js index c47e7a61..28876a77 100644 --- a/test/src/utils/transform.spec.js +++ b/test/src/utils/transform.spec.js @@ -1,7 +1,7 @@ import test from 'tape-promise/tape'; import {transformToViewState, applyViewStateToTransform} from 'react-map-gl/utils/transform'; -import Transform from './transform'; +import Transform from './mapbox-gl-mock/transform'; test('applyViewStateToTransform', t => { const tr = new Transform(); diff --git a/webpack.config.js b/webpack.config.js index fb5e513b..c5e04396 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,7 +14,11 @@ module.exports = env => { loader: 'babel-loader', exclude: [/node_modules/], options: { - presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'], + presets: [ + ['@babel/preset-env', {targets: 'last 2 chrome versions'}], + '@babel/preset-react', + '@babel/preset-typescript' + ], plugins: ['@babel/plugin-proposal-class-properties'] } } diff --git a/yarn.lock b/yarn.lock index 78ff80d5..64693bdf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3389,9 +3389,9 @@ camelcase@^5.0.0, camelcase@^5.3.1: integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== caniuse-lite@^1.0.30001286: - version "1.0.30001295" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001295.tgz" - integrity sha512-lSP16vcyC0FEy0R4ECc9duSPoKoZy+YkpGkue9G4D81OfPnliopaZrU10+qtPdT8PbGXad/PNx43TIQrOmJZSQ== + version "1.0.30001296" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001296.tgz" + integrity sha512-WfrtPEoNSoeATDlf4y3QvkwiELl9GyPLISV5GejTbbQRtQx4LhsXmc9IQ6XCL2d7UxCyEzToEZNMeqR79OUw8Q== caseless@~0.12.0: version "0.12.0"