refactoring and tests

This commit is contained in:
vcoppe committed 2026-10-09 08:58:00 +02:00
1 parent 3bb553d6b5
commit da9abbae5e
45 files changed
+3558 -1564

No files matched your search

+1 -82
View File
@@ -16,27 +16,6 @@ import {
} from '@lucide/svelte';
import type { Component } from 'svelte';
export const guides: Record<string, string[]> = {
'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [],
toolbar: [
'routing',
'poi',
'scissors',
'time',
'merge',
'extract',
'elevation',
'minify',
'clean',
],
'map-controls': [],
gpx: [],
integration: [],
faq: [],
};
export const guideIcons: Record<string, string | Component<IconProps>> = {
'getting-started': '🚀',
menu: '📂 ⚙️',
@@ -61,64 +40,4 @@ export const guideIcons: Record<string, string | Component<IconProps>> = {
faq: '🔮',
};
export function getPreviousGuide(currentGuide: string): string | undefined {
let subguides = currentGuide.split('/');
if (subguides.length === 1) {
let keys = Object.keys(guides);
let index = keys.indexOf(currentGuide);
if (index === 0) {
return undefined;
}
let previousGuide = keys[index - 1];
if (previousGuide === undefined) {
return undefined;
} else if (guides[previousGuide].length === 0) {
return previousGuide;
} else {
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex > 0) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
} else {
return subguides[0];
}
} else {
return undefined;
}
}
}
export function getNextGuide(currentGuide: string): string | undefined {
let subguides = currentGuide.split('/');
if (subguides.length === 1) {
if (guides.hasOwnProperty(currentGuide)) {
if (guides[currentGuide].length === 0) {
let keys = Object.keys(guides);
let index = keys.indexOf(currentGuide);
return keys[index + 1];
} else {
return `${currentGuide}/${guides[currentGuide][0]}`;
}
} else {
return undefined;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex < guides[subguides[0]].length - 1) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
} else {
let keys = Object.keys(guides);
let index = keys.indexOf(subguides[0]);
return keys[index + 1];
}
} else {
return undefined;
}
}
}
export { guides, getNextGuide, getPreviousGuide } from './guides';
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { describe, it } from 'node:test';
import { languages } from '$lib/languages';
import { getNextGuide, getPreviousGuide, guides } from './guides';
/** Every page of the help in reading order, as the links at the bottom of the pages follow them. */
const pages = Object.entries(guides).flatMap(([guide, subguides]) => [
guide,
...subguides.map((subguide) => `${guide}/${subguide}`),
]);
describe('guides', () => {
it('go from one page to the next, subguides included', () => {
assert.equal(getNextGuide('getting-started'), 'menu');
assert.equal(getNextGuide('menu'), 'menu/file');
assert.equal(getNextGuide('menu/file'), 'menu/edit');
assert.equal(getNextGuide('menu/settings'), 'files-and-stats');
assert.equal(getNextGuide('toolbar/clean'), 'map-controls');
assert.equal(getNextGuide('integration'), 'faq');
});
it('go from one page to the previous one, subguides included', () => {
assert.equal(getPreviousGuide('menu'), 'getting-started');
assert.equal(getPreviousGuide('menu/file'), 'menu');
assert.equal(getPreviousGuide('menu/edit'), 'menu/file');
assert.equal(getPreviousGuide('files-and-stats'), 'menu/settings');
assert.equal(getPreviousGuide('map-controls'), 'toolbar/clean');
assert.equal(getPreviousGuide('faq'), 'integration');
});
it('stop at the ends', () => {
assert.equal(getPreviousGuide('getting-started'), undefined);
assert.equal(getNextGuide('faq'), undefined);
});
it('know nothing of pages that do not exist', () => {
assert.equal(getNextGuide('nope'), undefined);
assert.equal(getNextGuide('nope/sub'), undefined);
assert.equal(getPreviousGuide('nope'), undefined);
assert.equal(getPreviousGuide('nope/sub'), undefined);
});
it('walk through every page in both directions', () => {
const forward = [pages[0]];
for (let next = getNextGuide(pages[0]); next; next = getNextGuide(next)) {
forward.push(next);
}
assert.deepEqual(forward, pages);
const backward = [pages.at(-1)!];
for (
let previous = getPreviousGuide(pages.at(-1)!);
previous;
previous = getPreviousGuide(previous)
) {
backward.push(previous);
}
assert.deepEqual(backward.reverse(), pages);
});
it('are written in every language of the site', () => {
const root = new URL('../../docs/', import.meta.url);
const missing: string[] = [];
for (const language of Object.keys(languages)) {
for (const page of pages) {
if (!existsSync(new URL(`${language}/${page}.mdx`, root))) {
missing.push(`${language}/${page}`);
}
}
}
assert.deepEqual(missing, []);
});
it('have a translation of the interface for every language of the site', () => {
const root = new URL('../../../locales/', import.meta.url);
for (const language of Object.keys(languages)) {
assert.ok(existsSync(new URL(`${language}.json`, root)), language);
}
});
});
+84
View File
@@ -0,0 +1,84 @@
// The guides of the help section, and how they follow one another. The icons are in `docs.ts`.
export const guides: Record<string, string[]> = {
'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [],
toolbar: [
'routing',
'poi',
'scissors',
'time',
'merge',
'extract',
'elevation',
'minify',
'clean',
],
'map-controls': [],
gpx: [],
integration: [],
faq: [],
};
export function getPreviousGuide(currentGuide: string): string | undefined {
const subguides = currentGuide.split('/');
if (subguides.length === 1) {
const keys = Object.keys(guides);
const index = keys.indexOf(currentGuide);
if (index === 0) {
return undefined;
}
const previousGuide = keys[index - 1];
if (previousGuide === undefined) {
return undefined;
} else if (guides[previousGuide].length === 0) {
return previousGuide;
} else {
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
const subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex > 0) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
} else {
return subguides[0];
}
} else {
return undefined;
}
}
}
export function getNextGuide(currentGuide: string): string | undefined {
const subguides = currentGuide.split('/');
if (subguides.length === 1) {
if (guides.hasOwnProperty(currentGuide)) {
if (guides[currentGuide].length === 0) {
const keys = Object.keys(guides);
const index = keys.indexOf(currentGuide);
return keys[index + 1];
} else {
return `${currentGuide}/${guides[currentGuide][0]}`;
}
} else {
return undefined;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
const subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex < guides[subguides[0]].length - 1) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
} else {
const keys = Object.keys(guides);
const index = keys.indexOf(subguides[0]);
return keys[index + 1];
}
} else {
return undefined;
}
}
}
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
allowedEmbeddingBasemaps,
convertOldEmbeddingOptions,
defaultEmbeddingOptions,
getCleanedEmbeddingOptions,
getFilesFromEmbeddingOptions,
getMergedEmbeddingOptions,
getURLForGoogleDriveFile,
} from './embedding';
describe('embedding options', () => {
it('are the defaults when nothing is given', () => {
assert.deepEqual(getMergedEmbeddingOptions({}), defaultEmbeddingOptions);
// and a copy of them
const merged = getMergedEmbeddingOptions({});
merged.elevation.show = false;
assert.equal(defaultEmbeddingOptions.elevation.show, true);
});
it('override the defaults, also inside the groups of options', () => {
const merged = getMergedEmbeddingOptions({
basemap: 'osm',
distanceUnits: 'imperial',
elevation: { fill: 'slope', hr: true },
files: ['https://example.com/a.gpx'],
});
assert.equal(merged.basemap, 'osm');
assert.equal(merged.distanceUnits, 'imperial');
assert.equal(merged.elevation.fill, 'slope');
assert.equal(merged.elevation.hr, true);
// the options of a group that are not given stay as they are
assert.equal(merged.elevation.height, 170);
assert.equal(merged.elevation.show, true);
assert.deepEqual(merged.files, ['https://example.com/a.gpx']);
assert.equal(merged.velocityUnits, 'speed');
});
it('take a list or a null as a value, not as a group', () => {
const merged = getMergedEmbeddingOptions({ ids: ['a', 'b'], key: null });
assert.deepEqual(merged.ids, ['a', 'b']);
assert.equal(merged.key, null);
});
it('are cleaned of what is a default', () => {
const options = getMergedEmbeddingOptions({ basemap: 'osm', elevation: { hr: true } });
assert.deepEqual(getCleanedEmbeddingOptions(options), {
basemap: 'osm',
elevation: { hr: true },
});
assert.deepEqual(getCleanedEmbeddingOptions(defaultEmbeddingOptions), {});
// merging what was cleaned gives the options back
assert.deepEqual(getMergedEmbeddingOptions(getCleanedEmbeddingOptions(options)), options);
});
it('are cleaned of the key of the site, and keep the key of the user', () => {
assert.deepEqual(
getCleanedEmbeddingOptions({ ...defaultEmbeddingOptions, key: 'test-key' }),
{}
);
assert.deepEqual(getCleanedEmbeddingOptions({ ...defaultEmbeddingOptions, key: 'mine' }), {
key: 'mine',
});
});
it('are not changed by cleaning them', () => {
const options = getMergedEmbeddingOptions({ elevation: { hr: true } });
const before = JSON.stringify(options);
getCleanedEmbeddingOptions(options);
assert.equal(JSON.stringify(options), before);
});
});
describe('embedded files', () => {
it('are the urls, then the files of Google Drive', () => {
const options = getMergedEmbeddingOptions({
files: ['https://example.com/a.gpx'],
ids: ['abc', 'def'],
});
assert.deepEqual(getFilesFromEmbeddingOptions(options), [
'https://example.com/a.gpx',
getURLForGoogleDriveFile('abc'),
getURLForGoogleDriveFile('def'),
]);
assert.ok(getURLForGoogleDriveFile('abc').includes('/files/abc?alt=media'));
assert.deepEqual(getFilesFromEmbeddingOptions(getMergedEmbeddingOptions({})), []);
});
it('can use the basemaps of the site except a few', () => {
assert.ok(allowedEmbeddingBasemaps.includes('libertyTopo'));
assert.ok(allowedEmbeddingBasemaps.includes('osm'));
assert.ok(!allowedEmbeddingBasemaps.includes('ordnanceSurvey'));
assert.ok(allowedEmbeddingBasemaps.includes(defaultEmbeddingOptions.basemap));
});
});
describe('options of the old embedding', () => {
const convert = (query: string) => convertOldEmbeddingOptions(new URLSearchParams(query));
it('are converted to the new ones', () => {
assert.deepEqual(convert(''), { key: 'test-key', files: [], ids: [] });
assert.deepEqual(
convert(
'source=otm&imperial&running&distance&direction&slope&state=' +
encodeURIComponent(JSON.stringify({ ids: ['a'], urls: ['https://x/y.gpx'] }))
),
{
key: 'test-key',
files: ['https://x/y.gpx'],
ids: ['a'],
basemap: 'openTopoMap',
distanceUnits: 'imperial',
velocityUnits: 'pace',
distanceMarkers: true,
directionMarkers: true,
elevation: { fill: 'slope' },
}
);
});
it('convert the names of the sources', () => {
assert.equal(convert('source=satellite').basemap, 'libertySatellite');
assert.equal(convert('source=otm').basemap, 'openTopoMap');
assert.equal(convert('source=ohm').basemap, 'openHikingMap');
assert.equal(convert('source=unknown').basemap, undefined);
});
it('ignore a state that is not valid', () => {
const empty = { key: 'test-key', files: [], ids: [] };
assert.deepEqual(convert('state=%7Bbroken'), empty);
assert.deepEqual(convert('state=null'), empty);
assert.deepEqual(convert('state=42'), empty);
assert.deepEqual(convert(`state=${encodeURIComponent('{"ids":"abc","urls":3}')}`), empty);
assert.deepEqual(convert(`state=${encodeURIComponent('{"ids":["a"]}')}&imperial`), {
...empty,
ids: ['a'],
distanceUnits: 'imperial',
});
});
it('can be merged with the defaults', () => {
const merged = getMergedEmbeddingOptions(convert('slope&source=satellite'));
assert.equal(merged.elevation.fill, 'slope');
assert.equal(merged.elevation.height, 170);
assert.equal(merged.basemap, 'libertySatellite');
});
});
@@ -115,11 +115,15 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
ids: [],
};
if (options.has('state')) {
let state = JSON.parse(options.get('state')!);
if (state.ids) {
// the state comes from the url: it may be anything
let state: any = {};
try {
state = JSON.parse(options.get('state')!) ?? {};
} catch {}
if (Array.isArray(state.ids)) {
newOptions.ids.push(...state.ids);
}
if (state.urls) {
if (Array.isArray(state.urls)) {
newOptions.files.push(...state.urls);
}
}
@@ -185,9 +185,7 @@
{i18n._('quantities.power')}
</Label>
</div>
<div
class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}"
>
<div class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}">
<Checkbox id="export-osm" bind:checked={exportOptions.osm} />
<Label for="export-osm" class="flex flex-row items-center gap-1">
<Earth size="16" />
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { sanitizeFileName, uniqueFileNames } from './file-names';
describe('sanitizeFileName', () => {
it('keeps the names that are fine', () => {
for (const name of [
'Morning ride',
'route-2024_05',
'Café du coin',
'日本の道',
'a.b.c',
'🚴 tour',
]) {
assert.equal(sanitizeFileName(name), name);
}
});
it('replaces what is not allowed in a name', () => {
assert.equal(sanitizeFileName('a/b'), 'a_b');
assert.equal(sanitizeFileName('a\\b'), 'a_b');
assert.equal(sanitizeFileName('../../etc/passwd'), '_.._etc_passwd');
assert.equal(
sanitizeFileName('what? <this> *is* "it": a|b'),
'what_ _this_ _is_ _it__ a_b'
);
assert.equal(sanitizeFileName('tab\there\nnew\u0000line\u007f'), 'tab_here_new_line_');
});
it('has no dots or spaces around, which systems drop or hide', () => {
assert.equal(sanitizeFileName(' name '), 'name');
assert.equal(sanitizeFileName('.hidden'), 'hidden');
assert.equal(sanitizeFileName('name...'), 'name');
assert.equal(sanitizeFileName('. . name . .'), 'name');
});
it('is never empty', () => {
assert.equal(sanitizeFileName(''), 'file');
assert.equal(sanitizeFileName(' '), 'file');
assert.equal(sanitizeFileName('...'), 'file');
});
it('does not use the names Windows keeps for devices', () => {
assert.equal(sanitizeFileName('con'), '_con');
assert.equal(sanitizeFileName('NUL'), '_NUL');
assert.equal(sanitizeFileName('com1'), '_com1');
assert.equal(sanitizeFileName('LPT9'), '_LPT9');
assert.equal(sanitizeFileName('aux.backup'), '_aux.backup');
assert.equal(sanitizeFileName('console'), 'console');
assert.equal(sanitizeFileName('com10'), 'com10');
});
it('is not too long, whatever the characters', () => {
assert.equal(sanitizeFileName('a'.repeat(500)).length, 100);
const emoji = sanitizeFileName('🚴'.repeat(500));
assert.equal([...emoji].length, 100);
// and does not end with what was left of a cut
assert.equal(sanitizeFileName('a'.repeat(99) + '. b'), 'a'.repeat(99));
});
it('is the same when sanitized again', () => {
for (const name of ['a/b', ' .x ', 'con', '', '🚴'.repeat(200), 'what?']) {
const once = sanitizeFileName(name);
assert.equal(sanitizeFileName(once), once);
}
});
});
describe('uniqueFileNames', () => {
it('keeps different names as they are', () => {
assert.deepEqual(uniqueFileNames(['a', 'b', 'c']), ['a', 'b', 'c']);
assert.deepEqual(uniqueFileNames([]), []);
});
it('numbers the names that are taken', () => {
assert.deepEqual(uniqueFileNames(['ride', 'ride', 'ride']), ['ride', 'ride-1', 'ride-2']);
// a name that looks like a number is taken too
assert.deepEqual(uniqueFileNames(['ride-1', 'ride', 'ride']), ['ride-1', 'ride', 'ride-2']);
});
it('does not tell upper and lower case apart', () => {
assert.deepEqual(uniqueFileNames(['Ride', 'ride', 'RIDE']), ['Ride', 'ride-1', 'RIDE-2']);
});
it('compares the names once they are clean', () => {
assert.deepEqual(uniqueFileNames(['a/b', 'a_b', 'a?b']), ['a_b', 'a_b-1', 'a_b-2']);
assert.deepEqual(uniqueFileNames(['', '', '...']), ['file', 'file-1', 'file-2']);
});
it('keeps the numbered names short enough', () => {
const long = 'x'.repeat(300);
const names = uniqueFileNames([long, long, long]);
assert.equal(new Set(names).size, 3);
assert.ok(names.every((name) => name.length <= 100));
assert.ok(names[1].endsWith('-1') && names[2].endsWith('-2'));
});
});
@@ -0,0 +1,47 @@
// The names of the files that are exported come from the files of the user, which can have any
// text as a name: they have to be made names that every system accepts.
/** Characters that no system accepts in a file name, or that mean something in a path. */
// eslint-disable-next-line no-control-regex
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
/** Names that Windows keeps for devices, with or without an extension. */
const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
/** Longest name, without the extension, in characters. */
const MAX_LENGTH = 100;
/** A name for a file without its extension, that is allowed on every system. */
export function sanitizeFileName(name: string): string {
let clean = name
.replace(FORBIDDEN, '_')
.normalize('NFC')
// no dots or spaces around the name: Windows drops them, a leading dot hides the file
.replace(/^[\s.]+|[\s.]+$/g, '');
clean = [...clean]
.slice(0, MAX_LENGTH)
.join('')
.replace(/[\s.]+$/, '');
if (clean === '') {
return 'file';
}
return RESERVED.test(clean.split('.')[0]) ? `_${clean}` : clean;
}
/**
* The names of files that go in the same folder (the same zip): clean, and different, even where
* the file system does not tell upper and lower case apart. A name that is taken gets `-1`, `-2`...
*/
export function uniqueFileNames(names: string[]): string[] {
const taken = new Set<string>();
return names.map((name) => {
const clean = sanitizeFileName(name);
let unique = clean;
for (let i = 1; taken.has(unique.toLowerCase()); i++) {
// the suffix is part of the length
unique = `${[...clean].slice(0, MAX_LENGTH - `-${i}`.length).join('')}-${i}`;
}
taken.add(unique.toLowerCase());
return unique;
});
}
@@ -2,6 +2,7 @@ import { engine, type ExportOptions } from '$lib/engine';
import FileSaver from 'file-saver';
import JSZip from 'jszip';
import { get } from 'svelte/store';
import { sanitizeFileName, uniqueFileNames } from './file-names';
export enum ExportState {
NONE,
@@ -38,16 +39,11 @@ async function exportFiles(fileIds: string[], options: ExportOptions) {
if (files.length === 1) {
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
FileSaver.saveAs(blob, `${files[0].name}.gpx`);
FileSaver.saveAs(blob, `${sanitizeFileName(files[0].name)}.gpx`);
} else if (files.length > 1) {
const zip = new JSZip();
for (const { name, gpx } of files) {
let filename = name;
for (let i = 1; zip.files[filename + '.gpx']; i++) {
filename = name + `-${i}`;
}
zip.file(filename + '.gpx', gpx);
}
const names = uniqueFileNames(files.map((file) => file.name));
files.forEach(({ gpx }, i) => zip.file(`${names[i]}.gpx`, gpx));
const blob = await zip.generateAsync({ type: 'blob' });
FileSaver.saveAs(blob, 'gpx-files.zip');
}
@@ -0,0 +1,149 @@
// The API that browser extensions use to add overlays to the map (`window.gpxstudio`).
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import { overlays, overlayTree, type LayerTreeType } from '$lib/assets/layers';
import { engine } from '$lib/engine';
import { settings } from '$lib/logic/settings';
import { ExtensionAPI, type CustomOverlay } from './extension-api';
import { isSelected } from './utils';
const original = { set: engine.setSetting, delete: engine.deleteSetting };
const overlayTreeBefore = JSON.stringify(overlayTree);
const overlayIdsBefore = Object.keys(overlays).sort();
const overlay = (id: string, extensionName = 'My extension'): CustomOverlay => ({
extensionName,
id,
name: `Overlay ${id}`,
tileUrls: [`https://tiles.example.com/${id}/{z}/{x}/{y}.png`],
});
let api: ExtensionAPI;
beforeEach(() => {
engine.setSetting = () => {};
engine.deleteSetting = () => {};
settings.connect({});
settings.initialize();
api = new ExtensionAPI();
});
afterEach(() => {
// the layers are shared by the whole app: leave them as they were
api.filterOverlays([]);
for (const extension of ['My extension', 'Other extension']) {
delete (overlayTree.overlays as LayerTreeType)[extension];
}
engine.setSetting = original.set;
engine.deleteSetting = original.delete;
assert.equal(JSON.stringify(overlayTree), overlayTreeBefore);
assert.deepEqual(Object.keys(overlays).sort(), overlayIdsBefore);
});
describe('ExtensionAPI', () => {
it('adds an overlay as a layer, in the tree of its extension', () => {
api.addOrUpdateOverlay(overlay('one'));
const layer = overlays['extension-one'];
assert.ok(layer && typeof layer === 'object');
const source = (layer as any).sources['extension-one'];
assert.equal(source.type, 'raster');
assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']);
assert.equal(source.tileSize, 256);
assert.equal(source.maxzoom, 22);
assert.ok((overlayTree.overlays as LayerTreeType)['My extension']);
assert.equal(isSelected(overlayTree, 'extension-one'), true);
assert.equal(isSelected(get(settings.selectedOverlayTree), 'extension-one'), true);
// not shown until the user chooses to
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), false);
assert.ok(get(api.isLayerFromExtension)('extension-one'));
assert.equal(get(api.getLayerName)('extension-one'), 'Overlay one');
assert.equal(get(api.getLayerName)('other'), '');
});
it('uses big tiles when the urls say so, and the max zoom it is given', () => {
api.addOrUpdateOverlay({
...overlay('big'),
tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'],
maxZoom: 15,
});
const source = (overlays['extension-big'] as any).sources['extension-big'];
assert.equal(source.tileSize, 512);
assert.equal(source.maxzoom, 15);
});
it('refuses an overlay that is not complete', () => {
for (const bad of [
{ ...overlay('a'), extensionName: '' },
{ ...overlay('a'), id: '' },
{ ...overlay('a'), name: '' },
{ ...overlay('a'), tileUrls: [] },
{ ...overlay('a'), tileUrls: undefined as unknown as string[] },
]) {
assert.throws(() => api.addOrUpdateOverlay(bad), /must have/);
}
assert.ok(!('extension-a' in overlays));
});
it('updates an overlay that is added again, keeping it shown', () => {
api.addOrUpdateOverlay(overlay('one'));
settings.currentOverlays.update((current) => {
(current.overlays as LayerTreeType)['My extension'] = { 'extension-one': true };
return current;
});
api.addOrUpdateOverlay({
...overlay('one'),
name: 'Renamed',
tileUrls: ['https://other/{z}'],
});
assert.equal(get(api.getLayerName)('extension-one'), 'Renamed');
assert.deepEqual((overlays['extension-one'] as any).sources['extension-one'].tiles, [
'https://other/{z}',
]);
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true);
});
it('removes the overlays that are not in the list anymore, everywhere', () => {
api.addOrUpdateOverlay(overlay('one'));
api.addOrUpdateOverlay(overlay('two', 'Other extension'));
api.filterOverlays(['two']);
assert.ok(!('extension-one' in overlays));
assert.ok('extension-two' in overlays);
assert.equal(get(api.isLayerFromExtension)('extension-one'), false);
assert.equal(get(api.isLayerFromExtension)('extension-two'), true);
for (const tree of [
overlayTree,
get(settings.selectedOverlayTree),
get(settings.currentOverlays)!,
]) {
assert.equal(isSelected(tree, 'extension-one'), false);
}
assert.equal(isSelected(overlayTree, 'extension-two'), true);
api.filterOverlays([]);
assert.ok(!('extension-two' in overlays));
});
it('puts the overlays in the order it is given', () => {
api.addOrUpdateOverlay(overlay('one'));
api.addOrUpdateOverlay(overlay('two'));
api.addOrUpdateOverlay(overlay('three'));
api.updateOverlaysOrder(['three', 'one', 'two']);
const extension = (get(settings.selectedOverlayTree).overlays as LayerTreeType)[
'My extension'
];
assert.deepEqual(Object.keys(extension as LayerTreeType), [
'extension-three',
'extension-one',
'extension-two',
]);
// ids it does not know are ignored
api.updateOverlaysOrder(['unknown', 'two']);
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two');
});
});
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { LayerTreeType } from '$lib/assets/layers';
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils';
// roads: bike (selected), foot
// satellite: esri, ign: fr (selected)
// osm
const tree = (): LayerTreeType => ({
roads: { bike: true, foot: false },
satellite: { esri: false, ign: { fr: true } },
osm: false,
});
describe('layer trees', () => {
it('tells if a layer is selected', () => {
assert.equal(isSelected(tree(), 'bike'), true);
assert.equal(isSelected(tree(), 'foot'), false);
assert.equal(isSelected(tree(), 'fr'), true);
assert.equal(isSelected(tree(), 'osm'), false);
assert.equal(isSelected(tree(), 'unknown'), false);
});
it('tells if anything is selected in a branch', () => {
const t = tree();
assert.equal(anySelectedLayer(t), true);
assert.equal(anySelectedLayer(t.roads as LayerTreeType), true);
assert.equal(anySelectedLayer(t.satellite as LayerTreeType), true);
assert.equal(anySelectedLayer({ a: false, b: { c: false } }), false);
assert.equal(anySelectedLayer({}), false);
});
it('flattens the layers of the leaves', () => {
assert.deepEqual(getLayers(tree()), {
bike: true,
foot: false,
esri: false,
fr: true,
osm: false,
});
assert.deepEqual(getLayers({}), {});
});
it('toggles a layer wherever it is', () => {
const t = toggle(tree(), 'fr');
assert.equal(isSelected(t, 'fr'), false);
assert.equal(isSelected(toggle(t, 'osm'), 'osm'), true);
assert.equal(isSelected(toggle(t, 'osm'), 'bike'), true);
// the tree is edited, and returned
const own = tree();
assert.equal(toggle(own, 'foot'), own);
assert.equal((own.roads as LayerTreeType).foot, true);
// an id that is not there changes nothing
assert.deepEqual(toggle(tree(), 'unknown'), tree());
});
it('removes layers and branches', () => {
assert.deepEqual(remove(tree(), 'bike'), {
roads: { foot: false },
satellite: { esri: false, ign: { fr: true } },
osm: false,
});
assert.deepEqual(remove(tree(), 'ign'), {
roads: { bike: true, foot: false },
satellite: { esri: false },
osm: false,
});
assert.deepEqual(removeAll(tree(), ['bike', 'fr', 'osm']), {
roads: { foot: false },
satellite: { esri: false, ign: {} },
});
assert.deepEqual(removeAll(tree(), []), tree());
});
});
@@ -0,0 +1,282 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { distance } from '$lib/geo';
import {
brouterError,
getTags,
graphHopperError,
graphhopperBlockPrivateCustomModels,
interpolatePoints,
parseBRouterRoute,
parseGraphHopperRoute,
routingProfiles,
} from './routing';
describe('routing profiles', () => {
it('block the private roads of every profile of GraphHopper that has a model', () => {
const profiles = Object.values(routingProfiles)
.filter((profile) => profile.engine === 'graphhopper')
.map((profile) => profile.profile);
assert.ok(profiles.length > 0);
for (const profile of profiles) {
assert.ok(graphhopperBlockPrivateCustomModels[profile], profile);
}
// and the models are about profiles that exist
for (const profile of Object.keys(graphhopperBlockPrivateCustomModels)) {
assert.ok(profiles.includes(profile), profile);
}
});
});
describe('GraphHopper', () => {
const response = (details: Record<string, unknown[]>, coordinates: number[][]) => ({
paths: [{ points: { coordinates }, details }],
});
const coordinates = [
[4.0, 50.0, 100],
[4.1, 50.1, 110],
[4.2, 50.2, 120],
[4.3, 50.3, 130],
[4.4, 50.4],
];
it('gives the points of the route, with the elevation of the previous one when missing', () => {
const route = parseGraphHopperRoute(response({}, coordinates));
assert.deepEqual(route.lng, [4.0, 4.1, 4.2, 4.3, 4.4]);
assert.deepEqual(route.lat, [50.0, 50.1, 50.2, 50.3, 50.4]);
assert.deepEqual(route.ele, [100, 110, 120, 130, 130]);
assert.deepEqual(route.surface, new Array(5).fill(undefined));
});
it('gives the details to the points of their ranges, the last range including the last point', () => {
const route = parseGraphHopperRoute(
response(
{
road_class: [
[0, 2, 'cycleway'],
[2, 4, 'path'],
],
surface: [
[0, 3, 'asphalt'],
[3, 4, 'gravel'],
],
hike_rating: [[0, 4, '2']],
mtb_rating: [
[0, 1, '1'],
[1, 4, '5'],
],
},
coordinates
)
);
assert.deepEqual(route.highway, ['cycleway', 'cycleway', 'path', 'path', 'path']);
assert.deepEqual(route.surface, ['asphalt', 'asphalt', 'asphalt', 'gravel', 'gravel']);
// the ratings are converted to the scales
assert.deepEqual(route.sacScale, new Array(5).fill('mountain_hiking'));
assert.deepEqual(route.mtbScale, ['0', '4', '4', '4', '4']);
});
it('ignores the details that are missing or unknown', () => {
const route = parseGraphHopperRoute(
response(
{
road_class: [
[0, 2, 'missing'],
[2, 4, 'primary'],
],
surface: [[0, 4, 'other']],
hike_rating: [[0, 4, '9']],
},
coordinates
)
);
assert.deepEqual(route.highway, [undefined, undefined, 'primary', 'primary', 'primary']);
assert.deepEqual(route.surface, new Array(5).fill(undefined));
assert.deepEqual(route.sacScale, new Array(5).fill(undefined));
});
it('turns its errors into the messages of the interface', () => {
const error = (message: string, details = '', count = 2) =>
graphHopperError({ message, hints: [{ details }] }, count).message;
assert.equal(error('Cannot find point 0: 1,2'), 'toolbar.routing.error.from');
assert.equal(error('Cannot find point 1: 1,2', '', 2), 'toolbar.routing.error.to');
assert.equal(error('Cannot find point 1: 1,2', '', 3), 'toolbar.routing.error.via');
assert.equal(
error('x', 'com.graphhopper.PointDistanceExceededException'),
'toolbar.routing.error.distance'
);
assert.equal(
error('x', 'ConnectionNotFoundException: no way'),
'toolbar.routing.error.connection'
);
assert.equal(error('something else'), 'something else');
// an error without hints is still an error
assert.equal(graphHopperError({ message: 'no hints' }, 2).message, 'no hints');
assert.equal(graphHopperError({}, 2).message, '');
});
});
describe('BRouter', () => {
const messages = [
[
'Longitude',
'Latitude',
'Elevation',
'Distance',
'CostPerKm',
'ElevCost',
'TurnCost',
'NodeCost',
'InitialCost',
'WayTags',
'NodeTags',
],
[
'4100000',
'50100000',
'100',
'10',
'0',
'0',
'0',
'0',
'0',
'highway=path surface=gravel sac_scale=hiking',
'',
],
[
'4300000',
'50300000',
'120',
'10',
'0',
'0',
'0',
'0',
'0',
'highway=residential surface=asphalt mtb:scale=1',
'',
],
];
const geojson = (coordinates: number[][], messages_: string[][] = messages) => ({
features: [{ geometry: { coordinates }, properties: { messages: messages_ } }],
});
it('reads the tags of a way', () => {
assert.deepEqual(getTags('highway=path surface=gravel'), {
highway: 'path',
surface: 'gravel',
});
assert.deepEqual(getTags('sac_scale=hiking mtb:scale=2'), {
sac_scale: 'hiking',
mtb_scale: '2',
});
assert.deepEqual(getTags('a:b:c=d'), { a_b_c: 'd' });
assert.deepEqual(getTags(''), { '': undefined });
});
it('gives each point the tags of its way', () => {
const route = parseBRouterRoute(
geojson([
[4.0, 50.0, 90],
[4.1, 50.1, 100],
[4.2, 50.2, 110],
[4.3, 50.3, 120],
[4.4, 50.4, 130],
])
);
assert.deepEqual(route.ele, [90, 100, 110, 120, 130]);
// the first way is the one of the first message, whose last point is the start of the next
assert.deepEqual(route.highway, [
'path',
'residential',
'residential',
undefined,
undefined,
]);
assert.deepEqual(route.surface, ['gravel', 'asphalt', 'asphalt', undefined, undefined]);
assert.deepEqual(route.sacScale, ['hiking', undefined, undefined, undefined, undefined]);
assert.deepEqual(route.mtbScale, [undefined, '1', '1', undefined, undefined]);
});
it('has no tags for a route without messages', () => {
const route = parseBRouterRoute(
geojson(
[
[4, 50, 1],
[4.1, 50.1, 2],
],
[messages[0]]
)
);
assert.deepEqual(route.lng, [4, 4.1]);
assert.deepEqual(route.highway, [undefined, undefined]);
});
it('turns its errors into the messages of the interface', () => {
assert.equal(
brouterError('from-position not mapped in existing datafile').message,
'toolbar.routing.error.from'
);
assert.equal(
brouterError('via1-position not mapped in existing datafile').message,
'toolbar.routing.error.via'
);
assert.equal(
brouterError('to-position not mapped in existing datafile').message,
'toolbar.routing.error.to'
);
assert.equal(
brouterError('operation killed by Time-out').message,
'toolbar.routing.error.timeout'
);
assert.equal(brouterError('other').message, 'other');
});
});
describe('straight routes', () => {
it('have a point every 50 m and the last point', () => {
const a = { lng: 4, lat: 50 };
const b = { lng: 4.001, lat: 50 };
const route = interpolatePoints([a, b]);
const length = distance(a, b);
assert.equal(route.lng.length, Math.ceil(length / 50) + 1);
assert.deepEqual([route.lng[0], route.lat[0]], [4, 50]);
assert.deepEqual([route.lng.at(-1), route.lat.at(-1)], [4.001, 50]);
// the points are in order, and evenly spaced
for (let i = 1; i < route.lng.length - 1; i++) {
const step = distance(
{ lng: route.lng[i - 1], lat: route.lat[i - 1] },
{ lng: route.lng[i], lat: route.lat[i] }
);
assert.ok(Math.abs(step - 50) < 1, `${step}`);
}
assert.ok(route.ele.every((ele) => ele === 0));
assert.equal(route.surface.length, route.lng.length);
});
it('go through the intermediate points', () => {
const route = interpolatePoints([
{ lng: 4, lat: 50 },
{ lng: 4.001, lat: 50 },
{ lng: 4.001, lat: 50.001 },
]);
assert.ok(route.lng.includes(4.001) && route.lat.includes(50.001));
const corner = route.lng.findIndex((lng, i) => lng === 4.001 && route.lat[i] === 50);
assert.ok(corner > 0);
});
it('are a point for one point, nothing for none', () => {
const route = interpolatePoints([{ lng: 4, lat: 50 }]);
assert.deepEqual(route.lng, [4]);
assert.deepEqual(interpolatePoints([]).lng, []);
// two points at the same place
assert.deepEqual(
interpolatePoints([
{ lng: 4, lat: 50 },
{ lng: 4, lat: 50 },
]).lng,
[4]
);
});
});
@@ -35,12 +35,12 @@ export type RoutedPoints = {
mtbScale: (string | undefined)[];
};
function emptyRoute(): RoutedPoints {
export function emptyRoute(): RoutedPoints {
return { lng: [], lat: [], ele: [], surface: [], highway: [], sacScale: [], mtbScale: [] };
}
/** Adds a trackpoint, which has the elevation of the previous one if it has none. */
function addPoint(route: RoutedPoints, lng: number, lat: number, ele: number | undefined) {
export function addPoint(route: RoutedPoints, lng: number, lat: number, ele: number | undefined) {
route.lng.push(lng);
route.lat.push(lat);
route.ele.push(ele ?? route.ele[route.ele.length - 1] ?? 0);
@@ -82,7 +82,7 @@ const mtbRatingToScale: { [key: string]: string } = {
'7': '6',
};
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
export const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
bike: {
priority: [
{
@@ -123,7 +123,7 @@ const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
},
],
},
motorcycle: {
motorbike: {
priority: [
{
if: 'road_access == PRIVATE',
@@ -155,26 +155,39 @@ async function getGraphHopperRoute(
});
if (!response.ok) {
const error = await response.json();
if (error.message.includes('Cannot find point 0')) {
throw new Error('toolbar.routing.error.from');
} else if (error.message.includes('Cannot find point 1')) {
if (points.length == 3) {
throw new Error('toolbar.routing.error.via');
} else {
throw new Error('toolbar.routing.error.to');
}
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
throw new Error('toolbar.routing.error.distance');
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
throw new Error('toolbar.routing.error.connection');
} else {
throw new Error(error.message);
}
throw graphHopperError(await response.json(), points.length);
}
let json = await response.json();
return parseGraphHopperRoute(await response.json());
}
/** The error to throw for the error response of GraphHopper. Its message is a translation key. */
export function graphHopperError(
error: { message?: string; hints?: { details?: string }[] },
pointCount: number
): Error {
const message = error.message ?? '';
const details = error.hints?.[0]?.details ?? '';
if (message.includes('Cannot find point 0')) {
return new Error('toolbar.routing.error.from');
} else if (message.includes('Cannot find point 1')) {
return new Error(
pointCount == 3 ? 'toolbar.routing.error.via' : 'toolbar.routing.error.to'
);
} else if (details.includes('PointDistanceExceededException')) {
return new Error('toolbar.routing.error.distance');
} else if (details.includes('ConnectionNotFoundException')) {
return new Error('toolbar.routing.error.connection');
}
return new Error(message);
}
/**
* The route of a response of GraphHopper. The details of the path (road class, surface...) are
* given as ranges of points: `[from, to, value]`, `to` excluded except for the last range, which
* includes the last point.
*/
export function parseGraphHopperRoute(json: any): RoutedPoints {
const route = emptyRoute();
const coordinates = json.paths[0].points.coordinates;
const details = json.paths[0].details;
@@ -184,7 +197,7 @@ async function getGraphHopperRoute(
}
for (const key of graphhopperDetails) {
const detail = details[key];
const detail = details[key] ?? [];
for (let i = 0; i < detail.length; i++) {
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1 ? 1 : 0); j++) {
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
@@ -214,22 +227,32 @@ async function getBRouterRoute(
let response = await fetch(url);
if (!response.ok) {
const error = await response.text();
if (error.includes('from-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.from');
} else if (error.includes('via1-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.via');
} else if (error.includes('to-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.to');
} else if (error.includes('Time-out')) {
throw new Error('toolbar.routing.error.timeout');
} else {
throw new Error(error);
}
throw brouterError(await response.text());
}
let geojson = await response.json();
return parseBRouterRoute(await response.json());
}
/** The error to throw for the error response of BRouter. Its message is a translation key. */
export function brouterError(error: string): Error {
if (error.includes('from-position not mapped in existing datafile')) {
return new Error('toolbar.routing.error.from');
} else if (error.includes('via1-position not mapped in existing datafile')) {
return new Error('toolbar.routing.error.via');
} else if (error.includes('to-position not mapped in existing datafile')) {
return new Error('toolbar.routing.error.to');
} else if (error.includes('Time-out')) {
return new Error('toolbar.routing.error.timeout');
}
return new Error(error);
}
/**
* The route of a response of BRouter (GeoJSON). The tags of the ways are in the `messages`, one
* per way: a way starts at the point whose position the message gives, and applies up to the
* next message.
*/
export function parseBRouterRoute(geojson: any): RoutedPoints {
const route = emptyRoute();
const coordinates = geojson.features[0].geometry.coordinates;
const messages = geojson.features[0].properties.messages;
@@ -263,7 +286,8 @@ async function getBRouterRoute(
return route;
}
function getTags(message: string): { [key: string]: string } {
/** The tags of a way, `key=value` separated by spaces, with the `:` of the keys as `_`. */
export function getTags(message: string): { [key: string]: string } {
const fields = message.split(' ');
let tags: { [key: string]: string } = {};
for (let i = 0; i < fields.length; i++) {
@@ -274,7 +298,11 @@ function getTags(message: string): { [key: string]: string } {
return tags;
}
function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
/**
* The points of a straight route through `points`: one every 50 m, and the last one. The
* elevations are 0.
*/
export function interpolatePoints(points: Coordinates[]): RoutedPoints {
const route = emptyRoute();
const step = 0.05;
@@ -289,8 +317,14 @@ function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
}
const last = points[points.length - 1];
addPoint(route, last.lng, last.lat, 0);
if (last) {
addPoint(route, last.lng, last.lat, 0);
}
return route;
}
function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
const route = interpolatePoints(points);
return getElevation(route.lng.map((lng, i) => ({ lng, lat: route.lat[i] }))).then(
(elevations) => {
route.ele = elevations;