// The frontend side of the engine, against the real engine (the WASM module of `wasm`, which has // to be built: `wasm-pack build --target bundler` in `wasm`). import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { beforeEach, describe, it } from 'node:test'; import { get } from 'svelte/store'; import { isHidden } from '$lib/engine/file-visibility'; import { ALL_EXPORT_OPTIONS, engine, FileStateCollectionObserver, NO_TIME, type ExportOptions, type FileState, } from '$lib/engine'; function fixture(name: string): Uint8Array { return new Uint8Array( readFileSync(new URL(`../../../../gpx-rs/data/${name}.gpx`, import.meta.url)) ); } async function load(...names: string[]) { return engine.loadFiles(names.map((name) => ({ data: fixture(name), name }))); } function fileStates(): FileState[] { return [...get(engine.files).values()].map((store) => get(store)); } function lastError() { return get(engine.lastError); } beforeEach(async () => { await engine.ready; await engine.deleteAll(); }); describe('files', () => { it('creates a file, selects it, and can take it back', async () => { assert.equal(await engine.newFile('route'), true); const [id] = get(engine.order); assert.equal(get(engine.order).length, 1); assert.equal(get(get(engine.files).get(id)!).structure.name, 'route'); assert.deepEqual(get(engine.selection), { type: 'file', fileIds: [id] }); assert.equal(get(engine.canUndo), true); await engine.undo(); assert.deepEqual(get(engine.order), []); assert.equal(get(engine.files).size, 0); assert.equal(get(engine.canRedo), true); await engine.redo(); assert.deepEqual(get(engine.order), [id]); }); it('starts a new file with a track when given a trackpoint', async () => { await engine.newFile('start', { lng: 4.4, lat: 50.8, ele: 120 }); const file = fileStates()[0]; assert.equal(file.structure.tracks.length, 1); assert.equal(file.segments.features.length, 1); assert.deepEqual(file.segments.features[0].geometry.coordinates, [[4.4, 50.8]]); assert.equal(get(engine.statistics).length, 1); assert.equal(get(engine.statistics).ele[0], 120); }); it('loads several files in one undo step and selects the first one', async () => { assert.equal(await load('simple', 'with_tracks'), true); const ids = get(engine.order); assert.equal(ids.length, 2); assert.deepEqual(get(engine.selection), { type: 'file', fileIds: [ids[0]] }); // the names of the files are the ones of the data, or the given ones assert.deepEqual( fileStates().map((file) => file.structure.name), ['simple', 'with_tracks'] ); await engine.undo(); assert.equal(get(engine.files).size, 0); }); it('skips what cannot be read, and tells why when nothing could', async () => { const broken = { data: new TextEncoder().encode(''), name: 'broken' }; assert.equal(await engine.loadFiles([broken]), false); assert.match(lastError() ?? '', /^invalid data/); assert.equal(get(engine.files).size, 0); assert.equal( await engine.loadFiles([broken, { data: fixture('simple'), name: 'ok' }]), true ); assert.equal(get(engine.files).size, 1); assert.equal(lastError(), undefined); }); it('duplicates and deletes the selection', async () => { await load('simple'); await engine.duplicate(); assert.equal(get(engine.order).length, 2); const [original, copy] = get(engine.order); assert.notEqual(original, copy); await engine.delete(); assert.equal(get(engine.files).size, 1); assert.equal(await engine.deleteAll(), true); assert.equal(await engine.deleteAll(), false); assert.equal(lastError(), 'nothing to do'); }); it('reorders the files', async () => { await load('simple', 'with_tracks', 'with_waypoint'); const [a, b, c] = get(engine.order); await engine.reorder([c], 0); assert.deepEqual(get(engine.order), [c, a, b]); assert.equal(get(engine.canUndo), true); // an order is not an edit: only the loading is undone await engine.undo(); assert.equal(get(engine.files).size, 0); }); }); describe('errors', () => { it('keeps the reason of the last call that did nothing', async () => { assert.equal(lastError(), 'nothing to do'); await engine.newFile('a'); assert.equal(lastError(), undefined); await engine.select([]); assert.equal(await engine.reverse(), false); assert.equal(lastError(), 'nothing to do'); // a call with wrong arguments says so assert.equal(await engine.crop(5, 1), false); assert.match(lastError() ?? '', /reversed/); // the next success clears it assert.equal(await engine.newFile('b'), true); assert.equal(lastError(), undefined); }); it('refuses a stale routing revision', async () => { await engine.newFile('a', { lng: 4, lat: 50 }); assert.equal(await engine.insertAnchor(-1, 4, 50), false); assert.match(lastError() ?? '', /selection changed/); }); }); describe('selection', () => { 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])); await engine.select([a], 'toggle'); assert.deepEqual((get(engine.selection) as any).fileIds, [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])); }); it('selects tracks and segments of a file', async () => { await load('with_tracks_and_segments'); const file = fileStates()[0]; const track = file.structure.tracks[0]; await engine.selectTracks(file.structure.id, [track.id]); assert.deepEqual(get(engine.selection), { type: 'track', fileId: file.structure.id, trackIds: [track.id], }); // the statistics follow the selection const trackLength = get(engine.statistics).length; assert.ok(trackLength > 0); await engine.select([file.structure.id]); assert.ok(get(engine.statistics).length > trackLength); }); it('ignores what does not exist', async () => { await load('simple'); const before = get(engine.selection); assert.equal(await engine.select(['00000000-0000-0000-0000-000000000000'], 'add'), false); assert.deepEqual(get(engine.selection), before); }); }); describe('statistics', () => { it('describes the selection', async () => { await load('simple'); const statistics = get(engine.statistics); const file = fileStates()[0]; assert.ok(statistics.length > 1); assert.equal(statistics.lng.length, statistics.length); assert.equal(statistics.ele.length, statistics.length); assert.equal(statistics.totalDistance[0], 0); assert.ok(statistics.global.totalDistance > 0); assert.equal(statistics.global.totalDistance, file.statistics.totalDistance); assert.equal(statistics.timestamps, undefined); assert.ok(statistics.global.bounds!.west <= statistics.global.bounds!.east); await engine.select([]); assert.equal(get(engine.statistics).length, 0); assert.equal(get(engine.statistics).global.totalDistance, 0); }); it('has timestamps when the file has some', async () => { await load('with_time'); const statistics = get(engine.statistics); assert.ok(statistics.timestamps); assert.equal(statistics.timestamps!.length, statistics.length); assert.ok(statistics.timestamps!.some((t) => t !== NO_TIME)); assert.ok(statistics.global.totalTime! > 0); assert.ok(statistics.global.movingSpeed === undefined || statistics.global.movingSpeed > 0); }); it('reads the metrics that are requested, and only these', async () => { await load('with_hr'); assert.equal(get(engine.statistics).hr, undefined); const request = engine.requestStatistics(); await request.set(['hr', 'anchors']); const statistics = get(engine.statistics); assert.equal(statistics.hr!.length, statistics.length); assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr))); assert.equal(statistics.cad, undefined); assert.equal(statistics.anchors!.indices[0], 0); assert.equal(statistics.anchors!.indices.at(-1), statistics.length - 1); // they are read again after a change, without being asked again await engine.select([]); await engine.select([get(engine.order)[0]]); assert.ok(get(engine.statistics).hr); request.release(); await engine.select([]); await engine.select([get(engine.order)[0]]); assert.equal(get(engine.statistics).hr, undefined); }); it('gives the statistics of a part of the selection', async () => { await load('simple'); const statistics = get(engine.statistics); const part = (await statistics.slice(2, 5))!; assert.ok(part.totalDistance > 0); assert.ok(part.totalDistance < statistics.global.totalDistance); assert.equal(await statistics.slice(0, statistics.length + 10), undefined); // a slice is only valid for the selection it was made on await engine.select([]); assert.equal(await statistics.slice(2, 5), undefined); }); }); describe('file state', () => { it('has one feature per segment and keeps the unchanged ones when editing', async () => { await load('with_tracks_and_segments'); const before = fileStates()[0]; assert.equal( before.segments.features.length, before.structure.tracks.reduce((sum, track) => sum + track.segments.length, 0) ); // renaming does not touch the segments: they are the same objects await engine.metadata('renamed', 'description'); const renamed = fileStates()[0]; assert.equal(renamed.structure.name, 'renamed'); assert.equal(renamed.structure.desc, 'description'); renamed.segments.features.forEach((feature, i) => { assert.equal(feature, before.segments.features[i]); }); // reversing a file reverses its tracks and their points await engine.reverse(); const reversed = fileStates()[0].segments.features.map((f) => f.geometry.coordinates); const expected = [...renamed.segments.features] .reverse() .map((f) => [...f.geometry.coordinates].reverse()); assert.deepEqual(reversed, expected); await engine.undo(); fileStates()[0].segments.features.forEach((feature, i) => { assert.deepEqual( feature.geometry.coordinates, renamed.segments.features[i].geometry.coordinates ); }); }); it('colors the files, and keeps the color of a file', async () => { await load('simple', 'with_tracks'); const [a, b] = fileStates(); assert.notEqual(a.color, b.color); await engine.select([a.structure.id]); await engine.style({ color: 'ff0000' }); assert.equal(get(get(engine.files).get(a.structure.id)!).color, '#ff0000'); const styled = get(get(engine.files).get(a.structure.id)!).segments.features[0]; assert.equal(styled.properties.color, '#ff0000'); await engine.undo(); assert.equal(get(get(engine.files).get(a.structure.id)!).color, a.color); }); it('has the waypoints as features', async () => { await load('with_waypoint'); const file = fileStates()[0]; assert.ok(file.waypoints.features.length > 0); const feature = file.waypoints.features[0]; const details = (await engine.waypoint(file.structure.id, feature.properties.waypointId))!; assert.deepEqual(feature.geometry.coordinates, [details.lng, details.lat]); assert.equal(feature.properties.name, details.name); assert.equal(details.links?.[0].href, 'https://gpx.studio'); assert.equal(details.links?.[0].text, 'waypoint link text'); }); }); describe('waypoints', () => { const waypoint = { lng: 4.4, lat: 50.8, ele: 100, name: 'summit', desc: 'a nice view', icon: 'Summit', link: 'https://example.com', }; it('creates, edits, moves and deletes a waypoint', async () => { await engine.newFile('file'); assert.equal(await engine.newWaypoint(waypoint), true); const file = fileStates()[0]; const id = file.waypoints.features[0].properties.waypointId; const fileId = file.structure.id; let details = (await engine.waypoint(fileId, id))!; assert.equal(details.name, 'summit'); assert.equal(details.sym, 'Summit'); assert.equal(details.links![0].href, 'https://example.com'); await engine.updateWaypoint(fileId, id, { ...waypoint, name: 'peak', link: '' }); details = (await engine.waypoint(fileId, id))!; assert.equal(details.name, 'peak'); assert.equal(details.links, undefined); await engine.moveWaypoint(fileId, id, 5, 51, 200); assert.deepEqual(fileStates()[0].waypoints.features[0].geometry.coordinates, [5, 51]); assert.equal((await engine.waypoint(fileId, id))!.ele, 200); await engine.deleteWaypoint(fileId, id); assert.equal(fileStates()[0].waypoints.features.length, 0); assert.equal(await engine.waypoint(fileId, id), undefined); assert.equal(await engine.deleteWaypoint(fileId, id), false); }); }); describe('clipboard', () => { it('copies and pastes', async () => { await load('simple'); assert.equal(get(engine.clipboard), undefined); assert.equal(get(engine.canPaste), false); await engine.copy(); assert.equal(get(engine.clipboard)?.type, 'files'); assert.equal(get(engine.canPaste), true); await engine.paste(); assert.equal(get(engine.files).size, 2); }); }); describe('observer of the files', () => { it('tells which files appear and disappear', async () => { const added: string[] = []; const removed: string[] = []; let destroyed = 0; await load('simple'); const [first] = get(engine.order); const observer = new FileStateCollectionObserver( (files) => files.forEach((_, id) => added.push(id)), (id) => removed.push(id), () => destroyed++ ); // the files that exist are reported at once assert.deepEqual(added, [first]); await load('with_tracks'); const second = get(engine.order).find((id) => id !== first)!; assert.deepEqual(added, [first, second]); // editing a file does not make it appear again await engine.metadata('x', ''); assert.equal(added.length, 2); await engine.deleteAll(); assert.deepEqual(new Set(removed), new Set([first, second])); observer.destroy(); assert.equal(destroyed, 1); }); }); describe('export', () => { const text = async (id: string, options: Partial = {}) => new TextDecoder().decode( await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, ...options }) ); it('writes tracks, or routes when asked to', async () => { await load('with_tracks_and_segments'); const file = fileStates()[0]; const segments = file.structure.tracks.reduce( (sum, track) => sum + track.segments.length, 0 ); const id = file.structure.id; const tracks = await text(id); assert.ok(tracks.includes('') && !tracks.includes('')); assert.equal(tracks.match(//g)?.length, segments); const routes = await text(id, { asRoute: true }); assert.ok(routes.includes('') && !routes.includes('')); assert.equal(routes.match(//g)?.length, segments); assert.ok(routes.includes(' { await load('simple'); const data = await engine.exportableData(get(engine.order)); assert.equal(data.asRoute, false); }); it('can read back the routes it writes', async () => { await load('with_tracks_and_segments'); const id = get(engine.order)[0]; const before = fileStates()[0].segments.features.length; const routes = (await engine.exportFile(id, { ...ALL_EXPORT_OPTIONS, asRoute: true }))!; await engine.loadFiles([{ data: routes, name: 'routes' }]); const [, loaded] = get(engine.order); const state = get(get(engine.files).get(loaded)!); // one route per segment, each read as a track of one segment assert.equal(state.structure.tracks.length, before); assert.equal(state.segments.features.length, before); }); }); describe('visibility', () => { it('hides the selection and shows it again', async () => { await load('simple'); const id = get(engine.order)[0]; const state = () => get(get(engine.files).get(id)!); assert.equal(isHidden(state(), id), false); await engine.setSelectionHidden(true); assert.equal(isHidden(state(), id), true); // the statistics still count what is hidden assert.ok(get(engine.statistics).length > 0); await engine.setSelectionHidden(false); assert.equal(isHidden(state(), id), false); }); });