mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
654 lines
21 KiB
TypeScript
654 lines
21 KiB
TypeScript
// FE-COMP-POIEXPLORE-001 to FE-COMP-POIEXPLORE-028
|
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
import type { PluginPoiCategory } from '@trek/shared';
|
|
import React from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { mapsApi } from '../../api/client';
|
|
import { pluginPoisApi } from '../../api/pluginPois';
|
|
import { TranslationProvider } from '../../i18n';
|
|
import { pluginPoiRepo } from '../../repo/pluginPoiRepo';
|
|
import { usePluginStore, type ActivePlugin } from '../../store/pluginStore';
|
|
import type { Poi } from './poiCategories';
|
|
import { usePoiExplore, type Bbox } from './usePoiExplore';
|
|
|
|
const net = vi.hoisted(() => ({ offline: false }));
|
|
vi.mock('../../sync/networkMode', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../sync/networkMode')>()),
|
|
isEffectivelyOffline: () => net.offline,
|
|
}));
|
|
|
|
type PoiResponse = Awaited<ReturnType<typeof mapsApi.pois>>;
|
|
|
|
const BBOX: Bbox = { south: 48.1, west: 16.3, north: 48.3, east: 16.4 };
|
|
|
|
function poi(over: Partial<Poi> = {}): Poi {
|
|
return {
|
|
osm_id: 'node/1',
|
|
name: 'Café Central',
|
|
lat: 48.21,
|
|
lng: 16.36,
|
|
category: 'cafe',
|
|
poi_type: 'cafe',
|
|
address: null,
|
|
website: null,
|
|
phone: null,
|
|
opening_hours: null,
|
|
cuisine: null,
|
|
source: 'openstreetmap',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function response(pois: Poi[]): PoiResponse {
|
|
return { pois, source: 'openstreetmap', truncated: false };
|
|
}
|
|
|
|
// Hand-controlled promise so a test can hold a request "in flight" and decide
|
|
// exactly when — and in which order — it settles.
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
|
|
|
|
function setup() {
|
|
return renderHook(() => usePoiExplore(), { wrapper });
|
|
}
|
|
|
|
const spyOnPois = () => vi.spyOn(mapsApi, 'pois');
|
|
let pois: ReturnType<typeof spyOnPois>;
|
|
|
|
const initialPlugins = usePluginStore.getState();
|
|
|
|
beforeEach(() => {
|
|
pois = spyOnPois().mockResolvedValue(response([poi()]));
|
|
net.offline = false;
|
|
usePluginStore.setState(initialPlugins, true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('usePoiExplore', () => {
|
|
it('FE-COMP-POIEXPLORE-001: starts with nothing selected, loaded or failed', () => {
|
|
const { result } = setup();
|
|
expect(result.current.active.size).toBe(0);
|
|
expect(result.current.pois).toEqual([]);
|
|
expect(result.current.loadingKeys.size).toBe(0);
|
|
expect(result.current.errorKeys.size).toBe(0);
|
|
expect(result.current.moved).toBe(false);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-002: panning with nothing active does not offer "search this area"', () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
expect(result.current.moved).toBe(false);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-003: toggling before the map reported a viewport selects but does not fetch', () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
expect(result.current.active.has('cafe')).toBe(true);
|
|
expect(pois).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-004: toggling fetches the category for the current viewport', async () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
expect(pois).toHaveBeenCalledWith('cafe', BBOX, 'en-US', expect.any(AbortSignal));
|
|
expect(result.current.pois[0].osm_id).toBe('node/1');
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-005: the spinner is on while the request is in flight and off when it lands', async () => {
|
|
const d = deferred<PoiResponse>();
|
|
pois.mockReturnValue(d.promise);
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
|
|
expect(result.current.loadingKeys.has('cafe')).toBe(true);
|
|
|
|
await act(async () => {
|
|
d.resolve(response([poi()]));
|
|
});
|
|
await waitFor(() => expect(result.current.loadingKeys.has('cafe')).toBe(false));
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-006: toggling the active category turns it off and drops its markers', async () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
expect(result.current.active.size).toBe(0);
|
|
expect(result.current.pois).toEqual([]);
|
|
expect(pois).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-007: the pill is single-select — a second category replaces the first', async () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
|
|
pois.mockResolvedValue(response([poi({ osm_id: 'node/2', category: 'bar', name: 'Loos Bar' })]));
|
|
act(() => {
|
|
result.current.toggle('bar');
|
|
});
|
|
// The previous category's markers vanish immediately, before the new results land.
|
|
expect(result.current.active).toEqual(new Set(['bar']));
|
|
expect(result.current.pois).toEqual([]);
|
|
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
expect(result.current.pois[0].osm_id).toBe('node/2');
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-008: panning with a category active marks the results stale', async () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
expect(result.current.moved).toBe(false);
|
|
|
|
act(() => {
|
|
result.current.onViewportChange({ ...BBOX, south: 48.0 });
|
|
});
|
|
expect(result.current.moved).toBe(true);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-009: searchArea refetches the active category for the new viewport', async () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
|
|
const moved: Bbox = { south: 40, west: 10, north: 41, east: 11 };
|
|
act(() => {
|
|
result.current.onViewportChange(moved);
|
|
});
|
|
act(() => {
|
|
result.current.searchArea();
|
|
});
|
|
|
|
expect(result.current.moved).toBe(false);
|
|
await waitFor(() => expect(pois).toHaveBeenCalledTimes(2));
|
|
expect(pois).toHaveBeenLastCalledWith('cafe', moved, 'en-US', expect.any(AbortSignal));
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-010: searchArea without a viewport is a no-op', () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.searchArea();
|
|
});
|
|
expect(pois).not.toHaveBeenCalled();
|
|
expect(result.current.moved).toBe(false);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-011: searchArea with nothing active fetches nothing', () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.searchArea();
|
|
});
|
|
expect(pois).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-012: a failed fetch surfaces a retryable error instead of a silent empty', async () => {
|
|
pois.mockRejectedValue(new Error('all Overpass mirrors down'));
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.errorKeys.has('cafe')).toBe(true));
|
|
expect(result.current.pois).toEqual([]);
|
|
expect(result.current.loadingKeys.has('cafe')).toBe(false);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-013: a retry that succeeds clears the error flag', async () => {
|
|
pois.mockRejectedValueOnce(new Error('timeout'));
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
await waitFor(() => expect(result.current.errorKeys.has('cafe')).toBe(true));
|
|
|
|
act(() => {
|
|
result.current.searchArea();
|
|
});
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
expect(result.current.errorKeys.has('cafe')).toBe(false);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-014: switching category clears a previous error flag', async () => {
|
|
pois.mockRejectedValueOnce(new Error('timeout'));
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
await waitFor(() => expect(result.current.errorKeys.has('cafe')).toBe(true));
|
|
|
|
act(() => {
|
|
result.current.toggle('bar');
|
|
});
|
|
expect(result.current.errorKeys.size).toBe(0);
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-015: results that land after the category was switched off are dropped', async () => {
|
|
const d = deferred<PoiResponse>();
|
|
pois.mockReturnValue(d.promise);
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
expect(result.current.active.size).toBe(0);
|
|
|
|
await act(async () => {
|
|
d.resolve(response([poi()]));
|
|
});
|
|
expect(result.current.pois).toEqual([]);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-016: a superseded request is not reported as a failure', async () => {
|
|
const first = deferred<PoiResponse>();
|
|
pois.mockReturnValueOnce(first.promise);
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
|
|
pois.mockResolvedValue(response([poi({ osm_id: 'node/9', category: 'bar' })]));
|
|
act(() => {
|
|
result.current.toggle('bar');
|
|
});
|
|
|
|
// The abandoned cafe request rejects with axios' cancellation error.
|
|
await act(async () => {
|
|
first.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' }));
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
expect(result.current.errorKeys.size).toBe(0);
|
|
expect(result.current.pois[0].osm_id).toBe('node/9');
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-017: the abandoned category stops loading when the selection moves on', async () => {
|
|
const first = deferred<PoiResponse>();
|
|
pois.mockReturnValueOnce(first.promise);
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
expect(result.current.loadingKeys.has('cafe')).toBe(true);
|
|
|
|
pois.mockResolvedValue(response([poi({ osm_id: 'node/9', category: 'bar' })]));
|
|
act(() => {
|
|
result.current.toggle('bar');
|
|
});
|
|
|
|
await act(async () => {
|
|
first.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' }));
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.loadingKeys.has('cafe')).toBe(false));
|
|
expect(result.current.loadingKeys.has('bar')).toBe(false);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-018: turning the category off stops its spinner as well', async () => {
|
|
const d = deferred<PoiResponse>();
|
|
pois.mockReturnValue(d.promise);
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
|
|
await act(async () => {
|
|
d.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' }));
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.loadingKeys.size).toBe(0));
|
|
});
|
|
});
|
|
|
|
// Plugin POI categories (#1781): a chip a plugin added is asked of that plugin, through
|
|
// the repo, with the same single selection, cancelling and retry as a core one.
|
|
describe('usePoiExplore with plugin categories', () => {
|
|
const TRAILHEADS: PluginPoiCategory = {
|
|
id: 'trailheads',
|
|
label: 'Trailheads',
|
|
labels: { de: 'Wanderparkplätze' },
|
|
icon: 'Signpost',
|
|
color: '#2f855a',
|
|
};
|
|
const KEY = 'plugin:trail-finder/trailheads';
|
|
const TRAIL_FINDER: ActivePlugin = {
|
|
id: 'trail-finder',
|
|
name: 'Trail finder',
|
|
type: 'integration',
|
|
icon: null,
|
|
poiCategories: [TRAILHEADS],
|
|
};
|
|
|
|
type PluginAnswer = Awaited<ReturnType<typeof pluginPoiRepo.search>>;
|
|
|
|
function pluginPoi(over: Partial<Poi> = {}): Poi {
|
|
return {
|
|
osm_id: 'plugin:trail-finder:th-1',
|
|
name: 'Hochalm trailhead',
|
|
lat: 48.2,
|
|
lng: 16.35,
|
|
category: KEY,
|
|
poi_type: KEY,
|
|
address: null,
|
|
website: null,
|
|
phone: null,
|
|
opening_hours: null,
|
|
cuisine: null,
|
|
brand: null,
|
|
brand_wikidata: null,
|
|
charging: null,
|
|
source: 'plugin:trail-finder',
|
|
pluginId: 'trail-finder',
|
|
rating: null,
|
|
details: [{ label: 'Parking', value: '40 spaces' }],
|
|
icon: 'Signpost',
|
|
color: '#2f855a',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function answer(rows: Poi[]): PluginAnswer {
|
|
return { pois: rows, source: 'plugin:trail-finder', truncated: false, clamped: false };
|
|
}
|
|
|
|
let search: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
usePluginStore.setState({ plugins: [TRAIL_FINDER], loaded: true });
|
|
search = vi.spyOn(pluginPoiRepo, 'search').mockResolvedValue(answer([pluginPoi()]));
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-019: hands the pill the core chips and then the plugin ones', () => {
|
|
const { result } = setup();
|
|
expect(result.current.categories.core.map((c) => c.key)).toContain('cafe');
|
|
expect(result.current.categories.plugin.map((c) => c.key)).toEqual([KEY]);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-020: a plugin key asks the plugin POI repo, never the core search', async () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
// The TREK language rather than the locale: that is what the SDK promises a plugin.
|
|
expect(search).toHaveBeenCalledWith('trail-finder', 'trailheads', BBOX, 'en', expect.any(AbortSignal));
|
|
expect(pois).not.toHaveBeenCalled();
|
|
expect(result.current.pois[0]).toMatchObject({
|
|
osm_id: 'plugin:trail-finder:th-1',
|
|
color: '#2f855a',
|
|
icon: 'Signpost',
|
|
});
|
|
expect(result.current.pois[0].details).toEqual([{ label: 'Parking', value: '40 spaces' }]);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-021: offline the chip fails at once and no request is made', async () => {
|
|
search.mockRestore();
|
|
net.offline = true;
|
|
const api = vi.spyOn(pluginPoisApi, 'search');
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.errorKeys.has(KEY)).toBe(true));
|
|
expect(api).not.toHaveBeenCalled();
|
|
expect(result.current.loadingKeys.has(KEY)).toBe(false);
|
|
expect(result.current.pois).toEqual([]);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-022: switching to a core category cancels the plugin request without an error', async () => {
|
|
const pending = deferred<PluginAnswer>();
|
|
let signal: AbortSignal | undefined;
|
|
search.mockImplementation((...args: unknown[]) => {
|
|
signal = args[4] as AbortSignal;
|
|
return pending.promise;
|
|
});
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
expect(signal?.aborted).toBe(true);
|
|
|
|
await act(async () => {
|
|
pending.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' }));
|
|
});
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
expect(result.current.pois[0].category).toBe('cafe');
|
|
expect(result.current.errorKeys.size).toBe(0);
|
|
expect(result.current.loadingKeys.size).toBe(0);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-023: a failed plugin answer is retried like a core one', async () => {
|
|
search.mockRejectedValueOnce(new Error('The plugin did not answer'));
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
await waitFor(() => expect(result.current.errorKeys.has(KEY)).toBe(true));
|
|
|
|
act(() => {
|
|
result.current.searchArea();
|
|
});
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
expect(result.current.errorKeys.size).toBe(0);
|
|
expect(search).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-024: an answer landing after the plugin chip was switched off is dropped', async () => {
|
|
const pending = deferred<PluginAnswer>();
|
|
search.mockReturnValue(pending.promise);
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
|
|
await act(async () => {
|
|
pending.resolve(answer([pluginPoi()]));
|
|
});
|
|
expect(result.current.pois).toEqual([]);
|
|
expect(result.current.active.size).toBe(0);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-025: a plugin chip that leaves the feed takes its selection and markers with it', async () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
|
|
// The admin switched the plugin off, and the feed was read again.
|
|
act(() => {
|
|
usePluginStore.setState({ plugins: [] });
|
|
});
|
|
expect(result.current.active.size).toBe(0);
|
|
expect(result.current.pois).toEqual([]);
|
|
expect(result.current.categories.plugin).toEqual([]);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-026: a request in flight when the chip leaves the feed is cancelled', async () => {
|
|
const pending = deferred<PluginAnswer>();
|
|
let signal: AbortSignal | undefined;
|
|
search.mockImplementation((...args: unknown[]) => {
|
|
signal = args[4] as AbortSignal;
|
|
return pending.promise;
|
|
});
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
|
|
act(() => {
|
|
usePluginStore.setState({ plugins: [{ ...TRAIL_FINDER, poiCategories: undefined }] });
|
|
});
|
|
expect(signal?.aborted).toBe(true);
|
|
await act(async () => {
|
|
pending.reject(Object.assign(new Error('canceled'), { name: 'CanceledError' }));
|
|
});
|
|
await waitFor(() => expect(result.current.loadingKeys.size).toBe(0));
|
|
expect(result.current.errorKeys.size).toBe(0);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-027: a core selection outlives any change to the plugin feed', async () => {
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle('cafe');
|
|
});
|
|
await waitFor(() => expect(result.current.pois).toHaveLength(1));
|
|
|
|
act(() => {
|
|
usePluginStore.setState({ plugins: [] });
|
|
});
|
|
expect(result.current.active).toEqual(new Set(['cafe']));
|
|
expect(result.current.pois).toHaveLength(1);
|
|
});
|
|
|
|
it('FE-COMP-POIEXPLORE-028: a 404 means the category is gone, so the plugin feed is read again', async () => {
|
|
const loadPlugins = vi.fn(async () => {});
|
|
usePluginStore.setState({ loadPlugins });
|
|
search.mockRejectedValue(
|
|
Object.assign(new Error('Request failed with status code 404'), { response: { status: 404 } })
|
|
);
|
|
const { result } = setup();
|
|
act(() => {
|
|
result.current.onViewportChange(BBOX);
|
|
});
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
|
|
await waitFor(() => expect(result.current.errorKeys.has(KEY)).toBe(true));
|
|
expect(loadPlugins).toHaveBeenCalledTimes(1);
|
|
// The fresh feed no longer lists it, so the chip and its selection go.
|
|
act(() => {
|
|
usePluginStore.setState({ plugins: [] });
|
|
});
|
|
expect(result.current.active.size).toBe(0);
|
|
expect(result.current.categories.plugin).toEqual([]);
|
|
|
|
// Any other failure keeps the feed as it is.
|
|
act(() => {
|
|
usePluginStore.setState({ plugins: [TRAIL_FINDER] });
|
|
});
|
|
search.mockRejectedValue(
|
|
Object.assign(new Error('Request failed with status code 502'), { response: { status: 502 } })
|
|
);
|
|
act(() => {
|
|
result.current.toggle(KEY);
|
|
});
|
|
await waitFor(() => expect(result.current.errorKeys.has(KEY)).toBe(true));
|
|
expect(loadPlugins).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|