Add tests for centered modal adoption, search modal results, and accounts batch actions

- Implement tests for the CenteredModal component to ensure it follows the shared modal shell pattern.
- Add comprehensive tests for the SearchModal results, including API interactions and UI behavior.
- Create tests for accounts batch actions, verifying the functionality of refreshing balances and selecting accounts via row clicks.
- Introduce tests for the accounts centered modal to confirm the use of CenteredModal for add/edit/rebind flows.
- Add tests for the accounts edit panel to ensure proper rendering and functionality when opening the edit modal.
- Implement tests for the accounts tokens header to verify the reuse of the accounts page header in the tokens segment.
- Create tests for the system proxy settings, ensuring the correct saving of the proxy URL.
- Add tests for the tokens actions layout to confirm the proper layout and styling of token row actions.
- Implement tests for the tokens batch actions, including deletion of selected tokens and row selection behavior.
- Introduce tests for the tokens focus navigation to ensure the correct highlighting of focused token rows.
This commit is contained in:
cita
2026-03-07 18:04:05 +08:00
parent c31bbcbb51
commit c437d93715
65 changed files with 3984 additions and 486 deletions
+68
View File
@@ -0,0 +1,68 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type CenteredModalProps = {
open: boolean;
onClose: () => void;
title: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
maxWidth?: number;
bodyStyle?: React.CSSProperties;
};
export default function CenteredModal({
open,
onClose,
title,
children,
footer,
maxWidth = 860,
bodyStyle,
}: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [open, onClose]);
if (!presence.shouldRender) return null;
const modal = (
<div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={onClose}
>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth }}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">{title}</div>
<div className="modal-body" style={bodyStyle}>
{children}
</div>
{footer ? <div className="modal-footer">{footer}</div> : null}
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+54 -4
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildAccountFocusPath, buildSiteFocusPath } from '../pages/helpers/navigationFocus.js';
import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
@@ -17,6 +17,19 @@ interface AccountResult {
username: string | null;
status?: string | null;
balance?: number | null;
segment?: 'session' | 'apikey';
site?: { name: string } | null;
}
interface AccountTokenResult {
id: number;
accountId: number;
name: string;
tokenGroup?: string | null;
account?: {
username?: string | null;
segment?: 'session' | 'apikey';
} | null;
site?: { name: string } | null;
}
@@ -45,6 +58,7 @@ interface ModelSearchResult {
interface SearchResult {
accounts: AccountResult[];
accountTokens: AccountTokenResult[];
sites: SiteResult[];
checkinLogs: CheckinLogResult[];
proxyLogs: ProxyLogResult[];
@@ -81,6 +95,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
setResults({
models: Array.isArray(res?.models) ? res.models : [],
accounts: Array.isArray(res?.accounts) ? res.accounts : [],
accountTokens: Array.isArray(res?.accountTokens) ? res.accountTokens : [],
sites: Array.isArray(res?.sites) ? res.sites : [],
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
@@ -116,6 +131,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
const hasResults = results && (
results.models.length
|| results.accounts.length
|| results.accountTokens.length
|| results.sites.length
|| results.checkinLogs.length
|| results.proxyLogs.length
@@ -189,15 +205,49 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
<button
key={a.id}
className="search-result-item"
onClick={() => goTo(buildAccountFocusPath(a.id, { openRebind: a.status === 'expired' }))}
onClick={() => goTo(buildAccountFocusPath(a.id, {
openRebind: a.status === 'expired',
segment: a.segment,
}))}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{a.username || `ID:${a.id}`}</div>
<div style={{ fontWeight: 500 }}>
{a.username?.trim() || (a.segment === 'apikey' ? t('API Key 连接') : `ID:${a.id}`)}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{a.site?.name || t('未关联站点')} · {t('余额')} ${(a.balance || 0).toFixed(2)}
{a.site?.name || t('未关联站点')}
{a.segment === 'apikey' ? ` · ${t('API Key 连接')}` : ''}
{' · '}
{t('余额')} ${(a.balance || 0).toFixed(2)}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.accountTokens.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号令牌')}</div>
{results.accountTokens.map((token) => (
<button
key={token.id}
className="search-result-item"
onClick={() => goTo(buildTokenFocusPath(token.id))}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{token.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{(token.account?.username?.trim() || (token.account?.segment === 'apikey' ? t('API Key 连接') : t('未命名')))}
{' · '}
{token.site?.name || t('未关联站点')}
{token.tokenGroup ? ` · ${token.tokenGroup}` : ''}
</div>
</div>
</button>
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('CenteredModal component', () => {
it('uses the shared centered modal shell pattern', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
expect(source).toContain('modal-backdrop');
expect(source).toContain('modal-content');
expect(source).toContain('useAnimatedVisibility');
expect(source).toContain('createPortal');
});
});
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter, useLocation } from 'react-router-dom';
import SearchModal from './SearchModal.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
search: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../i18n.js', () => ({
useI18n: () => ({
t: (value: string) => value,
}),
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function LocationProbe() {
const location = useLocation();
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('SearchModal results', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
Object.defineProperty(globalThis, 'window', {
value: globalThis,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, 'document', {
value: {
addEventListener: () => undefined,
removeEventListener: () => undefined,
},
configurable: true,
writable: true,
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.clearAllMocks();
});
it('renders account token results and navigates API key accounts to the apikey segment', async () => {
apiMock.search.mockResolvedValue({
models: [],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [
{
id: 8,
username: '',
balance: 0,
segment: 'apikey',
site: { name: 'Key Search Site' },
},
],
accountTokens: [
{
id: 15,
name: 'search-token',
tokenGroup: 'default',
accountId: 8,
account: { username: '' },
site: { name: 'Key Search Site' },
},
],
});
const onClose = vi.fn();
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<SearchModal open onClose={onClose} />
</MemoryRouter>,
);
});
const input = root.root.findByType('input');
await act(async () => {
input.props.onChange({ target: { value: 'search' } });
vi.advanceTimersByTime(300);
await Promise.resolve();
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('账号令牌');
expect(rendered).toContain('search-token');
expect(rendered).toContain('API Key 连接');
const buttons = root.root.findAll((node) => node.type === 'button');
const accountButton = buttons.find((node) => collectText(node).includes('API Key 连接'));
expect(accountButton).toBeTruthy();
await act(async () => {
accountButton!.props.onClick();
});
const locationAfterAccountClick = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterAccountClick)).toBe('/accounts?segment=apikey&focusAccountId=8');
} finally {
root?.unmount();
}
});
});