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
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,15 @@
}

.fieldGroup,
.checkboxField {
.checkboxField,
.radioField {
display: flex;
flex-direction: column;
gap: 8px;
}

.fieldGroup span {
.fieldGroup span,
.radioField legend {
color: $black-80;
font-size: 13px;
font-weight: 600;
Expand Down Expand Up @@ -222,6 +224,35 @@
font-size: 14px;
}

.radioField {
border: 0;
margin: 0;
padding: 0;
}

.radioOptions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 16px;
min-height: 42px;
}

.radioOption {
align-items: center;
color: $black-80;
cursor: pointer;
display: inline-flex;
font-size: 14px;
gap: 8px;
}

.radioOption input {
accent-color: #137d60;
cursor: pointer;
margin: 0;
}

.fieldError {
color: $red-100;
font-size: 12px;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, unicorn/no-null */
import '@testing-library/jest-dom'
import {
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import {
ChallengePhase,
CreateMarathonMatchConfigInput,
MarathonMatchConfig,
MarathonMatchDefaults,
MarathonMatchTester,
MarathonMatchTesterSummary,
} from '../../../../../lib/models'
import {
createMarathonMatchConfig,
fetchMarathonMatchConfig,
fetchMarathonMatchDefaults,
fetchTester,
fetchTesters,
} from '../../../../../lib/services'

import { MarathonMatchScorerSection } from './MarathonMatchScorerSection'

jest.mock('~/libs/ui', () => ({
BaseModal: (props: {
buttons?: JSX.Element
children?: JSX.Element
open?: boolean
title?: string
}) => (props.open
? (
<div aria-modal='true' role='dialog'>
{props.title ? <h4>{props.title}</h4> : undefined}
{props.children}
{props.buttons}
</div>
)
: null),
Button: (props: {
disabled?: boolean
label: string
onClick?: () => void
type?: 'button' | 'submit'
}) => (
<button
disabled={props.disabled}
onClick={props.onClick}
type={props.type === 'submit'
? 'submit'
: 'button'}
>
{props.label}
</button>
),
}), {
virtual: true,
})

jest.mock('../../../../../lib/services', () => ({
createMarathonMatchConfig: jest.fn(),
fetchMarathonMatchConfig: jest.fn(),
fetchMarathonMatchDefaults: jest.fn(),
fetchMarathonMatchTestSubmissionStatus: jest.fn(),
fetchTester: jest.fn(),
fetchTesters: jest.fn(),
rerunMarathonMatchScores: jest.fn(),
updateMarathonMatchConfig: jest.fn(),
uploadMarathonMatchTestSubmission: jest.fn(),
}))

jest.mock('../../../../../lib/utils', () => ({
formatDateTime: (value: unknown): string => String(value || ''),
showErrorToast: jest.fn(),
showInfoToast: jest.fn(),
showSuccessToast: jest.fn(),
}))

jest.mock('./TesterModal', () => ({
TesterModal: () => null,
}))

const CHALLENGE_ID = 'challenge-1'

const phases: ChallengePhase[] = [
{
id: 'phase-example',
name: 'Submission',
phaseId: 'phase-example',
},
{
id: 'phase-review',
name: 'Review',
phaseId: 'phase-review',
},
]

const defaults: MarathonMatchDefaults = {
compileTimeout: 300000,
reviewScorecardId: 'scorecard-1',
taskDefinitionName: 'runner-task',
taskDefinitionVersion: '1',
testTimeout: 600000,
}

const testerSummary: MarathonMatchTesterSummary = {
className: 'ExampleScorer',
compilationError: null,
compilationStatus: 'SUCCESS',
createdAt: '2026-06-24T00:00:00.000Z',
id: 'tester-1',
name: 'Example Scorer',
updatedAt: '2026-06-24T00:00:00.000Z',
version: '1.0.0',
}

const tester: MarathonMatchTester = {
...testerSummary,
sourceCode: 'public class ExampleScorer {}',
}

const mockCreateMarathonMatchConfig = createMarathonMatchConfig as jest.MockedFunction<
typeof createMarathonMatchConfig
>
const mockFetchMarathonMatchConfig = fetchMarathonMatchConfig as jest.MockedFunction<
typeof fetchMarathonMatchConfig
>
const mockFetchMarathonMatchDefaults = fetchMarathonMatchDefaults as jest.MockedFunction<
typeof fetchMarathonMatchDefaults
>
const mockFetchTester = fetchTester as jest.MockedFunction<typeof fetchTester>
const mockFetchTesters = fetchTesters as jest.MockedFunction<typeof fetchTesters>

function buildSavedConfig(
challengeId: string,
input: CreateMarathonMatchConfigInput,
): MarathonMatchConfig {
return {
active: input.active !== false,
challengeId,
compileTimeout: input.compileTimeout,
createdAt: '2026-06-24T00:00:00.000Z',
example: input.example || null,
id: 'config-1',
name: input.name,
provisional: input.provisional || null,
relativeScoringEnabled: input.relativeScoringEnabled !== false,
reviewScorecardId: input.reviewScorecardId,
scoreDirection: input.scoreDirection || 'MAXIMIZE',
system: input.system || null,
taskDefinitionName: input.taskDefinitionName,
taskDefinitionVersion: input.taskDefinitionVersion,
testerId: input.testerId,
testTimeout: input.testTimeout,
updatedAt: '2026-06-24T00:00:00.000Z',
}
}

describe('MarathonMatchScorerSection', () => {
beforeEach(() => {
jest.clearAllMocks()

mockFetchMarathonMatchDefaults.mockResolvedValue(defaults)
mockFetchMarathonMatchConfig.mockResolvedValue(undefined)
mockFetchTesters.mockResolvedValue([testerSummary])
mockFetchTester.mockResolvedValue(tester)
mockCreateMarathonMatchConfig.mockImplementation(
async (challengeId, input) => buildSavedConfig(challengeId, input),
)
})

it('defaults to Maximize and saves the selected Minimize score direction', async () => {
const user = userEvent.setup()

render(
<MarathonMatchScorerSection
challengeId={CHALLENGE_ID}
onScorerConfigChange={jest.fn()}
phases={phases}
/>,
)

const scoreDirectionGroup = await screen.findByRole('group', {
name: 'Score Direction',
})
const maximizeOption = within(scoreDirectionGroup)
.getByRole('radio', { name: 'Maximize' })
const minimizeOption = within(scoreDirectionGroup)
.getByRole('radio', { name: 'Minimize' })

expect(maximizeOption)
.toBeChecked()
expect(minimizeOption)
.not
.toBeChecked()

await user.selectOptions(
screen.getByRole('combobox', { name: /Scorer/ }),
testerSummary.id,
)
await user.click(minimizeOption)

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Save Scorer Config' }))
.toBeEnabled()
})

await user.click(screen.getByRole('button', { name: 'Save Scorer Config' }))

await waitFor(() => {
expect(mockCreateMarathonMatchConfig)
.toHaveBeenCalledWith(
CHALLENGE_ID,
expect.objectContaining({
scoreDirection: 'MINIMIZE',
testerId: testerSummary.id,
}),
)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MarathonMatchConfigType,
MarathonMatchDefaults,
MarathonMatchPhaseConfig,
MarathonMatchScoreDirection,
MarathonMatchTester,
MarathonMatchTesterSummary,
MarathonMatchTestSubmissionResponse,
Expand Down Expand Up @@ -76,6 +77,19 @@ const PHASE_DEFAULTS = {
startSeed: 1651246628,
},
} as const
const SCORE_DIRECTION_OPTIONS: Array<{
label: string
value: MarathonMatchScoreDirection
}> = [
{
label: 'Maximize',
value: 'MAXIMIZE',
},
{
label: 'Minimize',
value: 'MINIMIZE',
},
]

type PhaseDraftKey = keyof typeof PHASE_LABELS

Expand Down Expand Up @@ -1091,6 +1105,22 @@ export const MarathonMatchScorerSection: FC<MarathonMatchScorerSectionProps> = (
[updateDraft],
)

/**
* Updates the scorer draft with the selected score direction.
* @param event Radio change event carrying the selected marathon match score direction.
* @returns void
* Used by the Score Direction radios before persisting the scorer config payload.
*/
const handleScoreDirectionChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
updateDraft(currentDraft => ({
...currentDraft,
scoreDirection: event.target.value as MarathonMatchScoreDirection,
}))
},
[updateDraft],
)

const handleNumericFieldChange = useCallback(
(field: 'compileTimeout' | 'testTimeout') => (
event: ChangeEvent<HTMLInputElement>,
Expand Down Expand Up @@ -1999,6 +2029,29 @@ export const MarathonMatchScorerSection: FC<MarathonMatchScorerSectionProps> = (
<span>Relative Scoring</span>
</label>

<fieldset className={styles.radioField}>
<legend>Score Direction</legend>
<div className={styles.radioOptions}>
{SCORE_DIRECTION_OPTIONS.map(option => (
<label
className={styles.radioOption}
key={option.value}
>
<input
checked={
(draft.scoreDirection || DEFAULT_SCORE_DIRECTION) === option.value
}
name='marathon-match-score-direction'
onChange={handleScoreDirectionChange}
type='radio'
value={option.value}
/>
<span>{option.label}</span>
</label>
))}
</div>
</fieldset>

<label className={styles.fieldGroup}>
<span>Review Scorecard ID</span>
<input
Expand Down
Loading