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 }))} + /> + {JSON.stringify(values)} + {JSON.stringify(enabled)} + + ) +} 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 }))} + /> + {JSON.stringify(values)} + {JSON.stringify(enabled)} + + ) + } + 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 }))} + /> + {JSON.stringify(values)} + + ) +} + +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') -})