forked from boos/BoosAPI
fix: address review feedback for models and routing
This commit is contained in:
@@ -428,11 +428,11 @@ function ensureRouteGroupingSchema() {
|
||||
}
|
||||
|
||||
if (!tableColumnExists('route_channels', 'consecutive_fail_count')) {
|
||||
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;`);
|
||||
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;`);
|
||||
}
|
||||
|
||||
if (!tableColumnExists('route_channels', 'cooldown_level')) {
|
||||
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;`);
|
||||
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,20 @@ function createInspector(
|
||||
|
||||
describe('ensureRouteGroupingSchemaCompatibility', () => {
|
||||
it.each([
|
||||
{
|
||||
dialect: 'sqlite' as const,
|
||||
expectedSql: [
|
||||
'ALTER TABLE token_routes ADD COLUMN display_name text;',
|
||||
'ALTER TABLE token_routes ADD COLUMN display_icon text;',
|
||||
'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;',
|
||||
'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;',
|
||||
'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';',
|
||||
'ALTER TABLE route_channels ADD COLUMN source_model text;',
|
||||
'ALTER TABLE route_channels ADD COLUMN last_selected_at text;',
|
||||
'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;',
|
||||
'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;',
|
||||
],
|
||||
},
|
||||
{
|
||||
dialect: 'postgres' as const,
|
||||
expectedSql: [
|
||||
@@ -47,8 +61,8 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
|
||||
'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -81,18 +81,18 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit
|
||||
table: 'route_channels',
|
||||
column: 'consecutive_fail_count',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;',
|
||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;',
|
||||
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0',
|
||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0',
|
||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'route_channels',
|
||||
column: 'cooldown_level',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;',
|
||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;',
|
||||
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0',
|
||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0',
|
||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -193,7 +193,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
|
||||
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
||||
@@ -210,7 +210,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
|
||||
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
||||
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
|
||||
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`routing_strategy\` VARCHAR(32) NULL DEFAULT 'weighted', \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT DEFAULT 0, \`cooldown_level\` INT DEFAULT 0, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT NOT NULL DEFAULT 0, \`cooldown_level\` INT NOT NULL DEFAULT 0, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
|
||||
@@ -226,7 +226,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
|
||||
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
||||
|
||||
@@ -137,8 +137,8 @@ export const routeChannels = sqliteTable('route_channels', {
|
||||
lastUsedAt: text('last_used_at'),
|
||||
lastSelectedAt: text('last_selected_at'),
|
||||
lastFailAt: text('last_fail_at'),
|
||||
consecutiveFailCount: integer('consecutive_fail_count').default(0),
|
||||
cooldownLevel: integer('cooldown_level').default(0),
|
||||
consecutiveFailCount: integer('consecutive_fail_count').notNull().default(0),
|
||||
cooldownLevel: integer('cooldown_level').notNull().default(0),
|
||||
cooldownUntil: text('cooldown_until'),
|
||||
}, (table) => ({
|
||||
routeIdIdx: index('route_channels_route_id_idx').on(table.routeId),
|
||||
|
||||
@@ -320,4 +320,202 @@ describe('Models marketplace text', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-sorts models using site-scoped counts after selecting a site filter', async () => {
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
name: 'gpt-4o',
|
||||
accountCount: 4,
|
||||
tokenCount: 4,
|
||||
avgLatency: 300,
|
||||
successRate: 98,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
site: '站点 A',
|
||||
username: 'user-a-1',
|
||||
latency: 300,
|
||||
balance: 8,
|
||||
tokens: [{ id: 1, name: 'token-a-1', isDefault: true }],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
site: '站点 B',
|
||||
username: 'user-b-1',
|
||||
latency: 200,
|
||||
balance: 8,
|
||||
tokens: [{ id: 2, name: 'token-b-1', isDefault: true }],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
site: '站点 B',
|
||||
username: 'user-b-2',
|
||||
latency: 250,
|
||||
balance: 8,
|
||||
tokens: [{ id: 3, name: 'token-b-2', isDefault: true }],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
site: '站点 B',
|
||||
username: 'user-b-3',
|
||||
latency: 260,
|
||||
balance: 8,
|
||||
tokens: [{ id: 4, name: 'token-b-3', isDefault: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'claude-3-5-sonnet',
|
||||
accountCount: 2,
|
||||
tokenCount: 2,
|
||||
avgLatency: 420,
|
||||
successRate: 95,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
accounts: [
|
||||
{
|
||||
id: 5,
|
||||
site: '站点 A',
|
||||
username: 'user-a-2',
|
||||
latency: 410,
|
||||
balance: 9,
|
||||
tokens: [{ id: 5, name: 'token-a-2', isDefault: true }],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
site: '站点 A',
|
||||
username: 'user-a-3',
|
||||
latency: 430,
|
||||
balance: 9,
|
||||
tokens: [{ id: 6, name: 'token-a-3', isDefault: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const siteFilterItem = root!.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('filter-item')
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('站点 A')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
siteFilterItem.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const cards = root!.root.findAll((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.split(' ').includes('model-card')
|
||||
&& typeof node.props.onClick === 'function'
|
||||
));
|
||||
|
||||
expect(cards.length).toBe(2);
|
||||
expect(collectText(cards[0]!)).toContain('claude-3-5-sonnet');
|
||||
expect(collectText(cards[1]!)).toContain('gpt-4o');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders unknown latency instead of falling back to another site latency', async () => {
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
name: 'gpt-4o',
|
||||
accountCount: 2,
|
||||
tokenCount: 2,
|
||||
avgLatency: 680,
|
||||
successRate: 93,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
site: '站点 A',
|
||||
username: 'user-a',
|
||||
latency: null,
|
||||
balance: 12,
|
||||
tokens: [{ id: 1, name: 'token-a', isDefault: true }],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
site: '站点 B',
|
||||
username: 'user-b',
|
||||
latency: 680,
|
||||
balance: 12,
|
||||
tokens: [{ id: 2, name: 'token-b', isDefault: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const siteFilterItem = root!.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('filter-item')
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('站点 A')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
siteFilterItem.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const latencyBadge = root!.root.find((node) => (
|
||||
node.type === 'span'
|
||||
&& node.props['data-tooltip'] === '平均延迟'
|
||||
));
|
||||
|
||||
expect(String(latencyBadge.props.className || '')).toContain('badge-muted');
|
||||
expect(collectText(latencyBadge)).toContain('延迟');
|
||||
expect(collectText(latencyBadge)).toContain('—');
|
||||
expect(collectText(root!.root)).not.toContain('680ms');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+59
-30
@@ -49,7 +49,7 @@ interface ModelRow {
|
||||
name: string;
|
||||
accountCount: number;
|
||||
tokenCount: number;
|
||||
avgLatency: number;
|
||||
avgLatency: number | null;
|
||||
successRate: number | null;
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
@@ -68,7 +68,13 @@ interface ModelsMarketplaceResponse {
|
||||
refreshJobId?: string | null;
|
||||
};
|
||||
}
|
||||
function getMetricColor(latency: number) {
|
||||
|
||||
function isKnownLatency(latency: number | null | undefined): latency is number {
|
||||
return typeof latency === 'number' && Number.isFinite(latency);
|
||||
}
|
||||
|
||||
function getMetricColor(latency: number | null) {
|
||||
if (!isKnownLatency(latency)) return 'var(--color-text-muted)';
|
||||
if (latency >= 3000) return 'var(--color-danger)';
|
||||
if (latency >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))';
|
||||
if (latency >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))';
|
||||
@@ -77,12 +83,17 @@ function getMetricColor(latency: number) {
|
||||
return 'var(--color-success)';
|
||||
}
|
||||
|
||||
function getLatencyBadgeClass(latency: number) {
|
||||
function getLatencyBadgeClass(latency: number | null) {
|
||||
if (!isKnownLatency(latency)) return 'badge-muted';
|
||||
if (latency >= 3000) return 'badge-error';
|
||||
if (latency >= 1000) return 'badge-warning';
|
||||
return 'badge-success';
|
||||
}
|
||||
|
||||
function formatLatency(latency: number | null): string {
|
||||
return isKnownLatency(latency) ? `${latency}ms` : '—';
|
||||
}
|
||||
|
||||
function getSuccessBadgeClass(rate: number | null) {
|
||||
if (rate == null) return 'badge-muted';
|
||||
if (rate >= 90) return 'badge-success';
|
||||
@@ -113,6 +124,29 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string {
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50];
|
||||
|
||||
function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number {
|
||||
if (sortBy === 'name') {
|
||||
const cmp = a.name.localeCompare(b.name);
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
}
|
||||
|
||||
const resolveNumericSortValue = (model: ModelRow) => {
|
||||
if (sortBy === 'successRate') return model.successRate ?? -1;
|
||||
if (sortBy === 'avgLatency') {
|
||||
if (!isKnownLatency(model.avgLatency)) {
|
||||
return sortDir === 'asc' ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
return model.avgLatency;
|
||||
}
|
||||
return model[sortBy] ?? 0;
|
||||
};
|
||||
|
||||
const va = resolveNumericSortValue(a);
|
||||
const vb = resolveNumericSortValue(b);
|
||||
if (va === vb) return a.name.localeCompare(b.name);
|
||||
return sortDir === 'desc' ? vb - va : va - vb;
|
||||
}
|
||||
|
||||
/* ---- component ---- */
|
||||
export default function Models() {
|
||||
const toast = useToast();
|
||||
@@ -259,7 +293,7 @@ export default function Models() {
|
||||
return [...m.entries()].sort((a, b) => b[1] - a[1]);
|
||||
}, [data.models]);
|
||||
|
||||
/* ---- filtered + sorted ---- */
|
||||
/* ---- filtered ---- */
|
||||
const filteredModels = useMemo(() => {
|
||||
let list = data.models;
|
||||
|
||||
@@ -280,29 +314,19 @@ export default function Models() {
|
||||
list = list.filter(m => m.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
return [...list].sort((a, b) => {
|
||||
if (sortBy === 'name') {
|
||||
const cmp = a.name.localeCompare(b.name);
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
}
|
||||
const va = sortBy === 'successRate' ? (a.successRate ?? -1) : (a[sortBy] ?? 0);
|
||||
const vb = sortBy === 'successRate' ? (b.successRate ?? -1) : (b[sortBy] ?? 0);
|
||||
if (va === vb) return a.name.localeCompare(b.name);
|
||||
return sortDir === 'desc' ? vb - va : va - vb;
|
||||
});
|
||||
}, [data.models, search, activeSite, activeBrand, sortBy, sortDir]);
|
||||
return list;
|
||||
}, [data.models, search, activeSite, activeBrand]);
|
||||
|
||||
// Keep expanded detail consistent with filters (especially site filter).
|
||||
// The list-level filter uses "model has at least one account on this site" semantics;
|
||||
// once a model is shown, its detail should honor the active site as well.
|
||||
const detailModels = useMemo(() => {
|
||||
if (!activeSite) return filteredModels;
|
||||
return filteredModels.map((model) => {
|
||||
const scopedModels = activeSite ? filteredModels.map((model) => {
|
||||
const accounts = model.accounts.filter((account) => account.site === activeSite);
|
||||
const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite);
|
||||
const latencyValues = accounts
|
||||
.map((account) => account.latency)
|
||||
.filter((latency): latency is number => typeof latency === 'number' && Number.isFinite(latency));
|
||||
.filter(isKnownLatency);
|
||||
return {
|
||||
...model,
|
||||
accounts,
|
||||
@@ -311,13 +335,15 @@ export default function Models() {
|
||||
tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0),
|
||||
avgLatency: latencyValues.length > 0
|
||||
? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length)
|
||||
: model.avgLatency,
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}, [filteredModels, activeSite]);
|
||||
}) : filteredModels;
|
||||
|
||||
return [...scopedModels].sort((a, b) => compareModels(a, b, sortBy, sortDir));
|
||||
}, [filteredModels, activeSite, sortBy, sortDir]);
|
||||
|
||||
/* ---- pagination ---- */
|
||||
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
|
||||
const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize));
|
||||
const safePageVal = Math.min(page, totalPages);
|
||||
const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
|
||||
|
||||
@@ -334,9 +360,12 @@ export default function Models() {
|
||||
}
|
||||
return ids.size;
|
||||
})();
|
||||
const avgLatency = detailModels.length
|
||||
? Math.round(detailModels.reduce((s, m) => s + m.avgLatency, 0) / detailModels.length)
|
||||
: 0;
|
||||
const latencyMetrics = detailModels
|
||||
.map((model) => model.avgLatency)
|
||||
.filter(isKnownLatency);
|
||||
const avgLatency = latencyMetrics.length > 0
|
||||
? Math.round(latencyMetrics.reduce((sum, latency) => sum + latency, 0) / latencyMetrics.length)
|
||||
: null;
|
||||
|
||||
/* ---- copy ---- */
|
||||
const copyName = (name: string) => {
|
||||
@@ -532,12 +561,12 @@ export default function Models() {
|
||||
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
|
||||
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
|
||||
</span>
|
||||
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{avgLatency}ms</b></span>
|
||||
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty */}
|
||||
{filteredModels.length === 0 ? (
|
||||
{detailModels.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">
|
||||
<svg width="40" height="40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -572,7 +601,7 @@ export default function Models() {
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
data-tooltip={tr('平均延迟')}
|
||||
>
|
||||
{tr('延迟')} {m.avgLatency}ms
|
||||
{tr('延迟')} {formatLatency(m.avgLatency)}
|
||||
</span>
|
||||
<span
|
||||
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
|
||||
@@ -617,7 +646,7 @@ export default function Models() {
|
||||
{m.successRate != null && m.successRate < 60 && (
|
||||
<span className="model-tag model-tag-orange">{tr('风险')}</span>
|
||||
)}
|
||||
{m.avgLatency <= 500 && (
|
||||
{isKnownLatency(m.avgLatency) && m.avgLatency <= 500 && (
|
||||
<span className="model-tag model-tag-purple">{tr('低延迟')}</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -761,7 +790,7 @@ export default function Models() {
|
||||
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
|
||||
style={{ fontSize: 12, fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{m.avgLatency}ms
|
||||
{formatLatency(m.avgLatency)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -364,7 +364,6 @@ export default function TokenRoutes() {
|
||||
try {
|
||||
await api.updateRoute(route.id, { routingStrategy });
|
||||
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setRouteSummaries((prev) => prev.map((item) => (
|
||||
item.id === route.id
|
||||
@@ -372,9 +371,16 @@ export default function TokenRoutes() {
|
||||
: item
|
||||
)));
|
||||
toast.error(e.message || '更新路由策略失败');
|
||||
return;
|
||||
} finally {
|
||||
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: false }));
|
||||
}
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '路由策略已保存,但刷新列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
||||
|
||||
@@ -30,6 +30,29 @@ describe('tokenBindingPresentation', () => {
|
||||
expect(result.helperText).toContain('不会跟着变');
|
||||
});
|
||||
|
||||
it('describes fixed mode when the selected token is not the account default', () => {
|
||||
const result = describeTokenBinding([
|
||||
{ id: 1, name: 'default', isDefault: true },
|
||||
{ id: 2, name: 'backup', isDefault: false },
|
||||
], 2);
|
||||
|
||||
expect(result.bindingModeLabel).toBe('固定令牌');
|
||||
expect(result.effectiveTokenName).toBe('backup');
|
||||
expect(result.helperText).toContain('不会随账号默认变化');
|
||||
});
|
||||
|
||||
it('describes follow-account-default mode when no default token exists yet', () => {
|
||||
const result = describeTokenBinding([
|
||||
{ id: 2, name: 'backup', isDefault: false },
|
||||
{ id: 3, name: 'spare', isDefault: false },
|
||||
], 0);
|
||||
|
||||
expect(result.bindingModeLabel).toBe('跟随账号默认');
|
||||
expect(result.effectiveTokenName).toBe('未设置默认令牌');
|
||||
expect(result.helperText).toContain('当前账号还没有默认令牌');
|
||||
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
|
||||
});
|
||||
|
||||
it('formats fixed token options with clearer labels and descriptions', () => {
|
||||
const token = {
|
||||
id: 3,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import TokenRoutes from './TokenRoutes.js';
|
||||
|
||||
const { apiMock, getBrandMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getRoutesSummary: vi.fn(),
|
||||
getRouteChannels: vi.fn(),
|
||||
getModelTokenCandidates: vi.fn(),
|
||||
getRouteDecisionsBatch: vi.fn(),
|
||||
getRouteWideDecisionsBatch: vi.fn(),
|
||||
updateRoute: vi.fn(),
|
||||
},
|
||||
getBrandMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
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 routing strategy updates', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getBrandMock.mockReset();
|
||||
getBrandMock.mockReturnValue(null);
|
||||
apiMock.getRoutesSummary
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
displayName: 'gpt-4o-mini',
|
||||
displayIcon: null,
|
||||
modelMapping: null,
|
||||
routingStrategy: 'weighted',
|
||||
enabled: true,
|
||||
channelCount: 0,
|
||||
enabledChannelCount: 0,
|
||||
siteNames: [],
|
||||
decisionSnapshot: null,
|
||||
decisionRefreshedAt: null,
|
||||
},
|
||||
])
|
||||
.mockRejectedValueOnce(new Error('refresh failed'));
|
||||
apiMock.getRouteChannels.mockResolvedValue([]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||
apiMock.updateRoute.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the optimistic routing strategy when refresh fails after a successful save', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandButton = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||
));
|
||||
await act(async () => {
|
||||
expandButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const roundRobinOption = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
&& collectText(node).includes('轮询')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
roundRobinOption.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { routingStrategy: 'round_robin' });
|
||||
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
|
||||
|
||||
const strategyTrigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
&& collectText(node).includes('轮询')
|
||||
));
|
||||
expect(collectText(strategyTrigger)).toContain('轮询');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user