Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ red X to remove a converter from the live registry; confirmation is required.
chosen at launch. **Suites** maintain ordered collections of preset definitions.
They are stored separately from live component instances.

Editing a preset preserves unchanged parameter values exactly, including commas
and whitespace inside list entries, empty lists, duplicates, and their order.
Editing a list field uses the comma-separated format shown in the editor.

Targets, converters, and techniques added only to the live registry must be
recreated after a backend restart or reinitialization. Use
[initializers](../code/setup/0_setup.md) for repeatable component setup.
Expand Down
152 changes: 150 additions & 2 deletions frontend/src/components/ScenarioPresets/ScenarioPresetEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import { MemoryRouter, Route, Routes } from 'react-router'
import { Link, MemoryRouter, Route, Routes } from 'react-router'

import { scenarioPresetsApi, scenariosApi } from '@/services/api'
import { makeScenario } from '@/test-utils/scenarioFixtures'
import type { ScenarioDatasetSettings, ScenarioPreset, ScenarioRunSizeEstimateResponse } from '@/types'
import type { Parameter, ScenarioDatasetSettings, ScenarioPreset, ScenarioRunSizeEstimateResponse } from '@/types'

import ScenarioPresetEditor from './ScenarioPresetEditor'

Expand Down Expand Up @@ -740,6 +740,13 @@ describe('ScenarioPresetEditor dataset projection', () => {
})

describe('ScenarioPresetEditor dynamic parameters', () => {
const GOALS_PARAMETER: Parameter = {
name: 'goals',
type_name: 'list[str]',
is_list: true,
required: false,
default: ['Default goal'],
}
const SCENARIO_WITH_PARAM = makeScenario({
supported_parameters: [
{
Expand Down Expand Up @@ -790,4 +797,145 @@ describe('ScenarioPresetEditor dynamic parameters', () => {
'v1',
))
})

it.each([
{ label: 'comma-containing goals', goals: ['Hello, world!', 'Second goal'] },
{ label: 'a comma-only goal', goals: [','] },
{ label: 'an explicit empty list', goals: [] },
{ label: 'whitespace, duplicates and order', goals: [' third ', 'first', 'first'] },
])('should preserve $label during a description-only edit', async ({ goals }) => {
const user = userEvent.setup()
mockGetScenario.mockResolvedValue(makeScenario({ supported_parameters: [GOALS_PARAMETER] }))
mockGet.mockResolvedValue({
preset: { ...STORED_PRESET, scenario_params: { goals } },
version: 'v1',
issues: [],
})
renderEdit()

await screen.findByRole('textbox', { name: 'goals' })
await user.type(screen.getByRole('textbox', { name: 'Description' }), ' Updated.')
await user.click(screen.getByRole('button', { name: 'Save preset' }))

await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
'nightly_probe',
expect.objectContaining({ scenario_params: { goals } }),
'v1',
))
})

it.each([
{ input: 'a, b', goals: ['a', 'b'] },
{ input: 'padded, Second goal', goals: ['padded', 'Second goal'] },
])('should save an explicit list edit to "$input"', async ({ input, goals }) => {
const user = userEvent.setup()
mockGetScenario.mockResolvedValue(makeScenario({ supported_parameters: [GOALS_PARAMETER] }))
mockGet.mockResolvedValue({
preset: { ...STORED_PRESET, scenario_params: { goals: [' padded ', 'Second goal'] } },
version: 'v1',
issues: [],
})
renderEdit()

const field = await screen.findByRole('textbox', { name: 'goals' })
await user.clear(field)
await user.type(field, input)
await user.click(screen.getByRole('button', { name: 'Save preset' }))

await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
'nightly_probe',
expect.objectContaining({ scenario_params: { goals } }),
'v1',
))
})

it('should preserve an untouched list when the operator fills a missing required field', async () => {
const user = userEvent.setup()
mockGetScenario.mockResolvedValue(makeScenario({
supported_parameters: [
{ name: 'objective', type_name: 'str', required: true, multiline: true },
GOALS_PARAMETER,
],
}))
mockGet.mockResolvedValue({
preset: { ...STORED_PRESET, scenario_params: { goals: ['Hello, world!'] } },
version: 'v1',
issues: [],
})
renderEdit()

await user.type(await screen.findByRole('textbox', { name: 'objective' }), 'Test this objective')
await user.click(screen.getByRole('button', { name: 'Save preset' }))

await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
'nightly_probe',
expect.objectContaining({
scenario_params: { objective: 'Test this objective', goals: ['Hello, world!'] },
}),
'v1',
))
})

it('should estimate with the same untouched list that will be saved', async () => {
const user = userEvent.setup()
mockGetScenario.mockResolvedValue(makeScenario({ supported_parameters: [GOALS_PARAMETER] }))
mockGet.mockResolvedValue({
preset: { ...STORED_PRESET, scenario_params: { goals: ['Hello, world!'] } },
version: 'v1',
issues: [],
})
renderEdit()

await screen.findByRole('textbox', { name: 'goals' })
await waitFor(() => expect(mockEstimateRun).toHaveBeenCalledWith(
STORED_PRESET.scenario_name,
expect.objectContaining({ scenario_params: { goals: ['Hello, world!'] } }),
expect.any(AbortSignal),
))
await user.click(screen.getByRole('button', { name: 'Save preset' }))

await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
'nightly_probe',
expect.objectContaining({ scenario_params: { goals: ['Hello, world!'] } }),
'v1',
))
})

