diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss
index 0fced2607..43b1cd7c0 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss
@@ -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;
@@ -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;
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx
new file mode 100644
index 000000000..605b7e589
--- /dev/null
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx
@@ -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
+ ? (
+
+ {props.title ?
{props.title}
: undefined}
+ {props.children}
+ {props.buttons}
+
+ )
+ : null),
+ Button: (props: {
+ disabled?: boolean
+ label: string
+ onClick?: () => void
+ type?: 'button' | 'submit'
+ }) => (
+
+ ),
+}), {
+ 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
+const mockFetchTesters = fetchTesters as jest.MockedFunction
+
+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(
+ ,
+ )
+
+ 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,
+ }),
+ )
+ })
+ })
+})
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx
index dbacab6fa..7ac0570a9 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx
@@ -18,6 +18,7 @@ import {
MarathonMatchConfigType,
MarathonMatchDefaults,
MarathonMatchPhaseConfig,
+ MarathonMatchScoreDirection,
MarathonMatchTester,
MarathonMatchTesterSummary,
MarathonMatchTestSubmissionResponse,
@@ -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
@@ -1091,6 +1105,22 @@ export const MarathonMatchScorerSection: FC = (
[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): void => {
+ updateDraft(currentDraft => ({
+ ...currentDraft,
+ scoreDirection: event.target.value as MarathonMatchScoreDirection,
+ }))
+ },
+ [updateDraft],
+ )
+
const handleNumericFieldChange = useCallback(
(field: 'compileTimeout' | 'testTimeout') => (
event: ChangeEvent,
@@ -1999,6 +2029,29 @@ export const MarathonMatchScorerSection: FC = (
Relative Scoring
+
+