import { cleanup, fireEvent, screen } from '@testing-library/react' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { stubResizeObserver } from '@/test/jsdom' import type { ConfigFieldSchema } from '@/types/hermes' import { ConfigField } from './config-field' import { rankSearchOption, SearchableSelect } from './searchable-select' import { renderWithEnglish as render } from './test-locale' beforeAll(() => { stubResizeObserver() Element.prototype.scrollIntoView = vi.fn() Element.prototype.hasPointerCapture = vi.fn(() => false) Element.prototype.releasePointerCapture = vi.fn() }) afterEach(() => { cleanup() vi.clearAllMocks() }) describe('rankSearchOption', () => { it('ranks a final-segment match above a mid-path match', () => { // "york" hits the city segment of America/New_York (score 2) but only a // mid-path segment of America/New_York/Special (score 1). expect(rankSearchOption('America/New_York', 'york')).toBe(2) expect(rankSearchOption('America/New_York/Special', 'york')).toBe(1) expect(rankSearchOption('America/New_York', 'york')).toBeGreaterThan( rankSearchOption('America/New_York/Special', 'york') ) }) it('is case-insensitive', () => { expect(rankSearchOption('Asia/Kolkata', 'KOLKATA')).toBe(2) expect(rankSearchOption('ASIA/KOLKATA', 'kolkata')).toBe(2) }) it('scores a substring match anywhere as 1', () => { expect(rankSearchOption('America/New_York', 'amer')).toBe(1) }) it('scores a slashless option by plain substring', () => { expect(rankSearchOption('UTC', 'ut')).toBe(1) expect(rankSearchOption('UTC', 'xyz')).toBe(0) }) it('scores a non-match as 0', () => { expect(rankSearchOption('Europe/Berlin', 'tokyo')).toBe(0) }) }) describe('SearchableSelect', () => { const options = ['America/New_York', 'Asia/Kolkata', 'Europe/Berlin', 'UTC'] it('opens, filters, and selects an option', () => { const onChange = vi.fn() render() fireEvent.click(screen.getByRole('combobox')) fireEvent.change(screen.getByPlaceholderText('Search…'), { target: { value: 'kolkata' } }) fireEvent.click(screen.getByText('Asia/Kolkata')) expect(onChange).toHaveBeenCalledWith('Asia/Kolkata') }) it('renders the clear item when clearLabel is set and selecting it resets to blank', () => { const onChange = vi.fn() render() fireEvent.click(screen.getByRole('combobox')) fireEvent.click(screen.getByText('System default')) expect(onChange).toHaveBeenCalledWith('') }) it('omits the clear item without clearLabel', () => { render() fireEvent.click(screen.getByRole('combobox')) expect(screen.queryByText('System default')).toBeNull() }) it('shows the placeholder when the value is blank', () => { render() expect(screen.getByRole('combobox').textContent).toContain('Search…') }) }) describe('ConfigField searchable routing', () => { const searchableSchema: ConfigFieldSchema = { type: 'select', searchable: true, clearable: true, options: ['America/New_York', 'UTC'] } it('routes searchable select schemas to SearchableSelect, not a free-text input', () => { const { container } = render( ) // The searchable trigger renders; the generic free-text does not. expect(container.querySelector('[data-slot="searchable-select-trigger"]')).not.toBeNull() expect(container.querySelector('input[type="text"]')).toBeNull() }) it('keeps plain string schemas on the free-text input', () => { const { container } = render( ) expect(container.querySelector('[data-slot="searchable-select-trigger"]')).toBeNull() expect(screen.getByDisplayValue('hello')).not.toBeNull() }) it('surfaces the clear item via schema.clearable and resets to blank', () => { const onChange = vi.fn() render() fireEvent.click(screen.getByRole('combobox')) fireEvent.click(screen.getByText('System default')) expect(onChange).toHaveBeenCalledWith('') }) })