Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: automatic patching #236

Merged
merged 1 commit into from
Jan 3, 2025
Merged
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
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
Loading