From 30f55d55ba5ee3f3e4561a87ce293bcb63888736 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Jul 2026 09:57:50 +1000 Subject: [PATCH] PM-5510: Add marathon submission test type filter What was broken Marathon Match submissions could only be filtered by general submission fields, so System, Provisional, and Example test rows stayed intermingled in the Work app submissions list. Root cause The submissions list already displayed the current marathon test process from review summation metadata, but that normalized process was not part of the filter state or filter predicate. What was changed Added a Marathon Match-only Test type select with All, System, Provisional, and Example options. The filter uses the existing getSubmissionTestProgress helper so the filter matches the same current test process shown in the table. Any added/updated tests Added SubmissionsSection coverage that verifies marathon submissions can be filtered by System and Example test types. --- .../SubmissionsSection.spec.tsx | 238 ++++++++++++++++++ .../SubmissionsSection/SubmissionsSection.tsx | 62 ++++- 2 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.spec.tsx diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.spec.tsx new file mode 100644 index 000000000..f0d58c0b4 --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.spec.tsx @@ -0,0 +1,238 @@ +/* eslint-disable global-require, import/no-extraneous-dependencies */ +/* eslint-disable @typescript-eslint/no-var-requires, ordered-imports/ordered-imports */ +import { + fireEvent, + render, + screen, +} from '@testing-library/react' + +import type { + Challenge, + Submission, +} from '../../../../../lib/models' + +import { SubmissionsSection } from './SubmissionsSection' + +const mockUseDownloadAllSubmissions = jest.fn() +const mockUseDownloadSubmission = jest.fn() +const mockUseFetchSubmissions = jest.fn() +const mockFetchMembersByUserIds = jest.fn() +const mockIsMarathonMatchChallenge = jest.fn() + +jest.mock('~/libs/ui', () => ({ + Button: (props: { + disabled?: boolean + label: string + onClick?: () => void + }): JSX.Element => ( + + ), +}), { + virtual: true, +}) + +jest.mock('../../../../../lib/assets/icons/lock.svg', () => ({ + ReactComponent: (): JSX.Element => , +})) + +jest.mock('../../../../../lib/components', () => ({ + ArtifactsModal: (): JSX.Element =>
Artifacts modal
, + Pagination: (props: { total: number }): JSX.Element => ( +
{props.total}
+ ), + SubmissionRunnerLogsModal: (): JSX.Element =>
Runner logs modal
, + SubmissionsTable: (props: { submissions: Submission[] }): JSX.Element => ( +
+ {props.submissions.map(submission => ( +
+ {submission.id} + {submission.memberHandle} +
+ ))} +
+ ), +})) + +jest.mock('../../../../../lib/constants', () => ({ + PAGE_SIZE: 10, +})) + +jest.mock('../../../../../lib/contexts', () => { + const React = require('react') as typeof import('react') + + return { + WorkAppContext: React.createContext({ + isAdmin: false, + isAnonymous: false, + isCopilot: false, + isManager: false, + isReadOnly: false, + loginUserInfo: undefined, + userRoles: [], + }), + } +}) + +jest.mock('../../../../../lib/hooks', () => ({ + useDownloadAllSubmissions: (): unknown => mockUseDownloadAllSubmissions(), + useDownloadSubmission: (): unknown => mockUseDownloadSubmission(), + useFetchSubmissions: (...args: unknown[]): unknown => mockUseFetchSubmissions(...args), +})) + +jest.mock('../../../../../lib/services', () => ({ + fetchMembersByUserIds: (...args: unknown[]): unknown => mockFetchMembersByUserIds(...args), +})) + +jest.mock('../../../../../lib/utils', () => ({ + canDownloadSubmissions: (): boolean => false, + canViewMarathonMatchRunnerLogs: (): boolean => false, + getSubmissionFinalScore: (): number => 0, + getSubmissionInitialScore: (): number => 0, + getSubmissionProvisionalScore: (): number => 0, + getSubmissionSystemScore: (): number => 0, + getSubmissionTestProgress: ( + submission: { + reviewSummation?: Array<{ + metadata?: { + testProcess?: string + testType?: string + } + }> + }, + ): { process?: string } => { + const metadata = submission.reviewSummation?.[0]?.metadata + + return { + process: metadata?.testProcess ?? metadata?.testType, + } + }, + isMarathonMatchChallenge: (...args: unknown[]): unknown => mockIsMarathonMatchChallenge(...args), + showErrorToast: jest.fn(), +})) + +jest.mock('./SubmissionsSection.module.scss', () => new Proxy({}, { + get: (_target, property) => String(property), +})) + +const baseChallenge: Challenge = { + id: 'challenge-1', + name: 'Marathon Match', + status: 'ACTIVE', +} + +const submissions: Submission[] = [ + { + challengeId: 'challenge-1', + createdAt: '2026-07-01T10:00:00.000Z', + createdBy: 'member-1', + id: 'system-submission', + memberHandle: 'system-user', + reviewSummation: [ + { + metadata: { + testProcess: 'system', + }, + }, + ], + type: 'SUBMISSION', + }, + { + challengeId: 'challenge-1', + createdAt: '2026-07-01T11:00:00.000Z', + createdBy: 'member-2', + id: 'provisional-submission', + memberHandle: 'provisional-user', + reviewSummation: [ + { + metadata: { + testProcess: 'provisional', + }, + }, + ], + type: 'SUBMISSION', + }, + { + challengeId: 'challenge-1', + createdAt: '2026-07-01T12:00:00.000Z', + createdBy: 'member-3', + id: 'example-submission', + memberHandle: 'example-user', + reviewSummation: [ + { + metadata: { + testType: 'example', + }, + }, + ], + type: 'SUBMISSION', + }, +] + +describe('SubmissionsSection', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockIsMarathonMatchChallenge.mockReturnValue(true) + mockUseDownloadAllSubmissions.mockReturnValue({ + downloadAll: jest.fn(), + isDownloading: false, + progress: 0, + }) + mockUseDownloadSubmission.mockReturnValue({ + downloadSubmission: jest.fn(), + isLoading: {}, + }) + mockUseFetchSubmissions.mockReturnValue({ + error: undefined, + isError: false, + isLoading: false, + mutate: jest.fn(), + submissions, + total: submissions.length, + }) + mockFetchMembersByUserIds.mockResolvedValue([]) + }) + + it('filters marathon submissions by test type', () => { + render( + , + ) + + fireEvent.change(screen.getByLabelText('Test type'), { + target: { + value: 'system', + }, + }) + + expect(screen.getByText('system-submission')) + .toBeTruthy() + expect(screen.queryByText('provisional-submission')) + .toBeNull() + expect(screen.queryByText('example-submission')) + .toBeNull() + expect(screen.getByTestId('pagination-total').textContent) + .toBe('1') + + fireEvent.change(screen.getByLabelText('Test type'), { + target: { + value: 'example', + }, + }) + + expect(screen.getByText('example-submission')) + .toBeTruthy() + expect(screen.queryByText('system-submission')) + .toBeNull() + expect(screen.queryByText('provisional-submission')) + .toBeNull() + }) +}) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx index 70662c63d..f73961cd5 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx @@ -36,6 +36,7 @@ import { getSubmissionInitialScore, getSubmissionProvisionalScore, getSubmissionSystemScore, + getSubmissionTestProgress, isMarathonMatchChallenge, showErrorToast, } from '../../../../../lib/utils' @@ -57,6 +58,7 @@ interface FilterState { handle: string minScore: string startDate: string + testType: string } interface SubmissionsSectionProps { @@ -330,6 +332,37 @@ function matchesFilterHandle(submission: Submission, handleFilter: string): bool .includes(normalizedHandleFilter) } +/** + * Checks whether a submission matches the selected marathon test type. + * @param submission Submission row being evaluated. + * @param testTypeFilter Selected normalized test type filter value. + * @param useMarathonMatchScores Whether the current challenge is a Marathon Match. + * @returns True when the filter is empty, the challenge is not Marathon Match, or the current test type matches. + * Used by `matchesFilters` before the submissions table is sorted and paginated. + */ +function matchesFilterTestType( + submission: Submission, + testTypeFilter: string, + useMarathonMatchScores: boolean, +): boolean { + if (!testTypeFilter || !useMarathonMatchScores) { + return true + } + + const normalizedTestTypeFilter = normalizeValue(testTypeFilter) + .toLowerCase() + + return getSubmissionTestProgress(submission).process === normalizedTestTypeFilter +} + +/** + * Checks whether a submission satisfies all active submissions list filters. + * @param submission Submission row being evaluated. + * @param filters Current filter values from the submissions form. + * @param useMarathonMatchScores Whether the current challenge is a Marathon Match. + * @returns True when the submission should remain visible in the table. + * Used before sorting and paginating the submissions list. + */ function matchesFilters( submission: Submission, filters: FilterState, @@ -343,6 +376,10 @@ function matchesFilters( return false } + if (!matchesFilterTestType(submission, filters.testType, useMarathonMatchScores)) { + return false + } + return matchesFilterScore(submission, filters.minScore, useMarathonMatchScores) } @@ -361,6 +398,7 @@ export const SubmissionsSection: FC = ( handle: '', minScore: '', startDate: '', + testType: '', }) const [memberCache, setMemberCache] = useState({}) const [page, setPage] = useState(1) @@ -584,7 +622,9 @@ export const SubmissionsSection: FC = ( setPerPage(nextPerPage) }, []) - const handleFilterChange = useCallback((event: ChangeEvent): void => { + const handleFilterChange = useCallback(( + event: ChangeEvent, + ): void => { const fieldName: string = event.target.name const fieldValue: string = event.target.value @@ -642,6 +682,26 @@ export const SubmissionsSection: FC = ( /> + {isMarathonMatch + ? ( + + ) + : undefined} +