import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue' import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue' import type { Model, ProviderEndpoint } from '@/api/endpoints' import { updateModel } from '@/api/endpoints/models' const upstreamModelMocks = vi.hoisted(() => ({ fetchModels: vi.fn(), })) vi.mock('@/components/ui', async () => { const { defineComponent, h } = await import('vue') const passthrough = (name: string, tag = 'div') => defineComponent({ name, setup(_, { slots }) { return () => h(tag, [slots.default?.(), slots.footer?.()]) }, }) return { Button: defineComponent({ name: 'ButtonStub', setup(_, { attrs, slots }) { return () => h('button', { ...attrs, type: 'button' }, slots.default?.()) }, }), Dialog: passthrough('DialogStub'), Input: defineComponent({ name: 'InputStub', props: { modelValue: String }, emits: ['update:modelValue'], setup(props, { attrs, emit }) { return () => h('input', { ...attrs, value: props.modelValue ?? '', onInput: (event: Event) => emit( 'update:modelValue', (event.target as HTMLInputElement).value, ), }) }, }), Label: passthrough('LabelStub', 'label'), Select: passthrough('SelectStub'), SelectContent: passthrough('SelectContentStub'), SelectItem: passthrough('SelectItemStub'), SelectTrigger: passthrough('SelectTriggerStub'), SelectValue: passthrough('SelectValueStub', 'span'), } }) vi.mock('@/components/common/MultiSelect.vue', async () => { const { defineComponent, h } = await import('vue') return { default: defineComponent({ name: 'MultiSelectStub', props: { modelValue: { type: Array, default: () => [] }, options: { type: Array, default: () => [] }, }, emits: ['update:modelValue'], setup(props, { emit }) { return () => h('div', (props.options as Array<{ value: string, label: string }>).map(option => h( 'button', { type: 'button', 'data-endpoint-id': option.value, onClick: () => emit('update:modelValue', [option.value]), }, option.label, ))) }, }), } }) vi.mock('lucide-vue-next', async () => { const { defineComponent, h } = await import('vue') const Icon = defineComponent({ name: 'IconStub', setup() { return () => h('span') }, }) return { Check: Icon, ChevronDown: Icon, Loader2: Icon, Plus: Icon, RefreshCw: Icon, Search: Icon, Tag: Icon, Zap: Icon, } }) vi.mock('@/api/endpoints/models', () => ({ updateModel: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/composables/useToast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), }), })) vi.mock('../../composables/useUpstreamModelsCache', () => ({ useUpstreamModelsCache: () => ({ fetchModels: upstreamModelMocks.fetchModels, }), })) const mountedApps: Array<{ app: App, root: HTMLElement }> = [] afterEach(() => { vi.mocked(updateModel).mockClear() upstreamModelMocks.fetchModels.mockReset() for (const { app, root } of mountedApps.splice(0)) { app.unmount() root.remove() } }) describe('ModelMappingDialog', () => { it('initializes upstream models when lazily mounted in the open state', async () => { upstreamModelMocks.fetchModels.mockResolvedValue({ models: [], error: null, warning: null, }) const model: Model = { provider_id: 'provider-1', global_model_id: 'global-model-1', is_active: true, is_available: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', id: 'model-1', provider_model_name: 'provider-model-1', provider_model_mappings: [], } const root = document.createElement('div') document.body.appendChild(root) const app = createApp(defineComponent({ setup() { return () => h(ModelMappingDialog, { open: true, providerId: 'provider-1', models: [model], hasAutoFetchKey: true, }) }, })) app.mount(root) mountedApps.push({ app, root }) await vi.waitFor(() => expect(upstreamModelMocks.fetchModels).toHaveBeenCalledTimes(1)) expect(upstreamModelMocks.fetchModels).toHaveBeenCalledWith('provider-1', undefined, false) // Both manual fetch and refresh must bypass the backend cache. upstreamModelMocks.fetchModels.mockResolvedValue({ models: [{ id: 'gpt-old' }] }) await nextTick() const fetchButton = root.querySelector('[title="从提供商获取模型"]') expect(fetchButton).not.toBeNull() fetchButton!.click() await vi.waitFor(() => expect(root.textContent).toContain('gpt-old')) expect(upstreamModelMocks.fetchModels).toHaveBeenLastCalledWith('provider-1', undefined, true) upstreamModelMocks.fetchModels.mockResolvedValue({ models: [{ id: 'gpt-6-astra' }] }) const refreshButton = root.querySelector('[title="刷新上游模型"]') expect(refreshButton).not.toBeNull() refreshButton!.click() await vi.waitFor(() => expect(root.textContent).toContain('gpt-6-astra')) expect(root.textContent).not.toContain('gpt-old') expect(upstreamModelMocks.fetchModels).toHaveBeenLastCalledWith('provider-1', undefined, true) }) it('offers session compaction only for an explicitly selected Responses endpoint', async () => { const chatEndpoint = { id: 'endpoint-chat', api_format: 'openai:chat', base_url: 'https://api.example.com/v1', is_active: true, } as ProviderEndpoint const responsesEndpoint = { id: 'endpoint-responses', api_format: 'openai:responses', base_url: 'https://api.example.com/v1', is_active: true, } as ProviderEndpoint const model: Model = { provider_id: 'provider-1', global_model_id: 'global-model-1', is_active: true, is_available: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', id: 'model-sol', provider_model_name: 'gpt-5.6-sol', global_model_display_name: 'GPT-5.6 Sol', provider_model_mappings: [], } const open = ref(false) const root = document.createElement('div') document.body.appendChild(root) const app = createApp(defineComponent({ setup() { return () => h(ModelMappingDialog, { open: open.value, providerId: 'provider-1', endpoints: [chatEndpoint, responsesEndpoint], models: [model], preselectedModelId: model.id, 'onUpdate:open': (value: boolean) => { open.value = value }, }) }, })) app.mount(root) mountedApps.push({ app, root }) open.value = true await nextTick() await nextTick() expect(root.textContent).toContain('所有请求') expect(root.textContent).not.toContain('仅会话压缩') root.querySelector('[data-endpoint-id="endpoint-chat"]')?.click() await nextTick() expect(root.textContent).not.toContain('仅会话压缩') root.querySelector('[data-endpoint-id="endpoint-responses"]')?.click() await nextTick() expect(root.textContent).toContain('仅会话压缩') }) it('returns to all requests when a compact mapping switches away from Responses', async () => { const responsesEndpoint = { id: 'endpoint-responses', api_format: 'openai:responses', base_url: 'https://api.example.com/v1', is_active: true, } as ProviderEndpoint const chatEndpoint = { id: 'endpoint-chat', api_format: 'openai:chat', base_url: 'https://api.example.com/v1', is_active: true, } as ProviderEndpoint const model: Model = { provider_id: 'provider-1', global_model_id: 'global-model-1', is_active: true, is_available: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', id: 'model-sol', provider_model_name: 'gpt-5.6-sol', global_model_display_name: 'GPT-5.6 Sol', provider_model_mappings: [{ name: 'gpt-5.6-luna', priority: 1, endpoint_ids: [responsesEndpoint.id], operations: ['compact'], }], } const editingGroup: AliasGroup = { model, apiFormatsKey: '', apiFormats: [], endpointIdsKey: responsesEndpoint.id, endpointIds: [responsesEndpoint.id], operationsKey: 'compact', operations: ['compact'], aliases: model.provider_model_mappings ?? [], } const open = ref(false) const root = document.createElement('div') document.body.appendChild(root) const app = createApp(defineComponent({ setup() { return () => h(ModelMappingDialog, { open: open.value, providerId: 'provider-1', endpoints: [responsesEndpoint, chatEndpoint], models: [model], editingGroup, 'onUpdate:open': (value: boolean) => { open.value = value }, }) }, })) app.mount(root) mountedApps.push({ app, root }) open.value = true await nextTick() await nextTick() expect(root.textContent).toContain('仅会话压缩') root.querySelector('[data-endpoint-id="endpoint-chat"]')?.click() await nextTick() expect(root.textContent).not.toContain('仅会话压缩') expect(root.querySelector('[role="radio"][aria-checked="true"]')?.textContent) .toContain('所有请求') const saveButton = [...root.querySelectorAll('button')] .find(button => button.textContent?.includes('保存映射')) saveButton?.click() await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1)) expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', { provider_model_mappings: [{ name: 'gpt-5.6-luna', priority: 1, endpoint_ids: [chatEndpoint.id], }], }) }) it('preserves an edited compact scope when endpoint capabilities are unavailable', async () => { const model: Model = { provider_id: 'provider-1', global_model_id: 'global-model-1', is_active: true, is_available: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', id: 'model-sol', provider_model_name: 'gpt-5.6-sol', global_model_display_name: 'GPT-5.6 Sol', provider_model_mappings: [{ name: 'gpt-5.6-luna', priority: 1, endpoint_ids: ['endpoint-responses'], operations: ['compact'], }], } const editingGroup: AliasGroup = { model, apiFormatsKey: '', apiFormats: [], endpointIdsKey: 'endpoint-responses', endpointIds: ['endpoint-responses'], operationsKey: 'compact', operations: ['compact'], aliases: model.provider_model_mappings ?? [], } const open = ref(false) const root = document.createElement('div') document.body.appendChild(root) const app = createApp(defineComponent({ setup() { return () => h(ModelMappingDialog, { open: open.value, providerId: 'provider-1', endpoints: [], models: [model], editingGroup, 'onUpdate:open': (value: boolean) => { open.value = value }, }) }, })) app.mount(root) mountedApps.push({ app, root }) open.value = true await nextTick() await nextTick() const saveButton = [...root.querySelectorAll('button')] .find(button => button.textContent?.includes('保存映射')) saveButton?.click() await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1)) expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', { provider_model_mappings: [{ name: 'gpt-5.6-luna', priority: 1, endpoint_ids: ['endpoint-responses'], operations: ['compact'], }], }) }) it('normalizes and replaces an edited compact operation scope', async () => { const endpoint = { id: 'endpoint-responses', api_format: 'openai:responses', base_url: 'https://api.example.com/v1', is_active: true, } as ProviderEndpoint const model: Model = { provider_id: 'provider-1', global_model_id: 'global-model-1', is_active: true, is_available: true, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', id: 'model-sol', provider_model_name: 'gpt-5.6-sol', global_model_display_name: 'GPT-5.6 Sol', provider_model_mappings: [{ name: 'gpt-5.6-luna', priority: 1, endpoint_ids: [endpoint.id], operations: ['Compact'], }], } const editingGroup: AliasGroup = { model, apiFormatsKey: '', apiFormats: [], endpointIdsKey: endpoint.id, endpointIds: [endpoint.id], operationsKey: 'Compact', operations: ['Compact'], aliases: model.provider_model_mappings ?? [], } const open = ref(false) const root = document.createElement('div') document.body.appendChild(root) const app = createApp(defineComponent({ setup() { return () => h(ModelMappingDialog, { open: open.value, providerId: 'provider-1', endpoints: [endpoint], models: [model], editingGroup, 'onUpdate:open': (value: boolean) => { open.value = value }, }) }, })) app.mount(root) mountedApps.push({ app, root }) open.value = true await nextTick() await nextTick() expect(root.textContent).toContain('仅会话压缩') const scopeButtons = [...root.querySelectorAll('button')] scopeButtons.find(button => button.textContent?.includes('所有请求'))?.click() await nextTick() scopeButtons.find(button => button.textContent?.includes('仅会话压缩'))?.click() await nextTick() const saveButton = [...root.querySelectorAll('button')] .find(button => button.textContent?.includes('保存映射')) expect(saveButton).toBeDefined() saveButton?.click() await vi.waitFor(() => expect(updateModel).toHaveBeenCalledTimes(1)) expect(updateModel).toHaveBeenCalledWith('provider-1', 'model-sol', { provider_model_mappings: [{ name: 'gpt-5.6-luna', priority: 1, endpoint_ids: [endpoint.id], operations: ['compact'], }], }) }) })