diff --git a/gbajs3/src/components/modals/upload-rom.spec.tsx b/gbajs3/src/components/modals/upload-roms.spec.tsx similarity index 59% rename from gbajs3/src/components/modals/upload-rom.spec.tsx rename to gbajs3/src/components/modals/upload-roms.spec.tsx index 2176eca6..dbfb357a 100644 --- a/gbajs3/src/components/modals/upload-rom.spec.tsx +++ b/gbajs3/src/components/modals/upload-roms.spec.tsx @@ -2,7 +2,7 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import { UploadRomModal } from './upload-rom.tsx'; +import { UploadRomsModal } from './upload-roms.tsx'; import { testRomLocation } from '../../../test/mocks/handlers.ts'; import { renderWithContext } from '../../../test/render-with-context.tsx'; import * as contextHooks from '../../hooks/context.tsx'; @@ -12,7 +12,7 @@ import { productTourLocalStorageKey } from '../product-tour/consts.tsx'; import type { GBAEmulator } from '../../emulator/mgba/mgba-emulator.tsx'; -describe('', () => { +describe('', () => { it('uploads file and closes modal', async () => { const setIsModalOpenSpy = vi.fn(); const uploadRomSpy: (file: File, cb?: () => void) => void = vi.fn( @@ -51,7 +51,7 @@ describe('', () => { const testRom = new File(['Some rom file contents'], 'rom1.gba'); - renderWithContext(); + renderWithContext(); const romInput = screen.getByTestId('hidden-file-input'); @@ -67,9 +67,157 @@ describe('', () => { expect(uploadRomSpy).toHaveBeenCalledOnce(); expect(uploadRomSpy).toHaveBeenCalledWith(testRom, expect.anything()); + expect(runGameSpy).toHaveBeenCalledOnce(); + expect(runGameSpy).toHaveBeenCalledWith('rom1.gba'); expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledWith(false); + }); + + it('uploads multiple files, loads first file, and closes modal', async () => { + const setIsModalOpenSpy = vi.fn(); + const uploadRomSpy: (file: File, cb?: () => void) => void = vi.fn( + (_file, cb) => cb && cb() + ); + const syncActionIfEnabledSpy = vi.fn(); + const runGameSpy = vi.fn(() => true); + + const { + useEmulatorContext: originalEmulator, + useModalContext: originalModal + } = await vi.importActual('../../hooks/context.tsx'); + const { useAddCallbacks: originalCallbacks } = await vi.importActual< + typeof addCallbackHooks + >('../../hooks/emulator/use-add-callbacks.tsx'); + + vi.spyOn(contextHooks, 'useEmulatorContext').mockImplementation(() => ({ + ...originalEmulator(), + emulator: { + uploadRom: uploadRomSpy + } as GBAEmulator + })); + + vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({ + ...originalModal(), + setIsModalOpen: setIsModalOpenSpy + })); + + vi.spyOn(addCallbackHooks, 'useAddCallbacks').mockImplementation(() => ({ + ...originalCallbacks(), + syncActionIfEnabled: syncActionIfEnabledSpy + })); + + vi.spyOn(runGameHooks, 'useRunGame').mockReturnValue(runGameSpy); + + const testRomFiles = [ + new File(['Some rom file contents 1'], 'rom1.gba'), + new File(['Some rom file contents 2'], 'rom2.gba') + ]; + + renderWithContext(); + + const romInput = screen.getByTestId('hidden-file-input'); + + expect(romInput).toBeInTheDocument(); + + await userEvent.upload(romInput, testRomFiles); + + expect(screen.getByText('Files to upload:')).toBeVisible(); + expect(screen.getByText('rom1.gba')).toBeVisible(); + expect(screen.getByText('rom2.gba')).toBeVisible(); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect(uploadRomSpy).toHaveBeenCalledTimes(2); + expect(uploadRomSpy).toHaveBeenCalledWith( + testRomFiles[0], + expect.anything() + ); + expect(uploadRomSpy).toHaveBeenCalledWith( + testRomFiles[1], + expect.anything() + ); + expect(runGameSpy).toHaveBeenCalledOnce(); expect(runGameSpy).toHaveBeenCalledWith('rom1.gba'); + + expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledWith(false); + }); + + it('uploads multiple files, loads selected file, and closes modal', async () => { + const setIsModalOpenSpy = vi.fn(); + const uploadRomSpy: (file: File, cb?: () => void) => void = vi.fn( + (_file, cb) => cb && cb() + ); + const syncActionIfEnabledSpy = vi.fn(); + const runGameSpy = vi.fn(() => true); + + const { + useEmulatorContext: originalEmulator, + useModalContext: originalModal + } = await vi.importActual('../../hooks/context.tsx'); + const { useAddCallbacks: originalCallbacks } = await vi.importActual< + typeof addCallbackHooks + >('../../hooks/emulator/use-add-callbacks.tsx'); + + vi.spyOn(contextHooks, 'useEmulatorContext').mockImplementation(() => ({ + ...originalEmulator(), + emulator: { + uploadRom: uploadRomSpy + } as GBAEmulator + })); + + vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({ + ...originalModal(), + setIsModalOpen: setIsModalOpenSpy + })); + + vi.spyOn(addCallbackHooks, 'useAddCallbacks').mockImplementation(() => ({ + ...originalCallbacks(), + syncActionIfEnabled: syncActionIfEnabledSpy + })); + + vi.spyOn(runGameHooks, 'useRunGame').mockReturnValue(runGameSpy); + + const testRomFiles = [ + new File(['Some rom file contents 1'], 'rom1.gba'), + new File(['Some rom file contents 2'], 'rom2.gba') + ]; + + renderWithContext(); + + const romInput = screen.getByTestId('hidden-file-input'); + + expect(romInput).toBeInTheDocument(); + + await userEvent.upload(romInput, testRomFiles); + + await userEvent.click( + screen.getByLabelText('Load rom2.gba', { selector: 'input' }) + ); + expect( + screen.getByLabelText('Load rom2.gba', { selector: 'input' }) + ).toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect(uploadRomSpy).toHaveBeenCalledTimes(2); + expect(uploadRomSpy).toHaveBeenCalledWith( + testRomFiles[0], + expect.anything() + ); + expect(uploadRomSpy).toHaveBeenCalledWith( + testRomFiles[1], + expect.anything() + ); + + expect(runGameSpy).toHaveBeenCalledOnce(); + expect(runGameSpy).toHaveBeenCalledWith('rom2.gba'); + + expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce(); + expect(setIsModalOpenSpy).toHaveBeenCalledOnce(); expect(setIsModalOpenSpy).toHaveBeenCalledWith(false); }); @@ -112,7 +260,7 @@ describe('', () => { vi.spyOn(runGameHooks, 'useRunGame').mockReturnValue(runGameSpy); - renderWithContext(); + renderWithContext(); const uploadRomFromURLInput = screen.getByLabelText('Upload from a URL'); @@ -157,7 +305,7 @@ describe('', () => { } as GBAEmulator })); - renderWithContext(); + renderWithContext(); const uploadRomFromURLInput = screen.getByLabelText('Upload from a URL'); @@ -184,12 +332,27 @@ describe('', () => { ).toBeVisible(); }); + it('renders invalid url error', async () => { + renderWithContext(); + + await userEvent.type( + screen.getByLabelText('Upload from a URL'), + `invalid url` + ); + + await userEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect(screen.getByText(/Invalid URL/)).toBeVisible(); + }); + it('renders form validation error', async () => { - renderWithContext(); + renderWithContext(); await userEvent.click(screen.getByRole('button', { name: 'Upload' })); - expect(screen.getByText(/A rom file or URL is required/)).toBeVisible(); + expect( + screen.getByText(/At least one rom file or URL is required/) + ).toBeVisible(); }); it('closes modal using the close button', async () => { @@ -203,7 +366,7 @@ describe('', () => { setIsModalOpen: setIsModalOpenSpy })); - renderWithContext(); + renderWithContext(); // click the close button const closeButton = screen.getByText('Close', { selector: 'button' }); @@ -228,7 +391,7 @@ describe('', () => { '{"hasCompletedProductTourIntro":"finished"}' ); - renderWithContext(); + renderWithContext(); expect( await screen.findByText( diff --git a/gbajs3/src/components/modals/upload-rom.tsx b/gbajs3/src/components/modals/upload-roms.tsx similarity index 70% rename from gbajs3/src/components/modals/upload-rom.tsx rename to gbajs3/src/components/modals/upload-roms.tsx index 540eb4e7..431cc9b8 100644 --- a/gbajs3/src/components/modals/upload-rom.tsx +++ b/gbajs3/src/components/modals/upload-roms.tsx @@ -1,4 +1,4 @@ -import { Button, Divider, TextField } from '@mui/material'; +import { Button, Divider, Checkbox, TextField } from '@mui/material'; import { useCallback, useEffect, useId, useState } from 'react'; import { useForm, Controller, type SubmitHandler } from 'react-hook-form'; import { BiError } from 'react-icons/bi'; @@ -22,14 +22,41 @@ import { PacmanIndicator } from '../shared/loading-indicator.tsx'; +import type { CheckboxProps } from '@mui/material'; + type InputProps = { - romFile: File; + romFiles: File[]; + romFileToLoad: string; romURL: string; }; +type AdditionalRomActionsProps = { + fileName: string; + index: number; + value: string; + totalFiles: number; +} & Pick; + const validFileExtensions = ['.gba', '.gbc', '.gb', '.zip', '.7z']; -export const UploadRomModal = () => { +const AdditionalRomActions = ({ + fileName, + index, + totalFiles, + value, + onChange +}: AdditionalRomActionsProps) => + totalFiles && ( + + ); + +export const UploadRomsModal = () => { const theme = useTheme(); const { setIsModalOpen } = useModalContext(); const { emulator } = useEmulatorContext(); @@ -61,9 +88,7 @@ export const UploadRomModal = () => { const runCallback = () => { syncActionIfEnabled(); const hasSucceeded = runGame(externalRomFile.name); - if (hasSucceeded) { - setIsModalOpen(false); - } + if (hasSucceeded) setIsModalOpen(false); }; emulator?.uploadRom(externalRomFile, runCallback); setCurrentRomURL(null); @@ -80,29 +105,42 @@ export const UploadRomModal = () => { const onDrop = useCallback( (acceptedFiles: File[]) => { reset(); - setValue('romFile', acceptedFiles[0], { shouldValidate: true }); + setValue('romFiles', acceptedFiles, { shouldValidate: true }); }, [reset, setValue] ); - const onSubmit: SubmitHandler = async ({ romFile, romURL }) => { + const onSubmit: SubmitHandler = async ({ + romFiles, + romFileToLoad, + romURL + }) => { if (romURL) { setCurrentRomURL(romURL); await executeLoadExternalRom({ url: new URL(romURL) }); return; } - const runCallback = () => { - syncActionIfEnabled(); - const hasSucceeded = runGame(romFile.name); - if (hasSucceeded) { - setIsModalOpen(false); - } - }; - emulator?.uploadRom(romFile, runCallback); + await Promise.all( + romFiles.map( + (romFile, idx) => + new Promise((resolve) => { + const runCallback = () => + resolve( + (romFile.name === romFileToLoad || + (!romFileToLoad && idx === 0)) && + runGame(romFile.name) + ); + emulator?.uploadRom(romFile, runCallback); + }) + ) + ); + + syncActionIfEnabled(); + setIsModalOpen(false); }; - const isRomFileSet = !!watch('romFile'); + const isRomFileSet = !!watch('romFiles')?.length; const tourSteps: TourSteps = [ { @@ -144,17 +182,17 @@ export const UploadRomModal = () => { >
+ validate: (roms, formValues) => !!formValues.romURL || - !!rom || - 'A rom file or URL is required' + !!roms || + 'At least one rom file or URL is required' }} render={({ field: { name }, fieldState: { error } }) => ( { name={name} validFileExtensions={validFileExtensions} hideErrors={!!error} + multiple + renderAdditionalFileActions={({ fileName, ...rest }) => ( + { + if (e.target.checked) + setValue('romFileToLoad', fileName); + else setValue('romFileToLoad', 'none'); + }} + value={watch('romFileToLoad')} + fileName={fileName} + {...rest} + /> + )} >

- Drag and drop a rom or zipped rom file here, or click to - upload a file + Drag and drop roms or zipped rom files here, or click to + upload files

)} @@ -189,8 +240,8 @@ export const UploadRomModal = () => { validate: (romURL, formValues) => { if (!romURL) return true; - if (formValues.romFile) - return 'Cannot specify both a file and a URL'; + if (formValues.romFiles) + return 'Cannot specify both files and a URL'; try { new URL(romURL); @@ -209,10 +260,10 @@ export const UploadRomModal = () => { )} )} - {errors.romFile?.message && ( + {errors.romFiles?.message && ( } - text={errors.romFile.message} + text={errors.romFiles.message} /> )} diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx index bae9ae41..256f0908 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.spec.tsx @@ -23,7 +23,7 @@ import { SaveStatesModal } from '../modals/save-states.tsx'; import { UploadCheatsModal } from '../modals/upload-cheats.tsx'; import { UploadPatchesModal } from '../modals/upload-patches.tsx'; import { UploadRomToServerModal } from '../modals/upload-rom-to-server.tsx'; -import { UploadRomModal } from '../modals/upload-rom.tsx'; +import { UploadRomsModal } from '../modals/upload-roms.tsx'; import { UploadSaveToServerModal } from '../modals/upload-save-to-server.tsx'; import { UploadSavesModal } from '../modals/upload-saves.tsx'; @@ -90,7 +90,7 @@ describe('', () => { ['Upload Saves', ], ['Upload Cheats', ], ['Upload Patches', ], - ['Upload Rom', ], + ['Upload Roms', ], ['Load Local Rom', ], ['Controls', ], ['File System', ], diff --git a/gbajs3/src/components/navigation-menu/navigation-menu.tsx b/gbajs3/src/components/navigation-menu/navigation-menu.tsx index 926f08b9..9de98cd7 100644 --- a/gbajs3/src/components/navigation-menu/navigation-menu.tsx +++ b/gbajs3/src/components/navigation-menu/navigation-menu.tsx @@ -50,7 +50,7 @@ import { SaveStatesModal } from '../modals/save-states.tsx'; import { UploadCheatsModal } from '../modals/upload-cheats.tsx'; import { UploadPatchesModal } from '../modals/upload-patches.tsx'; import { UploadRomToServerModal } from '../modals/upload-rom-to-server.tsx'; -import { UploadRomModal } from '../modals/upload-rom.tsx'; +import { UploadRomsModal } from '../modals/upload-roms.tsx'; import { UploadSaveToServerModal } from '../modals/upload-save-to-server.tsx'; import { UploadSavesModal } from '../modals/upload-saves.tsx'; import { ButtonBase } from '../shared/custom-button-base.tsx'; @@ -230,11 +230,11 @@ export const NavigationMenu = () => { }} /> } onClick={() => { - setModalContent(); + setModalContent(); setIsModalOpen(true); }} /> diff --git a/gbajs3/src/components/shared/drag-and-drop-input.tsx b/gbajs3/src/components/shared/drag-and-drop-input.tsx index deeadda2..d0ebade5 100644 --- a/gbajs3/src/components/shared/drag-and-drop-input.tsx +++ b/gbajs3/src/components/shared/drag-and-drop-input.tsx @@ -24,6 +24,11 @@ type DragAndDropInputProps = { name: string; onDrop: (acceptedFiles: File[]) => void; validFileExtensions: Extension[]; + renderAdditionalFileActions?: (fileInfo: { + fileName: string; + index: number; + totalFiles: number; + }) => ReactNode; }; type DropAreaProps = { @@ -85,31 +90,40 @@ const IconSeparator = styled.div` const AcceptedFiles = ({ fileNames, + renderAdditionalActions, onDeleteFile }: { fileNames: string[]; + renderAdditionalActions?: (fileInfo: { + fileName: string; + index: number; + totalFiles: number; + }) => ReactNode; onDeleteFile: (fileName: string) => void; -}) => { - return ( - -

File{fileNames.length > 1 && 's'} to upload:

- {fileNames.map((name, idx) => ( - -

{name}

- - onDeleteFile(name)} - > - - - -
- ))} -
- ); -}; +}) => ( + +

File{fileNames.length > 1 && 's'} to upload:

+ {fileNames.map((fileName, index) => ( + +

{fileName}

+ + {renderAdditionalActions?.({ + fileName, + index, + totalFiles: fileNames.length + })} + onDeleteFile(fileName)} + > + + + +
+ ))} +
+); const hasValidFileExtension = (file: File, validExtensions: Extension[]) => { const fileExtension = `.${file.name.split('.').pop()}`; @@ -161,6 +175,7 @@ export const DragAndDropInput = ({ id, multiple = false, name, + renderAdditionalFileActions, onDrop, validFileExtensions }: DragAndDropInputProps) => { @@ -213,6 +228,7 @@ export const DragAndDropInput = ({ )} {!!rejectedFileErrors.length && !hideErrors && (