diff --git a/web/app/components/base/param-item/__tests__/index-slider.browser.spec.tsx b/web/app/components/base/param-item/__tests__/index-slider.browser.spec.tsx
new file mode 100644
index 00000000000..29f98c344ae
--- /dev/null
+++ b/web/app/components/base/param-item/__tests__/index-slider.browser.spec.tsx
@@ -0,0 +1,30 @@
+import { page, userEvent } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { ParamItemExample } from './param-item-example'
+
+beforeEach(async () => {
+ await page.viewport(800, 600)
+})
+
+// Native Tab/arrow input must update the caller and numeric field, not just emit a callback.
+it.each([
+ { max: 1, min: 0, value: 0.5, sliderValue: '50', next: 0.51, nextSliderValue: '51' },
+ { max: 10, min: 1, value: 5, sliderValue: '5', next: 6, nextSliderValue: '6' },
+])(
+ 'keeps keyboard slider edits in parameter units with max=$max',
+ async ({ max, min, value, sliderValue, next, nextSliderValue }) => {
+ await render()
+ const slider = page.getByRole('slider', { name: 'Test Param' })
+ const input = page.getByRole('textbox', { name: 'Test Param' })
+ await expect.element(slider).toHaveValue(sliderValue)
+ await input.click()
+ await userEvent.tab()
+ await expect.element(slider).toHaveFocus()
+ await userEvent.keyboard('{ArrowRight}')
+ await expect.element(input).toHaveValue(String(next))
+ await expect.element(slider).toHaveValue(nextSliderValue)
+ await expect
+ .element(page.getByRole('status', { name: 'Saved parameters' }))
+ .toHaveTextContent(JSON.stringify({ test_param: next }))
+ },
+)
diff --git a/web/app/components/base/param-item/__tests__/index-slider.spec.tsx b/web/app/components/base/param-item/__tests__/index-slider.spec.tsx
deleted file mode 100644
index 0a05eb43df9..00000000000
--- a/web/app/components/base/param-item/__tests__/index-slider.spec.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import ParamItem from '..'
-
-describe('ParamItem Slider onChange', () => {
- const defaultProps = {
- id: 'test_param',
- name: 'Test Param',
- enable: true,
- onChange: vi.fn(),
- }
-
- const getSlider = () => screen.getByRole('slider', { name: 'Test Param' })
-
- it('should divide slider value by 100 when max < 5', async () => {
- const user = userEvent.setup()
- render()
- const slider = getSlider()
-
- expect(slider).toHaveAttribute('aria-valuenow', '50')
- expect(slider).toHaveAttribute('aria-valuetext', '0.5')
-
- slider.focus()
- await user.keyboard('{ArrowRight}')
-
- // max=1 < 5, so slider value change (50->51) becomes 0.51
- expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0.51)
- })
-
- it('should not divide slider value when max >= 5', async () => {
- const user = userEvent.setup()
- render()
- const slider = getSlider()
-
- slider.focus()
- await user.keyboard('{ArrowRight}')
-
- // max=10 >= 5, so value remains raw (5->6)
- expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 6)
- })
-})
diff --git a/web/app/components/base/param-item/__tests__/index.browser.spec.tsx b/web/app/components/base/param-item/__tests__/index.browser.spec.tsx
new file mode 100644
index 00000000000..90283c1576b
--- /dev/null
+++ b/web/app/components/base/param-item/__tests__/index.browser.spec.tsx
@@ -0,0 +1,87 @@
+import { page, userEvent } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { ParamItemExample } from './param-item-example'
+
+beforeEach(async () => {
+ await page.viewport(800, 600)
+})
+
+describe('ParamItem', () => {
+ it('omits the tooltip trigger when tooltips are disabled', async () => {
+ await render()
+ await expect.element(page.getByRole('button', { name: 'Test Param' })).not.toBeInTheDocument()
+ })
+
+ it('disables both numeric and slider edits when the parameter is off', async () => {
+ await render()
+ await expect.element(page.getByRole('textbox', { name: 'Test Param' })).toBeDisabled()
+ await expect.element(page.getByRole('slider', { name: 'Test Param' })).toBeDisabled()
+ })
+
+ it('applies text edits to the named parameter and its slider', async () => {
+ await render()
+ const input = page.getByRole('textbox', { name: 'Test Param' })
+ await input.clear()
+ await userEvent.keyboard('0.8')
+ await expect.element(input).toHaveValue('0.8')
+ await expect
+ .element(page.getByRole('status', { name: 'Saved parameters' }))
+ .toHaveTextContent('{"test_param":0.8}')
+ await expect
+ .element(page.getByRole('slider', { name: 'Test Param' }))
+ .toHaveAttribute('aria-valuetext', '0.8')
+ })
+
+ it('allows replacing the minimum with a multi-digit value without saving the empty draft', async () => {
+ await render()
+ const input = page.getByRole('textbox', { name: 'Test Param' })
+ await input.clear()
+ await expect.element(input).toHaveValue('')
+ await expect
+ .element(page.getByRole('status', { name: 'Saved parameters' }))
+ .toHaveTextContent('{"test_param":1}')
+ await userEvent.keyboard('20')
+ await expect.element(input).toHaveValue('20')
+ await expect
+ .element(page.getByRole('status', { name: 'Saved parameters' }))
+ .toHaveTextContent('{"test_param":20}')
+ await expect.element(page.getByRole('slider', { name: 'Test Param' })).toHaveValue('20')
+ })
+
+ it('clamps text edits before saving the parameter and updating the slider', async () => {
+ await render()
+ const input = page.getByRole('textbox', { name: 'Test Param' })
+ await input.clear()
+ await userEvent.keyboard('1.5')
+ await expect
+ .element(page.getByRole('status', { name: 'Saved parameters' }))
+ .toHaveTextContent('{"test_param":1}')
+ await expect
+ .element(page.getByRole('slider', { name: 'Test Param' }))
+ .toHaveAttribute('aria-valuetext', '1')
+ })
+
+ it('applies the named switch change and disables the parameter controls', async () => {
+ await render()
+ const toggle = page.getByRole('switch', { name: 'Test Param' })
+ await toggle.click()
+ await expect.element(toggle).not.toBeChecked()
+ await expect
+ .element(page.getByRole('status', { name: 'Enabled parameters' }))
+ .toHaveTextContent('{"test_param":false}')
+ await expect.element(page.getByRole('textbox', { name: 'Test Param' })).toBeDisabled()
+ await expect.element(page.getByRole('slider', { name: 'Test Param' })).toBeDisabled()
+ })
+
+ it('applies the increment step to the saved parameter and both controls', async () => {
+ await render()
+ await page.getByRole('button', { name: 'Increment value' }).click()
+ await expect.element(page.getByRole('textbox', { name: 'Test Param' })).toHaveValue('0.6')
+ await expect
+ .element(page.getByRole('slider', { name: 'Test Param' }))
+ .toHaveAttribute('aria-valuetext', '0.6')
+ await expect
+ .element(page.getByRole('status', { name: 'Saved parameters' }))
+ .toHaveTextContent('{"test_param":0.6}')
+ })
+})
diff --git a/web/app/components/base/param-item/__tests__/index.spec.tsx b/web/app/components/base/param-item/__tests__/index.spec.tsx
deleted file mode 100644
index 100f7168697..00000000000
--- a/web/app/components/base/param-item/__tests__/index.spec.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { useState } from 'react'
-import ParamItem from '..'
-
-describe('ParamItem', () => {
- const defaultProps = {
- id: 'test_param',
- name: 'Test Param',
- value: 0.5,
- enable: true,
- max: 1,
- onChange: vi.fn(),
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- const getSlider = () =>
- screen.getByLabelText('Test Param', {
- selector: 'input[type="range"]',
- })
-
- describe('Rendering', () => {
- it('should not render tooltip trigger when noTooltip is true', () => {
- render()
-
- expect(screen.queryByRole('button', { name: 'Test Param' })).not.toBeInTheDocument()
- })
- })
-
- describe('Props', () => {
- it('should disable InputNumber when enable is false', () => {
- render()
-
- expect(screen.getByRole('textbox')).toBeDisabled()
- expect(getSlider()).toBeDisabled()
- })
- })
-
- describe('User Interactions', () => {
- it('should call onChange with id and value when InputNumber changes', async () => {
- const user = userEvent.setup()
- const StatefulParamItem = () => {
- const [value, setValue] = useState(defaultProps.value)
-
- return (
- {
- defaultProps.onChange(key, nextValue)
- setValue(nextValue)
- }}
- />
- )
- }
-
- render()
- const input = screen.getByRole('textbox')
-
- await user.clear(input)
- await user.type(input, '0.8')
-
- expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0.8)
- })
-
- it('should allow users to replace the minimum value with a multi-digit value', async () => {
- const user = userEvent.setup()
- const StatefulParamItem = () => {
- const [value, setValue] = useState(1)
-
- return (
- {
- defaultProps.onChange(key, nextValue)
- setValue(nextValue)
- }}
- />
- )
- }
-
- render()
-
- const input = screen.getByRole('textbox')
- await user.clear(input)
-
- expect(input).toHaveValue('')
- expect(defaultProps.onChange).not.toHaveBeenCalled()
-
- await user.type(input, '20')
-
- expect(input).toHaveValue('20')
- expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 20)
- expect(getSlider()).toHaveAttribute('aria-valuenow', '20')
- })
-
- it('should clamp out-of-range text edits before updating state', async () => {
- const user = userEvent.setup()
- const StatefulParamItem = () => {
- const [value, setValue] = useState(defaultProps.value)
-
- return (
- {
- defaultProps.onChange(key, nextValue)
- setValue(nextValue)
- }}
- />
- )
- }
-
- render()
-
- const input = screen.getByRole('textbox')
- await user.clear(input)
- await user.type(input, '1.5')
-
- expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 1)
- expect(getSlider()).toHaveAttribute('aria-valuenow', '100')
- })
-
- it('should pass scaled value to slider when max < 5', () => {
- render()
- const slider = getSlider()
-
- // When max < 5, slider value = value * 100 = 50
- expect(slider).toHaveAttribute('aria-valuenow', '50')
- expect(slider).toHaveAttribute('max', '100')
- })
-
- it('should pass raw value to slider when max >= 5', () => {
- render()
- const slider = getSlider()
-
- // When max >= 5, slider value = value = 5
- expect(slider).toHaveAttribute('aria-valuenow', '5')
- expect(slider).toHaveAttribute('max', '10')
- })
-
- it('should call onSwitchChange with id and value when switch is toggled', async () => {
- const user = userEvent.setup()
- const onSwitchChange = vi.fn()
- render()
-
- await user.click(screen.getByRole('switch'))
-
- expect(onSwitchChange).toHaveBeenCalledWith('test_param', false)
- })
-
- it('should call onChange with id when increment button is clicked', async () => {
- const user = userEvent.setup()
- render()
- const incrementBtn = screen.getByRole('button', { name: /increment/i })
-
- await user.click(incrementBtn)
-
- // step=0.1, so 0.5 + 0.1 = 0.6, clamped to [0,1] → 0.6
- expect(defaultProps.onChange).toHaveBeenCalledWith('test_param', 0.6)
- })
- })
-})
diff --git a/web/app/components/base/param-item/__tests__/param-item-example.tsx b/web/app/components/base/param-item/__tests__/param-item-example.tsx
new file mode 100644
index 00000000000..d6c145abfbf
--- /dev/null
+++ b/web/app/components/base/param-item/__tests__/param-item-example.tsx
@@ -0,0 +1,30 @@
+import type { ComponentProps } from 'react'
+import { useState } from 'react'
+import ParamItem from '..'
+
+type Props = Omit<
+ Partial>,
+ 'id' | 'name' | 'onChange' | 'onSwitchChange'
+>
+
+// A caller applies the public value/key payloads; no owner transformation is copied here.
+export function ParamItemExample({ value = 0.5, enable = true, max = 1, ...props }: Props) {
+ const [values, setValues] = useState({ test_param: value })
+ const [enabled, setEnabled] = useState({ test_param: enable })
+ return (
+ <>
+ setValues((previous) => ({ ...previous, [key]: next }))}
+ onSwitchChange={(key, next) => setEnabled((previous) => ({ ...previous, [key]: next }))}
+ />
+
+
+ >
+ )
+}
diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.browser.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.browser.spec.tsx
new file mode 100644
index 00000000000..b6e63f0884b
--- /dev/null
+++ b/web/app/components/base/param-item/__tests__/score-threshold-item.browser.spec.tsx
@@ -0,0 +1,71 @@
+import { useState } from 'react'
+import { page, userEvent } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import ScoreThresholdItem from '../score-threshold-item'
+
+beforeEach(async () => {
+ await page.viewport(800, 600)
+})
+
+it.each([
+ [undefined, '0.7'],
+ [-0.1, '0'],
+ [1.5, '1'],
+ [0.556, '0.56'],
+])('normalizes configured threshold %s to %s in both editors', async (value, expected) => {
+ await render()
+ await expect
+ .element(page.getByRole('textbox', { name: 'appDebug.datasetConfig.score_threshold' }))
+ .toHaveValue(expected)
+ await expect
+ .element(page.getByRole('slider', { name: 'appDebug.datasetConfig.score_threshold' }))
+ .toHaveAttribute('aria-valuetext', expected)
+})
+
+it('saves threshold edits in hundredths and makes both editors unavailable when switched off', async () => {
+ function Settings() {
+ const [values, setValues] = useState({ score_threshold: 0.5 })
+ const [enabled, setEnabled] = useState({ score_threshold: true })
+ return (
+ <>
+ setValues((previous) => ({ ...previous, [key]: next }))}
+ onSwitchChange={(key, next) => setEnabled((previous) => ({ ...previous, [key]: next }))}
+ />
+
+
+ >
+ )
+ }
+ await render()
+ const name = 'appDebug.datasetConfig.score_threshold'
+ const input = page.getByRole('textbox', { name })
+ const slider = page.getByRole('slider', { name })
+ const saved = page.getByRole('status', { name: 'Saved parameters' })
+ await page.getByRole('button', { name: 'Increment value' }).click()
+ await expect.element(input).toHaveValue('0.51')
+ await expect.element(slider).toHaveAttribute('aria-valuetext', '0.51')
+ await expect.element(saved).toHaveTextContent('{"score_threshold":0.51}')
+ await input.clear()
+ await userEvent.keyboard('0.55')
+ await expect.element(input).toHaveValue('0.55')
+ await expect.element(slider).toHaveAttribute('aria-valuetext', '0.55')
+ await expect.element(saved).toHaveTextContent('{"score_threshold":0.55}')
+ // Return through the real tab order rather than imperatively focusing the switch.
+ await userEvent.tab({ shift: true })
+ await expect.element(page.getByRole('button', { name })).toHaveFocus()
+ await userEvent.tab({ shift: true })
+ const toggle = page.getByRole('switch', { name })
+ await expect.element(toggle).toHaveFocus()
+ await userEvent.keyboard(' ')
+ await expect.element(toggle).not.toBeChecked()
+ await expect
+ .element(page.getByRole('status', { name: 'Enabled parameters' }))
+ .toHaveTextContent('{"score_threshold":false}')
+ await expect.element(input).toBeDisabled()
+ await expect.element(slider).toBeDisabled()
+ await expect.element(saved).toHaveTextContent('{"score_threshold":0.55}')
+})
diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx
deleted file mode 100644
index 5abfe4c5d40..00000000000
--- a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { useState } from 'react'
-import ScoreThresholdItem from '../score-threshold-item'
-
-it.each([
- [undefined, '0.7'],
- [-0.1, '0'],
- [1.5, '1'],
- [0.556, '0.56'],
-])('normalizes configured threshold %s to %s', (value, expected) => {
- render()
- expect(screen.getByRole('textbox')).toHaveValue(expected)
-})
-
-it('edits the score threshold in hundredths and disables its controls when switched off', async () => {
- const user = userEvent.setup()
- const onChange = vi.fn()
- const onSwitchChange = vi.fn()
- function Settings() {
- const [value, setValue] = useState(0.5)
- const [enable, setEnable] = useState(true)
- return (
- {
- onChange(key, next)
- setValue(next)
- }}
- onSwitchChange={(key, next) => {
- onSwitchChange(key, next)
- setEnable(next)
- }}
- />
- )
- }
- render()
- const input = screen.getByRole('textbox')
- await user.click(screen.getByRole('button', { name: /increment/i }))
- expect(input).toHaveValue('0.51')
- expect(onChange).toHaveBeenLastCalledWith('score_threshold', 0.51)
- await user.clear(input)
- await user.type(input, '0.55')
- expect(onChange).toHaveBeenLastCalledWith('score_threshold', 0.55)
- await user.click(screen.getByRole('switch'))
- expect(onSwitchChange).toHaveBeenLastCalledWith('score_threshold', false)
- expect(input).toBeDisabled()
- expect(
- screen.getByLabelText('appDebug.datasetConfig.score_threshold', {
- selector: 'input[type="range"]',
- }),
- ).toBeDisabled()
-})
diff --git a/web/app/components/base/param-item/__tests__/top-k-item.browser.spec.tsx b/web/app/components/base/param-item/__tests__/top-k-item.browser.spec.tsx
new file mode 100644
index 00000000000..be912fa3e97
--- /dev/null
+++ b/web/app/components/base/param-item/__tests__/top-k-item.browser.spec.tsx
@@ -0,0 +1,80 @@
+import { useState } from 'react'
+import { page, userEvent } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import TopKItem from '../top-k-item'
+
+vi.mock('@/env', () => ({ env: { NEXT_PUBLIC_TOP_K_MAX_VALUE: 10 } }))
+
+beforeEach(async () => {
+ await page.viewport(800, 600)
+})
+
+function Settings({ max }: { max?: number }) {
+ const [values, setValues] = useState({ top_k: 2 })
+ return (
+ <>
+ setValues((previous) => ({ ...previous, [key]: next }))}
+ />
+
+ >
+ )
+}
+
+it('keeps numeric edits and native slider keys synchronized within configured top-k bounds', async () => {
+ await render()
+ const input = page.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' })
+ const slider = page.getByRole('slider', { name: 'appDebug.datasetConfig.top_k' })
+ const saved = page.getByRole('status', { name: 'Saved parameters' })
+ await page.getByRole('button', { name: 'Increment value' }).click()
+ await expect.element(input).toHaveValue('3')
+ await expect.element(slider).toHaveValue('3')
+ await expect.element(saved).toHaveTextContent('{"top_k":3}')
+ await input.click()
+ await userEvent.tab()
+ await expect.element(slider).toHaveFocus()
+ await userEvent.keyboard('{End}')
+ await expect.element(input).toHaveValue('10')
+ await expect.element(saved).toHaveTextContent('{"top_k":10}')
+ await userEvent.keyboard('{ArrowRight}')
+ await expect.element(input).toHaveValue('10')
+ await expect.element(saved).toHaveTextContent('{"top_k":10}')
+ await userEvent.keyboard('{Home}')
+ await expect.element(input).toHaveValue('1')
+ await expect.element(saved).toHaveTextContent('{"top_k":1}')
+ await userEvent.keyboard('{ArrowLeft}')
+ await expect.element(input).toHaveValue('1')
+ await expect.element(saved).toHaveTextContent('{"top_k":1}')
+})
+
+it.each([
+ { enable: false, disabled: false },
+ { enable: true, disabled: true },
+])('makes both top-k editors unavailable with %j', async (props) => {
+ await render()
+ await expect
+ .element(page.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }))
+ .toBeDisabled()
+ await expect
+ .element(page.getByRole('slider', { name: 'appDebug.datasetConfig.top_k' }))
+ .toBeDisabled()
+})
+
+it('applies a feature-specific maximum to native slider edits and the saved top-k', async () => {
+ await render()
+ const slider = page.getByRole('slider', { name: 'appDebug.datasetConfig.top_k' })
+ await page.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }).click()
+ await userEvent.tab()
+ await expect.element(slider).toHaveFocus()
+ await userEvent.keyboard('{End}')
+ await expect.element(slider).toHaveValue('100')
+ await expect
+ .element(page.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' }))
+ .toHaveValue('100')
+ await expect
+ .element(page.getByRole('status', { name: 'Saved parameters' }))
+ .toHaveTextContent('{"top_k":100}')
+})
diff --git a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx
deleted file mode 100644
index b2f47fd5cc4..00000000000
--- a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { useState } from 'react'
-import TopKItem from '../top-k-item'
-
-vi.mock('@/env', () => ({ env: { NEXT_PUBLIC_TOP_K_MAX_VALUE: 10 } }))
-
-it('keeps the numeric field and slider synchronized within the configured top-k bounds', async () => {
- const user = userEvent.setup()
- const onChange = vi.fn()
- function Harness() {
- const [value, setValue] = useState(2)
- return (
- {
- onChange(key, next)
- setValue(next)
- }}
- />
- )
- }
- render()
- const input = screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' })
- const slider = screen.getByLabelText('appDebug.datasetConfig.top_k', {
- selector: 'input[type="range"]',
- })
- await user.click(screen.getByRole('button', { name: /increment/i }))
- expect(input).toHaveValue('3')
- expect(slider).toHaveValue('3')
- slider.focus()
- await user.keyboard('{End}{ArrowRight}')
- expect(input).toHaveValue('10')
- expect(onChange).toHaveBeenLastCalledWith('top_k', 10)
- await user.keyboard('{Home}{ArrowLeft}')
- expect(input).toHaveValue('1')
- expect(onChange).toHaveBeenLastCalledWith('top_k', 1)
-})
-
-it.each([
- { enable: false, disabled: false },
- { enable: true, disabled: true },
-])('prevents edits with %j', async (props) => {
- const user = userEvent.setup()
- const onChange = vi.fn()
- render()
- const input = screen.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' })
- expect(input).toBeDisabled()
- expect(
- screen.getByLabelText('appDebug.datasetConfig.top_k', { selector: 'input[type="range"]' }),
- ).toBeDisabled()
- await user.type(input, '9')
- expect(onChange).not.toHaveBeenCalled()
-})
-
-it('allows a feature-specific maximum', () => {
- render()
- expect(
- screen.getByLabelText('appDebug.datasetConfig.top_k', { selector: 'input[type="range"]' }),
- ).toHaveAttribute('max', '100')
-})