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( ) 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( ) return (next: unknown) => view.rerender( ) } 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)) }) })