forked from boos/BoosAPI
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:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user