130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
// Radix Select calls scrollIntoView / pointer-capture APIs jsdom lacks.
|
|
beforeAll(() => {
|
|
Element.prototype.scrollIntoView = vi.fn()
|
|
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
|
Element.prototype.releasePointerCapture = vi.fn()
|
|
})
|
|
|
|
const getGlobalModelOptions = vi.fn()
|
|
|
|
vi.mock('@/hermes', () => ({
|
|
getGlobalModelOptions: () => getGlobalModelOptions()
|
|
}))
|
|
|
|
beforeEach(() => {
|
|
getGlobalModelOptions.mockResolvedValue({
|
|
providers: [
|
|
{ name: 'GitHub Copilot', slug: 'copilot', models: ['gpt-5-mini', 'gpt-5.4-mini'] },
|
|
{ name: 'OpenAI Codex', slug: 'openai-codex', models: ['gpt-5.4-mini'] },
|
|
{ name: 'Nous', slug: 'nous', models: ['hermes-4'] }
|
|
]
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
async function renderField(value: unknown, onChange = vi.fn()) {
|
|
const { FallbackModelsField } = await import('./fallback-models-field')
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
|
|
render(
|
|
<QueryClientProvider client={client}>
|
|
<FallbackModelsField onChange={onChange} value={value} />
|
|
</QueryClientProvider>
|
|
)
|
|
|
|
return onChange
|
|
}
|
|
|
|
async function renderFieldWithRerender(value: unknown, onChange = vi.fn()) {
|
|
const { FallbackModelsField } = await import('./fallback-models-field')
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
|
|
const view = render(
|
|
<QueryClientProvider client={client}>
|
|
<FallbackModelsField onChange={onChange} value={value} />
|
|
</QueryClientProvider>
|
|
)
|
|
|
|
return (next: unknown) =>
|
|
view.rerender(
|
|
<QueryClientProvider client={client}>
|
|
<FallbackModelsField onChange={onChange} value={next} />
|
|
</QueryClientProvider>
|
|
)
|
|
}
|
|
|
|
const CHAIN = [
|
|
{ provider: 'copilot', model: 'gpt-5-mini' },
|
|
{ provider: 'openai-codex', model: 'gpt-5.4-mini' }
|
|
]
|
|
|
|
describe('FallbackModelsField', () => {
|
|
it('renders each {provider, model} entry as its own row (never "[object Object]")', async () => {
|
|
await renderField(CHAIN)
|
|
|
|
// One Remove control per entry proves the object list became rows — the old
|
|
// generic `list` input stringified the array to "[object Object]".
|
|
expect(screen.getAllByLabelText('Remove')).toHaveLength(2)
|
|
expect(screen.getByText('Add fallback')).toBeTruthy()
|
|
expect(screen.queryByText(/\[object Object\]/)).toBeNull()
|
|
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
|
|
})
|
|
|
|
it('removing a row emits the remaining entries', async () => {
|
|
const onChange = await renderField(CHAIN)
|
|
|
|
fireEvent.click(screen.getAllByLabelText('Remove')[0])
|
|
|
|
expect(onChange.mock.calls.at(-1)?.[0]).toEqual([{ provider: 'openai-codex', model: 'gpt-5.4-mini' }])
|
|
})
|
|
|
|
it('adding a blank row does not persist a partial entry', async () => {
|
|
const onChange = await renderField(CHAIN)
|
|
|
|
fireEvent.click(screen.getByText('Add fallback'))
|
|
|
|
// The new empty row stays in the UI but only complete pairs are emitted.
|
|
expect(onChange.mock.calls.at(-1)?.[0]).toEqual(CHAIN)
|
|
expect(screen.getAllByLabelText('Remove')).toHaveLength(3)
|
|
})
|
|
|
|
it('shows an empty-state hint when there are no fallbacks', async () => {
|
|
await renderField([])
|
|
|
|
expect(screen.getByText(/No fallback models/)).toBeTruthy()
|
|
expect(screen.queryAllByLabelText('Remove')).toHaveLength(0)
|
|
})
|
|
|
|
it('resyncs rows when persisted config changes', async () => {
|
|
const rerender = await renderFieldWithRerender(CHAIN)
|
|
expect(screen.getAllByLabelText('Remove')).toHaveLength(2)
|
|
|
|
rerender([{ provider: 'nous', model: 'hermes-4' }])
|
|
|
|
await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1))
|
|
})
|
|
|
|
it('keeps a draft row visible after autosave re-renders the same persisted chain', async () => {
|
|
const onChange = vi.fn()
|
|
const rerender = await renderFieldWithRerender([], onChange)
|
|
|
|
fireEvent.click(screen.getByText('Add fallback'))
|
|
|
|
expect(onChange.mock.calls.at(-1)?.[0]).toEqual([])
|
|
expect(screen.getAllByLabelText('Remove')).toHaveLength(1)
|
|
|
|
// Parent autosave echo — same complete chain, new array identity.
|
|
rerender([])
|
|
|
|
await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1))
|
|
})
|
|
})
|