import { beforeEach, describe, expect, it, vi } from 'vitest' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import ModelsAdmin from './ModelsAdmin' import api from '../api/client' vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, })) vi.mock('../hooks/useDialog', () => ({ useDialog: () => ({ dialogProps: {}, openConfirm: () => Promise.resolve(true) }), })) vi.mock('./Dialog', () => ({ default: () => null })) const MODELS = [ { id: 1, name: 'Haiku', model_id: 'claude-haiku-4-5', task: 'teach', is_active: true, is_default: true }, { id: 2, name: 'Sonnet', model_id: 'claude-sonnet-5', task: 'teach', is_active: true, is_default: false }, { id: 3, name: 'Opus', model_id: 'claude-opus-5', task: 'extraction', is_active: true, is_default: true }, ] beforeEach(() => { vi.clearAllMocks() api.get.mockResolvedValue({ data: MODELS }) api.put.mockResolvedValue({ data: {} }) api.delete.mockResolvedValue({}) api.post.mockImplementation(url => { if (url === '/admin/litellm/models') { return Promise.resolve({ data: { models: ['claude-haiku-4-5', 'gpt-5', 'gemini-3'] } }) } if (url === '/admin/models') { return Promise.resolve({ data: { id: 9, name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false } }) } return Promise.resolve({ data: { message: 'Answered' } }) }) }) describe('model selection', () => { it('asks one question per job rather than showing rows of buttons', async () => { render() // The old screen needed you to find a card, find a row, and press Set // Default on it — four buttons per row to express one fact. expect(await screen.findByRole('combobox', { name: 'Model for Tutor' })).toHaveValue('1') expect(screen.queryByRole('button', { name: 'Set Default' })).toBeNull() }) it('changes a job by choosing, and moves the choice off the old model', async () => { render() const pick = await screen.findByRole('combobox', { name: 'Model for Tutor' }) api.get.mockClear() await userEvent.selectOptions(pick, '2') await waitFor(() => expect(api.put).toHaveBeenCalledWith('/admin/models/2', { is_default: true, is_active: true })) expect(pick).toHaveValue('2') // Updated in place, not by refetching the section. expect(api.get).not.toHaveBeenCalled() }) it('offers no choice for a job with one model, because there is none', async () => { render() await screen.findByRole('combobox', { name: 'Model for Tutor' }) expect(screen.queryByRole('combobox', { name: 'Model for Extraction' })).toBeNull() expect(screen.getByText('Opus')).toBeInTheDocument() }) it('says when a job has nothing configured instead of showing an empty box', async () => { render() await screen.findByRole('combobox', { name: 'Model for Tutor' }) expect(screen.getAllByText('Falls back to the site default').length).toBeGreaterThan(0) }) it('tests the model a job actually uses', async () => { render() await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tutor' })) await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/1/test')) expect(await screen.findByText(/Answered/)).toBeInTheDocument() }) it('names the tool model as a job of its own, and tests it like any other', async () => { // The model that covers what another model cannot do — reading an image, // today — is chosen the same way as the tutor's or the extractor's. api.get.mockResolvedValue({ data: [...MODELS, { id: 4, name: 'gpt-4.1', model_id: 'gpt-4.1', task: 'tool', is_active: true, is_default: true }] }) render() await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tool' })) await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/4/test')) }) it('keeps the allow-list folded away, since it is set once', async () => { render() await screen.findByRole('combobox', { name: 'Model for Tutor' }) expect(screen.queryByRole('button', { name: /List what the proxy offers/ })).toBeNull() await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ })) expect(screen.getByRole('button', { name: /List what the proxy offers/ })).toBeInTheDocument() }) it('will not offer to allow a model that is already allowed', async () => { render() await screen.findByRole('combobox', { name: 'Model for Tutor' }) await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ })) await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ })) const offered = await screen.findByRole('button', { name: 'Allow gpt-5' }) expect(offered).toBeInTheDocument() // Already allowed for Tutor, so there is nothing to press. expect(screen.queryByRole('button', { name: 'Allow claude-haiku-4-5' })).toBeNull() }) it('adds an allowed model without reloading the section', async () => { render() await screen.findByRole('combobox', { name: 'Model for Tutor' }) await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ })) await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ })) api.get.mockClear() await userEvent.click(await screen.findByRole('button', { name: 'Allow gpt-5' })) await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models', { name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false, })) expect(api.get).not.toHaveBeenCalled() }) }) it('turns a model off without losing it, and allows an id the proxy never listed', async () => { // Two things ped-ai's page has and this one did not. Taking a model out // while the proxy misbehaves used to mean removing it and setting it up // again afterwards, losing the default flag and any key on it. And the // roster could only ever hold what /model/info happened to advertise, // though the gateway will route aliases it does not list. api.get.mockImplementation(url => Promise.resolve({ data: url === '/admin/models' ? [{ id: 1, name: 'ds-flash', model_id: 'ds-flash', task: 'extraction', is_active: true, is_default: true }] : {}, })) api.put.mockResolvedValue({ data: { id: 1, name: 'ds-flash', model_id: 'ds-flash', task: 'extraction', is_active: false, is_default: true } }) api.post.mockResolvedValue({ data: { id: 2, name: 'brand-new-alias', model_id: 'brand-new-alias', task: 'extraction', is_active: true, is_default: false } }) render() // The roster is behind its own disclosure. await userEvent.click(await screen.findByRole('button', { name: /Which models this site may use/ })) // The roster shows one job at a time; ds-flash is an extraction model. await userEvent.selectOptions( await screen.findByLabelText('Job to allow a model for'), 'extraction') await userEvent.click(await screen.findByRole('button', { name: /Turn off ds-flash/ })) expect(api.put).toHaveBeenCalledWith('/admin/models/1', { is_active: false }) // Still listed, still the default — off, not gone. expect(await screen.findByText('off')).toBeInTheDocument() expect(screen.getByText('in use')).toBeInTheDocument() await userEvent.type( screen.getByLabelText('A model id the proxy does not list'), 'brand-new-alias') await userEvent.click(screen.getByRole('button', { name: 'Allow it' })) expect(api.post).toHaveBeenCalledWith('/admin/models', expect.objectContaining({ model_id: 'brand-new-alias', task: 'extraction', is_active: true, })) })