improve typings

This commit is contained in:
vcoppe committed 2026-10-31 17:47:26 +01:00
1 parent 46563ef283
commit 972510bfbc
10 files changed
+273 -69

No files matched your search

@@ -9,6 +9,15 @@ import { ExtensionAPI, type CustomOverlay } from './extension-api';
import { isSelected } from './utils';
const original = { set: engine.setSetting, delete: engine.deleteSetting };
/** The raster source that an overlay added by an extension uses. */
function rasterSource(id: string) {
const style = overlays[id];
assert.ok(typeof style === 'object');
const source = style.sources[id];
assert.ok(source?.type === 'raster');
return source;
}
const overlayTreeBefore = JSON.stringify(overlayTree);
const overlayIdsBefore = Object.keys(overlays).sort();
@@ -45,10 +54,7 @@ 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');
const source = rasterSource('extension-one');
assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']);
assert.equal(source.tileSize, 256);
assert.equal(source.maxzoom, 22);
@@ -69,7 +75,7 @@ describe('ExtensionAPI', () => {
tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'],
maxZoom: 15,
});
const source = (overlays['extension-big'] as any).sources['extension-big'];
const source = rasterSource('extension-big');
assert.equal(source.tileSize, 512);
assert.equal(source.maxzoom, 15);
});
@@ -100,9 +106,7 @@ describe('ExtensionAPI', () => {
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.deepEqual(rasterSource('extension-one').tiles, ['https://other/{z}']);
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true);
});
+11 -3
View File
@@ -85,15 +85,23 @@ export class MapLibreGLMap {
try {
const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`;
const response = await fetch(request);
const geojson = await response.json();
results.features = geojson.map((result: any) => {
const places: {
lat: string;
lon: string;
type: string;
display_name: string;
}[] = await response.json();
results.features = places.map((result) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat],
coordinates: [Number(result.lon), Number(result.lat)],
},
properties: {},
text: result.display_name,
place_name: result.display_name,
place_type: [result.type],
};
});
} catch {}
+19
View File
@@ -1178,6 +1178,25 @@ export type CustomLayer = {
value: string | StyleSpecification;
};
/** Whether a saved value is a custom layer, with all that the layers have had from the start. */
export function isCustomLayer(value: unknown): value is CustomLayer {
if (typeof value !== 'object' || value === null) {
return false;
}
const layer: Partial<Record<keyof CustomLayer, unknown>> = value;
return (
typeof layer.id === 'string' &&
typeof layer.name === 'string' &&
Array.isArray(layer.tileUrls) &&
layer.tileUrls.every((url) => typeof url === 'string') &&
typeof layer.maxZoom === 'number' &&
(layer.layerType === 'basemap' || layer.layerType === 'overlay') &&
(layer.resourceType === 'raster' || layer.resourceType === 'vector') &&
(typeof layer.value === 'string' ||
(typeof layer.value === 'object' && layer.value !== null))
);
}
type OverpassQueryData = {
icon: {
svg: string;
+9 -3
View File
@@ -143,19 +143,25 @@ describe('errors', () => {
});
describe('selection', () => {
/** The selected files, when files are selected. */
const selectedFiles = () => {
const selection = get(engine.selection);
return selection.type === 'file' ? selection.fileIds : [];
};
it('adds and toggles files', async () => {
await load('simple', 'with_tracks', 'with_waypoint');
const [a, b, c] = get(engine.order);
await engine.select([a]);
await engine.select([b], 'add');
assert.deepEqual(new Set((get(engine.selection) as any).fileIds), new Set([a, b]));
assert.deepEqual(new Set(selectedFiles()), new Set([a, b]));
await engine.select([a], 'toggle');
assert.deepEqual((get(engine.selection) as any).fileIds, [b]);
assert.deepEqual(selectedFiles(), [b]);
await engine.select([]);
assert.deepEqual(get(engine.selection), { type: 'empty' });
assert.equal(await engine.selectAll(), true);
assert.deepEqual(new Set((get(engine.selection) as any).fileIds), new Set([a, b, c]));
assert.deepEqual(new Set(selectedFiles()), new Set([a, b, c]));
});
it('selects tracks and segments of a file', async () => {
+12 -7
View File
@@ -19,18 +19,23 @@ function sources(dir: string): string[] {
});
}
function has(dictionary: any, key: string): boolean {
let value = dictionary;
/** The value at a key (`a.b.c`) of a dictionary, `undefined` if there is none. */
function valueAt(dictionary: object, key: string): unknown {
let value: unknown = dictionary;
for (const part of key.split('.')) {
if (value === null || typeof value !== 'object' || !(part in value)) {
return false;
return undefined;
}
value = value[part];
value = (value as Record<string, unknown>)[part];
}
return typeof value === 'string';
return value;
}
function keysOf(dictionary: any, prefix = ''): string[] {
function has(dictionary: object, key: string): boolean {
return typeof valueAt(dictionary, key) === 'string';
}
function keysOf(dictionary: object, prefix = ''): string[] {
return Object.entries(dictionary).flatMap(([key, value]) =>
typeof value === 'object' && value !== null
? keysOf(value, `${prefix}${key}.`)
@@ -60,7 +65,7 @@ describe('translations', () => {
it('have no empty text in english', () => {
const empty = keysOf(english).filter((key) => {
const value = key.split('.').reduce((v: any, part) => v[part], english);
const value = valueAt(english, key);
return typeof value === 'string' && value.trim() === '';
});
assert.deepEqual(empty, []);
+1 -1
View File
@@ -1,7 +1,7 @@
import fs from 'fs';
import { languages } from '$lib/i18n/languages';
function localizeManifest(manifestTemplateData: any, language: string) {
function localizeManifest(manifestTemplateData: Record<string, unknown>, language: string) {
const localizedManifestFile = `static/${language}.manifest.webmanifest`;
const localizedStringsFile = `src/locales/${language}.json`;
const localizedStrings = JSON.parse(fs.readFileSync(localizedStringsFile, 'utf8'));
+99 -6
View File
@@ -1,13 +1,17 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import type { LayerTreeType } from '$lib/data/layers';
import { isCustomLayer, type LayerTreeType } from '$lib/data/layers';
import {
filterLayerTree,
getArrayValidator,
getLayerTreeValidator,
getLayerValidator,
getListValidator,
getRecordValidator,
getValueValidator,
isNumber,
isString,
Setting,
SettingInitOnFirstRead,
storedValue,
@@ -48,7 +52,9 @@ describe('validators', () => {
const validate = getValueValidator(['metric', 'imperial'], 'metric');
assert.equal(validate('imperial'), 'imperial');
assert.equal(validate('nautical'), 'metric');
assert.equal(validate(undefined as any), 'metric');
assert.equal(validate(undefined), 'metric');
assert.equal(validate(null), 'metric');
assert.equal(validate(3), 'metric');
const optional = getValueValidator<string | undefined>(['slope', undefined], undefined);
assert.equal(optional('slope'), 'slope');
assert.equal(optional('other'), undefined);
@@ -60,6 +66,48 @@ describe('validators', () => {
assert.deepEqual(validate([]), []);
});
it('keeps nothing of a saved value that is not a list', () => {
const validate = getArrayValidator(['hr', 'cad', 'power']);
for (const saved of [null, 'hr', 3, { 0: 'hr' }]) {
assert.deepEqual(validate(saved), []);
}
});
it('keeps the items of a list that are what it holds', () => {
const validate = getListValidator(isString);
assert.deepEqual(validate(['a', 3, null, 'b']), ['a', 'b']);
assert.deepEqual(validate('a'), []);
});
it('keeps the entries of a record that are what it holds, else takes the fallback', () => {
const fallback = { a: 0.5 };
const validate = getRecordValidator(isNumber, fallback);
assert.deepEqual(validate({ a: 0.4, b: 'x', c: 1 }), { a: 0.4, c: 1 });
for (const saved of [null, 3, ['a'], 'a']) {
assert.equal(validate(saved), fallback);
}
});
it('recognizes a custom layer', () => {
const layer = {
id: 'custom-0',
name: 'Layer',
tileUrls: ['https://tiles/{z}/{x}/{y}.png'],
maxZoom: 20,
layerType: 'basemap',
resourceType: 'raster',
value: { version: 8, sources: {}, layers: [] },
};
assert.ok(isCustomLayer(layer));
assert.ok(isCustomLayer({ ...layer, resourceType: 'vector', value: 'https://style.json' }));
assert.ok(!isCustomLayer({ ...layer, layerType: 'other' }));
assert.ok(!isCustomLayer({ ...layer, tileUrls: [3] }));
assert.ok(!isCustomLayer({ ...layer, value: null }));
const { name: _name, ...nameless } = layer;
assert.ok(!isCustomLayer(nameless));
assert.ok(!isCustomLayer(null));
});
it('accepts the known layers, and the custom and extension ones', () => {
const validate = getLayerValidator({ libertyTopo: 'url', osm: 'url' }, 'libertyTopo');
assert.equal(validate('osm'), 'osm');
@@ -67,6 +115,9 @@ describe('validators', () => {
assert.equal(validate('extension-some-id'), 'extension-some-id');
assert.equal(validate('gone'), 'libertyTopo');
assert.equal(validate('toString'), 'libertyTopo');
for (const saved of [null, 3, ['osm'], { id: 'osm' }]) {
assert.equal(validate(saved), 'libertyTopo');
}
});
});
@@ -116,12 +167,33 @@ describe('layer trees', () => {
a: false,
b: false,
});
// a branch saved for a layer, and values that are not choices
assert.deepEqual(filterLayerTree({ world: { a: { x: true }, b: 'yes' } }, allowed).world, {
a: true,
b: false,
});
});
it('takes the defaults when what was saved is not a tree', () => {
for (const saved of [null, true, 'world', 3, ['world']]) {
assert.deepEqual(filterLayerTree(saved, allowed), allowed);
}
assert.deepEqual(
filterLayerTree({ world: null, old: null, other: [true] }, allowed),
allowed
);
});
it('does not share its branches with the allowed tree', () => {
const filtered = filterLayerTree({}, allowed);
(filtered.world as LayerTreeType).a = false;
assert.equal((allowed.world as LayerTreeType).a, true);
});
});
describe('Setting', () => {
it('has its initial value until it is connected', () => {
const setting = new Setting('key', 'initial');
const setting = new Setting('key', 'initial', getValueValidator(['initial'], 'initial'));
assert.equal(get(setting), 'initial');
setting.connect({});
assert.equal(get(setting), 'initial');
@@ -145,6 +217,19 @@ describe('Setting', () => {
assert.deepEqual(saved, {});
});
it('takes a saved boolean or number of the type of its initial value', () => {
const flag = new Setting('flag', false);
flag.connect({ flag: 'true' });
assert.equal(get(flag), true);
flag.connect({ flag: '"yes"' });
assert.equal(get(flag), false);
const size = new SettingInitOnFirstRead('size', 170);
size.connect({ size: '240' });
assert.equal(get(size), 240);
size.connect({ size: 'null' });
assert.equal(get(size), 170);
});
it('saves what it is set to, when it changes', () => {
const setting = new Setting('key', 1);
setting.set(2);
@@ -160,13 +245,17 @@ describe('Setting', () => {
it('always saves objects, which may have been changed in place', () => {
const value = { a: 1 };
const setting = new Setting('key', value);
const setting = new Setting('key', value, getRecordValidator(isNumber, value));
setting.set(value);
assert.deepEqual(saved, { key: '{"a":1}' });
});
it('forgets what it saved when it has no value', () => {
const setting = new Setting<string | undefined>('key', 'x');
const setting = new Setting<string | undefined>(
'key',
'x',
getValueValidator<string | undefined>(['x', undefined], undefined)
);
setting.set(undefined);
assert.equal(get(setting), undefined);
assert.ok('key' in saved);
@@ -187,7 +276,11 @@ describe('Setting', () => {
describe('SettingInitOnFirstRead', () => {
it('has no value until it is connected', () => {
const setting = new SettingInitOnFirstRead('key', { a: true });
const setting = new SettingInitOnFirstRead(
'key',
{ a: true },
getLayerTreeValidator({ a: true })
);
assert.equal(get(setting), undefined);
setting.connect({});
assert.deepEqual(get(setting), { a: true });
+92 -35
View File
@@ -20,7 +20,7 @@ export function useSettingsStorage(newStorage: SettingsStorage | undefined) {
}
/** Reads the saved value of a setting, `undefined` if there is none or if it cannot be read. */
export function storedValue(stored: StoredSettings, key: string): { value: any } | undefined {
export function storedValue(stored: StoredSettings, key: string): { value: unknown } | undefined {
if (!Object.hasOwn(stored, key)) {
return undefined;
}
@@ -41,6 +41,26 @@ function save(key: string, value: unknown) {
}
}
/**
* Makes a saved value one that the setting can hold: what was saved can be anything (an old
* format, a value that is not allowed anymore), the validator checks it.
*/
export type Validator<V> = (value: unknown) => V;
/**
* The validator of a setting: a boolean or a number can do without one, its saved value is then
* checked against the type of its initial value. The other settings say what they can hold: a
* string is often one of a few, an object has a shape.
*/
type ValidatorArgument<V> = [V] extends [boolean] | [number]
? [validator?: Validator<V>]
: [validator: Validator<V>];
/** Keeps a saved value that has the type of the initial one (a boolean or a number). */
function sameTypeAs<V>(initial: V): Validator<V> {
return (value) => (typeof value === typeof initial ? (value as V) : initial);
}
/**
* A setting: a store whose value is kept when it changes. `S` is what the store holds, which is
* `V` or, for a setting that has no value until it is read, `V | undefined`.
@@ -48,9 +68,9 @@ function save(key: string, value: unknown) {
abstract class SettingBase<V, S extends V | undefined> {
protected _key: string;
protected _value: Writable<S>;
protected _validator?: (value: V) => V;
protected _validator: Validator<V>;
protected constructor(key: string, initial: S, validator?: (value: V) => V) {
protected constructor(key: string, initial: S, validator: Validator<V>) {
this._key = key;
this._value = writable(initial);
this._validator = validator;
@@ -59,7 +79,7 @@ abstract class SettingBase<V, S extends V | undefined> {
/** The saved value, validated, `undefined` if there is none. */
protected saved(stored: StoredSettings): { value: V } | undefined {
const saved = storedValue(stored, this._key);
return saved && { value: this._validator ? this._validator(saved.value) : saved.value };
return saved && { value: this._validator(saved.value) };
}
/** Takes the saved value, if there is one. */
@@ -83,8 +103,8 @@ abstract class SettingBase<V, S extends V | undefined> {
}
export class Setting<V> extends SettingBase<V, V> {
constructor(key: string, initial: V, validator?: (value: V) => V) {
super(key, initial, validator);
constructor(key: string, initial: V, ...[validator]: ValidatorArgument<V>) {
super(key, initial, validator ?? sameTypeAs(initial));
}
connect(stored: StoredSettings) {
@@ -99,8 +119,8 @@ export class Setting<V> extends SettingBase<V, V> {
export class SettingInitOnFirstRead<V> extends SettingBase<V, V | undefined> {
private _initial: V;
constructor(key: string, initial: V, validator?: (value: V) => V) {
super(key, undefined, validator);
constructor(key: string, initial: V, ...[validator]: ValidatorArgument<V>) {
super(key, undefined, validator ?? sameTypeAs(initial));
this._initial = initial;
}
@@ -114,53 +134,90 @@ export class SettingInitOnFirstRead<V> extends SettingBase<V, V | undefined> {
}
}
export function getValueValidator<V>(allowed: V[], fallback: V) {
const dict = new Set<V>(allowed);
return (value: V) => (dict.has(value) ? value : fallback);
/** Whether a value is one of the allowed ones. */
function oneOf<V>(allowed: V[]) {
const dict = new Set<unknown>(allowed);
return (value: unknown): value is V => dict.has(value);
}
export function getArrayValidator<V>(allowed: V[]) {
const dict = new Set<V>(allowed);
return (value: V[]) => value.filter((v) => dict.has(v));
export function getValueValidator<V>(allowed: V[], fallback: V): Validator<V> {
const isAllowed = oneOf(allowed);
return (value) => (isAllowed(value) ? value : fallback);
}
export function getLayerValidator(allowed: Record<string, any>, fallback: string) {
return (layer: string) =>
Object.hasOwn(allowed, layer) ||
export function isString(value: unknown): value is string {
return typeof value === 'string';
}
export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
/** Keeps the items of a saved list that are what it holds, in their order; nothing if it is not a list. */
export function getListValidator<V>(isItem: (value: unknown) => value is V): Validator<V[]> {
return (value) => (Array.isArray(value) ? value.filter(isItem) : []);
}
/** Keeps the allowed values of a list, in their order; nothing if what was saved is not a list. */
export function getArrayValidator<V>(allowed: V[]): Validator<V[]> {
return getListValidator(oneOf(allowed));
}
/** Keeps the entries of a saved record that are what it holds; `fallback` if it is not a record. */
export function getRecordValidator<V>(
isEntry: (value: unknown) => value is V,
fallback: Record<string, V>
): Validator<Record<string, V>> {
return (value) =>
isRecord(value)
? Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, V] => isEntry(entry[1]))
)
: fallback;
}
export function getLayerValidator(
allowed: Record<string, unknown>,
fallback: string
): Validator<string> {
return (layer) =>
typeof layer === 'string' &&
(Object.hasOwn(allowed, layer) ||
layer.startsWith('custom-') ||
layer.startsWith('extension-')
layer.startsWith('extension-'))
? layer
: fallback;
}
export function filterLayerTree(
t: LayerTreeType,
allowed: LayerTreeType | undefined
): LayerTreeType {
/** Whether a value is a record, which can hold the layers of a tree: an object, not null or a list. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* The saved tree `t`, with the layers of `allowed`: the choices saved for them are kept, the new
* ones have their default, and of the others only the custom and extension layers are kept.
*/
export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined): LayerTreeType {
const tree = isRecord(t) ? t : {};
const filtered: LayerTreeType = {};
if (allowed) {
Object.entries(allowed).forEach(([key, value]) => {
if (Object.hasOwn(t, key)) {
const saved = Object.hasOwn(tree, key) ? tree[key] : undefined;
if (typeof value === 'boolean') {
filtered[key] = t[key];
} else if (typeof value === 'object') {
filtered[key] = filterLayerTree(
typeof t[key] === 'object' ? t[key] : {},
value
);
}
filtered[key] = typeof saved === 'boolean' ? saved : value;
} else {
filtered[key] = value;
filtered[key] = filterLayerTree(saved, value);
}
});
}
Object.entries(t).forEach(([key, value]) => {
Object.entries(tree).forEach(([key, value]) => {
if (!Object.hasOwn(filtered, key)) {
if (typeof value === 'boolean') {
if (key.startsWith('custom-') || key.startsWith('extension-')) {
filtered[key] = value;
}
} else if (typeof value === 'object') {
} else if (isRecord(value)) {
filtered[key] = filterLayerTree(value, undefined);
}
}
@@ -168,6 +225,6 @@ export function filterLayerTree(
return filtered;
}
export function getLayerTreeValidator(allowed: LayerTreeType) {
return (value: LayerTreeType) => filterLayerTree(value, allowed);
export function getLayerTreeValidator(allowed: LayerTreeType): Validator<LayerTreeType> {
return (value) => filterLayerTree(value, allowed);
}
+13 -5
View File
@@ -8,8 +8,8 @@ import {
defaultOverpassQueries,
defaultOverpassTree,
defaultTerrainSource,
isCustomLayer,
overpassTree,
type CustomLayer,
} from '$lib/data/layers';
import { browser } from '$app/environment';
import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/units/conversions';
@@ -19,7 +19,11 @@ import {
getArrayValidator,
getLayerTreeValidator,
getLayerValidator,
getListValidator,
getRecordValidator,
getValueValidator,
isNumber,
isString,
Setting,
SettingInitOnFirstRead,
useSettingsStorage,
@@ -136,10 +140,14 @@ export const settings = {
defaultOverpassTree,
getLayerTreeValidator(overpassTree)
),
opacities: new Setting('opacities', defaultOpacities),
customLayers: new Setting<Record<string, CustomLayer>>('customLayers', {}),
customBasemapOrder: new Setting<string[]>('customBasemapOrder', []),
customOverlayOrder: new Setting<string[]>('customOverlayOrder', []),
opacities: new Setting(
'opacities',
defaultOpacities,
getRecordValidator(isNumber, defaultOpacities)
),
customLayers: new Setting('customLayers', {}, getRecordValidator(isCustomLayer, {})),
customBasemapOrder: new Setting('customBasemapOrder', [], getListValidator(isString)),
customOverlayOrder: new Setting('customOverlayOrder', [], getListValidator(isString)),
terrainSource: new Setting<TerrainSource>(
'terrainSource',
defaultTerrainSource,
@@ -7,8 +7,12 @@
import { getURLForLanguage } from '$lib/utils';
import { ChevronLeft, ChevronRight, PenLine, CornerDownRight } from '@lucide/svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import type { Component } from 'svelte';
let { data }: { data: { guideModule: any; guideTitles: Record<string, string> } } = $props();
let {
data,
}: { data: { guideModule: { default: Component }; guideTitles: Record<string, string> } } =
$props();
let previousGuide = $derived(getPreviousGuide(page.params.guide ?? ''));
let nextGuide = $derived(getNextGuide(page.params.guide ?? ''));