mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
c8a4b18d34
commit
071e2adede
6 changed files with 200 additions and 39 deletions
137
apps/desktop/src/app/settings/searchable-select.test.tsx
Normal file
137
apps/desktop/src/app/settings/searchable-select.test.tsx
Normal 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('')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -610,6 +610,9 @@ export const ar = defineLocale({
|
|||
noneParen: '(لا شيء)',
|
||||
notSet: 'غير مضبوط',
|
||||
commaSeparated: 'قيم مفصولة بفواصل',
|
||||
searchPlaceholder: 'بحث…',
|
||||
noResults: 'لا توجد نتائج',
|
||||
systemDefault: 'إعداد النظام الافتراضي',
|
||||
loading: 'جار تحميل إعدادات Hermes...',
|
||||
emptyTitle: 'لا توجد إعدادات',
|
||||
emptyDesc: 'لا يحتوي هذا القسم على إعدادات قابلة للتعديل.',
|
||||
|
|
|
|||
|
|
@ -639,6 +639,9 @@ export const ja = defineLocale({
|
|||
builtinOnly: '内蔵のみ',
|
||||
notSet: '未設定',
|
||||
commaSeparated: 'カンマ区切りの値',
|
||||
searchPlaceholder: '検索…',
|
||||
noResults: '結果が見つかりません',
|
||||
systemDefault: 'システムのデフォルト',
|
||||
loading: 'Hermes の設定を読み込み中...',
|
||||
emptyTitle: '設定項目がありません',
|
||||
emptyDesc: 'このセクションには調整できる設定がありません。',
|
||||
|
|
|
|||
|
|
@ -627,6 +627,9 @@ export const zhHant = defineLocale({
|
|||
builtinOnly: '僅內建',
|
||||
notSet: '未設定',
|
||||
commaSeparated: '逗號分隔的值',
|
||||
searchPlaceholder: '搜尋…',
|
||||
noResults: '找不到結果',
|
||||
systemDefault: '系統預設',
|
||||
loading: '正在載入 Hermes 設定...',
|
||||
emptyTitle: '無可設定項目',
|
||||
emptyDesc: '此區段沒有可調整的設定。',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue