@panoramax/web-viewer 3.0.2-develop-a8ea8e60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.dockerignore +6 -0
  2. package/.gitlab-ci.yml +71 -0
  3. package/CHANGELOG.md +428 -0
  4. package/CODE_OF_CONDUCT.md +134 -0
  5. package/Dockerfile +14 -0
  6. package/LICENSE +21 -0
  7. package/README.md +39 -0
  8. package/build/editor.html +1 -0
  9. package/build/index.css +36 -0
  10. package/build/index.css.map +1 -0
  11. package/build/index.html +1 -0
  12. package/build/index.js +25 -0
  13. package/build/index.js.map +1 -0
  14. package/build/map.html +1 -0
  15. package/build/viewer.html +1 -0
  16. package/config/env.js +104 -0
  17. package/config/getHttpsConfig.js +66 -0
  18. package/config/getPackageJson.js +25 -0
  19. package/config/jest/babelTransform.js +29 -0
  20. package/config/jest/cssTransform.js +14 -0
  21. package/config/jest/fileTransform.js +40 -0
  22. package/config/modules.js +134 -0
  23. package/config/paths.js +72 -0
  24. package/config/pnpTs.js +35 -0
  25. package/config/webpack/persistentCache/createEnvironmentHash.js +9 -0
  26. package/config/webpack.config.js +885 -0
  27. package/config/webpackDevServer.config.js +127 -0
  28. package/docs/01_Start.md +149 -0
  29. package/docs/02_Usage.md +828 -0
  30. package/docs/03_URL_settings.md +140 -0
  31. package/docs/04_Advanced_examples.md +214 -0
  32. package/docs/05_Compatibility.md +85 -0
  33. package/docs/09_Develop.md +62 -0
  34. package/docs/90_Releases.md +27 -0
  35. package/docs/images/class_diagram.drawio +129 -0
  36. package/docs/images/class_diagram.jpg +0 -0
  37. package/docs/images/screenshot.jpg +0 -0
  38. package/mkdocs.yml +45 -0
  39. package/package.json +254 -0
  40. package/public/editor.html +54 -0
  41. package/public/favicon.ico +0 -0
  42. package/public/index.html +59 -0
  43. package/public/map.html +53 -0
  44. package/public/viewer.html +67 -0
  45. package/scripts/build.js +217 -0
  46. package/scripts/start.js +176 -0
  47. package/scripts/test.js +52 -0
  48. package/src/Editor.css +37 -0
  49. package/src/Editor.js +359 -0
  50. package/src/StandaloneMap.js +114 -0
  51. package/src/Viewer.css +203 -0
  52. package/src/Viewer.js +1186 -0
  53. package/src/components/CoreView.css +64 -0
  54. package/src/components/CoreView.js +159 -0
  55. package/src/components/Loader.css +56 -0
  56. package/src/components/Loader.js +111 -0
  57. package/src/components/Map.css +65 -0
  58. package/src/components/Map.js +841 -0
  59. package/src/components/Photo.css +36 -0
  60. package/src/components/Photo.js +687 -0
  61. package/src/img/arrow_360.svg +14 -0
  62. package/src/img/arrow_flat.svg +11 -0
  63. package/src/img/arrow_triangle.svg +10 -0
  64. package/src/img/arrow_turn.svg +9 -0
  65. package/src/img/bg_aerial.jpg +0 -0
  66. package/src/img/bg_streets.jpg +0 -0
  67. package/src/img/loader_base.jpg +0 -0
  68. package/src/img/loader_hd.jpg +0 -0
  69. package/src/img/logo_dead.svg +91 -0
  70. package/src/img/marker.svg +17 -0
  71. package/src/img/marker_blue.svg +20 -0
  72. package/src/img/switch_big.svg +44 -0
  73. package/src/img/switch_mini.svg +48 -0
  74. package/src/index.js +10 -0
  75. package/src/translations/de.json +163 -0
  76. package/src/translations/en.json +164 -0
  77. package/src/translations/eo.json +6 -0
  78. package/src/translations/es.json +164 -0
  79. package/src/translations/fi.json +1 -0
  80. package/src/translations/fr.json +164 -0
  81. package/src/translations/hu.json +133 -0
  82. package/src/translations/nl.json +1 -0
  83. package/src/translations/zh_Hant.json +136 -0
  84. package/src/utils/API.js +709 -0
  85. package/src/utils/Exif.js +198 -0
  86. package/src/utils/I18n.js +75 -0
  87. package/src/utils/Map.js +382 -0
  88. package/src/utils/PhotoAdapter.js +45 -0
  89. package/src/utils/Utils.js +568 -0
  90. package/src/utils/Widgets.js +477 -0
  91. package/src/viewer/URLHash.js +334 -0
  92. package/src/viewer/Widgets.css +711 -0
  93. package/src/viewer/Widgets.js +1196 -0
  94. package/tests/Editor.test.js +125 -0
  95. package/tests/StandaloneMap.test.js +44 -0
  96. package/tests/Viewer.test.js +363 -0
  97. package/tests/__snapshots__/Editor.test.js.snap +300 -0
  98. package/tests/__snapshots__/StandaloneMap.test.js.snap +30 -0
  99. package/tests/__snapshots__/Viewer.test.js.snap +195 -0
  100. package/tests/components/CoreView.test.js +91 -0
  101. package/tests/components/Loader.test.js +38 -0
  102. package/tests/components/Map.test.js +230 -0
  103. package/tests/components/Photo.test.js +335 -0
  104. package/tests/components/__snapshots__/Loader.test.js.snap +15 -0
  105. package/tests/components/__snapshots__/Map.test.js.snap +767 -0
  106. package/tests/components/__snapshots__/Photo.test.js.snap +205 -0
  107. package/tests/data/Map_geocoder_ban.json +36 -0
  108. package/tests/data/Map_geocoder_nominatim.json +56 -0
  109. package/tests/data/Viewer_pictures_1.json +148 -0
  110. package/tests/setupTests.js +5 -0
  111. package/tests/utils/API.test.js +906 -0
  112. package/tests/utils/Exif.test.js +124 -0
  113. package/tests/utils/I18n.test.js +28 -0
  114. package/tests/utils/Map.test.js +105 -0
  115. package/tests/utils/Utils.test.js +300 -0
  116. package/tests/utils/Widgets.test.js +107 -0
  117. package/tests/utils/__snapshots__/API.test.js.snap +132 -0
  118. package/tests/utils/__snapshots__/Exif.test.js.snap +43 -0
  119. package/tests/utils/__snapshots__/Map.test.js.snap +48 -0
  120. package/tests/utils/__snapshots__/Utils.test.js.snap +41 -0
  121. package/tests/utils/__snapshots__/Widgets.test.js.snap +44 -0
  122. package/tests/viewer/URLHash.test.js +537 -0
  123. package/tests/viewer/Widgets.test.js +127 -0
  124. package/tests/viewer/__snapshots__/URLHash.test.js.snap +98 -0
  125. package/tests/viewer/__snapshots__/Widgets.test.js.snap +393 -0
@@ -0,0 +1,906 @@
1
+ import API from "../../src/utils/API";
2
+
3
+ jest.mock("maplibre-gl", () => ({
4
+ addProtocol: jest.fn(),
5
+ }));
6
+
7
+ const ENDPOINT = "https://panoramax.ign.fr/api";
8
+ const VALID_LANDING = {
9
+ stac_version: "1.0.0",
10
+ links: [
11
+ { "rel": "data", "type": "application/rss+xml", "href": ENDPOINT+"/collections?format=rss" },
12
+ { "rel": "data", "type": "application/json", "href": ENDPOINT+"/collections" },
13
+ { "rel": "search", "type": "application/geo+json", "href": ENDPOINT+"/search" },
14
+ { "rel": "xyz", "type": "application/vnd.mapbox-vector-tile", "href": ENDPOINT+"/map/{z}/{x}/{y}.mvt" },
15
+ { "rel": "collection-preview", "type": "image/jpeg", "href": ENDPOINT+"/collections/{id}/thumb.jpg" },
16
+ { "rel": "item-preview", "type": "image/jpeg", "href": ENDPOINT+"/pictures/{id}/thumb.jpg" },
17
+ { "rel": "report", "type": "application/json", "href": ENDPOINT+"/reports" },
18
+ ],
19
+ "extent": {
20
+ "spatial": {
21
+ "bbox": [[-0.586, 0, 6.690, 49.055]]
22
+ },
23
+ "temporal": {
24
+ "interval": [[ "2019-08-18T14:11:29+00:00", "2023-05-30T18:16:21.167000+00:00" ]]
25
+ }
26
+ }
27
+ };
28
+ const LANDING_NO_PREVIEW = {
29
+ stac_version: "1.0.0",
30
+ links: [
31
+ { "rel": "data", "type": "application/json", "href": ENDPOINT+"/collections" },
32
+ { "rel": "search", "type": "application/geo+json", "href": ENDPOINT+"/search" },
33
+ ]
34
+ };
35
+
36
+ describe("constructor", () => {
37
+ // Mock landing fetch
38
+ global.fetch = jest.fn(() => Promise.resolve({
39
+ json: () => Promise.resolve(VALID_LANDING)
40
+ }));
41
+
42
+ it("works with valid endpoint", () => {
43
+ const api = new API(ENDPOINT, { skipReadLanding: true });
44
+ expect(api._endpoint).toBe(ENDPOINT);
45
+ });
46
+
47
+ it("works with relative path", () => {
48
+ const api = new API("/api", { skipReadLanding: true });
49
+ expect(api._endpoint).toBe("http://localhost/api");
50
+ });
51
+
52
+ it("handles tiles overrides", () => {
53
+ // Mock landing fetch
54
+ global.fetch = jest.fn(() => Promise.resolve({
55
+ json: () => Promise.resolve(VALID_LANDING)
56
+ }));
57
+
58
+ const api = new API(ENDPOINT, { tiles: "https://my.custom.tiles/" });
59
+ return api.onceReady().then(() => {
60
+ expect(api._endpoint).toBe(ENDPOINT);
61
+ expect(api._endpoints.tiles).toBe("https://my.custom.tiles/");
62
+ });
63
+ });
64
+
65
+ it("fails if endpoint is invalid", () => {
66
+ expect(() => new API("not an url")).toThrow("endpoint parameter is not a valid URL: not an url");
67
+ });
68
+
69
+ it("fails if endpoint is empty", () => {
70
+ expect(() => new API()).toThrow("endpoint parameter is empty or not a valid string");
71
+ });
72
+
73
+ it("accepts fetch options", () => {
74
+ const api = new API("/api", { skipReadLanding: true, fetch: { bla: "bla" } });
75
+ expect(api._getFetchOptions()).toEqual({ bla: "bla" });
76
+ });
77
+ });
78
+
79
+ describe("onceReady", () => {
80
+ it("works if API is ready", async () => {
81
+ // Mock landing fetch
82
+ global.fetch = jest.fn(() => Promise.resolve({
83
+ json: () => Promise.resolve(VALID_LANDING)
84
+ }));
85
+
86
+ const api = new API(ENDPOINT);
87
+ const res = await api.onceReady();
88
+ expect(res).toBe("API is ready");
89
+
90
+ // Also work after initial promise resolve
91
+ const res2 = await api.onceReady();
92
+ expect(res2).toBe("API is ready");
93
+ });
94
+
95
+ it("handles API failures", async () => {
96
+ // Mock landing fetch
97
+ fetch.mockRejectedValueOnce();
98
+ global.console = { error: jest.fn() };
99
+
100
+ const api = new API(ENDPOINT);
101
+ await expect(api.onceReady()).rejects.toBe("Viewer failed to communicate with API");
102
+
103
+ // Also work after initial promise end
104
+ await expect(api.onceReady()).rejects.toBe("Viewer failed to communicate with API");
105
+ });
106
+ });
107
+
108
+ describe("isReady", () => {
109
+ global.console = { warn: jest.fn(), error: jest.fn() };
110
+
111
+ // Randomly fails for no reason
112
+ it.skip("works if API is ready", async () => {
113
+ // Mock landing fetch
114
+ global.fetch = jest.fn(() => Promise.resolve({
115
+ json: () => Promise.resolve(VALID_LANDING)
116
+ }));
117
+
118
+ const api = new API(ENDPOINT);
119
+ await api.onceReady();
120
+ expect(api.isReady()).toBeTruthy();
121
+ });
122
+
123
+ it("works with API failing", async () => {
124
+ // Mock landing fetch
125
+ fetch.mockRejectedValueOnce();
126
+
127
+ const api = new API(ENDPOINT);
128
+ try {
129
+ return await api.onceReady();
130
+ } catch {
131
+ expect(api.isReady()).toBeFalsy();
132
+ }
133
+ });
134
+ });
135
+
136
+ describe("_parseLanding", () => {
137
+ it("handles overrides for tiles URL", () => {
138
+ global.console = { warn: jest.fn() };
139
+ const api = new API (ENDPOINT, { skipReadLanding: true });
140
+ api._parseLanding(VALID_LANDING, { tiles: "https://my.custom.tiles/" });
141
+ expect(api._endpoints.tiles).toBe("https://my.custom.tiles/");
142
+ });
143
+
144
+ it("fails if landing JSON lacks info", () => {
145
+ const api = new API (ENDPOINT, { skipReadLanding: true });
146
+ expect(() => api._parseLanding({})).toThrow("API Landing page doesn't contain 'links' list");
147
+ });
148
+
149
+ it.each([
150
+ ["search", "application/geo+json"],
151
+ ["data", "application/json"],
152
+ ["data", "application/rss+xml"],
153
+ ["xyz", "application/vnd.mapbox-vector-tile"],
154
+ ["xyz-style", "application/json"],
155
+ ["user-xyz", "application/vnd.mapbox-vector-tile"],
156
+ ["user-xyz-style", "application/json"],
157
+ ["user-search", "application/json"],
158
+ ["collection-preview", "image/jpeg"],
159
+ ["item-preview", "image/jpeg"],
160
+ ["report", "application/json"],
161
+ ])("fails if link rel=%s type=%s is invalid", (rel, type) => {
162
+ const api = new API (ENDPOINT, { skipReadLanding: true });
163
+ const landing = {
164
+ stac_version: "1.0.0",
165
+ links: [
166
+ { "rel": rel, "href": "bla", "type": type }
167
+ ]
168
+ };
169
+ try {
170
+ api._parseLanding(landing);
171
+ throw new Error("Should not succeed");
172
+ }
173
+ catch(e) {
174
+ expect(e.message).toMatchSnapshot();
175
+ }
176
+ });
177
+
178
+
179
+ it("fails if API version is not supported", () => {
180
+ const api = new API (ENDPOINT, { skipReadLanding: true });
181
+ const landing = { stac_version: "0.1", links: [] };
182
+ expect(() => api._parseLanding(landing)).toThrow("API is not in a supported STAC version (Panoramax viewer supports only 1.0, API is 0.1)");
183
+ });
184
+
185
+ it("fails if mandatory links are not set", () => {
186
+ const api = new API (ENDPOINT, { skipReadLanding: true });
187
+ const landing = {
188
+ stac_version: "1.0.0",
189
+ links: []
190
+ };
191
+ try {
192
+ api._parseLanding(landing);
193
+ throw new Error("Should not succeed");
194
+ }
195
+ catch(e) {
196
+ expect(e.message).toMatchSnapshot();
197
+ }
198
+ });
199
+ });
200
+
201
+ describe("_loadMapStyles", () => {
202
+ it("works if no background style set", async() => {
203
+ const api = new API(ENDPOINT, { skipReadLanding: true });
204
+ api._parseLanding(VALID_LANDING);
205
+ global.console = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
206
+ await api._loadMapStyles();
207
+ expect(api.mapStyle).toMatchSnapshot();
208
+ });
209
+
210
+ it("loads background style from string", async () => {
211
+ const api = new API(ENDPOINT, { skipReadLanding: true });
212
+ api._parseLanding(LANDING_NO_PREVIEW);
213
+ global.console = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
214
+ global.fetch = () => Promise.resolve({ json: () => ({
215
+ name: "Provider",
216
+ sources: { provider: {} },
217
+ layers: [{id: "provlayer"}],
218
+ })});
219
+ await api._loadMapStyles("https://tiles.provider/style.json");
220
+ expect(api.mapStyle).toMatchSnapshot();
221
+ });
222
+
223
+ it("loads background style from json", async () => {
224
+ const api = new API(ENDPOINT, { skipReadLanding: true });
225
+ api._parseLanding(LANDING_NO_PREVIEW);
226
+ global.console = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
227
+ await api._loadMapStyles({
228
+ name: "Provider",
229
+ sources: { provider: {} },
230
+ layers: [{id: "provlayer"}],
231
+ });
232
+ expect(api.mapStyle).toMatchSnapshot();
233
+ });
234
+
235
+ it("handles default user", async () => {
236
+ const api = new API(ENDPOINT, { skipReadLanding: true });
237
+ api._parseLanding(VALID_LANDING);
238
+ global.console = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
239
+ await api._loadMapStyles(undefined, ["geovisio"]);
240
+ expect(api.mapStyle).toMatchSnapshot();
241
+ });
242
+
243
+ it("handles various users", async () => {
244
+ const api = new API(ENDPOINT, { skipReadLanding: true });
245
+ api._parseLanding({
246
+ stac_version: "1.0.0",
247
+ links: [
248
+ { "rel": "data", "type": "application/json", "href": ENDPOINT+"/collections" },
249
+ { "rel": "search", "type": "application/geo+json", "href": ENDPOINT+"/search" },
250
+ { "rel": "xyz-style", "type": "application/json", "href": ENDPOINT+"/map/style.json" },
251
+ { "rel": "user-xyz-style", "type": "application/json", "href": ENDPOINT+"/users/{userId}/map/style.json" },
252
+ ],
253
+ "extent": {
254
+ "spatial": {
255
+ "bbox": [[-0.586, 0, 6.690, 49.055]]
256
+ },
257
+ "temporal": {
258
+ "interval": [[ "2019-08-18T14:11:29+00:00", "2023-05-30T18:16:21.167000+00:00" ]]
259
+ }
260
+ }
261
+ });
262
+ global.console = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
263
+ global.fetch = (url) => {
264
+ if(url.includes("/users") && url.includes("style.json")) {
265
+ let user = null;
266
+ if(url.includes("/bla/")) { user = "bla"; }
267
+ if(url.includes("/blo/")) { user = "blo"; }
268
+
269
+ return Promise.resolve({ json: () => Promise.resolve({
270
+ sources: { [`provider_${user}`]: {} },
271
+ layers: [{id: `provider_${user}`}],
272
+ }) });
273
+ }
274
+ else if(url === ENDPOINT+"/map/style.json") {
275
+ return Promise.resolve({ json: () => Promise.resolve({
276
+ sources: { [`provider`]: {} },
277
+ layers: [{id: `provider`}],
278
+ }) });
279
+ }
280
+ };
281
+ await api._loadMapStyles(undefined, ["bla", "blo"]);
282
+ expect(api.mapStyle).toMatchSnapshot();
283
+ });
284
+ });
285
+
286
+ describe("_getMapRequestTransform", () => {
287
+ it("does nothing if no tiles enabled", () => {
288
+ const api = new API(ENDPOINT, { skipReadLanding: true });
289
+ api._parseLanding(LANDING_NO_PREVIEW);
290
+ expect(api._getMapRequestTransform()).toBe(undefined);
291
+ });
292
+
293
+ it("does nothing if no fetch options defined", () => {
294
+ const api = new API(ENDPOINT, { skipReadLanding: true });
295
+ api._parseLanding(VALID_LANDING);
296
+ expect(api._getMapRequestTransform()).toBe(undefined);
297
+ });
298
+
299
+ it("returns a function with correct options if fetch options defined", () => {
300
+ const api = new API(ENDPOINT, {
301
+ skipReadLanding: true,
302
+ fetch: {
303
+ credentials: "include",
304
+ headers: { "Accept-Header": "Whatever" }
305
+ }
306
+ });
307
+ api._parseLanding(VALID_LANDING);
308
+
309
+ // With tiles endpoint called
310
+ const res = api._getMapRequestTransform();
311
+ const res1 = res(ENDPOINT+"/map/8/1234/4567.mvt");
312
+ expect(res1).toEqual({
313
+ url: ENDPOINT+"/map/8/1234/4567.mvt",
314
+ credentials: "include",
315
+ headers: { "Accept-Header": "Whatever" }
316
+ });
317
+
318
+ // With external endpoint called
319
+ const res2 = res("https://my-tile-provider.fr/map/1/2/3.mvt");
320
+ expect(res2).toEqual(undefined);
321
+ });
322
+ });
323
+
324
+ describe("getPicturesAroundCoordinatesUrl", () => {
325
+ it("works with valid coordinates", () => {
326
+ const api = new API(ENDPOINT, { skipReadLanding: true });
327
+ api._parseLanding(VALID_LANDING);
328
+ api._isReady = 1;
329
+ expect(api.getPicturesAroundCoordinatesUrl(48.7, -1.25)).toBe(`${ENDPOINT}/search?bbox=-1.2505,48.6995,-1.2495,48.7005`);
330
+ });
331
+
332
+ it("fails if coordinates are invalid", () => {
333
+ const api = new API(ENDPOINT, { skipReadLanding: true });
334
+ api._parseLanding(VALID_LANDING);
335
+ api._isReady = 1;
336
+ expect(() => api.getPicturesAroundCoordinatesUrl()).toThrow("lat and lon parameters should be valid numbers");
337
+ });
338
+ });
339
+
340
+ describe("getPictureMetadataUrl", () => {
341
+ it("works with valid ID", () => {
342
+ const api = new API(ENDPOINT, { skipReadLanding: true });
343
+ api._parseLanding(VALID_LANDING);
344
+ api._isReady = 1;
345
+ expect(api.getPictureMetadataUrl("whatever-id")).toBe(`${ENDPOINT}/search?ids=whatever-id`);
346
+ });
347
+
348
+ it("works with valid ID and sequence", () => {
349
+ const api = new API(ENDPOINT, { skipReadLanding: true });
350
+ api._parseLanding(VALID_LANDING);
351
+ api._isReady = 1;
352
+ expect(api.getPictureMetadataUrl("whatever-id", "my-sequence")).toBe(`${ENDPOINT}/collections/my-sequence/items/whatever-id`);
353
+ });
354
+
355
+ it("fails if picId is invalid", () => {
356
+ const api = new API(ENDPOINT, { skipReadLanding: true });
357
+ api._parseLanding(VALID_LANDING);
358
+ api._isReady = 1;
359
+ expect(() => api.getPictureMetadataUrl()).toThrow("id should be a valid picture unique identifier");
360
+ });
361
+ });
362
+
363
+ describe("getMapStyle", () => {
364
+ it("sends ready mapstyle", () => {
365
+ const api = new API(ENDPOINT, { skipReadLanding: true });
366
+ api._parseLanding(VALID_LANDING);
367
+ api._isReady = 1;
368
+ api.mapStyle = {name: "Ready"};
369
+ expect(api.getMapStyle()).toBe(api.mapStyle);
370
+ });
371
+
372
+ it("loads style from endpoint", async () => {
373
+ const api = new API(ENDPOINT, { skipReadLanding: true });
374
+ api._parseLanding({
375
+ stac_version: "1.0.0",
376
+ links: [
377
+ { "rel": "data", "type": "application/json", "href": ENDPOINT+"/collections" },
378
+ { "rel": "search", "type": "application/geo+json", "href": ENDPOINT+"/search" },
379
+ { "rel": "xyz-style", "type": "application/json", "href": ENDPOINT+"/map/style.json" },
380
+ ],
381
+ "extent": {
382
+ "spatial": {
383
+ "bbox": [[-0.586, 0, 6.690, 49.055]]
384
+ },
385
+ "temporal": {
386
+ "interval": [[ "2019-08-18T14:11:29+00:00", "2023-05-30T18:16:21.167000+00:00" ]]
387
+ }
388
+ }
389
+ });
390
+ global.fetch = jest.fn(() => Promise.resolve({
391
+ json: () => ({ name: "Ready" })
392
+ }));
393
+ const res = await api.getMapStyle();
394
+ expect(res).toStrictEqual({ name: "Ready" });
395
+ });
396
+
397
+ it("creates style from tiles endpoint", async () => {
398
+ const api = new API(ENDPOINT, { skipReadLanding: true });
399
+ api._parseLanding(VALID_LANDING);
400
+ const res = await api.getMapStyle();
401
+ expect(res).toStrictEqual({
402
+ "version": 8,
403
+ "sources": {
404
+ "geovisio": {
405
+ "type": "vector",
406
+ "tiles": [ ENDPOINT+"/map/{z}/{x}/{y}.mvt" ],
407
+ "minzoom": 0,
408
+ "maxzoom": 15
409
+ }
410
+ }
411
+ });
412
+ });
413
+
414
+ it("fallbacks to /map route if any", async () => {
415
+ const api = new API(ENDPOINT, { skipReadLanding: true });
416
+ api._parseLanding(LANDING_NO_PREVIEW);
417
+ global.console = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
418
+ global.fetch = jest.fn(() => Promise.resolve());
419
+ const res = await api.getMapStyle();
420
+ expect(res).toStrictEqual({
421
+ "version": 8,
422
+ "sources": {
423
+ "geovisio": {
424
+ "type": "vector",
425
+ "tiles": [ ENDPOINT+"/map/{z}/{x}/{y}.mvt" ],
426
+ "minzoom": 0,
427
+ "maxzoom": 15
428
+ }
429
+ }
430
+ });
431
+ });
432
+
433
+ it("fails if no fallback /map route", async () => {
434
+ const api = new API(ENDPOINT, { skipReadLanding: true });
435
+ api._parseLanding(LANDING_NO_PREVIEW);
436
+ global.console = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
437
+ global.fetch = jest.fn(() => Promise.reject());
438
+ await expect(async () => await api.getMapStyle()).rejects.toEqual(new Error("API doesn't offer a vector tiles endpoint"));
439
+ });
440
+ });
441
+
442
+ describe("getUserMapStyle", () => {
443
+ it("fails if not ready", async () => {
444
+ const api = new API(ENDPOINT, { skipReadLanding: true });
445
+ await expect(async () => await api.getUserMapStyle("bla")).rejects.toEqual(new Error("API is not ready to use"));
446
+ });
447
+
448
+ it("fails if no userId", async () => {
449
+ const api = new API(ENDPOINT, { skipReadLanding: true });
450
+ api._isReady = 1;
451
+ await expect(async () => await api.getUserMapStyle()).rejects.toEqual(new Error("Parameter userId is empty"));
452
+ });
453
+
454
+ it("loads style from endpoint", async () => {
455
+ const api = new API(ENDPOINT, { skipReadLanding: true });
456
+ api._parseLanding({
457
+ stac_version: "1.0.0",
458
+ links: [
459
+ { "rel": "data", "type": "application/json", "href": ENDPOINT+"/collections" },
460
+ { "rel": "search", "type": "application/geo+json", "href": ENDPOINT+"/search" },
461
+ { "rel": "user-xyz-style", "type": "application/json", "href": ENDPOINT+"/users/{userId}/map/style.json" },
462
+ ],
463
+ "extent": {
464
+ "spatial": {
465
+ "bbox": [[-0.586, 0, 6.690, 49.055]]
466
+ },
467
+ "temporal": {
468
+ "interval": [[ "2019-08-18T14:11:29+00:00", "2023-05-30T18:16:21.167000+00:00" ]]
469
+ }
470
+ }
471
+ });
472
+ api._isReady = 1;
473
+ global.fetch = (url) => {
474
+ expect(url).toBe(ENDPOINT+"/users/bla/map/style.json");
475
+ return Promise.resolve({ json: () => Promise.resolve({ name: "Ready" }) });
476
+ };
477
+ const res = await api.getUserMapStyle("bla");
478
+ expect(res).toStrictEqual({ name: "Ready" });
479
+ });
480
+
481
+ it("creates style from tiles endpoint", async () => {
482
+ const api = new API(ENDPOINT, { skipReadLanding: true });
483
+ api._parseLanding({
484
+ stac_version: "1.0.0",
485
+ links: [
486
+ { "rel": "data", "type": "application/json", "href": ENDPOINT+"/collections" },
487
+ { "rel": "search", "type": "application/geo+json", "href": ENDPOINT+"/search" },
488
+ { "rel": "user-xyz", "type": "application/vnd.mapbox-vector-tile", "href": ENDPOINT+"/users/{userId}/map/{z}/{x}/{y}.mvt" },
489
+ ],
490
+ "extent": {
491
+ "spatial": {
492
+ "bbox": [[-0.586, 0, 6.690, 49.055]]
493
+ },
494
+ "temporal": {
495
+ "interval": [[ "2019-08-18T14:11:29+00:00", "2023-05-30T18:16:21.167000+00:00" ]]
496
+ }
497
+ }
498
+ });
499
+ api._isReady = 1;
500
+ const res = await api.getUserMapStyle("bla");
501
+ expect(res).toStrictEqual({
502
+ "version": 8,
503
+ "sources": {
504
+ "geovisio_bla": {
505
+ "type": "vector",
506
+ "tiles": [ ENDPOINT+"/users/bla/map/{z}/{x}/{y}.mvt" ],
507
+ "minzoom": 0,
508
+ "maxzoom": 15
509
+ }
510
+ }
511
+ });
512
+ });
513
+
514
+ it("fails if no style found", async () => {
515
+ const api = new API(ENDPOINT, { skipReadLanding: true });
516
+ api._parseLanding(LANDING_NO_PREVIEW);
517
+ api._isReady = 1;
518
+ await expect(async () => await api.getUserMapStyle("bla")).rejects.toEqual(new Error("API doesn't offer map style for specific user"));
519
+ });
520
+ });
521
+
522
+ describe("findThumbnailInPictureFeature", () => {
523
+ it("works if a thumbnail exists", () => {
524
+ const api = new API(ENDPOINT, { skipReadLanding: true });
525
+ api._parseLanding(VALID_LANDING);
526
+ api._isReady = 1;
527
+ const res = api.findThumbnailInPictureFeature({
528
+ assets: {
529
+ t: {
530
+ roles: ["thumbnail"],
531
+ type: "image/jpeg",
532
+ href: "https://geovisio.fr/thumb.jpg"
533
+ }
534
+ }
535
+ });
536
+ expect(res).toEqual("https://geovisio.fr/thumb.jpg");
537
+ });
538
+
539
+ it("works if a visual exists", () => {
540
+ const api = new API(ENDPOINT, { skipReadLanding: true });
541
+ api._parseLanding(VALID_LANDING);
542
+ api._isReady = 1;
543
+ const res = api.findThumbnailInPictureFeature({
544
+ assets: {
545
+ t: {
546
+ roles: ["visual"],
547
+ type: "image/jpeg",
548
+ href: "https://geovisio.fr/thumb.jpg"
549
+ }
550
+ }
551
+ });
552
+ expect(res).toEqual("https://geovisio.fr/thumb.jpg");
553
+ });
554
+
555
+ it("works if no thumbnail is found", () => {
556
+ const api = new API(ENDPOINT, { skipReadLanding: true });
557
+ api._parseLanding(VALID_LANDING);
558
+ api._isReady = 1;
559
+ const res = api.findThumbnailInPictureFeature({});
560
+ expect(res).toBe(null);
561
+ });
562
+ });
563
+
564
+ describe("getPictureThumbnailURLForSequence", () => {
565
+ it("works with a collection-preview endpoint", () => {
566
+ const api = new API(ENDPOINT, { skipReadLanding: true });
567
+ api._parseLanding(VALID_LANDING);
568
+ api._isReady = 1;
569
+ return api.getPictureThumbnailURLForSequence("12345").then(url => {
570
+ expect(url).toBe(ENDPOINT+"/collections/12345/thumb.jpg");
571
+ });
572
+ });
573
+
574
+ it("works if a preview is defined in sequence metadata", () => {
575
+ const api = new API(ENDPOINT, { skipReadLanding: true });
576
+ api._parseLanding(LANDING_NO_PREVIEW);
577
+ api._isReady = 1;
578
+ const seq = {
579
+ links: [
580
+ { "type": "image/jpeg", "rel": "preview", "href": "https://geovisio.fr/preview/thumb.jpg" }
581
+ ]
582
+ };
583
+ return api.getPictureThumbnailURLForSequence("12345", seq).then(url => {
584
+ expect(url).toBe("https://geovisio.fr/preview/thumb.jpg");
585
+ });
586
+ });
587
+
588
+
589
+ it("works with an existing sequence", () => {
590
+ const resPicId = "cbfc3add-8173-4464-98c8-de2a43c6a50f";
591
+ const thumbUrl = "http://my.custom.api/pic/thumb.jpg";
592
+ // Mock API search
593
+ global.fetch = jest.fn(() => Promise.resolve({
594
+ json: () => Promise.resolve({
595
+ features: [ {
596
+ "id": resPicId,
597
+ "assets": {
598
+ "thumb": {
599
+ "href": thumbUrl,
600
+ "roles": ["thumbnail"],
601
+ "type": "image/jpeg"
602
+ }
603
+ }
604
+ }]
605
+ })
606
+ }));
607
+
608
+ const api = new API(ENDPOINT, { skipReadLanding: true });
609
+ api._parseLanding(LANDING_NO_PREVIEW);
610
+ api._isReady = 1;
611
+ return api.getPictureThumbnailURLForSequence("208b981a-262e-4966-97b6-98ee0ceb8df0").then(url => {
612
+ expect(url).toBe(thumbUrl);
613
+ });
614
+ });
615
+
616
+ it("works with no results", () => {
617
+ // Mock API search
618
+ global.fetch = jest.fn(() => Promise.resolve({
619
+ json: () => Promise.resolve({
620
+ features: []
621
+ })
622
+ }));
623
+
624
+ const api = new API(ENDPOINT, { skipReadLanding: true });
625
+ api._parseLanding(LANDING_NO_PREVIEW);
626
+ api._isReady = 1;
627
+ return api.getPictureThumbnailURLForSequence("208b981a-262e-4966-97b6-98ee0ceb8df0").then(url => {
628
+ expect(url).toBe(null);
629
+ });
630
+ });
631
+ });
632
+
633
+ describe("getPictureThumbnailURL", () => {
634
+ it("works with a item-preview endpoint", () => {
635
+ const api = new API(ENDPOINT, { skipReadLanding: true });
636
+ api._parseLanding(VALID_LANDING);
637
+ api._isReady = 1;
638
+ return api.getPictureThumbnailURL("12345").then(url => {
639
+ expect(url).toBe(ENDPOINT+"/pictures/12345/thumb.jpg");
640
+ });
641
+ });
642
+
643
+ it("works with picture and sequence ID defined", () => {
644
+ const api = new API(ENDPOINT, { skipReadLanding: true });
645
+ api._parseLanding(LANDING_NO_PREVIEW);
646
+ api._isReady = 1;
647
+
648
+ // Mock API search
649
+ global.fetch = jest.fn(() => Promise.resolve({
650
+ json: () => Promise.resolve({
651
+ "assets": {
652
+ "thumb": {
653
+ "href": ENDPOINT+"/pictures/pic1/thumb.jpg",
654
+ "roles": ["thumbnail"],
655
+ "type": "image/jpeg"
656
+ }
657
+ }
658
+ })
659
+ }));
660
+
661
+ return api.getPictureThumbnailURL("pic1", "seq1").then(url => {
662
+ expect(url).toBe(ENDPOINT+"/pictures/pic1/thumb.jpg");
663
+ });
664
+ });
665
+
666
+ it("works with picture and sequence ID defined, but no thumb found", () => {
667
+ const api = new API(ENDPOINT, { skipReadLanding: true });
668
+ api._parseLanding(LANDING_NO_PREVIEW);
669
+ api._isReady = 1;
670
+
671
+ // Mock API search
672
+ global.fetch = jest.fn(() => Promise.resolve({
673
+ json: () => Promise.resolve({})
674
+ }));
675
+
676
+ return api.getPictureThumbnailURL("pic1", "seq1").then(url => {
677
+ expect(url).toBe(null);
678
+ });
679
+ });
680
+
681
+ it("works with picture ID defined", () => {
682
+ const api = new API(ENDPOINT, { skipReadLanding: true });
683
+ api._parseLanding(LANDING_NO_PREVIEW);
684
+ api._isReady = 1;
685
+
686
+ // Mock API search
687
+ global.fetch = jest.fn(() => Promise.resolve({
688
+ json: () => Promise.resolve({
689
+ features: [{
690
+ "assets": {
691
+ "thumb": {
692
+ "href": ENDPOINT+"/pictures/pic1/thumb.jpg",
693
+ "roles": ["thumbnail"],
694
+ "type": "image/jpeg"
695
+ }
696
+ }
697
+ }]
698
+ })
699
+ }));
700
+
701
+ return api.getPictureThumbnailURL("pic1").then(url => {
702
+ expect(url).toBe(ENDPOINT+"/pictures/pic1/thumb.jpg");
703
+ });
704
+ });
705
+
706
+ it("works with picture ID defined but no results", () => {
707
+ const api = new API(ENDPOINT, { skipReadLanding: true });
708
+ api._parseLanding(LANDING_NO_PREVIEW);
709
+ api._isReady = 1;
710
+
711
+ // Mock API search
712
+ global.fetch = jest.fn(() => Promise.resolve({
713
+ json: () => Promise.resolve({
714
+ features: []
715
+ })
716
+ }));
717
+
718
+ return api.getPictureThumbnailURL("pic1").then(url => {
719
+ expect(url).toBe(null);
720
+ });
721
+ });
722
+ });
723
+
724
+ describe("getRSSURL", () => {
725
+ it("works without RSS", () => {
726
+ const api = new API(ENDPOINT, { skipReadLanding: true });
727
+ api._parseLanding(LANDING_NO_PREVIEW);
728
+ api._isReady = 1;
729
+ expect(api.getRSSURL()).toBeNull();
730
+ });
731
+
732
+ it("works with RSS and no bbox", () => {
733
+ const api = new API(ENDPOINT, { skipReadLanding: true });
734
+ api._parseLanding(VALID_LANDING);
735
+ api._isReady = 1;
736
+ expect(api.getRSSURL()).toBe(ENDPOINT+"/collections?format=rss");
737
+ });
738
+
739
+ it("works with RSS and bbox with query string", () => {
740
+ const api = new API(ENDPOINT, { skipReadLanding: true });
741
+ api._parseLanding(VALID_LANDING);
742
+ api._isReady = 1;
743
+ const bbox = {
744
+ getSouth: () => -1.7,
745
+ getNorth: () => -1.6,
746
+ getWest: () => 47.1,
747
+ getEast: () => 48.2
748
+ };
749
+ expect(api.getRSSURL(bbox)).toBe(ENDPOINT+"/collections?format=rss&bbox=47.1,-1.7,48.2,-1.6");
750
+ });
751
+
752
+ it("works with RSS and bbox without query string", () => {
753
+ const api = new API(ENDPOINT, { skipReadLanding: true });
754
+ api._parseLanding({
755
+ stac_version: "1.0.0",
756
+ links: [
757
+ { "rel": "data", "type": "application/json", "href": ENDPOINT+"/collections" },
758
+ { "rel": "search", "type": "application/geo+json", "href": ENDPOINT+"/search" },
759
+ { "rel": "data", "href": ENDPOINT+"/collections", "type": "application/rss+xml" }
760
+ ]
761
+ });
762
+ api._isReady = 1;
763
+ const bbox = {
764
+ getSouth: () => -1.7,
765
+ getNorth: () => -1.6,
766
+ getWest: () => 47.1,
767
+ getEast: () => 48.2
768
+ };
769
+ expect(api.getRSSURL(bbox)).toBe(ENDPOINT+"/collections?bbox=47.1,-1.7,48.2,-1.6");
770
+ });
771
+ });
772
+
773
+ describe("getSequenceMetadataUrl", () => {
774
+ it("works", () => {
775
+ const api = new API(ENDPOINT, { skipReadLanding: true });
776
+ api._parseLanding(VALID_LANDING);
777
+ api._isReady = 1;
778
+ expect(api.getSequenceMetadataUrl("blabla")).toBe(ENDPOINT+"/collections/blabla");
779
+ });
780
+ });
781
+
782
+ describe("getDataBbox", () => {
783
+ it("works with landing spatial extent defined", () => {
784
+ const api = new API(ENDPOINT, { skipReadLanding: true });
785
+ api._parseLanding(VALID_LANDING);
786
+ api._isReady = 1;
787
+ expect(api.getDataBbox()).toEqual([[-0.586, 0], [6.690, 49.055]]);
788
+ });
789
+
790
+ it("works with no landing spatial extent defined", () => {
791
+ const api = new API(ENDPOINT, { skipReadLanding: true });
792
+ api._parseLanding(LANDING_NO_PREVIEW);
793
+ api._isReady = 1;
794
+ expect(api.getDataBbox()).toBe(null);
795
+ });
796
+ });
797
+
798
+ describe("sendReport", () => {
799
+ let api;
800
+
801
+ beforeEach(() => {
802
+ api = new API(ENDPOINT, { skipReadLanding: true });
803
+ api._parseLanding(VALID_LANDING);
804
+ api.isReady = () => true;
805
+ });
806
+
807
+ it("throws an error if API is not ready", () => {
808
+ api.isReady = () => false;
809
+ expect(() => api.sendReport({})).toThrow("API is not ready to use");
810
+ });
811
+
812
+ it("throws an error if report endpoint is not available", () => {
813
+ api._endpoints.report = null;
814
+ expect(() => api.sendReport({})).toThrow("Report sending is not available");
815
+ });
816
+
817
+ it("sends a report successfully", async () => {
818
+ // Mock fetch response
819
+ const mockResponse = {
820
+ status: 200,
821
+ json: jest.fn().mockResolvedValue({ id: "bla" })
822
+ };
823
+ global.fetch = jest.fn().mockResolvedValue(mockResponse);
824
+
825
+ const data = {
826
+ issue: "blur_missing",
827
+ picture_id: "bla1",
828
+ sequence_id: "bla2",
829
+ };
830
+
831
+ const response = await api.sendReport(data);
832
+
833
+ expect(fetch).toHaveBeenCalledWith(ENDPOINT+"/reports", {
834
+ method: "POST",
835
+ body: JSON.stringify(data),
836
+ headers: { "Content-Type": "application/json" }
837
+ });
838
+ expect(response).toEqual({ id: "bla" });
839
+ });
840
+
841
+ it("handles API errors and rejects with message", async () => {
842
+ // Mock fetch response with an error
843
+ const mockResponse = {
844
+ status: 400,
845
+ text: jest.fn().mockResolvedValue(JSON.stringify({ message: "Error occurred" }))
846
+ };
847
+ global.fetch = jest.fn().mockResolvedValue(mockResponse);
848
+
849
+ const data = {
850
+ issue: "blur_missing",
851
+ picture_id: "bla1",
852
+ sequence_id: "bla2",
853
+ };
854
+
855
+ await expect(api.sendReport(data)).rejects.toEqual("Error occurred");
856
+
857
+ expect(fetch).toHaveBeenCalledWith(ENDPOINT+"/reports", {
858
+ method: "POST",
859
+ body: JSON.stringify(data),
860
+ headers: { "Content-Type": "application/json" }
861
+ });
862
+ });
863
+
864
+ it("handles API errors and rejects with text if no message in JSON", async () => {
865
+ // Mock fetch response with an error and no "message" key
866
+ const mockResponse = {
867
+ status: 400,
868
+ text: jest.fn().mockResolvedValue("Some error text")
869
+ };
870
+ global.fetch = jest.fn().mockResolvedValue(mockResponse);
871
+
872
+ const data = {
873
+ issue: "blur_missing",
874
+ picture_id: "bla1",
875
+ sequence_id: "bla2",
876
+ };
877
+
878
+ await expect(api.sendReport(data)).rejects.toEqual("Some error text");
879
+
880
+ expect(fetch).toHaveBeenCalledWith(ENDPOINT+"/reports", {
881
+ method: "POST",
882
+ body: JSON.stringify(data),
883
+ headers: { "Content-Type": "application/json" }
884
+ });
885
+ });
886
+ });
887
+
888
+ describe("isValidHttpUrl", () => {
889
+ it("works with valid endpoint", () => {
890
+ expect(API.isValidHttpUrl(ENDPOINT)).toBeTruthy();
891
+ });
892
+
893
+ it("fails if endpoint is invalid", () => {
894
+ expect(API.isValidHttpUrl("not an url")).toBeFalsy();
895
+ });
896
+ });
897
+
898
+ describe("isValidId", () => {
899
+ it("works with valid ID", () => {
900
+ expect(API.isIdValid("blabla")).toBeTruthy();
901
+ });
902
+
903
+ it("fails with invalid ID", () => {
904
+ expect(() => API.isIdValid(null)).toThrowError("id should be a valid picture unique identifier");
905
+ });
906
+ });