216 lines
7.6 KiB
TypeScript
216 lines
7.6 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'
|
||
|
||
import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu'
|
||
import { $localModelsEnabled } from '@/store/local-models-flag'
|
||
import { $localRuntimeJobs } from '@/store/local-runtime-jobs'
|
||
import {
|
||
$modelVisibilityOpen,
|
||
$visibleModels,
|
||
modelVisibilityKey,
|
||
setModelVisibilityOpen,
|
||
setVisibleModels
|
||
} from '@/store/model-visibility'
|
||
import { openPreview } from '@/store/preview'
|
||
import type { LocalRuntimeJob } from '@/types/hermes'
|
||
|
||
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
|
||
|
||
// Radix calls these on open; jsdom doesn't implement them.
|
||
beforeAll(() => {
|
||
Element.prototype.scrollIntoView = vi.fn()
|
||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||
Element.prototype.releasePointerCapture = vi.fn()
|
||
})
|
||
|
||
const getGlobalModelOptions = vi.fn()
|
||
vi.mock('@/store/preview', () => ({ openPreview: vi.fn() }))
|
||
|
||
vi.mock('@/hermes', () => ({
|
||
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
|
||
// The menu kicks the app-level job poller on mount; echo the store so a
|
||
// poll can't wipe the jobs a test staged (the real backend is authority,
|
||
// and here the store plays that part).
|
||
getLocalModelsJobs: vi.fn(async () => {
|
||
const { $localRuntimeJobs } = await import('@/store/local-runtime-jobs')
|
||
|
||
return { jobs: [...$localRuntimeJobs.get()] }
|
||
}),
|
||
getLocalModelsStatus: vi.fn().mockResolvedValue({ loading: {} }),
|
||
setApiRequestProfile: vi.fn()
|
||
}))
|
||
|
||
beforeEach(() => {
|
||
$visibleModels.set(null)
|
||
$localRuntimeJobs.set([])
|
||
// These suites exercise the local-models rows, which ship behind --local.
|
||
$localModelsEnabled.set(true)
|
||
setModelVisibilityOpen(false)
|
||
getGlobalModelOptions.mockResolvedValue({
|
||
providers: [{ models: ['gemini-3.1-pro', 'gemini-2.5-flash'], name: 'Google', slug: 'google' }]
|
||
})
|
||
})
|
||
|
||
afterEach(() => {
|
||
cleanup()
|
||
vi.clearAllMocks()
|
||
})
|
||
|
||
// A minimal controller — these tests are about the CATALOG's own behaviour
|
||
// (what it lists, what it offers), not about what any host does with a pick.
|
||
function renderMenu() {
|
||
const select = vi.fn()
|
||
|
||
const controller: ModelMenuController = {
|
||
applyPreset: vi.fn(),
|
||
current: { effort: '', fast: false, model: '', provider: '' },
|
||
presetFor: () => ({}),
|
||
select,
|
||
setOptions: vi.fn()
|
||
}
|
||
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||
|
||
render(
|
||
<QueryClientProvider client={client}>
|
||
<DropdownMenu open>
|
||
<DropdownMenuContent>
|
||
<ModelCatalogMenu controller={controller} />
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</QueryClientProvider>
|
||
)
|
||
|
||
return select
|
||
}
|
||
|
||
// Curation is ONE global preference, so it belongs to the catalog rather than
|
||
// to whichever surface mounted it. If a host had to opt in, the composer and
|
||
// the kanban board would end up disagreeing about what "my models" means —
|
||
// which is exactly the drift extracting this component was meant to prevent.
|
||
describe('the catalog owns model curation', () => {
|
||
it('opens the chosen TurkServis model details without selecting that model', async () => {
|
||
getGlobalModelOptions.mockResolvedValue({ providers: [{
|
||
models: ['Outer'], name: 'TurkServis', slug: 'turkservis', api_url: 'https://ai.turkservis.online/v1'
|
||
}] })
|
||
const select = renderMenu()
|
||
const trigger = await screen.findByRole('menuitem', { name: /Outer/ })
|
||
|
||
fireEvent.keyDown(trigger, { key: 'ArrowRight' })
|
||
fireEvent.click(await screen.findByRole('menuitem', { name: /Model \/ combo (details|ayrıntıları)/ }))
|
||
|
||
expect(openPreview).toHaveBeenCalledWith(expect.objectContaining({
|
||
kind: 'url', url: 'https://turkservis.online/router?model=Outer'
|
||
}))
|
||
expect(select).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('honours the stored Edit Models shortlist', async () => {
|
||
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')]))
|
||
|
||
renderMenu()
|
||
|
||
await screen.findByText(/Gemini 2\.5 Flash/i)
|
||
expect(screen.queryByText(/Gemini 3\.1 Pro/i)).toBeNull()
|
||
})
|
||
|
||
it('still finds a hidden model by search — curation narrows the default view, not the catalog', async () => {
|
||
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')]))
|
||
|
||
renderMenu()
|
||
await screen.findByText(/Gemini 2\.5 Flash/i)
|
||
|
||
const input = screen.getByRole('textbox', { name: /Search models|Model ara/ })
|
||
|
||
fireEvent.change(input, { target: { value: 'gemini-3.1' } })
|
||
|
||
await vi.waitFor(() => {
|
||
expect(screen.queryByText(/Gemini 3\.1 Pro/i)).not.toBeNull()
|
||
})
|
||
})
|
||
|
||
it('offers Edit Models without the host wiring it up', async () => {
|
||
renderMenu()
|
||
await screen.findByText(/Gemini 3\.1 Pro/i)
|
||
|
||
fireEvent.click(screen.getByText(/Edit models…|Modelleri düzenle…/))
|
||
|
||
expect($modelVisibilityOpen.get()).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('in-flight local downloads', () => {
|
||
const DOWNLOAD_JOB: LocalRuntimeJob = {
|
||
job_id: 'dl1',
|
||
kind: 'model-download',
|
||
target: 'Qwen3.8 Flash Next (UD-Q4_K_XL)',
|
||
model_id: 'qwen3.8-flash-next',
|
||
status: 'running',
|
||
phase: 'downloading',
|
||
detail: '',
|
||
total_bytes: 100,
|
||
done_bytes: 41,
|
||
percent: 41,
|
||
error: null
|
||
}
|
||
|
||
it('shows a downloading model as a disabled progress row in its own Local group', async () => {
|
||
// No llamacpp provider in the catalog (first-ever download).
|
||
$localRuntimeJobs.set([DOWNLOAD_JOB])
|
||
renderMenu()
|
||
await screen.findByText(/Gemini 3\.1 Pro/i)
|
||
|
||
const row = screen.getByText('Qwen3.8 Flash Next (UD-Q4_K_XL)')
|
||
|
||
expect(row).toBeTruthy()
|
||
expect(screen.getByText('41%')).toBeTruthy()
|
||
expect(row.closest('[role="menuitem"]')?.getAttribute('aria-disabled')).toBe('true')
|
||
})
|
||
|
||
it('shows the download inside the Local provider group when it exists', async () => {
|
||
getGlobalModelOptions.mockResolvedValue({
|
||
providers: [
|
||
{ models: ['Qwen3.6-27B-UD-Q4_K_XL'], name: 'Local', slug: 'llamacpp' },
|
||
{ models: ['gemini-3.1-pro'], name: 'Google', slug: 'google' }
|
||
]
|
||
})
|
||
$localRuntimeJobs.set([DOWNLOAD_JOB])
|
||
renderMenu()
|
||
|
||
await screen.findByText(/Qwen3\.6 27B/i)
|
||
expect(screen.getByText('Qwen3.8 Flash Next (UD-Q4_K_XL)')).toBeTruthy()
|
||
// One Local heading — the trailing fallback group must not double up.
|
||
expect(screen.getAllByText('Local').length).toBe(1)
|
||
})
|
||
|
||
it('drops the placeholder row once the download settles', async () => {
|
||
$localRuntimeJobs.set([DOWNLOAD_JOB])
|
||
renderMenu()
|
||
await screen.findByText('Qwen3.8 Flash Next (UD-Q4_K_XL)')
|
||
|
||
$localRuntimeJobs.set([{ ...DOWNLOAD_JOB, status: 'done', phase: 'done' }])
|
||
await waitFor(() => {
|
||
expect(screen.queryByText('Qwen3.8 Flash Next (UD-Q4_K_XL)')).toBeNull()
|
||
})
|
||
})
|
||
|
||
it('hides the local provider group and download rows without the --local flag (strict)', async () => {
|
||
$localModelsEnabled.set(false)
|
||
getGlobalModelOptions.mockResolvedValue({
|
||
providers: [
|
||
{ models: ['Qwen3.6-27B-UD-Q4_K_XL'], name: 'Local', slug: 'llamacpp' },
|
||
{ models: ['gemini-3.1-pro'], name: 'Google', slug: 'google' }
|
||
]
|
||
})
|
||
$localRuntimeJobs.set([DOWNLOAD_JOB])
|
||
renderMenu()
|
||
|
||
// Staged models exist and a download is running — none of it shows.
|
||
await screen.findByText(/Gemini 3\.1 Pro/i)
|
||
expect(screen.queryByText(/Qwen3\.6 27B/i)).toBeNull()
|
||
expect(screen.queryByText('Qwen3.8 Flash Next (UD-Q4_K_XL)')).toBeNull()
|
||
expect(screen.queryByText('Local')).toBeNull()
|
||
})
|
||
})
|