forked from andychase/gbajs2
-
Notifications
You must be signed in to change notification settings - Fork 30
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- support patch file uploads - patches must be named the same as the rom - patches matching the above criteria are loaded automatically - bumps mGBA wasm core
- Loading branch information
1 parent
3172b45
commit c5c4bd0
Showing
9 changed files
with
358 additions
and
16 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,215 @@ | ||
import { screen } from '@testing-library/react'; | ||
import { userEvent } from '@testing-library/user-event'; | ||
import { describe, expect, it, vi } from 'vitest'; | ||
|
||
import { UploadPatchesModal } from './upload-patches.tsx'; | ||
import { renderWithContext } from '../../../test/render-with-context.tsx'; | ||
import * as contextHooks from '../../hooks/context.tsx'; | ||
import * as addCallbackHooks from '../../hooks/emulator/use-add-callbacks.tsx'; | ||
import { productTourLocalStorageKey } from '../product-tour/consts.tsx'; | ||
|
||
import type { GBAEmulator } from '../../emulator/mgba/mgba-emulator.tsx'; | ||
|
||
describe('<UploadPatchesModal />', () => { | ||
it('uploads file and closes modal', async () => { | ||
const uploadPatchSpy: (file: File, cb?: () => void) => void = vi.fn( | ||
(_file, cb) => cb && cb() | ||
); | ||
const syncActionIfEnabledSpy = vi.fn(); | ||
const setIsModalOpenSpy = vi.fn(); | ||
|
||
const { | ||
useEmulatorContext: originalEmulator, | ||
useModalContext: originalModal | ||
} = await vi.importActual<typeof contextHooks>('../../hooks/context.tsx'); | ||
const { useAddCallbacks: originalCallbacks } = await vi.importActual< | ||
typeof addCallbackHooks | ||
>('../../hooks/emulator/use-add-callbacks.tsx'); | ||
|
||
vi.spyOn(contextHooks, 'useEmulatorContext').mockImplementation(() => ({ | ||
...originalEmulator(), | ||
emulator: { | ||
uploadPatch: uploadPatchSpy | ||
} as GBAEmulator | ||
})); | ||
|
||
vi.spyOn(addCallbackHooks, 'useAddCallbacks').mockImplementation(() => ({ | ||
...originalCallbacks(), | ||
syncActionIfEnabled: syncActionIfEnabledSpy | ||
})); | ||
|
||
vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({ | ||
...originalModal(), | ||
setIsModalOpen: setIsModalOpenSpy | ||
})); | ||
|
||
const testPatchFile = new File(['Some patch file contents'], 'rom1.ips'); | ||
|
||
renderWithContext(<UploadPatchesModal />); | ||
|
||
const patchInput = screen.getByTestId('hidden-file-input'); | ||
|
||
expect(patchInput).toBeInTheDocument(); | ||
|
||
await userEvent.upload(patchInput, testPatchFile); | ||
|
||
expect(screen.getByText('File to upload:')).toBeVisible(); | ||
expect(screen.getByText('rom1.ips')).toBeVisible(); | ||
|
||
await userEvent.click(screen.getByRole('button', { name: 'Upload' })); | ||
|
||
expect(uploadPatchSpy).toHaveBeenCalledOnce(); | ||
expect(uploadPatchSpy).toHaveBeenCalledWith( | ||
testPatchFile, | ||
expect.anything() | ||
); | ||
expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce(); | ||
expect(setIsModalOpenSpy).toHaveBeenCalledWith(false); | ||
}); | ||
|
||
it('uploads multiple files and closes modal', async () => { | ||
const uploadPatchSpy: (file: File, cb?: () => void) => void = vi.fn( | ||
(_file, cb) => cb && cb() | ||
); | ||
const syncActionIfEnabledSpy = vi.fn(); | ||
const setIsModalOpenSpy = vi.fn(); | ||
|
||
const { | ||
useEmulatorContext: originalEmulator, | ||
useModalContext: originalModal | ||
} = await vi.importActual<typeof contextHooks>('../../hooks/context.tsx'); | ||
const { useAddCallbacks: originalCallbacks } = await vi.importActual< | ||
typeof addCallbackHooks | ||
>('../../hooks/emulator/use-add-callbacks.tsx'); | ||
|
||
vi.spyOn(contextHooks, 'useEmulatorContext').mockImplementation(() => ({ | ||
...originalEmulator(), | ||
emulator: { | ||
uploadPatch: uploadPatchSpy | ||
} as GBAEmulator | ||
})); | ||
|
||
vi.spyOn(addCallbackHooks, 'useAddCallbacks').mockImplementation(() => ({ | ||
...originalCallbacks(), | ||
syncActionIfEnabled: syncActionIfEnabledSpy | ||
})); | ||
|
||
vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({ | ||
...originalModal(), | ||
setIsModalOpen: setIsModalOpenSpy | ||
})); | ||
|
||
const testPatchFiles = [ | ||
new File(['Some patch file contents 1'], 'rom1.ips'), | ||
new File(['Some patch file contents 2'], 'rom2.ips') | ||
]; | ||
|
||
renderWithContext(<UploadPatchesModal />); | ||
|
||
const patchInput = screen.getByTestId('hidden-file-input'); | ||
|
||
expect(patchInput).toBeInTheDocument(); | ||
|
||
await userEvent.upload(patchInput, testPatchFiles); | ||
|
||
expect(screen.getByText('Files to upload:')).toBeVisible(); | ||
expect(screen.getByText('rom1.ips')).toBeVisible(); | ||
expect(screen.getByText('rom2.ips')).toBeVisible(); | ||
|
||
await userEvent.click(screen.getByRole('button', { name: 'Upload' })); | ||
|
||
expect(uploadPatchSpy).toHaveBeenCalledTimes(2); | ||
expect(uploadPatchSpy).toHaveBeenCalledWith( | ||
testPatchFiles[0], | ||
expect.anything() | ||
); | ||
expect(uploadPatchSpy).toHaveBeenCalledWith( | ||
testPatchFiles[1], | ||
expect.anything() | ||
); | ||
expect(syncActionIfEnabledSpy).toHaveBeenCalledOnce(); | ||
expect(setIsModalOpenSpy).toHaveBeenCalledWith(false); | ||
}); | ||
|
||
it('renders form validation error', async () => { | ||
renderWithContext(<UploadPatchesModal />); | ||
|
||
await userEvent.click(screen.getByRole('button', { name: 'Upload' })); | ||
|
||
expect( | ||
screen.getByText(/At least one .ips\/.ups\/.bps file is required/) | ||
).toBeVisible(); | ||
}); | ||
|
||
it('closes modal using the close button', async () => { | ||
const setIsModalOpenSpy = vi.fn(); | ||
const { useModalContext: original } = await vi.importActual< | ||
typeof contextHooks | ||
>('../../hooks/context.tsx'); | ||
|
||
vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({ | ||
...original(), | ||
setIsModalOpen: setIsModalOpenSpy | ||
})); | ||
|
||
renderWithContext(<UploadPatchesModal />); | ||
|
||
// click the close button | ||
const closeButton = screen.getByText('Close', { selector: 'button' }); | ||
expect(closeButton).toBeInTheDocument(); | ||
await userEvent.click(closeButton); | ||
|
||
expect(setIsModalOpenSpy).toHaveBeenCalledWith(false); | ||
}); | ||
|
||
it('renders tour steps', async () => { | ||
const { useModalContext: original } = await vi.importActual< | ||
typeof contextHooks | ||
>('../../hooks/context.tsx'); | ||
|
||
vi.spyOn(contextHooks, 'useModalContext').mockImplementation(() => ({ | ||
...original(), | ||
isModalOpen: true | ||
})); | ||
|
||
localStorage.setItem( | ||
productTourLocalStorageKey, | ||
'{"hasCompletedProductTourIntro":"finished"}' | ||
); | ||
|
||
renderWithContext(<UploadPatchesModal />); | ||
|
||
expect( | ||
await screen.findByText( | ||
'Use this area to drag and drop .ips/.ups/.bps patch files, or click to select files.' | ||
) | ||
).toBeInTheDocument(); | ||
expect( | ||
screen.getByText( | ||
'The name of your patch files must match the name of your rom.' | ||
) | ||
).toBeInTheDocument(); | ||
expect( | ||
screen.getByText('You may drop or select multiple files!') | ||
).toBeInTheDocument(); | ||
|
||
// click joyride floater | ||
await userEvent.click( | ||
screen.getByRole('button', { name: 'Open the dialog' }) | ||
); | ||
|
||
expect( | ||
screen.getByText( | ||
'Use this area to drag and drop .ips/.ups/.bps patch files, or click to select files.' | ||
) | ||
).toBeVisible(); | ||
expect( | ||
screen.getByText( | ||
'The name of your patch files must match the name of your rom.' | ||
) | ||
).toBeVisible(); | ||
expect( | ||
screen.getByText('You may drop or select multiple files!') | ||
).toBeVisible(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
import { Button } from '@mui/material'; | ||
import { useCallback, useId } from 'react'; | ||
import { Controller, useForm, type SubmitHandler } from 'react-hook-form'; | ||
|
||
import { ModalBody } from './modal-body.tsx'; | ||
import { ModalFooter } from './modal-footer.tsx'; | ||
import { ModalHeader } from './modal-header.tsx'; | ||
import { useEmulatorContext, useModalContext } from '../../hooks/context.tsx'; | ||
import { useAddCallbacks } from '../../hooks/emulator/use-add-callbacks.tsx'; | ||
import { | ||
EmbeddedProductTour, | ||
type TourSteps | ||
} from '../product-tour/embedded-product-tour.tsx'; | ||
import { DragAndDropInput } from '../shared/drag-and-drop-input.tsx'; | ||
|
||
type InputProps = { | ||
patchFiles: File[]; | ||
}; | ||
|
||
const validFileExtensions = ['.ips', '.ups', '.bps']; | ||
|
||
export const UploadPatchesModal = () => { | ||
const { setIsModalOpen } = useModalContext(); | ||
const { emulator } = useEmulatorContext(); | ||
const { syncActionIfEnabled } = useAddCallbacks(); | ||
const { reset, handleSubmit, setValue, control } = useForm<InputProps>(); | ||
const uploadPatchesFormId = useId(); | ||
|
||
const onDrop = useCallback( | ||
(acceptedFiles: File[]) => { | ||
reset(); | ||
setValue('patchFiles', acceptedFiles, { shouldValidate: true }); | ||
}, | ||
[reset, setValue] | ||
); | ||
|
||
const onSubmit: SubmitHandler<InputProps> = async ({ patchFiles }) => { | ||
await Promise.all( | ||
patchFiles.map( | ||
(patchFile) => | ||
new Promise<void>((resolve) => | ||
emulator?.uploadPatch(patchFile, resolve) | ||
) | ||
) | ||
); | ||
|
||
await syncActionIfEnabled(); | ||
setIsModalOpen(false); | ||
}; | ||
|
||
const tourSteps: TourSteps = [ | ||
{ | ||
content: ( | ||
<> | ||
<p> | ||
Use this area to drag and drop .ips/.ups/.bps patch files, or click | ||
to select files. | ||
</p> | ||
<p>The name of your patch files must match the name of your rom.</p> | ||
<p>You may drop or select multiple files!</p> | ||
</> | ||
), | ||
target: `#${CSS.escape(`${uploadPatchesFormId}--drag-and-drop`)}` | ||
} | ||
]; | ||
|
||
return ( | ||
<> | ||
<ModalHeader title="Upload Patches" /> | ||
<ModalBody> | ||
<form | ||
id={uploadPatchesFormId} | ||
aria-label="Upload Patches Form" | ||
onSubmit={handleSubmit(onSubmit)} | ||
> | ||
<Controller | ||
control={control} | ||
name="patchFiles" | ||
rules={{ | ||
validate: (patchFiles) => | ||
patchFiles?.length > 0 || | ||
'At least one .ips/.ups/.bps file is required' | ||
}} | ||
render={({ field: { name, value }, fieldState: { error } }) => ( | ||
<DragAndDropInput | ||
ariaLabel="Upload Patches" | ||
id={`${uploadPatchesFormId}--drag-and-drop`} | ||
onDrop={onDrop} | ||
name={name} | ||
validFileExtensions={validFileExtensions} | ||
error={error?.message} | ||
hideAcceptedFiles={!value?.length} | ||
multiple | ||
> | ||
<p>Drag and drop patch files here, or click to upload files</p> | ||
</DragAndDropInput> | ||
)} | ||
/> | ||
</form> | ||
</ModalBody> | ||
<ModalFooter> | ||
<Button form={uploadPatchesFormId} type="submit" variant="contained"> | ||
Upload | ||
</Button> | ||
<Button variant="outlined" onClick={() => setIsModalOpen(false)}> | ||
Close | ||
</Button> | ||
</ModalFooter> | ||
<EmbeddedProductTour | ||
steps={tourSteps} | ||
completedProductTourStepName="hasCompletedUploadPatchesTour" | ||
/> | ||
</> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.