feat(tests): add comprehensive tests for TokenRouter and related components

- Implement tests for TokenRouter downstream policy, covering allowedRouteIds and site weight multipliers.
- Add tests for TokenRouter patterns and model mapping, including regex pattern matching and model mapping precedence.
- Create tests for BrandIcon component to verify brand detection logic.
- Develop tests for Accounts page, focusing on rebind panel behavior and refresh action on error.
- Introduce fuzzy search utility tests to validate substring and subsequence matching.
- Implement navigation focus helpers tests for building and parsing focus paths.
- Add progressive render tests to ensure correct initial and next visible counts.
- Create route model candidates index tests to validate route candidate indexing and matching logic.
This commit is contained in:
cita
2026-03-02 13:33:35 +08:00
parent 99c9e145c3
commit f6389a999a
98 changed files with 11286 additions and 801 deletions
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { getBrand } from './BrandIcon.js';
describe('getBrand', () => {
it('detects simple prefixed model names', () => {
expect(getBrand('claude-opus-4-6')?.name).toBe('Anthropic');
expect(getBrand('gpt-4o-mini')?.name).toBe('OpenAI');
});
it('detects brand for regex and wrapped model patterns', () => {
expect(getBrand('re:^claude-(opus|sonnet)-4-5$')?.name).toBe('Anthropic');
expect(getBrand('[Summer] gpt-5.2-codex')?.name).toBe('OpenAI');
});
it('detects brand from namespaced model paths', () => {
expect(getBrand('openrouter/anthropic/claude-3-7-sonnet')?.name).toBe('Anthropic');
expect(getBrand('openrouter/google/gemini-2.5-pro')?.name).toBe('Google');
});
it('returns null for unknown model names', () => {
expect(getBrand('totally-unknown-model')).toBeNull();
});
});
+70 -11
View File
@@ -100,17 +100,76 @@ const BRAND_MAP: Array<{ prefixes: string[]; brand: BrandInfo }> = [
prefixes: ['flux', 'sd-', 'stable-diffusion', 'sdxl'],
brand: { name: 'Stability', icon: 'stability', color: 'linear-gradient(135deg, #8b5cf6, #a855f7)' }
},
];
export function getBrand(modelName: string): BrandInfo | null {
const lower = modelName.toLowerCase();
for (const entry of BRAND_MAP) {
for (const prefix of entry.prefixes) {
if (lower.startsWith(prefix)) return entry.brand;
}
}
return null;
}
];
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const BRAND_RULES = BRAND_MAP.map((entry) => ({
brand: entry.brand,
prefixMatchers: entry.prefixes.map((prefix) => ({
prefix,
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(prefix)}(?=$|[^a-z0-9])`),
})),
}));
function collectBrandCandidates(modelName: string): string[] {
const queue: string[] = [];
const seen = new Set<string>();
const push = (value: string) => {
const normalized = (value || '').trim().toLowerCase();
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
queue.push(normalized);
};
push(modelName);
for (let i = 0; i < queue.length; i += 1) {
const candidate = queue[i];
// Strip common route/model wrappers, e.g. "[Summer] gpt-5.2".
push(candidate.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, ''));
if (candidate.startsWith('re:')) {
push(candidate.slice(3).trim());
}
push(candidate.replace(/^\^+/, '').replace(/\$+$/, ''));
if (candidate.includes('/')) {
for (const part of candidate.split('/')) push(part);
}
if (candidate.includes(':')) {
for (const part of candidate.split(':')) push(part);
}
}
return queue;
}
export function getBrand(modelName: string): BrandInfo | null {
const candidates = collectBrandCandidates(modelName);
// Prefer strict prefix matching first for predictable behavior.
for (const candidate of candidates) {
for (const rule of BRAND_RULES) {
for (const matcher of rule.prefixMatchers) {
if (candidate.startsWith(matcher.prefix)) return rule.brand;
}
}
}
// Fallback for regex/model wrappers where brand token appears inside the pattern.
for (const candidate of candidates) {
for (const rule of BRAND_RULES) {
for (const matcher of rule.prefixMatchers) {
if (matcher.boundaryRegex.test(candidate)) return rule.brand;
}
}
}
return null;
}
const FALLBACK_COLORS = [
'linear-gradient(135deg, #4f46e5, #818cf8)',
+66 -44
View File
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react';
type ModernSelectOption = {
value: string;
label: string;
description?: string;
disabled?: boolean;
};
value: string;
label: string;
description?: string;
disabled?: boolean;
iconUrl?: string;
iconText?: string;
};
type ModernSelectProps = {
value: string;
@@ -18,7 +20,7 @@ type ModernSelectProps = {
size?: 'md' | 'sm';
};
export default function ModernSelect({
export default function ModernSelect({
value,
onChange,
options,
@@ -28,9 +30,9 @@ export default function ModernSelect({
menuMaxHeight = 280,
className = '',
size = 'md',
}: ModernSelectProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
}: ModernSelectProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const selected = useMemo(
() => options.find((item) => item.value === value),
@@ -59,11 +61,21 @@ export default function ModernSelect({
};
}, [open]);
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
return (
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
const renderOptionIcon = (item: ModernSelectOption) => {
if (item.iconUrl) {
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
}
if (item.iconText) {
return <span className="modern-select-option-icon-text">{item.iconText}</span>;
}
return null;
};
return (
<div
ref={rootRef}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
@@ -76,12 +88,19 @@ export default function ModernSelect({
}}
aria-expanded={open}
disabled={disabled}
>
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
{selected?.label || placeholder}
</span>
<svg
className="modern-select-chevron"
>
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
{selected ? (
<span className="modern-select-value-content">
{renderOptionIcon(selected)}
<span>{selected.label}</span>
</span>
) : (
placeholder
)}
</span>
<svg
className="modern-select-chevron"
width="14"
height="14"
fill="none"
@@ -103,30 +122,33 @@ export default function ModernSelect({
key={item.value}
type="button"
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
onClick={() => {
if (item.disabled) return;
onChange(item.value);
setOpen(false);
}}
disabled={item.disabled}
>
<div style={{ minWidth: 0 }}>
<div className="modern-select-option-label">{item.label}</div>
{item.description && (
<div className="modern-select-option-desc">{item.description}</div>
)}
</div>
{active && (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})
)}
</div>
</div>
onClick={() => {
if (item.disabled) return;
onChange(item.value);
setOpen(false);
}}
disabled={item.disabled}
>
<div className="modern-select-option-main">
{renderOptionIcon(item)}
<div style={{ minWidth: 0 }}>
<div className="modern-select-option-label">{item.label}</div>
{item.description && (
<div className="modern-select-option-desc">{item.description}</div>
)}
</div>
</div>
{active && (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})
)}
</div>
</div>
);
}
+48 -22
View File
@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js';
const levelColors: Record<string, string> = {
@@ -33,6 +35,7 @@ export default function NotificationPanel({
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<string>('');
const panelRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const load = useCallback(async () => {
setLoading(true);
@@ -110,30 +113,53 @@ export default function NotificationPanel({
{tr('暂无通知')}
</div>
)}
{events.map((ev: any) => (
<div key={ev.id} style={{
padding: '10px 16px',
borderBottom: '1px solid var(--color-border-light)',
display: 'flex', gap: 10, alignItems: 'flex-start',
}}>
<div style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, marginTop: 5,
background: levelColors[ev.level] || 'var(--color-info)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 500, fontSize: 13 }}>{ev.title}</span>
<span style={{ fontSize: 10, padding: '1px 5px', borderRadius: 8, background: 'var(--color-bg)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border-light)' }}>
{tr(typeLabels[ev.type] || ev.type)}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>{ev.message}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
{formatDateTimeMinuteLocal(ev.createdAt)}
{events.map((ev: any) => {
const targetPath = buildEventNavigationPath(ev);
const openTarget = () => {
onClose();
navigate(targetPath);
};
return (
<div
key={ev.id}
className="notification-event-item"
style={{
padding: '10px 16px',
borderBottom: '1px solid var(--color-border-light)',
display: 'flex',
gap: 10,
alignItems: 'flex-start',
cursor: 'pointer',
}}
onClick={openTarget}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openTarget();
}
}}
>
<div style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, marginTop: 5,
background: levelColors[ev.level] || 'var(--color-info)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 500, fontSize: 13 }}>{ev.title}</span>
<span style={{ fontSize: 10, padding: '1px 5px', borderRadius: 8, background: 'var(--color-bg)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border-light)' }}>
{tr(typeLabels[ev.type] || ev.type)}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>{ev.message}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
{formatDateTimeMinuteLocal(ev.createdAt)}
</div>
</div>
</div>
</div>
))}
);
})}
</div>
</div>
);
+8 -2
View File
@@ -2,6 +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 { useI18n } from '../i18n.js';
interface SiteResult {
@@ -13,6 +14,7 @@ interface SiteResult {
interface AccountResult {
id: number;
username: string | null;
status?: string | null;
balance?: number | null;
site?: { name: string } | null;
}
@@ -165,7 +167,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
{results.sites.map((s) => (
<button key={s.id} className="search-result-item" onClick={() => goTo('/sites')}>
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
</svg>
@@ -182,7 +184,11 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
{results.accounts.map((a) => (
<button key={a.id} className="search-result-item" onClick={() => goTo('/accounts')}>
<button
key={a.id}
className="search-result-item"
onClick={() => goTo(buildAccountFocusPath(a.id, { openRebind: a.status === 'expired' }))}
>
<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>