diff --git a/website/src/lib/selection-helpers.test.ts b/website/src/lib/selection-helpers.test.ts new file mode 100644 index 000000000..bc17f553e --- /dev/null +++ b/website/src/lib/selection-helpers.test.ts @@ -0,0 +1,140 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { Selection } from 'gpx-rs'; +import { + hasSelectionWithin, + isCovered, + isEmpty, + isSelected, + selectedFileIds, + type FileTreeNode, +} from './selection-helpers'; + +// Two files; F1 has the tracks T1 (segments S1, S2) and T2 (segment S3), and waypoints W1, W2. +const nodes: Record = { + F1: { type: 'file', fileId: 'F1' }, + F2: { type: 'file', fileId: 'F2' }, + T1: { type: 'track', fileId: 'F1', trackId: 'T1' }, + T2: { type: 'track', fileId: 'F1', trackId: 'T2' }, + S1: { type: 'segment', fileId: 'F1', trackId: 'T1', segmentId: 'S1' }, + S2: { type: 'segment', fileId: 'F1', trackId: 'T1', segmentId: 'S2' }, + S3: { type: 'segment', fileId: 'F1', trackId: 'T2', segmentId: 'S3' }, + WPTS: { type: 'waypoints', fileId: 'F1' }, + W1: { type: 'waypoint', fileId: 'F1', waypointId: 'W1' }, + W2: { type: 'waypoint', fileId: 'F1', waypointId: 'W2' }, + // same ids in another file + F2_WPTS: { type: 'waypoints', fileId: 'F2' }, +}; + +const empty: Selection = { type: 'empty' }; +const files = (...fileIds: string[]): Selection => ({ type: 'file', fileIds }); +const tracks = (...trackIds: string[]): Selection => ({ type: 'track', fileId: 'F1', trackIds }); +const segments = (trackId: string, ...segmentIds: string[]): Selection => ({ + type: 'segment', + fileId: 'F1', + trackId, + segmentIds, +}); +const waypoints: Selection = { type: 'waypoints', fileId: 'F1' }; +const waypoint = (...waypointIds: string[]): Selection => ({ + type: 'waypoint', + fileId: 'F1', + waypointIds, +}); + +/** The names of the nodes for which `predicate` holds, to compare with what is expected. */ +function matching(selection: Selection, predicate: typeof isSelected): string[] { + return Object.keys(nodes).filter((name) => predicate(selection, nodes[name])); +} + +describe('selection helpers', () => { + it('tells whether the selection is empty', () => { + assert.equal(isEmpty(empty), true); + assert.equal(isEmpty(files('F1')), false); + assert.equal(isEmpty(waypoints), false); + }); + + it('lists the files holding something selected', () => { + assert.deepEqual(selectedFileIds(empty), []); + assert.deepEqual(selectedFileIds(files('F1', 'F2')), ['F1', 'F2']); + assert.deepEqual(selectedFileIds(tracks('T1')), ['F1']); + assert.deepEqual(selectedFileIds(segments('T1', 'S1')), ['F1']); + assert.deepEqual(selectedFileIds(waypoints), ['F1']); + assert.deepEqual(selectedFileIds(waypoint('W1')), ['F1']); + }); + + describe('isSelected', () => { + it('matches the selected nodes only', () => { + assert.deepEqual(matching(empty, isSelected), []); + assert.deepEqual(matching(files('F1', 'F2'), isSelected), ['F1', 'F2']); + assert.deepEqual(matching(tracks('T1', 'T2'), isSelected), ['T1', 'T2']); + assert.deepEqual(matching(segments('T1', 'S1'), isSelected), ['S1']); + assert.deepEqual(matching(waypoints, isSelected), ['WPTS']); + assert.deepEqual(matching(waypoint('W2'), isSelected), ['W2']); + }); + + it('does not mix up the track of a segment, nor the file', () => { + // S3 belongs to T2: selecting "S3 of T1" does not select it + assert.equal(isSelected(segments('T1', 'S3'), nodes.S3), false); + assert.equal(isSelected(files('F2'), nodes.F1), false); + }); + }); + + describe('isCovered', () => { + it('covers everything below a selected file', () => { + assert.deepEqual( + matching(files('F1'), isCovered), + Object.keys(nodes).filter((name) => name !== 'F2' && name !== 'F2_WPTS') + ); + }); + + it('covers the segments of a selected track, not its siblings', () => { + assert.deepEqual(matching(tracks('T1'), isCovered), ['T1', 'S1', 'S2']); + }); + + it('covers the waypoints below the waypoints node', () => { + assert.deepEqual(matching(waypoints, isCovered), ['WPTS', 'W1', 'W2']); + }); + + it('does not cover parents, siblings or the other files', () => { + assert.deepEqual(matching(segments('T1', 'S1'), isCovered), ['S1']); + assert.deepEqual(matching(waypoint('W1'), isCovered), ['W1']); + assert.deepEqual(matching(empty, isCovered), []); + }); + }); + + describe('hasSelectionWithin', () => { + it('looks at the descendants of a file, whatever their kind', () => { + for (const selection of [ + files('F1'), + tracks('T1'), + segments('T1', 'S1'), + waypoints, + waypoint('W1'), + ]) { + assert.equal(hasSelectionWithin(selection, nodes.F1), true); + assert.equal(hasSelectionWithin(selection, nodes.F2), false); + } + assert.equal(hasSelectionWithin(empty, nodes.F1), false); + }); + + it('looks at the segments of a track', () => { + assert.deepEqual(matching(tracks('T1'), hasSelectionWithin), ['F1', 'T1']); + assert.deepEqual(matching(segments('T1', 'S2'), hasSelectionWithin), [ + 'F1', + 'T1', + 'S2', + ]); + }); + + it('looks at the waypoints below the waypoints node', () => { + assert.deepEqual(matching(waypoints, hasSelectionWithin), ['F1', 'WPTS']); + assert.deepEqual(matching(waypoint('W1'), hasSelectionWithin), ['F1', 'WPTS', 'W1']); + }); + + it('does not look at ancestors', () => { + // a selected file does not mean that its segments hold a selection + assert.deepEqual(matching(files('F1'), hasSelectionWithin), ['F1']); + }); + }); +}); diff --git a/website/src/lib/selection-helpers.ts b/website/src/lib/selection-helpers.ts new file mode 100644 index 000000000..ba4c11b3d --- /dev/null +++ b/website/src/lib/selection-helpers.ts @@ -0,0 +1,109 @@ +import type { Selection } from 'gpx-rs'; + +/** + * An element of the file tree, to ask questions about the selection: + * + * file ─┬─ track ─── segment + * └─ waypoints ─── waypoint + * + * `waypoints` is the node standing for all the waypoints of a file. + */ +export type FileTreeNode = + | { type: 'file'; fileId: string } + | { type: 'track'; fileId: string; trackId: string } + | { type: 'segment'; fileId: string; trackId: string; segmentId: string } + | { type: 'waypoints'; fileId: string } + | { type: 'waypoint'; fileId: string; waypointId: string }; + +export function isEmpty(selection: Selection): boolean { + return selection.type === 'empty'; +} + +/** Ids of the files holding something selected (or selected themselves). */ +export function selectedFileIds(selection: Selection): string[] { + switch (selection.type) { + case 'empty': + return []; + case 'file': + return selection.fileIds; + default: + return [selection.fileId]; + } +} + +/** Whether the node itself is selected. */ +export function isSelected(selection: Selection, node: FileTreeNode): boolean { + switch (node.type) { + case 'file': + return selection.type === 'file' && selection.fileIds.includes(node.fileId); + case 'track': + return ( + selection.type === 'track' && + selection.fileId === node.fileId && + selection.trackIds.includes(node.trackId) + ); + case 'segment': + return ( + selection.type === 'segment' && + selection.fileId === node.fileId && + selection.trackId === node.trackId && + selection.segmentIds.includes(node.segmentId) + ); + case 'waypoints': + return selection.type === 'waypoints' && selection.fileId === node.fileId; + case 'waypoint': + return ( + selection.type === 'waypoint' && + selection.fileId === node.fileId && + selection.waypointIds.includes(node.waypointId) + ); + } +} + +/** The parent of a node, `undefined` for a file. */ +function parent(node: FileTreeNode): FileTreeNode | undefined { + switch (node.type) { + case 'file': + return undefined; + case 'track': + case 'waypoints': + return { type: 'file', fileId: node.fileId }; + case 'segment': + return { type: 'track', fileId: node.fileId, trackId: node.trackId }; + case 'waypoint': + return { type: 'waypoints', fileId: node.fileId }; + } +} + +/** Whether the node, or one of its ancestors, is selected: the node is covered by the selection. */ +export function isCovered(selection: Selection, node: FileTreeNode): boolean { + for (let n: FileTreeNode | undefined = node; n; n = parent(n)) { + if (isSelected(selection, n)) { + return true; + } + } + return false; +} + +/** Whether the node, or one of its descendants, is selected. */ +export function hasSelectionWithin(selection: Selection, node: FileTreeNode): boolean { + switch (node.type) { + case 'file': + return selectedFileIds(selection).includes(node.fileId); + case 'track': + return ( + isSelected(selection, node) || + (selection.type === 'segment' && + selection.fileId === node.fileId && + selection.trackId === node.trackId) + ); + case 'waypoints': + return ( + isSelected(selection, node) || + (selection.type === 'waypoint' && selection.fileId === node.fileId) + ); + case 'segment': + case 'waypoint': + return isSelected(selection, node); + } +}