test+i18n: follow-up for salvaged PR #68969

- Extract the cmdk filter into exported rankSearchOption and cover it,
  SearchableSelect selection/clear/placeholder, and ConfigField
  searchable-schema routing with 12 vitest cases (sabotage-verified:
  the backend schema test fails on unfixed main).
- Add searchPlaceholder/noResults/systemDefault strings to ja/ar/zh-hant
  (zh + en came with the salvaged commits; defineLocale would have
  fallen back to English otherwise).
- Add a backend invariant test: timezone ships as a searchable,
  clearable select of sorted IANA ids with a UTC fallback.
This commit is contained in:
Teknium 2026-07-28 10:18:15 -07:00
parent c8a4b18d34
commit 071e2adede
6 changed files with 200 additions and 39 deletions

View file

@ -0,0 +1,137 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { ConfigFieldSchema } from '@/types/hermes'
import { ConfigField } from './config-field'
import { rankSearchOption, SearchableSelect } from './searchable-select'
// Radix Popover + cmdk call scrollIntoView / pointer-capture / ResizeObserver
// APIs jsdom lacks.
class TestResizeObserver {
disconnect() {}
observe() {}
unobserve() {}
}
beforeAll(() => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
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(<SearchableSelect onChange={onChange} options={options} placeholder="Search…" value="" />)
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(<SearchableSelect clearLabel="System default" onChange={onChange} options={options} value="Asia/Kolkata" />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('System default'))
expect(onChange).toHaveBeenCalledWith('')
})
it('omits the clear item without clearLabel', () => {
render(<SearchableSelect onChange={vi.fn()} options={options} value="" />)
fireEvent.click(screen.getByRole('combobox'))
expect(screen.queryByText('System default')).toBeNull()
})
it('shows the placeholder when the value is blank', () => {
render(<SearchableSelect onChange={vi.fn()} options={options} placeholder="Search…" value="" />)
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(
<ConfigField onChange={vi.fn()} schema={searchableSchema} schemaKey="timezone" value="UTC" />
)
// The searchable trigger renders; the generic free-text <Input> 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(
<ConfigField onChange={vi.fn()} schema={{ type: 'string' }} schemaKey="some.other.key" value="hello" />
)
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(<ConfigField onChange={onChange} schema={searchableSchema} schemaKey="timezone" value="UTC" />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('System default'))
expect(onChange).toHaveBeenCalledWith('')
})
})

View file

@ -1,11 +1,33 @@
import { useCallback, useRef, useState } from 'react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { controlVariants } from '@/components/ui/control'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
/**
* cmdk filter score for one option. Case-insensitive substring match, with
* the final path segment (after the last "/") ranked above matches anywhere
* else so "york" ranks "America/New_York" over "America/New_York/Special".
* Exported for tests.
*/
export function rankSearchOption(option: string, search: string): number {
const lower = search.toLowerCase()
const itemLower = option.toLowerCase()
const slash = itemLower.lastIndexOf('/')
if (slash !== -1 && itemLower.slice(slash + 1).includes(lower)) {
return 2
}
if (itemLower.includes(lower)) {
return 1
}
return 0
}
/**
* Searchable select for large option lists (e.g. ~590 IANA timezones).
* Built on Popover + cmdk Command the same stack as Shadcn's Combobox.
@ -50,6 +72,7 @@ export function SearchableSelect({
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<button
aria-expanded={open}
aria-haspopup="listbox"
className={cn(
controlVariants(),
@ -60,57 +83,26 @@ export function SearchableSelect({
ref={triggerRef}
role="combobox"
type="button"
aria-expanded={open}
>
<span className="truncate">{displayValue}</span>
<Codicon className="shrink-0 opacity-60" name={open ? 'chevron-up' : 'chevron-down'} size="1rem" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<Command
filter={(value, search) => {
// cmdk's default filter is case-insensitive substring match on
// the item's value. For timezone-like values we also want to
// match segments after "/" so "york" matches "America/New_York".
const lower = search.toLowerCase()
const itemLower = value.toLowerCase()
// Prioritize city/region segment (after last "/") so "york" ranks
// "America/New_York" above "America/New_York/Special".
const slash = itemLower.lastIndexOf('/')
if (slash !== -1 && itemLower.slice(slash + 1).includes(lower)) return 2
if (itemLower.includes(lower)) return 1
return 0
}}
>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command filter={rankSearchOption}>
<CommandInput autoFocus placeholder={placeholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{clearLabel && (
<CommandItem
onSelect={() => handleSelect('')}
value={clearLabel}
>
<Codicon
className={cn('mr-2 size-4', value === '' ? 'opacity-100' : 'opacity-0')}
name="check"
/>
<CommandItem onSelect={() => handleSelect('')} value={clearLabel}>
<Codicon className={cn('mr-2 size-4', value === '' ? 'opacity-100' : 'opacity-0')} name="check" />
{clearLabel}
</CommandItem>
)}
{options.map(option => (
<CommandItem
key={option}
onSelect={() => handleSelect(option)}
value={option}
>
<Codicon
className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')}
name="check"
/>
<CommandItem key={option} onSelect={() => handleSelect(option)} value={option}>
<Codicon className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')} name="check" />
{option}
</CommandItem>
))}

View file

@ -610,6 +610,9 @@ export const ar = defineLocale({
noneParen: '(لا شيء)',
notSet: 'غير مضبوط',
commaSeparated: 'قيم مفصولة بفواصل',
searchPlaceholder: 'بحث…',
noResults: 'لا توجد نتائج',
systemDefault: 'إعداد النظام الافتراضي',
loading: 'جار تحميل إعدادات Hermes...',
emptyTitle: 'لا توجد إعدادات',
emptyDesc: 'لا يحتوي هذا القسم على إعدادات قابلة للتعديل.',

View file

@ -639,6 +639,9 @@ export const ja = defineLocale({
builtinOnly: '内蔵のみ',
notSet: '未設定',
commaSeparated: 'カンマ区切りの値',
searchPlaceholder: '検索…',
noResults: '結果が見つかりません',
systemDefault: 'システムのデフォルト',
loading: 'Hermes の設定を読み込み中...',
emptyTitle: '設定項目がありません',
emptyDesc: 'このセクションには調整できる設定がありません。',

View file

@ -627,6 +627,9 @@ export const zhHant = defineLocale({
builtinOnly: '僅內建',
notSet: '未設定',
commaSeparated: '逗號分隔的值',
searchPlaceholder: '搜尋…',
noResults: '找不到結果',
systemDefault: '系統預設',
loading: '正在載入 Hermes 設定...',
emptyTitle: '無可設定項目',
emptyDesc: '此區段沒有可調整的設定。',

View file

@ -5327,6 +5327,29 @@ class TestBuildSchemaFromConfig:
missing = set(list_memory_provider_names()) - options
assert missing == set(), f"discovered providers missing from schema options: {missing}"
def test_timezone_field_is_searchable_select(self):
"""timezone must ship as a searchable, clearable select of IANA ids.
Desktop renders this via SearchableSelect (Popover + cmdk); the old
free-text input let users type invalid timezone strings (#68970).
Invariants, not snapshots: valid IANA entries present, sorted, no
blank entry server-side (the clear item is client-side via
``clearable``), and never empty even without tzdata (UTC fallback).
"""
from hermes_cli.web_server import CONFIG_SCHEMA, _timezone_options
entry = CONFIG_SCHEMA["timezone"]
assert entry["type"] == "select"
assert entry.get("searchable") is True
assert entry.get("clearable") is True
options = entry["options"]
assert len(options) >= 1
assert options == sorted(options)
assert "" not in options
assert "UTC" in options
# Fallback path: never returns an empty list.
assert len(_timezone_options()) >= 1
def test_dynamic_merge_recomputes_memory_provider_options(self, monkeypatch):
"""The per-request schema merge re-discovers memory providers.