Skip to content

Commit

Permalink
feat: automatic patching (#236)
Browse files Browse the repository at this point in the history
- 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
thenick775 authored Jan 3, 2025
1 parent 3172b45 commit c5c4bd0
Show file tree
Hide file tree
Showing 9 changed files with 358 additions and 16 deletions.
8 changes: 4 additions & 4 deletions gbajs3/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion gbajs3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@emotion/styled": "^11.13.5",
"@mui/material": "^6.0.2",
"@mui/x-tree-view": "^7.0.0",
"@thenick775/mgba-wasm": "^1.0.20",
"@thenick775/mgba-wasm": "^1.1.0",
"@uidotdev/usehooks": "^2.4.1",
"jwt-decode": "^4.0.0",
"nanoid": "^5.0.7",
Expand Down
6 changes: 3 additions & 3 deletions gbajs3/src/components/modals/upload-cheats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ export const UploadCheatsModal = () => {
await Promise.all(
cheatFiles.map(
(cheatFile) =>
new Promise<void>((resolve) => {
emulator?.uploadCheats(cheatFile, resolve);
})
new Promise<void>((resolve) =>
emulator?.uploadCheats(cheatFile, resolve)
)
)
);

Expand Down
215 changes: 215 additions & 0 deletions gbajs3/src/components/modals/upload-patches.spec.tsx
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();
});
});
115 changes: 115 additions & 0 deletions gbajs3/src/components/modals/upload-patches.tsx
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"
/>
</>
);
};
6 changes: 3 additions & 3 deletions gbajs3/src/components/modals/upload-saves.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ export const UploadSavesModal = () => {
await Promise.all(
saveFiles.map(
(saveFile) =>
new Promise<void>((resolve) => {
emulator?.uploadSaveOrSaveState(saveFile, resolve);
})
new Promise<void>((resolve) =>
emulator?.uploadSaveOrSaveState(saveFile, resolve)
)
)
);

Expand Down
Loading

0 comments on commit c5c4bd0

Please sign in to comment.