fix(routing): preserve allowlist edits and save state

This commit is contained in:
elky
2026-08-14 11:43:24 +08:00
parent fb33ea57b0
commit a1d64e5239
5 changed files with 615 additions and 50 deletions
@@ -99,7 +99,7 @@ describe('routingPolicy', () => {
allowed_models: ['legacy-model'],
})
expect(parseAllowedModelsInput(' gpt-5, claude-*\nlegacy-model, gpt-5 ')).toEqual([
expect(parseAllowedModelsInput(' gpt-5\nclaude-*\nlegacy-model\ngpt-5 ')).toEqual([
'gpt-5',
'claude-*',
'legacy-model',
@@ -107,10 +107,10 @@ describe('routingPolicy', () => {
const restricted = updateAllowedModelsFromInput(
config,
'gpt-5, claude-*\nlegacy-model, gpt-5',
'gpt-5\nclaude-*\nlegacy-model\ngpt-5',
)
expect(restricted.allowed_models).toEqual(['gpt-5', 'claude-*', 'legacy-model'])
expect(formatAllowedModelsInput(restricted.allowed_models)).toBe('gpt-5, claude-*, legacy-model')
expect(formatAllowedModelsInput(restricted.allowed_models)).toBe('gpt-5\nclaude-*\nlegacy-model')
expect(routingModelScopeLabel(restricted)).toBe('3 个模型')
const unrestricted = clearAllowedModels(restricted)
@@ -118,6 +118,23 @@ describe('routingPolicy', () => {
expect(routingModelScopeLabel(unrestricted)).toBe('全部模型')
})
it('round-trips selectors containing commas and labels wildcard scope as unrestricted', () => {
const selectors = ['vendor,model', 'gpt-*']
expect(parseAllowedModelsInput(formatAllowedModelsInput(selectors))).toEqual(selectors)
const wildcard = normalizeRoutingGroupConfig({ allowed_models: ['gpt-*', '*'] })
expect(routingModelScopeLabel(wildcard)).toBe('全部模型')
})
it('preserves historical empty selectors until unrestricted scope is explicit', () => {
const legacy = normalizeRoutingGroupConfig({ allowed_models: ['', ' '] })
expect(updateAllowedModelsFromInput(legacy, ' \n')).toMatchObject({
allowed_models: ['', ' '],
})
expect(clearAllowedModels(legacy).allowed_models).toEqual([])
})
it('preserves an explicit model allowlist across per-model editing actions', () => {
const allowlist = ['gpt-*', 'legacy-model']
let config = normalizeRoutingGroupConfig({
@@ -114,7 +114,7 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
export function parseAllowedModelsInput(value: string): string[] {
const seen = new Set<string>()
return value
.split(/[,\r\n]+/u)
.split(/\r\n?|\n/u)
.map(item => item.trim())
.filter(Boolean)
.filter((model) => {
@@ -125,7 +125,7 @@ export function parseAllowedModelsInput(value: string): string[] {
}
export function formatAllowedModelsInput(models: string[]): string {
return models.join(', ')
return models.join('\n')
}
export function updateAllowedModelsFromInput(
@@ -133,6 +133,14 @@ export function updateAllowedModelsFromInput(
value: string,
): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
// Preserve the historical "empty selector" form until the user explicitly
// chooses the unrestricted scope. It is distinct from an empty allowlist in
// the routing core, where it matches no normal model.
const hasHistoricalEmptySelector = next.allowed_models.length > 0
&& next.allowed_models.every(model => model.trim() === '')
if (value.trim() === '' && hasHistoricalEmptySelector) {
return next
}
next.allowed_models = parseAllowedModelsInput(value)
return next
}
@@ -144,8 +152,11 @@ export function clearAllowedModels(config: RoutingGroupConfig): RoutingGroupConf
}
export function routingModelScopeLabel(config: RoutingGroupConfig): string {
const count = normalizeRoutingGroupConfig(config).allowed_models.length
return count ? `${count} 个模型` : '全部模型'
const models = normalizeRoutingGroupConfig(config).allowed_models
if (models.length === 0 || models.some(model => model.trim() === '*')) {
return '全部模型'
}
return `${models.length} 个模型`
}
export function allowedModelsMirrorPerModelPolicies(config: RoutingGroupConfig): boolean {
+80 -37
View File
@@ -187,6 +187,8 @@
<Card
v-if="draft"
class="overflow-hidden"
:inert="saving"
:aria-busy="saving"
>
<div class="border-b border-border/60 px-5 py-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
@@ -336,20 +338,21 @@
>
模型白名单
</h3>
<Badge :variant="draft.config_json.allowed_models.length ? 'outline' : 'secondary'">
{{ draft.config_json.allowed_models.length ? `${draft.config_json.allowed_models.length}` : '全部模型' }}
<Badge :variant="routingModelScopeLabel(draft.config_json) === '全部模型' ? 'secondary' : 'outline'">
{{ routingModelScopeLabel(draft.config_json) }}
</Badge>
</div>
<p class="mt-1 text-xs text-muted-foreground">
控制此策略分组适用于哪些模型留空表示全部模型它与区分模型中的专属调度覆盖相互独立支持精确值* 和前缀通配符 gpt-*多个值用英文逗号或换行分隔
控制此策略分组适用于哪些模型留空表示全部模型它与区分模型中的专属调度覆盖相互独立支持精确值* 和前缀通配符 gpt-*每行填写一个值
</p>
</div>
<Button
v-if="draft.config_json.allowed_models.length"
v-if="routingModelScopeLabel(draft.config_json) !== '全部模型'"
type="button"
variant="ghost"
size="sm"
class="shrink-0 text-muted-foreground hover:text-foreground"
:disabled="saving"
data-testid="clear-allowed-models"
@click="clearAllowedModelScope"
>
@@ -357,24 +360,15 @@
</Button>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<Input
v-model="allowedModelsInput"
class="min-w-0 flex-1"
data-testid="allowed-models-input"
aria-label="模型白名单"
placeholder="留空表示全部模型,例如:gpt-5, claude-*, legacy-model"
/>
<Button
type="button"
variant="outline"
class="shrink-0"
data-testid="apply-allowed-models"
@click="applyAllowedModelScope"
>
应用范围
</Button>
</div>
<Textarea
:model-value="allowedModelsInput"
class="min-h-[96px] font-mono"
:disabled="saving"
data-testid="allowed-models-input"
aria-label="模型白名单"
placeholder="留空表示全部模型,每行一个模型"
@update:model-value="updateAllowedModelScope"
/>
<div
v-if="draft.config_json.allowed_models.length"
@@ -736,10 +730,34 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ChevronDown, ChevronRight, Copy, Key, Layers, Plus, Save, Star, Trash2 } from 'lucide-vue-next'
import {
ChevronDown,
ChevronRight,
Copy,
Key,
Layers,
Plus,
Save,
SlidersHorizontal,
Star,
Trash2,
} from 'lucide-vue-next'
import { PageContainer } from '@/components/layout'
import { Badge, Button, Card, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
import {
Badge,
Button,
Card,
Input,
Table,
TableBody,
TableCard,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from '@/components/ui'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { AlertDialog } from '@/components/common'
import {
@@ -824,6 +842,7 @@ const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const isCreating = ref(false)
let draftGeneration = 0
const switchModelTarget = ref<string | null>(null)
const switchModelDialogOpen = ref(false)
@@ -950,6 +969,7 @@ function paramToString(value: unknown): string | null {
}
function clearDraftState(): void {
draftGeneration += 1
isCreating.value = false
selectedGroupId.value = null
draft.value = null
@@ -964,6 +984,7 @@ function clearDraftState(): void {
function selectGroup(group: RoutingGroupRecord): void {
const normalized = normalizeRecord(group)
draftGeneration += 1
isCreating.value = false
selectedGroupId.value = normalized.id
draft.value = buildDraft(normalized)
@@ -979,6 +1000,7 @@ function setDraftEnabled(value: boolean): void {
}
function startCreate(): void {
draftGeneration += 1
isCreating.value = true
selectedGroupId.value = null
draft.value = {
@@ -1309,18 +1331,18 @@ function globalModelLabel(modelName: string): string {
return `${model.display_name} (${model.name})`
}
function applyAllowedModelScope(): void {
if (!draft.value) return
const next = updateAllowedModelsFromInput(draft.value.config_json, allowedModelsInput.value)
function updateAllowedModelScope(value: string): void {
if (!draft.value || saving.value) return
allowedModelsInput.value = value
const next = updateAllowedModelsFromInput(draft.value.config_json, value)
updateDraftConfig(next)
if (editingConfig.value) {
editingConfig.value = updateAllowedModelsFromInput(editingConfig.value, allowedModelsInput.value)
editingConfig.value = updateAllowedModelsFromInput(editingConfig.value, value)
}
allowedModelsInput.value = formatAllowedModelsInput(next.allowed_models)
}
function clearAllowedModelScope(): void {
if (!draft.value) return
if (!draft.value || saving.value) return
const next = clearAllowedModels(draft.value.config_json)
updateDraftConfig(next)
if (editingConfig.value) {
@@ -1329,7 +1351,7 @@ function clearAllowedModelScope(): void {
allowedModelsInput.value = ''
}
function replaceGroup(group: RoutingGroupRecord): void {
function replaceGroup(group: RoutingGroupRecord, select = true): void {
const normalized = normalizeRecord(group)
const index = groups.value.findIndex(item => item.id === normalized.id)
if (index >= 0) {
@@ -1337,7 +1359,9 @@ function replaceGroup(group: RoutingGroupRecord): void {
} else {
groups.value.unshift(normalized)
}
selectGroup(normalized)
if (select) {
selectGroup(normalized)
}
}
async function fetchGroups(): Promise<void> {
@@ -1373,7 +1397,7 @@ async function loadGlobalModels(options: { cacheTtlMs?: number } = {}): Promise<
}
async function saveDraft(): Promise<void> {
if (!draft.value) return
if (!draft.value || saving.value) return
const name = draft.value.name.trim()
if (!name) {
showError('策略名称不能为空')
@@ -1385,6 +1409,10 @@ async function saveDraft(): Promise<void> {
return
}
const targetGroupId = draft.value.id ?? null
const submittedGeneration = draftGeneration
const submittedSnapshot = draftSnapshotValue(draft.value)
const wasCreating = isCreating.value || !draft.value.id
saving.value = true
try {
const payload = {
@@ -1394,13 +1422,28 @@ async function saveDraft(): Promise<void> {
is_system_default: draft.value.is_system_default,
config_json: config,
}
const wasCreating = isCreating.value || !draft.value.id
const saved = wasCreating
? await createRoutingGroup(payload)
: await updateRoutingGroup(draft.value.id, payload)
isCreating.value = false
replaceGroup(saved)
if (wasCreating) {
const sameDraftGeneration = draftGeneration === submittedGeneration
const stillEditingSubmittedDraft = wasCreating
? sameDraftGeneration
&& isCreateRoute.value
&& isCreating.value
&& draft.value != null
&& draftSnapshotValue(draft.value) === submittedSnapshot
: routeGroupId.value === targetGroupId
&& draft.value?.id === targetGroupId
&& (sameDraftGeneration
? draftSnapshotValue(draft.value) === submittedSnapshot
: !draftDirty.value)
if (stillEditingSubmittedDraft) {
isCreating.value = false
}
replaceGroup(saved, stillEditingSubmittedDraft)
if (wasCreating && stillEditingSubmittedDraft) {
await router.replace({ name: 'RoutingProfileDetail', params: { groupId: saved.id } })
}
success('调度策略已保存')
@@ -0,0 +1,498 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import RoutingProfiles from '../RoutingProfiles.vue'
import type {
RoutingGroupCreateRequest,
RoutingGroupRecord,
RoutingGroupUpdateRequest,
} from '@/api/routing-profiles'
const apiMocks = vi.hoisted(() => ({
listRoutingGroups: vi.fn(),
createRoutingGroup: vi.fn(),
updateRoutingGroup: vi.fn(),
deleteRoutingGroup: vi.fn(),
getGlobalModels: vi.fn(),
}))
const routeMocks = vi.hoisted(() => ({
route: null as null | { name: string; params: Record<string, string> },
push: vi.fn(),
replace: vi.fn(),
}))
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
vi.mock('vue-router', async () => {
const { reactive } = await import('vue')
routeMocks.route = reactive({
name: 'RoutingProfileDetail',
params: { groupId: 'group-1' },
})
return {
useRoute: () => routeMocks.route,
useRouter: () => ({ push: routeMocks.push, replace: routeMocks.replace }),
}
})
vi.mock('@/api/routing-profiles', () => ({
listRoutingGroups: apiMocks.listRoutingGroups,
createRoutingGroup: apiMocks.createRoutingGroup,
updateRoutingGroup: apiMocks.updateRoutingGroup,
deleteRoutingGroup: apiMocks.deleteRoutingGroup,
}))
vi.mock('@/api/global-models', () => ({ getGlobalModels: apiMocks.getGlobalModels }))
vi.mock('@/composables/useToast', () => ({ useToast: () => toastMocks }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn() } }))
vi.mock('@/components/layout', async () => {
const { defineComponent, h } = await import('vue')
return {
PageContainer: defineComponent({
setup(_, { slots }) {
return () => h('main', slots.default?.())
},
}),
}
})
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const wrapper = (tag = 'div') => defineComponent({
inheritAttrs: false,
props: { class: String },
setup(props, { attrs, slots }) {
return () => h(tag, { ...attrs, class: props.class }, [
slots.header?.(),
slots.default?.(),
])
},
})
const Input = defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: [String, Number], default: '' },
class: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
class: props.class,
disabled: props.disabled,
value: props.modelValue,
onInput: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLInputElement).value,
),
})
},
})
const Textarea = defineComponent({
inheritAttrs: false,
props: {
modelValue: { type: String, default: '' },
class: String,
disabled: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('textarea', {
...attrs,
class: props.class,
disabled: props.disabled,
value: props.modelValue,
onInput: (event: Event) => emit(
'update:modelValue',
(event.target as HTMLTextAreaElement).value,
),
})
},
})
const Button = defineComponent({
inheritAttrs: false,
props: {
class: String,
disabled: Boolean,
type: { type: String, default: 'button' },
},
setup(props, { attrs, slots }) {
return () => h('button', {
...attrs,
class: props.class,
disabled: props.disabled,
type: props.type,
}, slots.default?.())
},
})
return {
Badge: wrapper(),
Button,
Card: wrapper('section'),
Input,
Table: wrapper('table'),
TableBody: wrapper('tbody'),
TableCard: wrapper(),
TableCell: wrapper('td'),
TableHead: wrapper('th'),
TableHeader: wrapper('thead'),
TableRow: wrapper('tr'),
Textarea,
}
})
vi.mock('@/components/ui/dropdown-menu', async () => {
const { defineComponent, h } = await import('vue')
const wrapper = defineComponent({
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
return {
DropdownMenu: wrapper,
DropdownMenuContent: wrapper,
DropdownMenuItem: wrapper,
DropdownMenuTrigger: wrapper,
}
})
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return { AlertDialog: defineComponent({ setup: () => () => h('div') }) }
})
vi.mock('@/features/routing/components', async () => {
const { defineComponent, h } = await import('vue')
return {
RoutingPriorityPolicyEditor: defineComponent({
setup: () => () => h('div', { 'data-testid': 'routing-policy-editor' }),
}),
}
})
let app: App | undefined
let root: HTMLElement | undefined
function routingGroup(
allowedModels: string[] = [],
overrides: Partial<RoutingGroupRecord> = {},
): RoutingGroupRecord {
return {
id: 'group-1',
name: 'Default routing',
description: null,
enabled: true,
is_system_default: true,
config_json: {
allowed_models: allowedModels,
default_policy: {
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
},
model_policies: [],
rules: [],
},
version: 1,
created_at: 1,
updated_at: 1,
published_at: null,
...overrides,
}
}
async function flushPromises(iterations = 5): Promise<void> {
for (let index = 0; index < iterations; index += 1) {
await Promise.resolve()
}
await nextTick()
}
async function mountPage(
input: RoutingGroupRecord | RoutingGroupRecord[] = routingGroup(),
): Promise<void> {
const groups = Array.isArray(input) ? input : [input]
apiMocks.listRoutingGroups.mockResolvedValue({ items: groups, total: groups.length })
apiMocks.getGlobalModels.mockResolvedValue({ models: [] })
apiMocks.updateRoutingGroup.mockImplementation(
async (groupId: string, payload: RoutingGroupUpdateRequest) => {
const group = groups.find(item => item.id === groupId)
if (!group) throw new Error(`unknown routing group: ${groupId}`)
return {
...group,
...payload,
config_json: payload.config_json ?? group.config_json,
updated_at: 2,
}
},
)
root = document.createElement('div')
document.body.appendChild(root)
app = createApp(defineComponent({
setup: () => () => h(RoutingProfiles),
}))
app.mount(root)
await flushPromises()
}
function setTextareaValue(textarea: HTMLTextAreaElement, value: string): void {
textarea.value = value
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
beforeEach(() => {
vi.clearAllMocks()
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.name = 'RoutingProfileDetail'
routeMocks.route.params = { groupId: 'group-1' }
})
afterEach(() => {
app?.unmount()
root?.remove()
app = undefined
root = undefined
})
describe('RoutingProfiles model allowlist', () => {
it('saves one selector per line without an extra apply step', async () => {
await mountPage()
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
expect(textarea).toBeInstanceOf(HTMLTextAreaElement)
setTextareaValue(textarea, 'gpt-5\nclaude-*\nvendor,model')
await nextTick()
const saveButton = root?.querySelector(
'button[aria-label="保存"]',
) as HTMLButtonElement
expect(saveButton.disabled).toBe(false)
saveButton.click()
await flushPromises()
expect(apiMocks.updateRoutingGroup).toHaveBeenCalledWith(
'group-1',
expect.objectContaining({
config_json: expect.objectContaining({
allowed_models: ['gpt-5', 'claude-*', 'vendor,model'],
}),
}),
)
})
it('locks the editor while a save is in flight', async () => {
const group = routingGroup(['model-a'])
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupUpdateRequest | undefined
await mountPage(group)
apiMocks.updateRoutingGroup.mockImplementationOnce(
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveUpdate = resolve
})
},
)
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
setTextareaValue(textarea, 'model-a\nmodel-b')
await nextTick()
const saveButton = root?.querySelector(
'button[aria-label="保存"]',
) as HTMLButtonElement
saveButton.click()
await nextTick()
const editor = root?.querySelector('[aria-busy="true"]') as HTMLElement
const clearButton = root?.querySelector(
'[data-testid="clear-allowed-models"]',
) as HTMLButtonElement
expect(editor.hasAttribute('inert')).toBe(true)
expect(textarea.disabled).toBe(true)
expect(clearButton.disabled).toBe(true)
expect(saveButton.disabled).toBe(true)
setTextareaValue(textarea, 'model-c')
await nextTick()
expect(submittedPayload?.config_json?.allowed_models).toEqual(['model-a', 'model-b'])
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
resolveUpdate({
...group,
...submittedPayload,
config_json: submittedPayload.config_json ?? group.config_json,
updated_at: 2,
})
await flushPromises()
expect(root?.querySelector('[aria-busy="true"]')).toBeNull()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-a\nmodel-b')
expect(apiMocks.updateRoutingGroup).toHaveBeenCalledTimes(1)
})
it('keeps another group selected when an earlier save response arrives', async () => {
const firstGroup = routingGroup(['model-a'], {
id: 'group-1',
name: 'First routing',
})
const secondGroup = routingGroup(['model-b'], {
id: 'group-2',
name: 'Second routing',
is_system_default: false,
})
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupUpdateRequest | undefined
await mountPage([firstGroup, secondGroup])
apiMocks.updateRoutingGroup.mockImplementationOnce(
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveUpdate = resolve
})
},
)
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
setTextareaValue(textarea, 'model-a\nmodel-a-new')
await nextTick()
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
await nextTick()
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.params = { groupId: 'group-2' }
await nextTick()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-b')
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
resolveUpdate({
...firstGroup,
...submittedPayload,
config_json: submittedPayload.config_json ?? firstGroup.config_json,
updated_at: 2,
})
await flushPromises()
expect(root?.querySelector('h2')?.textContent).toContain('Second routing')
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-b')
expect(routeMocks.replace).not.toHaveBeenCalled()
})
it('refreshes a clean draft when returning to the saved group before the response arrives', async () => {
const group = routingGroup(['model-a'])
let resolveUpdate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupUpdateRequest | undefined
await mountPage(group)
apiMocks.updateRoutingGroup.mockImplementationOnce(
async (_groupId: string, payload: RoutingGroupUpdateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveUpdate = resolve
})
},
)
const textarea = root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement
setTextareaValue(textarea, 'model-a\nmodel-b')
await nextTick()
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
await nextTick()
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.name = 'RoutingProfiles'
routeMocks.route.params = {}
await nextTick()
routeMocks.route.name = 'RoutingProfileDetail'
routeMocks.route.params = { groupId: 'group-1' }
await nextTick()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-a')
if (!resolveUpdate || !submittedPayload) throw new Error('save request did not start')
resolveUpdate({
...group,
...submittedPayload,
config_json: submittedPayload.config_json ?? group.config_json,
updated_at: 2,
})
await flushPromises()
expect((root?.querySelector(
'[data-testid="allowed-models-input"]',
) as HTMLTextAreaElement).value).toBe('model-a\nmodel-b')
expect((root?.querySelector(
'button[aria-label="保存"]',
) as HTMLButtonElement).disabled).toBe(true)
})
it('does not attach an old create response to a recreated draft', async () => {
if (!routeMocks.route) throw new Error('route mock was not initialized')
routeMocks.route.name = 'RoutingProfileCreate'
routeMocks.route.params = {}
let resolveCreate: ((value: RoutingGroupRecord) => void) | undefined
let submittedPayload: RoutingGroupCreateRequest | undefined
await mountPage([])
apiMocks.createRoutingGroup.mockImplementationOnce(
async (payload: RoutingGroupCreateRequest) => {
submittedPayload = payload
return await new Promise<RoutingGroupRecord>((resolve) => {
resolveCreate = resolve
})
},
)
;(root?.querySelector('button[aria-label="保存"]') as HTMLButtonElement).click()
await nextTick()
routeMocks.route.name = 'RoutingProfiles'
await nextTick()
routeMocks.route.name = 'RoutingProfileCreate'
await nextTick()
expect(root?.querySelector('h2')?.textContent).toContain('新建调度策略')
if (!resolveCreate || !submittedPayload) throw new Error('create request did not start')
const config = submittedPayload.config_json
resolveCreate({
...routingGroup(config?.allowed_models ?? [], {
id: 'created-group',
name: submittedPayload.name,
description: submittedPayload.description,
enabled: submittedPayload.enabled ?? false,
is_system_default: submittedPayload.is_system_default ?? false,
}),
config_json: config ?? routingGroup().config_json,
})
await flushPromises()
expect(root?.querySelector('h2')?.textContent).toContain('新建调度策略')
expect(routeMocks.replace).not.toHaveBeenCalled()
expect(apiMocks.createRoutingGroup).toHaveBeenCalledTimes(1)
})
})