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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user