fix(web): retry transient map load failures
This commit is contained in:
@@ -167,13 +167,16 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders data that arrives before the delayed AMap SDK is ready', async () => {
|
||||
test('recovers in place after a transient AMap failure and renders data that arrived while retrying', async () => {
|
||||
let resolveAMap!: (value: AMapLike) => void;
|
||||
const delayedAMap = new Promise<AMapLike>((resolve) => {
|
||||
resolveAMap = resolve;
|
||||
});
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(() => delayedAMap) };
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('temporary map network failure'))
|
||||
.mockImplementationOnce(() => delayedAMap);
|
||||
window.AMapLoader = { load };
|
||||
|
||||
render(
|
||||
<FleetMap
|
||||
@@ -183,6 +186,8 @@ test('renders data that arrives before the delayed AMap SDK is ready', async ()
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新加载地图/ }));
|
||||
expect(setData).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
@@ -196,6 +201,8 @@ test('renders data that arrives before the delayed AMap SDK is ready', async ()
|
||||
const clusterStyles = setStyle.mock.calls[setStyle.mock.calls.length - 1]?.[0] as Array<{ url: string }>;
|
||||
expect(decodeURIComponent(clusterStyles[5].url)).toContain('>12</text>');
|
||||
expect(decodeURIComponent(clusterStyles[5].url)).not.toContain('10+');
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByText(/地图加载失败/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('detaches AMap listeners and destroys the map on unmount', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconEyeClosed, IconEyeOpened, IconMapPin } from '@douyinfe/semi-icons';
|
||||
import { IconEyeClosed, IconEyeOpened, IconMapPin, IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
|
||||
import {
|
||||
@@ -169,6 +169,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
const centeredVinRef = useRef('');
|
||||
const followSelectedRef = useRef(true);
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [followSelected, setFollowSelected] = useState(true);
|
||||
const [mapZoom, setMapZoom] = useState(5);
|
||||
@@ -314,7 +315,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
amapRef.current = null;
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [loadAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
const mass = massRef.current;
|
||||
@@ -511,7 +512,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
<div className={`v2-map-state is-${state}`}>
|
||||
{state === 'loading' ? <><span className="v2-spinner" />高德地图加载中</> : null}
|
||||
{state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null}
|
||||
{state === 'error' ? '地图加载失败,请检查高德 Key、域名白名单和网络' : null}
|
||||
{state === 'error' ? <><span>地图加载失败,请检查高德 Key、域名白名单和网络</span><button type="button" onClick={() => setLoadAttempt((value) => value + 1)}><IconRefresh />重新加载地图</button></> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="v2-map-legend" aria-label="车辆状态图例">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import type { HistoryLocationRow, TrackStop } from '../../api/types';
|
||||
import type { AMapLike } from '../../integrations/amap';
|
||||
@@ -98,9 +98,11 @@ afterEach(() => {
|
||||
mapInstances.length = 0;
|
||||
});
|
||||
|
||||
test('defers the map runtime until valid track data exists and releases it when data is cleared', async () => {
|
||||
test('defers the map runtime until valid data exists, retries a transient failure, and releases it when cleared', async () => {
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
const load = vi.fn(async () => amapMock());
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('temporary map network failure'))
|
||||
.mockResolvedValueOnce(amapMock());
|
||||
window.AMapLoader = { load };
|
||||
const view = render(<TrackMap
|
||||
points={[]}
|
||||
@@ -125,8 +127,11 @@ test('defers the map runtime until valid track data exists and releases it when
|
||||
onSelectIndex={() => undefined}
|
||||
onFollowChange={() => undefined}
|
||||
/>);
|
||||
expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新加载地图/ }));
|
||||
await waitFor(() => expect(mapInstances).toHaveLength(1));
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByText(/地图加载失败/)).not.toBeInTheDocument();
|
||||
|
||||
view.rerender(<TrackMap
|
||||
points={[]}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { HistoryLocationRow, TrackStop } from '../../api/types';
|
||||
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
|
||||
@@ -31,6 +32,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow
|
||||
const valid = useMemo(() => points.map((point, index) => ({ point, index })).filter(({ point }) => isValidAMapCoordinate(point.longitude, point.latitude)), [points]);
|
||||
const hasValidPoints = valid.length > 0;
|
||||
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'fallback' | 'error'>(() => hasValidPoints ? 'loading' : 'idle');
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const passedCountByPoint = useMemo(() => {
|
||||
const counts = new Uint16Array(points.length);
|
||||
let validIndex = 0;
|
||||
@@ -84,7 +86,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow
|
||||
mapRef.current = null;
|
||||
amapRef.current = null;
|
||||
};
|
||||
}, [hasValidPoints]);
|
||||
}, [hasValidPoints, loadAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
const AMap = amapRef.current;
|
||||
@@ -151,7 +153,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow
|
||||
{state === 'loading' ? <><span className="v2-spinner" />轨迹地图加载中</> : null}
|
||||
{state === 'idle' ? '当前轨迹没有可展示的有效坐标' : null}
|
||||
{state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null}
|
||||
{state === 'error' ? '地图加载失败,请检查高德地图配置' : null}
|
||||
{state === 'error' ? <><span>地图加载失败,请检查高德地图配置或网络</span><button type="button" onClick={() => setLoadAttempt((value) => value + 1)}><IconRefresh />重新加载地图</button></> : null}
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -192,7 +192,9 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-map-selection-marker > b { position: absolute; top: 18px; left: 18px; width: 12px; height: 12px; border: 3px solid #fff; border-radius: 50%; background: var(--v2-blue); box-shadow: 0 2px 8px rgba(18,104,243,.45); }
|
||||
.v2-map-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 9px; background: #eef3f7; color: #637083; font-size: 12px; }
|
||||
.v2-map-state.is-loading { background: rgba(255,255,255,.82); backdrop-filter: blur(2px); }
|
||||
.v2-map-state.is-error { color: #b42318; }
|
||||
.v2-map-state.is-error { flex-direction: column; padding: 20px; color: #b42318; text-align: center; }
|
||||
.v2-map-state.is-error button { display: inline-flex; height: 32px; align-items: center; gap: 6px; border: 1px solid #f2b8b5; border-radius: 7px; background: #fff; padding: 0 11px; color: #a61b13; cursor: pointer; font-size: 11px; font-weight: 700; box-shadow: 0 3px 10px rgba(180,35,24,.08); }
|
||||
.v2-map-state.is-error button:hover { border-color: #df8a85; background: #fff8f7; }
|
||||
.v2-map-legend { position: absolute; bottom: 12px; left: 50%; display: flex; width: max-content; max-width: calc(100% - 28px); height: 36px; align-items: center; justify-content: center; gap: 18px; border: 1px solid #d8e2ed; border-radius: 8px; background: #fff; padding: 0 16px; color: #5f6e82; box-shadow: 0 6px 18px rgba(21,32,51,.1); font-size: 9px; transform: translateX(-50%); }
|
||||
.v2-map-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.v2-map-legend i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
|
||||
|
||||
Reference in New Issue
Block a user