fix(web): isolate paginated filter scopes
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { AccessVehicleRow, Page } from '../../api/types';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
import AccessPage from './AccessPage';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accessSummary: vi.fn(), accessVehicles: vi.fn(), accessUnresolvedIdentities: vi.fn(),
|
||||
accessThresholds: vi.fn(), updateAccessThresholds: vi.fn()
|
||||
}));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: 'admin' } }) }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
function accessRow(vin: string, plate: string): AccessVehicleRow {
|
||||
return {
|
||||
vin, plate, oem: '测试品牌', model: '测试车型', company: '测试公司', protocol: 'JT808', provider: '测试厂家', source: 'production',
|
||||
firstSeenAt: '2026-07-16T00:00:00Z', latestEventAt: '2026-07-16T04:00:00Z', latestReceivedAt: '2026-07-16T04:00:01Z', reportIntervalSec: 10,
|
||||
dataDelaySec: 1, freshnessSec: 3, onlineState: 'online', thresholdSec: 300, latestMessageType: 'location', latestEventId: `${vin}-event`, latestError: '', delayAbnormal: false,
|
||||
firstSeenEvidence: 'test first seen', firstSeenSource: 'test', reportIntervalEvidence: 'test interval', reportSampleCount: 10,
|
||||
expectedProtocols: ['JT808'], actualProtocols: ['JT808'], missingProtocols: [], connectionState: 'healthy', expectationEvidence: 'test expectation',
|
||||
protocolStatuses: [{ protocol: 'JT808', expected: true, connected: true, provider: '测试厂家', firstSeenAt: '2026-07-16T00:00:00Z', latestEventAt: '2026-07-16T04:00:00Z', latestReceivedAt: '2026-07-16T04:00:01Z', reportIntervalSec: 10, dataDelaySec: 1, freshnessSec: 3, onlineState: 'online', thresholdSec: 300, delayAbnormal: false, firstSeenEvidence: 'test first seen', reportIntervalEvidence: 'test interval' }]
|
||||
};
|
||||
}
|
||||
|
||||
function prepareBaseData() {
|
||||
mocks.accessSummary.mockResolvedValue({ totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, longOfflineVehicles: 0, neverReported: 0, unknownVehicles: 0, delayAbnormal: 0, reportedToday: 2, onlineRate: 100, healthyVehicles: 2, incompleteVehicles: 0, degradedVehicles: 0, protocols: [], oems: [{ name: '测试品牌', total: 2, online: 2, onlineRate: 100 }], asOf: '2026-07-16T04:00:00Z', thresholdVersion: 1 });
|
||||
mocks.accessUnresolvedIdentities.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
mocks.accessThresholds.mockResolvedValue({ version: 1, defaultThresholdSec: 300, delayThresholdSec: 60, longOfflineSec: 86_400, protocols: [], updatedBy: 'test', updatedAt: '2026-07-16T04:00:00Z', audit: [] });
|
||||
}
|
||||
|
||||
test('removes old access rows immediately when the vehicle filter scope changes', async () => {
|
||||
prepareBaseData();
|
||||
let resolveNew!: (value: Page<AccessVehicleRow>) => void;
|
||||
mocks.accessVehicles.mockImplementation((query: { keyword?: string }) => query.keyword === 'OLDVIN'
|
||||
? Promise.resolve({ items: [accessRow('OLDVIN', '旧接入车牌')], total: 1, limit: 50, offset: 0 })
|
||||
: new Promise<Page<AccessVehicleRow>>((resolve) => { resolveNew = resolve; }));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access?keyword=OLDVIN']}><AccessPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('旧接入车牌')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '车辆' }), { target: { value: 'NEWVIN' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
|
||||
expect(await screen.findByText('正在更新车辆接入状态…')).toBeInTheDocument();
|
||||
expect(screen.queryByText('旧接入车牌')).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => resolveNew({ items: [accessRow('NEWVIN', '新接入车牌')], total: 1, limit: 50, offset: 0 }));
|
||||
expect(await screen.findByText('新接入车牌')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('正在更新车辆接入状态…')).not.toBeInTheDocument());
|
||||
});
|
||||
@@ -3,12 +3,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow } from '../../api/types';
|
||||
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types';
|
||||
import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister } from '../auth/session';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
|
||||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
|
||||
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
|
||||
@@ -100,8 +100,9 @@ export default function AccessPage() {
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState('');
|
||||
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>(); const queryClient = useQueryClient();
|
||||
const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
|
||||
const vehicleScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]);
|
||||
const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: ({ signal }) => api.accessSummary({}, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||
const vehiclesQuery = useQuery<Page<AccessVehicleRow>>({ queryKey: ['access-vehicles', vehicleScope, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: retainPreviousPageWithinScope<Page<AccessVehicleRow>>(vehicleScope), gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: ({ signal }) => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { AlertEvent, Page } from '../../api/types';
|
||||
import AlertsPage from './AlertsPage';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
@@ -18,6 +19,14 @@ afterEach(() => {
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
function alertEvent(id: string, vin: string, plate: string): AlertEvent {
|
||||
return {
|
||||
id, ruleId: 'speed-rule', ruleName: `${plate}速度告警`, ruleVersion: 1, severity: 'major', status: 'unprocessed',
|
||||
vin, plate, protocol: 'JT808', metric: 'speed_kmh', operator: 'gt', triggerValue: 90, threshold: 80, thresholdHigh: 0,
|
||||
unit: 'km/h', durationSec: 60, location: '测试位置', sourceEventId: `${id}-source`, eventAt: '2026-07-16T04:00:00Z', receivedAt: '2026-07-16T04:00:01Z', triggeredAt: '2026-07-16T04:00:00Z', recoveredAt: '', handler: '', version: 1, actions: []
|
||||
};
|
||||
}
|
||||
|
||||
test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => {
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
@@ -62,3 +71,33 @@ test('loads one full notification query on a direct notifications entry', async
|
||||
expect(mocks.alertNotificationsV2).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.alertNotificationsV2.mock.calls[0][0].toString()).toBe('limit=100');
|
||||
});
|
||||
|
||||
test('clears old alert rows and inspector evidence when the event scope changes', async () => {
|
||||
const oldEvent = alertEvent('old-event', 'OLDVIN', '旧告警车牌');
|
||||
const newEvent = alertEvent('new-event', 'NEWVIN', '新告警车牌');
|
||||
let resolveNew!: (value: Page<AlertEvent>) => void;
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockImplementation((query: { keyword?: string }) => query.keyword === 'OLDVIN'
|
||||
? Promise.resolve({ items: [oldEvent], total: 1, limit: 20, offset: 0 })
|
||||
: new Promise<Page<AlertEvent>>((resolve) => { resolveNew = resolve; }));
|
||||
mocks.alertEventV2.mockImplementation((id: string) => Promise.resolve(id === oldEvent.id ? oldEvent : newEvent));
|
||||
mocks.alertRulesV2.mockResolvedValue([]);
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?keyword=OLDVIN']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect((await screen.findAllByText('旧告警车牌')).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText('old-event')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN / 规则名称'), { target: { value: 'NEWVIN' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
|
||||
expect(await screen.findByText('正在更新事件…')).toBeInTheDocument();
|
||||
expect(screen.queryByText('旧告警车牌')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('old-event')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('选择告警事件')).toBeInTheDocument();
|
||||
|
||||
await act(async () => resolveNew({ items: [newEvent], total: 1, limit: 20, offset: 0 }));
|
||||
expect((await screen.findAllByText('新告警车牌')).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText('new-event')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('正在更新事件…')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, rule
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister, canOperate } from '../auth/session';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
|
||||
type Tab = 'events' | 'rules' | 'notifications';
|
||||
type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string };
|
||||
@@ -40,13 +40,15 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on
|
||||
}
|
||||
|
||||
function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, editable, onTab }: { filters: Filters; draft: Filters; setDraft: (next: Filters) => void; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) {
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selectedID, setSelectedID] = useState(''); const [note, setNote] = useState(''); const queryClient = useQueryClient();
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selection, setSelection] = useState<{ scope: string; id: string }>(); const [note, setNote] = useState(''); const queryClient = useQueryClient();
|
||||
const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]);
|
||||
const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]);
|
||||
const eventScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]);
|
||||
const summary = useQuery({ queryKey: ['alert-summary-v2', baseQuery], queryFn: ({ signal }) => api.alertSummaryV2(baseQuery, signal), staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const events = useQuery({ queryKey: ['alert-events-v2', query], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: (previous) => previous, staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||
const events = useQuery<Page<AlertEvent>>({ queryKey: ['alert-events-v2', eventScope, limit, offset], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: retainPreviousPageWithinScope<Page<AlertEvent>>(eventScope), staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||
const rows = events.data?.items ?? [];
|
||||
useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelectedID(rows[0].id); }, [rows, selectedID]);
|
||||
const selectedID = selection?.scope === eventScope ? selection.id : '';
|
||||
useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelection({ scope: eventScope, id: rows[0].id }); }, [eventScope, rows, selectedID]);
|
||||
const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: ({ signal }) => api.alertEventV2(selectedID, signal), enabled: Boolean(selectedID), staleTime: 3_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } });
|
||||
const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); };
|
||||
@@ -56,7 +58,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
|
||||
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '告警汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
|
||||
<section className="v2-alert-kpis">{[['活跃告警', sums?.active, '', ''], ['未处理', sums?.unprocessed, 'unprocessed', 'unprocessed'], ['处理中', sums?.processing, 'processing', 'processing'], ['已恢复', sums?.recovered, 'recovered', 'recovered'], ['已关闭', sums?.closed, 'closed', 'closed'], ['已忽略', sums?.ignored, 'ignored', 'ignored'], ['未读通知', unread, 'notice', 'notice']].map(([label, value, tone, status]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={() => status === 'notice' ? onTab('notifications') : quickStatus(String(status))}><small>{label as string}</small><strong>{Number(value ?? 0).toLocaleString('zh-CN')}</strong></button>)}</section>
|
||||
{events.isError ? <InlineError message={events.error instanceof Error ? events.error.message : '告警事件读取失败'} onRetry={() => events.refetch()} /> : null}
|
||||
<div className="v2-alert-workspace"><section className="v2-alert-table-card"><header><strong>告警事件</strong><div><span>共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条</span><button onClick={() => Promise.all([events.refetch(), summary.refetch(), detail.refetch()])}><IconRefresh />刷新</button></div></header><div className="v2-alert-table-scroll"><table><thead><tr><th /><th>严重程度</th><th>车牌 / VIN</th><th>规则</th><th>协议</th><th>触发时间</th><th>恢复时间</th><th>状态</th><th>触发值</th><th>阈值</th><th>位置</th><th>处理人</th></tr></thead><tbody><AlertRows rows={rows} selectedID={selectedID} onSelect={setSelectedID} /></tbody></table>{events.isFetching ? <div className="v2-alert-loading"><i />正在更新事件…</div> : null}{!events.isFetching && !rows.length ? <div className="v2-alert-empty">当前筛选条件没有告警事件</div> : null}</div><footer><span>第 {page} / {totalPages} 页</span><div><button disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option></select></div></footer></section><EventInspector event={detail.data ?? rows.find((row) => row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} /></div></>;
|
||||
<div className="v2-alert-workspace"><section className="v2-alert-table-card"><header><strong>告警事件</strong><div><span>共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条</span><button onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])}><IconRefresh />刷新</button></div></header><div className="v2-alert-table-scroll"><table><thead><tr><th /><th>严重程度</th><th>车牌 / VIN</th><th>规则</th><th>协议</th><th>触发时间</th><th>恢复时间</th><th>状态</th><th>触发值</th><th>阈值</th><th>位置</th><th>处理人</th></tr></thead><tbody><AlertRows rows={rows} selectedID={selectedID} onSelect={(id) => setSelection({ scope: eventScope, id })} /></tbody></table>{events.isFetching ? <div className="v2-alert-loading"><i />正在更新事件…</div> : null}{!events.isFetching && !rows.length ? <div className="v2-alert-empty">当前筛选条件没有告警事件</div> : null}</div><footer><span>第 {page} / {totalPages} 页</span><div><button disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option></select></div></footer></section><EventInspector event={detail.data ?? rows.find((row) => row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} /></div></>;
|
||||
}
|
||||
|
||||
function emptyRule(): AlertRuleInput { return { id: '', name: '', description: '', severity: 'major', valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 100, durationSec: 60, recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600, scopeProtocols: [], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [], notificationChannels: ['in_app'], enabled: true, version: 0 }; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { QUERY_MEMORY, retainPreviousPageWithinScope } from './queryPolicy';
|
||||
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from './queryPolicy';
|
||||
|
||||
describe('query memory policy', () => {
|
||||
it('drops high-volume route results immediately and bounds reusable small results', () => {
|
||||
@@ -16,4 +16,10 @@ describe('query memory policy', () => {
|
||||
expect(placeholder(previous, { queryKey: ['history-data', 'vehicle=B&date=2026-07-16', 50, 0] })).toBeUndefined();
|
||||
expect(placeholder(previous)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds a stable scope key independent of object order and empty values', () => {
|
||||
expect(queryScopeKey({ status: 'offline', keyword: '粤A 1', limit: undefined, protocol: '' }))
|
||||
.toBe(queryScopeKey({ protocol: '', keyword: '粤A 1', status: 'offline' }));
|
||||
expect(queryScopeKey({ status: 'offline', keyword: '粤A 1' })).toBe('keyword=%E7%B2%A4A%201&status=offline');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,3 +7,11 @@ export const QUERY_MEMORY = {
|
||||
export function retainPreviousPageWithinScope<T>(scope: string) {
|
||||
return (previous: T | undefined, previousQuery?: { queryKey: readonly unknown[] }) => previousQuery?.queryKey[1] === scope ? previous : undefined;
|
||||
}
|
||||
|
||||
export function queryScopeKey(query: object) {
|
||||
return Object.entries(query)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user