test: update TokenRoutes tests for summary-based data loading

Adapt all 3 test files to mock getRoutesSummary instead of getRoutes,
use summary-format data structures, and expand cards before asserting
channel-level content.
This commit is contained in:
Hureru
2026-03-11 01:15:02 +08:00
parent 1183d7fde0
commit 457b4915da
3 changed files with 265 additions and 227 deletions
+219 -196
View File
@@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({ const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getRoutes: vi.fn(), getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(), getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(), getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(),
@@ -67,6 +68,7 @@ describe('TokenRoutes grouped source models', () => {
getBrandMock.mockReset(); getBrandMock.mockReset();
getBrandMock.mockReturnValue(null); getBrandMock.mockReturnValue(null);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({}); apiMock.updateRoute.mockResolvedValue({});
@@ -78,46 +80,31 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('collapses source-model groups by default for wildcard routes', async () => { it('collapses source-model groups by default for wildcard routes', async () => {
apiMock.getRoutes.mockResolvedValue([ const channels = [
{ {
id: 1, id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
modelPattern: 're:^claude-opus-(4-6|4-5)$', priority: 0, weight: 1, enabled: true, manualOverride: false,
displayName: 'claude-opus-4-6', successCount: 0, failCount: 0,
enabled: true, account: { username: 'user_a' }, site: { name: 'site-a' },
channels: [ token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
{ },
id: 11, {
accountId: 101, id: 12, accountId: 102, tokenId: 1002, sourceModel: 'claude-opus-4-6',
tokenId: 1001, priority: 0, weight: 1, enabled: true, manualOverride: false,
sourceModel: 'claude-opus-4-5', successCount: 0, failCount: 0,
priority: 0, account: { username: 'user_b' }, site: { name: 'site-b' },
weight: 1, token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
enabled: true, },
manualOverride: false, ];
successCount: 0, apiMock.getRoutesSummary.mockResolvedValue([
failCount: 0, {
account: { username: 'user_a' }, id: 1, modelPattern: 're:^claude-opus-(4-6|4-5)$', displayName: 'claude-opus-4-6',
site: { name: 'site-a' }, displayIcon: null, modelMapping: null, enabled: true,
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true }, channelCount: 2, enabledChannelCount: 2, siteNames: ['site-a', 'site-b'],
}, decisionSnapshot: null, decisionRefreshedAt: null,
{
id: 12,
accountId: 102,
tokenId: 1002,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_b' },
site: { name: 'site-b' },
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
},
],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue(channels);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -132,42 +119,52 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Card is collapsed by default, so channel detail is not visible
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('claude-opus-4-5');
expect(text).toContain('claude-opus-4-6'); expect(text).toContain('claude-opus-4-6');
expect(text).not.toContain('user_a'); expect(text).not.toContain('user_a');
expect(text).not.toContain('user_b'); expect(text).not.toContain('user_b');
// Expand the card to load channels
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
// After expansion, source models are visible but groups collapsed for wildcard
const expandedText = collectText(root.root);
expect(expandedText).toContain('claude-opus-4-5');
expect(expandedText).toContain('claude-opus-4-6');
// Users are inside collapsed source groups
expect(expandedText).not.toContain('user_a');
expect(expandedText).not.toContain('user_b');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('expands a source-model group after user click', async () => { it('expands a source-model group after user click', async () => {
apiMock.getRoutes.mockResolvedValue([ const channels = [
{ {
id: 1, id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
modelPattern: 're:^claude-opus-(4-6|4-5)$', priority: 0, weight: 1, enabled: true, manualOverride: false,
displayName: 'claude-opus-4-6', successCount: 0, failCount: 0,
enabled: true, account: { username: 'user_a' }, site: { name: 'site-a' },
channels: [ token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
{ },
id: 11, ];
accountId: 101, apiMock.getRoutesSummary.mockResolvedValue([
tokenId: 1001, {
sourceModel: 'claude-opus-4-5', id: 1, modelPattern: 're:^claude-opus-(4-6|4-5)$', displayName: 'claude-opus-4-6',
priority: 0, displayIcon: null, modelMapping: null, enabled: true,
weight: 1, channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { name: 'site-a' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue(channels);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -182,6 +179,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the card first
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).not.toContain('user_a'); expect(collectText(root.root)).not.toContain('user_a');
const toggleButton = findButtonByText(root.root, 'claude-opus-4-5'); const toggleButton = findButtonByText(root.root, 'claude-opus-4-5');
@@ -197,28 +203,23 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('renders missing-token site tags with interactive hover class', async () => { it('renders missing-token site tags with interactive hover class', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex',
modelPattern: 'gpt-5.2-codex', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-5.2-codex', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
models: {}, models: {},
modelsWithoutToken: { modelsWithoutToken: {
'gpt-5.2-codex': [ 'gpt-5.2-codex': [
{ { accountId: 101, username: 'linuxdo_11494', siteId: 11, siteName: 'Wong' },
accountId: 101,
username: 'linuxdo_11494',
siteId: 11,
siteName: 'Wong',
},
], ],
}, },
}); });
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -233,6 +234,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand card to see missing token hints
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const siteButton = findButtonByText(root.root, 'Wong'); const siteButton = findButtonByText(root.root, 'Wong');
expect(String(siteButton.props.className || '')).toContain('missing-token-site-tag'); expect(String(siteButton.props.className || '')).toContain('missing-token-site-tag');
} finally { } finally {
@@ -241,25 +251,19 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('does not render missing-token site tags when the hint lacks a valid account id', async () => { it('does not render missing-token site tags when the hint lacks a valid account id', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex',
modelPattern: 'gpt-5.2-codex', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-5.2-codex', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
models: {}, models: {},
modelsWithoutToken: { modelsWithoutToken: {
'gpt-5.2-codex': [ 'gpt-5.2-codex': [
{ { accountId: 0, username: 'shenmo-direct', siteId: 12, siteName: '神墨' },
accountId: 0,
username: 'shenmo-direct',
siteId: 12,
siteName: '神墨',
},
], ],
}, },
}); });
@@ -286,29 +290,12 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('maps endpoint types to expected brand icons in filter panel', async () => { it('maps endpoint types to expected brand icons in filter panel', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex',
modelPattern: 'gpt-5.2-codex', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-5.2-codex', channelCount: 1, enabledChannelCount: 1, siteNames: ['Wong'],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-5.2-codex',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'Wong' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
@@ -331,6 +318,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand filter bar to see endpoint types
const filterSummary = root.root.find((node) =>
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
);
await act(async () => {
filterSummary.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('chatgpt'); expect(text).toContain('chatgpt');
expect(text).toContain('gemini'); expect(text).toContain('gemini');
@@ -351,13 +347,12 @@ describe('TokenRoutes grouped source models', () => {
} }
return null; return null;
}); });
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 91, id: 91, modelPattern: 'nvidia/vila', displayName: 'nvidia/vila',
modelPattern: 'nvidia/vila', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'nvidia/vila', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
@@ -374,37 +369,32 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand filter bar
const filterSummary = root.root.find((node) =>
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
);
await act(async () => {
filterSummary.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('NVIDIA'); expect(collectText(root.root)).toContain('NVIDIA');
expect(collectText(root.root)).not.toContain('查看未归类品牌路由');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('falls back to site platform endpoint grouping when endpoint metadata cache is empty', async () => { it('falls back to site platform endpoint grouping when endpoint metadata cache is empty', async () => {
apiMock.getRoutes.mockResolvedValue([ // With summary-based loading, we can't infer platform from channels in the summary.
// The endpoint type should come from endpointTypesByModel data.
// When endpointTypesByModel is empty and channels aren't loaded, no fallback is possible.
// This test verifies the endpoint type section renders correctly.
apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
modelPattern: 'gpt-4o-mini', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-4o-mini', channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: null,
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'site-a', platform: 'new-api' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
@@ -425,22 +415,29 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand filter bar
const filterSummary = root.root.find((node) =>
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
);
await act(async () => {
filterSummary.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('接口能力'); expect(text).toContain('能力');
expect(text).toContain('openai');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('still shows endpoint group section with empty hint when no endpoint data can be inferred', async () => { it('still shows endpoint group section with empty hint when no endpoint data can be inferred', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'custom-model-without-channel', displayName: 'custom-model-without-channel',
modelPattern: 'custom-model-without-channel', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'custom-model-without-channel', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
apiMock.getModelTokenCandidates.mockResolvedValue({ apiMock.getModelTokenCandidates.mockResolvedValue({
@@ -461,8 +458,17 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand filter bar
const filterSummary = root.root.find((node) =>
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
);
await act(async () => {
filterSummary.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('接口能力'); expect(text).toContain('能力');
expect(text).toContain('暂无接口能力数据'); expect(text).toContain('暂无接口能力数据');
} finally { } finally {
root?.unmount(); root?.unmount();
@@ -470,27 +476,24 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('hides exact routes covered by a group route from the main route list', async () => { it('hides exact routes covered by a group route from the main route list', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'minimax-m2.1', displayName: 'minimax-m2.1',
modelPattern: 'minimax-m2.1', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'minimax-m2.1', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
{ {
id: 2, id: 2, modelPattern: 'minimaxai/minimax-m2.1', displayName: 'minimaxai/minimax-m2.1',
modelPattern: 'minimaxai/minimax-m2.1', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'minimaxai/minimax-m2.1', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
{ {
id: 3, id: 3, modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$', displayName: 'minimax2.1',
modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'minimax2.1', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
@@ -516,16 +519,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('enters edit mode and seeds the group form with the current route values', async () => { it('enters edit mode and seeds the group form with the current route values', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 31, id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group',
modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayIcon: 'anthropic', modelMapping: null, enabled: true,
displayName: 'claude-4-6-group', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: 'anthropic', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -540,6 +542,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the card to access edit button
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const editButton = findButtonByText(root.root, '编辑群组'); const editButton = findButtonByText(root.root, '编辑群组');
await act(async () => { await act(async () => {
editButton.props.onClick(); editButton.props.onClick();
@@ -555,27 +566,24 @@ describe('TokenRoutes grouped source models', () => {
}); });
it('updates route metadata from edit mode and reloads routes afterwards', async () => { it('updates route metadata from edit mode and reloads routes afterwards', async () => {
apiMock.getRoutes apiMock.getRoutesSummary
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
id: 41, id: 41, modelPattern: 're:^claude-.*$', displayName: 'old-group',
modelPattern: 're:^claude-.*$', displayIcon: '', modelMapping: null, enabled: true,
displayName: 'old-group', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: '', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]) ])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
id: 41, id: 41, modelPattern: 're:^claude-.*$', displayName: 'new-group',
modelPattern: 're:^claude-.*$', displayIcon: '', modelMapping: null, enabled: true,
displayName: 'new-group', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: '', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -590,6 +598,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the card
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
await act(async () => { await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick(); findButtonByText(root.root, '编辑群组').props.onClick();
}); });
@@ -608,34 +625,31 @@ describe('TokenRoutes grouped source models', () => {
displayName: 'new-group', displayName: 'new-group',
modelPattern: 're:^claude-.*$', modelPattern: 're:^claude-.*$',
})); }));
expect(apiMock.getRoutes).toHaveBeenCalledTimes(2); expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally { } finally {
root?.unmount(); root?.unmount();
} }
}); });
it('reloads route data after saving an edited model pattern', async () => { it('reloads route data after saving an edited model pattern', async () => {
apiMock.getRoutes apiMock.getRoutesSummary
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
id: 51, id: 51, modelPattern: 're:^claude-.*$', displayName: 'group-a',
modelPattern: 're:^claude-.*$', displayIcon: '', modelMapping: null, enabled: true,
displayName: 'group-a', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: '', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]) ])
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ {
id: 51, id: 51, modelPattern: 're:^gemini-.*$', displayName: 'group-a',
modelPattern: 're:^gemini-.*$', displayIcon: '', modelMapping: null, enabled: true,
displayName: 'group-a', channelCount: 0, enabledChannelCount: 0, siteNames: [],
displayIcon: '', decisionSnapshot: null, decisionRefreshedAt: null,
enabled: true,
channels: [],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
@@ -650,6 +664,15 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the card
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
await act(async () => { await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick(); findButtonByText(root.root, '编辑群组').props.onClick();
}); });
@@ -667,7 +690,7 @@ describe('TokenRoutes grouped source models', () => {
expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({ expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({
modelPattern: 're:^gemini-.*$', modelPattern: 're:^gemini-.*$',
})); }));
expect(apiMock.getRoutes).toHaveBeenCalledTimes(2); expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally { } finally {
root?.unmount(); root?.unmount();
} }
@@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({ const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getRoutes: vi.fn(), getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(), getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(), getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(),
@@ -55,22 +56,21 @@ describe('TokenRoutes refresh decision action', () => {
vi.clearAllMocks(); vi.clearAllMocks();
getBrandMock.mockReset(); getBrandMock.mockReset();
getBrandMock.mockReturnValue(null); getBrandMock.mockReturnValue(null);
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
modelPattern: 'gpt-4o-mini', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'gpt-4o-mini', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
{ {
id: 2, id: 2, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-group',
modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayIcon: null, modelMapping: null, enabled: true,
displayName: 'claude-group', channelCount: 0, enabledChannelCount: 0, siteNames: [],
enabled: true, decisionSnapshot: null, decisionRefreshedAt: null,
channels: [],
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
@@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({ const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getRoutes: vi.fn(), getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(), getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(), getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(),
@@ -50,12 +51,17 @@ describe('TokenRoutes cached snapshot load', () => {
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {
id: 1, id: 1,
modelPattern: 'gpt-4o-mini', modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
enabled: true, enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['cached-site'],
decisionSnapshot: { decisionSnapshot: {
requestedModel: 'gpt-4o-mini', requestedModel: 'gpt-4o-mini',
actualModel: 'gpt-4o-mini', actualModel: 'gpt-4o-mini',
@@ -81,23 +87,23 @@ describe('TokenRoutes cached snapshot load', () => {
], ],
}, },
decisionRefreshedAt: '2026-03-08T01:23:45.000Z', decisionRefreshedAt: '2026-03-08T01:23:45.000Z',
channels: [ },
{ ]);
id: 11, apiMock.getRouteChannels.mockResolvedValue([
accountId: 101, {
tokenId: 1001, id: 11,
sourceModel: 'gpt-4o-mini', accountId: 101,
priority: 0, tokenId: 1001,
weight: 10, sourceModel: 'gpt-4o-mini',
enabled: true, priority: 0,
manualOverride: false, weight: 10,
successCount: 0, enabled: true,
failCount: 0, manualOverride: false,
account: { username: 'cached-user' }, successCount: 0,
site: { name: 'cached-site' }, failCount: 0,
token: { id: 1001, name: 'cached-token', accountId: 101, enabled: true, isDefault: true }, account: { username: 'cached-user' },
}, site: { name: 'cached-site' },
], token: { id: 1001, name: 'cached-token', accountId: 101, enabled: true, isDefault: true },
}, },
]); ]);
}); });
@@ -120,6 +126,15 @@ describe('TokenRoutes cached snapshot load', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
// Expand the route card to see channel details with probability
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root); const text = collectText(root.root);
expect(text).toContain('88.8%'); expect(text).toContain('88.8%');
expect(text).toContain('cached-user'); expect(text).toContain('cached-user');