Skip to content
Open
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
@@ -0,0 +1,43 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import Box from '@cloudscape-design/components/box';
import Modal from '@cloudscape-design/components/modal';
import { useTranslation } from 'react-i18next';

interface SubmissionVideoModalProps {
videoUrl: string;
title: string;
onDismiss: () => void;
}

/**
* Streams the submission video directly from the S3 presigned URL.
*
* DeepRacer SimApp writes MP4s without `-movflags +faststart` so the moov atom
* sits at the end of the file. Modern browsers handle this automatically by
* issuing a Range request to seek to the moov atom before starting playback –
* this works because the S3Client is configured with
* `responseChecksumValidation: 'WHEN_REQUIRED'` which keeps Range-compatible
* presigned URLs clean of checksum headers.
*/
const SubmissionVideoModal = ({ videoUrl, title, onDismiss }: SubmissionVideoModalProps) => {
const { t } = useTranslation('raceDetails');

return (
<Modal visible onDismiss={onDismiss} header={t('videoModal.header', { title })} size="large">
<Box textAlign="center">
<video
src={videoUrl}
controls
autoPlay
muted
preload="auto"
style={{ maxWidth: '100%', maxHeight: '70vh', borderRadius: '4px' }}
/>
</Box>
</Modal>
);
};

export default SubmissionVideoModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import i18n from '#i18n/index.js';
import { render, screen, fireEvent } from '#utils/testUtils.js';

import SubmissionVideoModal from '../SubmissionVideoModal.js';

const mockVideoUrl = 'https://example.com/video.mp4';
const mockTitle = 'testModelName #1';
const mockOnDismiss = vi.fn();

