mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
LocationControls offers the indicator with showOrbit(active) in its presented tray instead of building it on the body, and the scene chrome teleports it there. Orbit toggles, including the voice switch, light it through the controls.
203 lines
6.3 KiB
JavaScript
203 lines
6.3 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { LocationControls } from './locationControls.js';
|
|
|
|
function node() {
|
|
const classes = new Set();
|
|
return {
|
|
children: [],
|
|
dataset: {},
|
|
listeners: new Map(),
|
|
className: '',
|
|
textContent: '',
|
|
classList: {
|
|
add: (...names) => names.forEach((name) => classes.add(name)),
|
|
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
|
contains: (name) => classes.has(name),
|
|
toggle(name, enabled = !classes.has(name)) {
|
|
if (enabled) classes.add(name);
|
|
else classes.delete(name);
|
|
},
|
|
},
|
|
addEventListener(name, callback) {
|
|
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
|
|
this.listeners.get(name).add(callback);
|
|
},
|
|
removeEventListener(name, callback) {
|
|
this.listeners.get(name)?.delete(callback);
|
|
},
|
|
fire(name, event = {}) {
|
|
for (const callback of this.listeners.get(name) || []) callback(event);
|
|
},
|
|
appendChild(child) {
|
|
this.children.push(child);
|
|
child.parent = this;
|
|
},
|
|
append(...children) {
|
|
for (const child of children)
|
|
if (typeof child !== 'string') this.appendChild(child);
|
|
},
|
|
replaceChildren() {
|
|
this.children = [];
|
|
},
|
|
remove() {
|
|
if (this.parent)
|
|
this.parent.children = this.parent.children.filter(
|
|
(child) => child !== this,
|
|
);
|
|
},
|
|
querySelectorAll(selector) {
|
|
return this.children.flatMap((child) => [
|
|
...(child.className === selector.slice(1) ? [child] : []),
|
|
...child.querySelectorAll(selector),
|
|
]);
|
|
},
|
|
focus() {
|
|
this.focused = true;
|
|
},
|
|
};
|
|
}
|
|
function fixture() {
|
|
const elements = {
|
|
search: node(),
|
|
searchToggle: node(),
|
|
resetButtons: [node(), node()],
|
|
};
|
|
const doc = node();
|
|
doc.createElement = node;
|
|
const cities = {
|
|
a: { name: 'City A', pois: [{ name: 'First' }, { name: 'Second' }] },
|
|
b: { name: 'City B', pois: [{ name: 'Elsewhere' }] },
|
|
};
|
|
const calls = [];
|
|
const frames = new Map();
|
|
const cancelled = [];
|
|
let next = 0;
|
|
const views = [];
|
|
const controls = new LocationControls({
|
|
elements,
|
|
cities,
|
|
getExpandedCity: () => 'a',
|
|
onCity: (id) => calls.push(['city', id]),
|
|
onPoi: (id, index) => calls.push(['poi', id, index]),
|
|
onSearch: (query) => calls.push(['search', query]),
|
|
onSearchEdit: () => calls.push(['edit']),
|
|
onReset: () => calls.push(['reset']),
|
|
publish: (view) => views.push(view),
|
|
doc,
|
|
requestFrame: (fn) => {
|
|
const id = next++;
|
|
frames.set(id, fn);
|
|
return id;
|
|
},
|
|
cancelFrame: (id) => cancelled.push(id),
|
|
});
|
|
const view = () => views.at(-1);
|
|
return { elements, doc, controls, calls, frames, cancelled, views, view };
|
|
}
|
|
test('the tray presents its cities, empty status and no landmarks at first', () => {
|
|
const f = fixture();
|
|
assert.deepEqual(f.view(), {
|
|
cities: [
|
|
{ id: 'a', label: 'City A', active: false },
|
|
{ id: 'b', label: 'City B', active: false },
|
|
],
|
|
poiCity: null,
|
|
pois: [],
|
|
poisExpanded: false,
|
|
status: { city: '📍 Location: --', poi: 'Landmark: --' },
|
|
searchError: null,
|
|
orbit: null,
|
|
});
|
|
f.controls.highlightCity('b');
|
|
assert.deepEqual(
|
|
f.view().cities.map((city) => city.active),
|
|
[false, true],
|
|
);
|
|
f.controls.setSearchError('Not found');
|
|
assert.equal(f.view().searchError, 'Not found');
|
|
f.controls.setSearchError(null);
|
|
assert.equal(f.view().searchError, null);
|
|
});
|
|
test('hiding a POI row cancels frame zero and rejects an already queued expansion', () => {
|
|
const f = fixture();
|
|
f.controls.showPois('a');
|
|
const callback = f.frames.get(0);
|
|
f.controls.hidePois();
|
|
callback();
|
|
assert.deepEqual(f.cancelled, [0]);
|
|
assert.equal(f.view().poisExpanded, false);
|
|
});
|
|
test('replacing POIs routes clicks to the final row and expands it a frame later', () => {
|
|
const f = fixture();
|
|
f.controls.showPois('a');
|
|
f.controls.highlightPoi(1);
|
|
assert.deepEqual(
|
|
f.view().pois.map((poi) => [poi.key, poi.name, poi.active]),
|
|
[
|
|
['Q', 'First', false],
|
|
['W', 'Second', true],
|
|
],
|
|
);
|
|
f.controls.showPois('b');
|
|
assert.equal(f.view().poiCity, 'b');
|
|
assert.deepEqual(
|
|
f.view().pois.map((poi) => [poi.name, poi.active]),
|
|
[['Elsewhere', false]],
|
|
);
|
|
f.controls.choosePoi(0);
|
|
assert.deepEqual(f.calls, [['poi', 'b', 0]]);
|
|
f.frames.get(0)();
|
|
assert.equal(f.view().poisExpanded, false);
|
|
f.frames.get(1)();
|
|
assert.equal(f.view().poisExpanded, true);
|
|
});
|
|
test('destruction revokes document, search, reset, city and POI actions and removes orbit UI', () => {
|
|
const f = fixture();
|
|
f.controls.showPois('a');
|
|
f.controls.showOrbit(true);
|
|
assert.deepEqual(f.view().orbit, { active: true });
|
|
f.controls.destroy();
|
|
assert.equal(f.view().orbit, null);
|
|
f.controls.destroy();
|
|
const published = f.views.length;
|
|
f.controls.showOrbit(true);
|
|
f.controls.chooseCity('a');
|
|
f.controls.choosePoi(0);
|
|
f.controls.highlightCity('a');
|
|
f.elements.search.value = 'Place';
|
|
f.elements.search.fire('keydown', { key: 'Enter' });
|
|
f.doc.fire('keydown', { key: 'Q' });
|
|
f.elements.resetButtons[0].fire('click');
|
|
f.frames.get(0)();
|
|
assert.deepEqual(f.calls, []);
|
|
assert.equal(f.views.length, published);
|
|
});
|
|
test('the mini status shows the city, a landmark or a searched place', () => {
|
|
const f = fixture();
|
|
f.controls.renderStatus({ searchedLabel: 'Somewhere' });
|
|
assert.equal(f.view().status.poi, 'Searched location');
|
|
f.controls.renderStatus({});
|
|
assert.deepEqual(f.view().status, {
|
|
city: '📍 Location: --',
|
|
poi: 'Landmark: --',
|
|
});
|
|
});
|
|
test('location and POI keys route once while form controls retain typing', () => {
|
|
const f = fixture();
|
|
f.doc.fire('keydown', { key: 'W', target: { matches: () => false } });
|
|
f.doc.fire('keydown', { key: 'Q', target: { matches: () => true } });
|
|
f.elements.resetButtons[1].fire('click');
|
|
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
|
|
});
|
|
test('typing clears a failed search error and closing the emptied field drops it too', () => {
|
|
const f = fixture();
|
|
f.elements.search.value = 'Pariss';
|
|
f.elements.search.fire('blur');
|
|
assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error');
|
|
f.elements.search.fire('input');
|
|
f.elements.search.value = '';
|
|
f.elements.search.fire('blur');
|
|
assert.deepEqual(f.calls, [['edit'], ['edit']]);
|
|
});
|