it('should preserve values from the newly opened preset rather than the previous form', async () => {
const user = userEvent.setup()
mockGetScenario.mockResolvedValue(makeScenario({ supported_parameters: [GOALS_PARAMETER] }))
mockGet.mockImplementation((name: string) => Promise.resolve({
preset: {
...STORED_PRESET,
name,
scenario_params: { goals: name === 'first' ? ['Hello, world!'] : [' Next, goal '] },
},
version: 'v1',
issues: [],
}))
render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={['/registry/scenario-presets/first/edit']}>
<Link to="/registry/scenario-presets/second/edit">Open second preset</Link>
<Routes>
<Route
path="/registry/scenario-presets/:presetName/edit"
element={<ScenarioPresetEditor mode="edit" />}
/>
</Routes>
</MemoryRouter>
</FluentProvider>,
)

await user.type(await screen.findByRole('textbox', { name: 'goals' }), ' discarded edit')
await user.click(screen.getByRole('link', { name: 'Open second preset' }))
await waitFor(() => expect(screen.getByRole('textbox', { name: 'goals' })).toHaveValue(' Next, goal '))
await user.click(screen.getByRole('button', { name: 'Save preset' }))

await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
'second',
expect.objectContaining({ scenario_params: { goals: [' Next, goal '] } }),
'v1',
))
})
})
12 changes: 3 additions & 9 deletions frontend/src/components/ScenarioPresets/ScenarioPresetEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import ScenarioDatasetTable from '@/components/Scenarios/ScenarioDatasetTable'
import ScenarioTechniqueSelector from '@/components/Scenarios/ScenarioTechniqueSelector'
import { scenarioDatasetDefaults, scenarioDatasetOptions } from '@/components/Scenarios/datasetPresentation'
import {
buildScenarioConfig,
dynamicScenarioParameters,
initialScenarioConfigState,
uniqueTechniqueOptions,
Expand All @@ -44,6 +43,7 @@ import ScannerVisibilityLabel from './ScannerVisibilityLabel'
import { useScenarioPresetEditorStyles } from './ScenarioPresetEditor.styles'
import { PRESETS_ROUTE } from './presetRoutes'
import {
buildPresetScenarioConfig,
configToPreset,
presetToConfigState,
uneditableScenarioParams,
Expand Down Expand Up @@ -195,14 +195,8 @@ function ScenarioPresetEditorContent({ mode, presetName }: ScenarioPresetEditorC
)
const techniqueIssues = loaded?.issues.filter((issue) => issue.field === 'techniques') ?? []
const builtConfig = useMemo(
() => (config ? buildScenarioConfig({
techniques: config.techniques,
dynamicParameters,
scenarioParamValues: config.scenarioParamValues,
datasets: config.datasets,
includeBaseline: config.includeBaseline,
}) : null),
[config, dynamicParameters],
() => (scenario && config ? buildPresetScenarioConfig(scenario, config, loaded?.preset ?? null) : null),
[config, loaded, scenario],
)
const scenarioName = scenario?.scenario_name
const projectionRequest = !scenarioLoading && builtConfig?.ok ? builtConfig.config : null
Expand Down
159 changes: 159 additions & 0 deletions frontend/src/components/ScenarioPresets/scenarioPresetForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { makeScenario } from '@/test-utils/scenarioFixtures'
import type { Parameter, ScenarioPreset } from '@/types'

import {
buildPresetScenarioConfig,
configToPreset,
presetToConfigState,
uneditableScenarioParams,
Expand Down Expand Up @@ -196,6 +197,139 @@ describe('presetToConfigState', () => {
})
})

describe('buildPresetScenarioConfig', () => {
const goalsParameter: Parameter = {
name: 'goals',
type_name: 'list[str]',
is_list: true,
required: false,
default: ['Default goal'],
}
const scenario = makeScenario({ supported_parameters: [goalsParameter] })

it.each([
['commas', ['Hello, world!', 'Second goal']],
['only commas', [',']],
['an empty list', []],
['empty entries', ['', 'Second goal']],
['whitespace', [' padded ', 'Second goal']],
['duplicates and order', ['third', 'first', 'first']],
])('should preserve %s in an untouched list', (_label, goals) => {
const previous = makePreset({ scenario_params: { goals } })
const result = buildPresetScenarioConfig(scenario, presetToConfigState(scenario, previous), previous)

expect(result).toEqual({
ok: true,
config: expect.objectContaining({ scenario_params: { goals } }),
})
})

it('should honor an edit even when the parsed value matches the parsed original', () => {
const previous = makePreset({ scenario_params: { goals: [' padded ', 'Second goal'] } })
const state = presetToConfigState(scenario, previous)
state.scenarioParamValues.goals = 'padded, Second goal'

expect(buildPresetScenarioConfig(scenario, state, previous)).toEqual({
ok: true,
config: expect.objectContaining({ scenario_params: { goals: ['padded', 'Second goal'] } }),
})
})

it('should omit a stored list that the operator clears', () => {
const previous = makePreset({ scenario_params: { goals: ['Hello, world!'] } })
const state = presetToConfigState(scenario, previous)
state.scenarioParamValues.goals = ''
const result = buildPresetScenarioConfig(scenario, state, previous)
if (!result.ok) {
throw new Error(result.error)
}

expect(configToPreset(IDENTITY, result.config, { scenario, previous })).not.toHaveProperty('scenario_params')
})

it('should not let an omitted required parameter disable preservation for other fields', () => {
const requiredScenario = makeScenario({
supported_parameters: [
{ name: 'objective', type_name: 'str', required: true },
goalsParameter,
],
})
const previous = makePreset({ scenario_params: { goals: ['Hello, world!'] } })
const state = presetToConfigState(requiredScenario, previous)

expect(buildPresetScenarioConfig(requiredScenario, state, previous)).toEqual({
ok: false,
error: 'objective is required.',
})

state.scenarioParamValues.objective = 'New objective'

expect(buildPresetScenarioConfig(requiredScenario, state, previous)).toEqual({
ok: true,
config: expect.objectContaining({
scenario_params: { objective: 'New objective', goals: ['Hello, world!'] },
}),
})
})

it('should preserve an untouched required empty list without parsing its blank display text', () => {
const requiredScenario = makeScenario({
supported_parameters: [{ ...goalsParameter, required: true }],
})
const previous = makePreset({ scenario_params: { goals: [] } })
const state = presetToConfigState(requiredScenario, previous)

expect(buildPresetScenarioConfig(requiredScenario, state, previous)).toEqual({
ok: true,
config: expect.objectContaining({ scenario_params: { goals: [] } }),
})
})

it('should still validate a required list that the operator clears', () => {
const requiredScenario = makeScenario({
supported_parameters: [{ ...goalsParameter, required: true }],
})
const previous = makePreset({ scenario_params: { goals: ['original'] } })
const state = presetToConfigState(requiredScenario, previous)
state.scenarioParamValues.goals = ''

expect(buildPresetScenarioConfig(requiredScenario, state, previous)).toEqual({
ok: false,
error: 'goals is required.',
})
})

it('should keep an untouched list while parsing and validating another parameter', () => {
const parameterScenario = makeScenario({
supported_parameters: [goalsParameter, ITERATION_PARAMETER],
})
const previous = makePreset({ scenario_params: { goals: ['Hello, world!'] } })
const state = presetToConfigState(parameterScenario, previous)
state.scenarioParamValues.max_turns = '9'

expect(buildPresetScenarioConfig(parameterScenario, state, previous)).toEqual({
ok: true,
config: expect.objectContaining({ scenario_params: { goals: ['Hello, world!'], max_turns: 9 } }),
})

state.scenarioParamValues.max_turns = 'not a number'

expect(buildPresetScenarioConfig(parameterScenario, state, previous)).toEqual({
ok: false,
error: 'max_turns must be a number.',
})
})

it('should keep scenario defaults unpinned when creating a preset', () => {
const result = buildPresetScenarioConfig(scenario, initialScenarioConfigState(scenario), null)
if (!result.ok) {
throw new Error(result.error)
}

expect(configToPreset(IDENTITY, result.config, { scenario, previous: null })).not.toHaveProperty('scenario_params')
})
})

describe('configToPreset', () => {
function buildConfig(overrides: Partial<Parameters<typeof buildScenarioConfig>[0]> = {}) {
const result = buildScenarioConfig({
Expand Down Expand Up @@ -432,6 +566,31 @@ describe('configToPreset', () => {
expect(roundTripped.scenario_params).toEqual({ max_turns: 5, retired_knob: 'x', max_concurrency: 4 })
})

it('keeps a stored list whose elements contain commas when it is not edited', () => {
const goals: Parameter = { name: 'goals', type_name: 'list[str]', is_list: true, required: false }
const scenario = makeScenario({ supported_parameters: [goals] })
const original = makePreset({ scenario_params: { goals: ['Hello, world!', 'Second goal'] } })
const state = presetToConfigState(scenario, original)

const built = buildPresetScenarioConfig(scenario, state, original)
if (!built.ok) {
throw new Error(built.error)
}
const roundTripped = configToPreset(
{ ...IDENTITY, description: 'new description' },
built.config,
{ scenario, previous: original },
)
expect(roundTripped.scenario_params).toEqual({ goals: ['Hello, world!', 'Second goal'] })

const edited = configToPreset(
IDENTITY,
buildConfig({ dynamicParameters: [goals], scenarioParamValues: { goals: 'a, b' } }),
{ scenario, previous: original },
)
expect(edited.scenario_params).toEqual({ goals: ['a', 'b'] })
})

it('round-trips a fully populated preset back through the form state', () => {
const scenario = makeScenario()
const original = makePreset({
Expand Down
Loading
Loading