From bc233d31688ceb96f1dca0c3618ffcf21040667f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 25 Jun 2026 09:35:49 +1000 Subject: [PATCH] PM-5454: Add score direction radios to MM scorer config What was broken The Marathon Match scorer config editor already loaded and saved scoreDirection, but operators had no field in the Work app to choose MAXIMIZE or MINIMIZE. Root cause (if identifiable) The UI draft and API payload included scoreDirection defaults, but the Scorer Settings form did not render a control for it. What was changed Added a Score Direction radio group with Maximize and Minimize options, defaulting to Maximize when the draft has no saved direction. Wired the radio choice into the existing scorer config draft and save payload. Added local styles matching the existing settings grid. Any added/updated tests Added a MarathonMatchScorerSection test covering the default Maximize selection and saving Minimize to the create config payload. --- .../MarathonMatchScorerSection.module.scss | 35 ++- .../MarathonMatchScorerSection.spec.tsx | 225 ++++++++++++++++++ .../MarathonMatchScorerSection.tsx | 53 +++++ 3 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx 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 => ( + + ))} +
+
+