feat(ui): render the location tray's pills, mini status and search error from a store

LocationControls (./ui/location) no longer builds the city and landmark
pills or writes the mini status and the search error: it presents the tray
and hands it to its publish option, so the entry stays Vue-free. The shell
publishes it to a new gev-location store and connects the controls, and
the dock renders it and calls chooseCity() and choosePoi(). The landmark
row still expands a frame after its pills render. The search field, its
toggle and the reset buttons keep their listeners. The location QA harness
reads the rendered elements.
This commit is contained in:
Sameh Khamis committed 2026-10-31 12:19:44 -07:00
1 parent 4966877859
commit 5f8f9bb921
14 files changed
+333 -141

No files matched your search

+100
View File
@@ -7,6 +7,8 @@ import { createPinia } from 'pinia';
import { createApp, h, nextTick } from 'vue';
import { setLocale } from '../i18n/index.js';
import { useDisplayStore } from './stores/display.js';
import { useLocationStore } from './stores/location.js';
import { LocationControls } from './locationControls.js';
import CommandDockView from './views/CommandDockView.vue';
import SceneChromeView from './views/SceneChromeView.vue';
import { VisualSettings } from './visualSettings.js';
@@ -152,3 +154,101 @@ test('the map source row renders the published chips and selects through the con
assert.equal($('#map-stack-status').classList.contains('warn'), false);
assert.equal(document.querySelectorAll('.map-stack-chip').length, 0);
});
test('the location tray renders what LocationControls publishes', async (t) => {
const { $, pinia } = fixture(t);
assert.equal($('#location-mini-city').textContent, '📍 Location: --');
assert.equal($('#location-mini-poi').textContent, 'Landmark: --');
const store = useLocationStore(pinia);
const calls = [];
const frames = [];
const controls = new LocationControls({
elements: {
search: $('#location-search'),
searchToggle: $('#search-toggle'),
resetButtons: [],
},
cities: {
paris: { name: 'Paris', pois: [{ name: 'Louvre' }, { name: 'Eiffel' }] },
rome: { name: 'Rome', pois: [{ name: 'Colosseum' }] },
},
getExpandedCity: () => null,
onCity: (id) => calls.push(['city', id]),
onPoi: (id, index) => calls.push(['poi', id, index]),
onSearch() {},
onReset() {},
publish: (view) => store.publish(view),
requestFrame: (fn) => frames.push(fn),
cancelFrame() {},
});
t.after(() => controls.destroy());
store.connect(controls);
await nextTick();
const pills = [
...document.querySelectorAll('#location-pills .location-pill'),
];
assert.deepEqual(
pills.map((pill) => `${pill.dataset.locationId}:${pill.textContent}`),
['paris:Paris', 'rome:Rome'],
);
pills[1].click();
assert.deepEqual(calls, [['city', 'rome']]);
pills[1].classList.add('voice-highlight');
controls.highlightCity('rome');
controls.showPois('paris');
await nextTick();
assert.equal(pills[1].classList.contains('active'), true);
assert.equal(pills[1].classList.contains('voice-highlight'), true);
const row = $('#poi-row');
const pois = [...row.querySelectorAll('.poi-pill')];
assert.deepEqual(
pois.map((poi) => poi.textContent),
['QLouvre', 'WEiffel'],
);
assert.equal(row.classList.contains('expanded'), false);
frames.shift()();
await nextTick();
assert.equal(row.classList.contains('expanded'), true);
assert.equal($('#location-bar-divider').classList.contains('visible'), true);
controls.highlightPoi(1);
await nextTick();
assert.equal(pois[1].classList.contains('active'), true);
pois[1].click();
assert.deepEqual(calls.at(-1), ['poi', 'paris', 1]);
// Switching cities while the row is open swaps its pills and keeps it open.
controls.showPois('rome');
await nextTick();
assert.equal(row.classList.contains('expanded'), true);
const romePois = [...row.querySelectorAll('.poi-pill')];
assert.deepEqual(
romePois.map((poi) => poi.textContent),
['QColosseum'],
);
assert.equal(romePois[0].classList.contains('active'), false);
romePois[0].click();
assert.deepEqual(calls.at(-1), ['poi', 'rome', 0]);
frames.shift()();
controls.renderStatus({
city: { name: 'Paris' },
currentPoi: { name: 'Eiffel' },
});
controls.setSearchError('No match');
await nextTick();
assert.match($('#location-mini-city').textContent, /Paris/);
assert.match($('#location-mini-poi').textContent, /Eiffel/);
assert.equal($('#location-search-error').textContent, 'No match');
const search = $('#location-search');
assert.equal(search.getAttribute('aria-invalid'), 'true');
assert.equal(
search.getAttribute('aria-describedby'),
'location-search-error',
);
controls.setSearchError(null);
controls.hidePois();
await nextTick();
assert.equal(search.hasAttribute('aria-invalid'), false);
assert.equal(search.hasAttribute('aria-describedby'), false);
assert.equal($('#location-search-error').textContent, '');
assert.equal(row.classList.contains('expanded'), false);
assert.equal($('#location-bar-divider').classList.contains('visible'), false);
});