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 +
+ Score Direction +
+ {SCORE_DIRECTION_OPTIONS.map(option => ( + + ))} +
+
+