Files
BossAI/src/web/pages/tokenRoutes.desktop-detail-panel.test.tsx
T
boos 0eb7c32585 fix: restore frontend source code (src/web/) that was accidentally deleted
Frontend source is needed for development/building the SPA.
Only Node.js runtime server code should be removed.
2026-05-18 09:33:56 +08:00

383 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes, { DesktopDetailPanelPresence } from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecision: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
updateRoute: vi.fn(),
rebuildRoutes: vi.fn(),
deleteRoute: vi.fn(),
deleteChannel: vi.fn(),
},
getBrandMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => false,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
<span>{brand?.name || icon || model || ''}</span>
),
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: (...args: unknown[]) => getBrandMock(...args),
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('TokenRoutes desktop detail panel', () => {
const originalIntersectionObserver = globalThis.IntersectionObserver;
const originalMatchMedia = globalThis.matchMedia;
beforeEach(() => {
vi.clearAllMocks();
globalThis.IntersectionObserver = class {
observe() {}
disconnect() {}
unobserve() {}
takeRecords() { return []; }
readonly root = null;
readonly rootMargin = '0px';
readonly thresholds = [];
} as unknown as typeof IntersectionObserver;
const defaultMatchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => false),
}));
globalThis.matchMedia = defaultMatchMedia as unknown as typeof matchMedia;
if (typeof window !== 'undefined') {
window.matchMedia = defaultMatchMedia as unknown as typeof window.matchMedia;
}
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['site-a'],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
{
id: 2,
modelPattern: 'claude-3.7-sonnet',
displayName: 'claude-3.7-sonnet',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['site-b'],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-4o-mini',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'site-a', platform: 'openai' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecision.mockResolvedValue({ decision: null });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({});
apiMock.rebuildRoutes.mockResolvedValue({ rebuild: { createdRoutes: 0, createdChannels: 0 } });
apiMock.deleteRoute.mockResolvedValue({});
apiMock.deleteChannel.mockResolvedValue({});
});
afterEach(() => {
vi.clearAllMocks();
globalThis.IntersectionObserver = originalIntersectionObserver;
globalThis.matchMedia = originalMatchMedia;
if (typeof window !== 'undefined') {
window.matchMedia = originalMatchMedia as typeof window.matchMedia;
}
});
it('keeps summary cards stable and opens a separate desktop detail panel', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
))).toHaveLength(2);
const firstSummaryCard = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
await act(async () => {
await firstSummaryCard.props.onClick();
});
await flushMicrotasks();
const collapsedCardsAfterExpand = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
));
expect(collapsedCardsAfterExpand).toHaveLength(2);
const detailPanels = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-detail-panel')
));
expect(detailPanels).toHaveLength(1);
const detailPanelText = collectText(detailPanels[0]!);
expect(detailPanelText).toContain('gpt-4o-mini');
expect(detailPanelText).toContain('路由策略');
} finally {
root?.unmount();
}
});
it('animates the desktop detail panel closed before unmounting it', async () => {
vi.useFakeTimers();
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const firstSummaryCard = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
await act(async () => {
await firstSummaryCard.props.onClick();
});
await flushMicrotasks();
const closeButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('收起详情')
));
await act(async () => {
await closeButton.props.onClick();
});
await flushMicrotasks();
let detailPanelPresence = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-detail-panel-presence')
));
expect(detailPanelPresence).toHaveLength(1);
expect(String(detailPanelPresence[0]!.props.className || '')).not.toContain('anim-collapse');
expect(String(detailPanelPresence[0]!.props.className || '')).not.toContain('is-open');
expect(String(detailPanelPresence[0]!.props.className || '')).toContain('is-closing');
const detailPanelsWhileClosing = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-detail-panel')
));
expect(detailPanelsWhileClosing).toHaveLength(1);
const summaryCardWhileClosing = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
expect(String(summaryCardWhileClosing.props.className || '')).toContain('is-active');
await act(async () => {
vi.advanceTimersByTime(260);
});
detailPanelPresence = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-detail-panel-presence')
));
expect(detailPanelPresence).toHaveLength(0);
const summaryCardAfterClosing = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
expect(String(summaryCardAfterClosing.props.className || '')).not.toContain('is-active');
} finally {
root?.unmount();
vi.useRealTimers();
}
});
it('does not schedule a close timer before the desktop detail panel has ever opened', async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<DesktopDetailPanelPresence open={false}>
{() => <div>detail</div>}
</DesktopDetailPanelPresence>,
);
});
await flushMicrotasks();
expect(root.toJSON()).toBeNull();
expect(setTimeoutSpy).not.toHaveBeenCalled();
} finally {
root?.unmount();
vi.useRealTimers();
}
});
it('closes the desktop detail panel immediately when reduced motion is preferred', async () => {
vi.useFakeTimers();
const reducedMotionMatchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => false),
}));
globalThis.matchMedia = reducedMotionMatchMedia as unknown as typeof matchMedia;
if (typeof window !== 'undefined') {
window.matchMedia = reducedMotionMatchMedia as unknown as typeof window.matchMedia;
}
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const firstSummaryCard = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
await act(async () => {
await firstSummaryCard.props.onClick();
});
await flushMicrotasks();
const closeButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('收起详情')
));
await act(async () => {
await closeButton.props.onClick();
});
await flushMicrotasks();
await act(async () => {
vi.advanceTimersByTime(1);
});
await flushMicrotasks();
const detailPanelPresence = root.root.findAll((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-detail-panel-presence')
));
expect(detailPanelPresence).toHaveLength(0);
const summaryCardAfterClosing = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('gpt-4o-mini')
));
expect(String(summaryCardAfterClosing.props.className || '')).not.toContain('is-active');
} finally {
root?.unmount();
vi.useRealTimers();
}
});
});