describe('<SubmissionVideoModal />', () => {
beforeEach(() => {
mockOnDismiss.mockClear();
});

it('renders the modal with the correct header', () => {
render(<SubmissionVideoModal videoUrl={mockVideoUrl} title={mockTitle} onDismiss={mockOnDismiss} />);

expect(screen.getByText(i18n.t('raceDetails:videoModal.header', { title: mockTitle }))).toBeInTheDocument();
});

it('renders a video element with the provided URL', () => {
render(<SubmissionVideoModal videoUrl={mockVideoUrl} title={mockTitle} onDismiss={mockOnDismiss} />);

const video = document.querySelector('video');
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute('src', mockVideoUrl);
});

it('renders the video with autoPlay and controls attributes, and muted property', () => {
render(<SubmissionVideoModal videoUrl={mockVideoUrl} title={mockTitle} onDismiss={mockOnDismiss} />);

const video = document.querySelector('video') as HTMLVideoElement;
expect(video).toHaveAttribute('autoplay');
expect(video).toHaveAttribute('controls');
// jsdom does not reflect the muted React prop as an HTML attribute; check the property directly
expect(video.muted).toBe(true);
});

it('calls onDismiss when the modal dismiss button is clicked', () => {
render(<SubmissionVideoModal videoUrl={mockVideoUrl} title={mockTitle} onDismiss={mockOnDismiss} />);

// Cloudscape modal dismiss button has no accessible text; target it by its CSS class
const closeButton = document.querySelector('button[class*="dismiss-control"]') as HTMLButtonElement;
expect(closeButton).toBeInTheDocument();
fireEvent.click(closeButton);

expect(mockOnDismiss).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

export { default } from './SubmissionVideoModal';
35 changes: 32 additions & 3 deletions source/apps/website/src/i18n/en/raceDetails.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,33 @@
"info": "Info",
"yourFastestTime": "Your fastest time",
"fastestModelSubmitted": "Fastest model submitted",
"videoModal": {
"header": "Submission video – {{title}}",
"watchVideo": "Watch video",
"downloadVideo": "Download video",
"noVideo": "No video available",
"noVideoSupport": "Your browser does not support HTML5 video.",
"loading": "Downloading video…",
"loadError": "Failed to load video. Please try again."
},
"submissionsTable": {
"tableHeader": "Your submissions",
"refresh": "Refresh submissions",
"header": {
"modelName": "Model name",
"submissionNumber": "#",
"time": "Time",
"status": "Status",
"date": "Date submitted to race"
"bestLapTime": "Best lap time",
"avgLapTime": "Average lap time",
"totalLapTime": "Total lap time",
"completedLaps": "Completed laps",
"resets": "Resets",
"avgResets": "Average resets",
"offTrack": "Off-track count",
"collisions": "Collision count",
"date": "Date submitted to race",
"video": "Video"
},
"emptySubtitle": "No submissions to display.",
"emptyTitle": "No submissions",
Expand Down Expand Up @@ -114,14 +134,23 @@
}
},
"raceLeaderboardTable": {
"refresh": "Refresh leaderboard",
"header": {
"rank": "Rank",
"rank": "#",
"racer": "Racer",
"time": "Time",
"gapToFirst": "Gap to 1st",
"video": "Video",
"offtrack": "Off-track",
"name": "{{name}} rankings"
"name": "{{name}} rankings",
"bestLapTime": "Best lap time",
"avgLapTime": "Average lap time",
"totalLapTime": "Total lap time",
"completedLaps": "Completed laps",
"resets": "Resets",
"avgResets": "Average resets",
"collisions": "Collision count",
"date": "Date submitted to race"
},
"emptySubtitle": "No racers have submitted a qualifying model to this race.",
"emptyTitle": "No qualifying submissions",
Expand Down
105 changes: 99 additions & 6 deletions source/apps/website/src/pages/RaceDetails/RaceDetails.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ describe('<RaceDetails />', () => {
await screen.findByText(i18n.t('raceDetails:submissionsTable.header.modelName'));
await screen.findByText(i18n.t('raceDetails:submissionsTable.header.date'));

await waitFor(() =>
expect(screen.queryByText(i18n.t('raceDetails:raceLeaderboardTable.header.rank'))).not.toBeInTheDocument(),
);
// rank header is now '#', same as submissionNumber — use uniquely-leaderboard columns to confirm the leaderboard table is hidden
await waitFor(() =>
expect(screen.queryByText(i18n.t('raceDetails:raceLeaderboardTable.header.racer'))).not.toBeInTheDocument(),
);
Expand Down Expand Up @@ -99,9 +97,7 @@ describe('<RaceDetails />', () => {
await screen.findByText(i18n.t('raceDetails:submissionsTable.header.modelName'));
await screen.findByText(i18n.t('raceDetails:submissionsTable.header.date'));

await waitFor(() =>
expect(screen.queryByText(i18n.t('raceDetails:raceLeaderboardTable.header.rank'))).not.toBeInTheDocument(),
);
// rank header is now '#', same as submissionNumber — use uniquely-leaderboard columns to confirm the leaderboard table is hidden
await waitFor(() =>
expect(screen.queryByText(i18n.t('raceDetails:raceLeaderboardTable.header.racer'))).not.toBeInTheDocument(),
);
Expand Down Expand Up @@ -179,4 +175,101 @@ describe('<RaceDetails />', () => {
});
});
});

describe('Refresh buttons', () => {
it('renders a refresh button on the leaderboard tab', async () => {
await OALeaderboard.run();

await screen.findByText(i18n.t('raceDetails:raceType.OBJECT_AVOIDANCE'));

const refreshButton = await screen.findByRole('button', {
name: i18n.t('raceDetails:raceLeaderboardTable.refresh'),
});
expect(refreshButton).toBeInTheDocument();
});

it('renders a refresh button on the submissions tab', async () => {
await OALeaderboard.run();

const tabButton = screen.getAllByText(`${i18n.t('raceDetails:submissionsTable.tableHeader')} (3)`);
fireEvent.click(tabButton[0]);

const refreshButton = await screen.findByRole('button', {
name: i18n.t('raceDetails:submissionsTable.refresh'),
});
expect(refreshButton).toBeInTheDocument();
});
});

describe('Video buttons', () => {
it('renders watch and download video buttons on the leaderboard tab', async () => {
await OALeaderboard.run();

await screen.findByText(i18n.t('raceDetails:raceType.OBJECT_AVOIDANCE'));

const watchButtons = await screen.findAllByRole('button', {
name: i18n.t('raceDetails:videoModal.watchVideo'),
});
const downloadButtons = await screen.findAllByRole('button', {
name: i18n.t('raceDetails:videoModal.downloadVideo'),
});

expect(watchButtons.length).toBeGreaterThan(0);
expect(downloadButtons.length).toBeGreaterThan(0);
});

it('opens the video modal when the watch button is clicked on a leaderboard ranking', async () => {
await OALeaderboard.run();

await screen.findByText(i18n.t('raceDetails:raceType.OBJECT_AVOIDANCE'));

const watchButtons = await screen.findAllByRole('button', {
name: i18n.t('raceDetails:videoModal.watchVideo'),
});
fireEvent.click(watchButtons[0]);

// Wait for the video element to appear inside the modal
const video = await waitFor(() => {
const el = document.querySelector('video');
if (!el) throw new Error('video not found');
return el;
});
expect(video).toHaveAttribute('src', 'https://mock-submission-video-url');
});

it('renders watch and download video buttons on the submissions tab', async () => {
await OALeaderboard.run();

const tabButton = screen.getAllByText(`${i18n.t('raceDetails:submissionsTable.tableHeader')} (3)`);
fireEvent.click(tabButton[0]);

const watchButtons = await screen.findAllByRole('button', {
name: i18n.t('raceDetails:videoModal.watchVideo'),
});
expect(watchButtons.length).toBeGreaterThan(0);
});

it('opens the video modal when the watch button is clicked on a completed submission', async () => {
await OALeaderboard.run();

const tabButton = screen.getAllByText(`${i18n.t('raceDetails:submissionsTable.tableHeader')} (3)`);
fireEvent.click(tabButton[0]);

// Only the COMPLETED submission has an enabled watch button
const watchButtons = await screen.findAllByRole('button', {
name: i18n.t('raceDetails:videoModal.watchVideo'),
});
const enabledWatchButton = watchButtons.find((btn) => !btn.hasAttribute('disabled'));
expect(enabledWatchButton).toBeDefined();
if (enabledWatchButton) fireEvent.click(enabledWatchButton);

// Wait for the video element to appear inside the modal
const video = await waitFor(() => {
const el = document.querySelector('video');
if (!el) throw new Error('video not found');
return el;
});
expect(video).toHaveAttribute('src', 'https://mock-submission-video-url');
});
});
});
16 changes: 14 additions & 2 deletions source/apps/website/src/pages/RaceDetails/RaceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ const RaceDetails = () => {
const { leaderboardId = '' } = useParams();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { data: rankings = [] } = useListRankingsQuery({ leaderboardId }, { refetchOnMountOrArgChange: true });
const {
data: rankings = [],
refetch: refetchRankings,
isFetching: isRankingsFetching,
} = useListRankingsQuery({ leaderboardId }, { refetchOnMountOrArgChange: true });
const { data: personalRanking } = useGetRankingQuery({ leaderboardId }, { refetchOnMountOrArgChange: true });
const { data: submissions = [] } = useListSubmissionsQuery({ leaderboardId }, { refetchOnMountOrArgChange: true });
const {
data: submissions = [],
refetch: refetchSubmissions,
isFetching: isSubmissionsFetching,
} = useListSubmissionsQuery({ leaderboardId }, { refetchOnMountOrArgChange: true });
const [deleteLeaderboard] = useDeleteLeaderboardMutation();
const {
data: leaderboard,
Expand Down Expand Up @@ -145,6 +153,8 @@ const RaceDetails = () => {
rankings={rankings}
leaderboard={leaderboard}
submissionPeriodOpen={liveRaceState?.race?.submissionPeriodOpen}
onRefresh={refetchRankings}
isRefreshing={isRankingsFetching}
/>
),
id: 'leaderboard',
Expand All @@ -156,6 +166,8 @@ const RaceDetails = () => {
submissions={submissions}
leaderboard={leaderboard}
submissionPeriodOpen={liveRaceState?.race?.submissionPeriodOpen}
onRefresh={refetchSubmissions}
isRefreshing={isSubmissionsFetching}
/>
),
id: 'yourSubmissions',
Expand Down
Loading