From 81c2f99e0feb840af3d001ca7414b9141dfbad02 Mon Sep 17 00:00:00 2001
From: Miguel Piedrafita
Date: Thu, 11 Dec 2025 19:59:58 +0000
Subject: [PATCH 1/2] wip
---
package.json | 1 +
pnpm-lock.yaml | 8 ++++
resources/js/common/hooks/useRcheevos.test.ts | 35 ++++++++++++++++
resources/js/common/hooks/useRcheevos.ts | 17 ++++++++
.../HashesMainRoot/HashCheckerSection.tsx | 42 +++++++++++++++++++
.../HashesMainRoot/HashesMainRoot.tsx | 11 ++++-
6 files changed, 113 insertions(+), 1 deletion(-)
create mode 100644 resources/js/common/hooks/useRcheevos.test.ts
create mode 100644 resources/js/common/hooks/useRcheevos.ts
create mode 100644 resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx
diff --git a/package.json b/package.json
index 963a98eede..29e19acf81 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"linkify-html": "^4.3.2",
"motion": "^12.23.22",
"radix-ui": "^1.4.2",
+ "rcheevos": "^0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 697755a18b..cd4b80308d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -89,6 +89,9 @@ importers:
radix-ui:
specifier: ^1.4.2
version: 1.4.2(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ rcheevos:
+ specifier: ^0.1
+ version: 0.1.0
react:
specifier: ^19.2.0
version: 19.2.0
@@ -5035,6 +5038,9 @@ packages:
'@types/react-dom':
optional: true
+ rcheevos@0.1.0:
+ resolution: {integrity: sha512-CNhv2DiRw5s8MFG05zQCVH8qhSSg89V2m/WI7EYYoyKI7oH+V3SieR2T0AUjhqlF0HTekbQkp0ybKfXgNHkMbQ==}
+
react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies:
@@ -11321,6 +11327,8 @@ snapshots:
'@types/react': 19.1.0
'@types/react-dom': 19.1.1(@types/react@19.1.0)
+ rcheevos@0.1.0: {}
+
react-dom@19.2.0(react@19.2.0):
dependencies:
react: 19.2.0
diff --git a/resources/js/common/hooks/useRcheevos.test.ts b/resources/js/common/hooks/useRcheevos.test.ts
new file mode 100644
index 0000000000..2561a3a846
--- /dev/null
+++ b/resources/js/common/hooks/useRcheevos.test.ts
@@ -0,0 +1,35 @@
+import { renderHook, waitFor } from '@/test';
+
+import { useRcheevos } from './useRcheevos';
+
+const mockRcheevosInstance = vi.hoisted(() => ({ initialized: true }) as any);
+
+vi.mock('rcheevos', () => {
+ const initialize = vi.fn(() => Promise.resolve(mockRcheevosInstance));
+
+ return {
+ RCheevos: {
+ initialize,
+ },
+ };
+});
+
+describe('Hook: useRcheevos', () => {
+ it('returns a ref initialized to null', () => {
+ // ARRANGE
+ const { result } = renderHook(() => useRcheevos());
+
+ // ASSERT
+ expect(result.current.current).toBeNull();
+ });
+
+ it('populates the ref with the initialized RCheevos instance', async () => {
+ // ARRANGE
+ const { result } = renderHook(() => useRcheevos());
+
+ // ASSERT
+ await waitFor(() => {
+ expect(result.current.current).toBe(mockRcheevosInstance);
+ });
+ });
+});
diff --git a/resources/js/common/hooks/useRcheevos.ts b/resources/js/common/hooks/useRcheevos.ts
new file mode 100644
index 0000000000..d8c8e9329b
--- /dev/null
+++ b/resources/js/common/hooks/useRcheevos.ts
@@ -0,0 +1,17 @@
+import { RCheevos } from 'rcheevos';
+import type { RefObject } from 'react';
+import { useEffect, useRef } from 'react';
+
+const promise = RCheevos.initialize();
+
+export function useRcheevos(): RefObject {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ promise.then((instance) => {
+ ref.current = instance;
+ });
+ }, []);
+
+ return ref;
+}
diff --git a/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx
new file mode 100644
index 0000000000..61041a01b4
--- /dev/null
+++ b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx
@@ -0,0 +1,42 @@
+import type { ChangeEvent, FC } from 'react';
+import { memo, useCallback, useRef, useState } from 'react';
+
+import { usePageProps } from '@/common/hooks/usePageProps';
+import { useRcheevos } from '@/common/hooks/useRcheevos';
+
+interface HashCheckerSection {
+ systemID: number;
+}
+
+export const HashCheckerSection: FC = memo(({ systemID }) => {
+ const hasher = useRcheevos();
+ const fileRef = useRef(null);
+ const [hash, setHash] = useState(null);
+ const { hashes } = usePageProps();
+
+ const onFileSelected = useCallback(
+ async (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file || !hasher.current) return;
+
+ const result = hasher.current.computeHash(systemID, await file.arrayBuffer());
+
+ setHash(result);
+ },
+ [hasher, systemID],
+ );
+
+ return (
+
+
+ {hash && (
+
+
+ {'Got Hash:'} {hash}
+
+
{hashes.some((h) => h.md5 === hash) ? '✅' : '❌'}
+
+ )}
+
+ );
+});
diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx
index 8e17e461fb..240f40202f 100644
--- a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx
+++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.tsx
@@ -10,6 +10,7 @@ import { GameHeading } from '@/common/components/GameHeading/GameHeading';
import { InertiaLink } from '@/common/components/InertiaLink';
import { usePageProps } from '@/common/hooks/usePageProps';
+import { HashCheckerSection } from './HashCheckerSection';
import { HashesList } from './HashesList';
import { OtherHashesSection } from './OtherHashesSection';
@@ -52,7 +53,13 @@ export const HashesMainRoot: FC = memo(() => {
,
+ 1: (
+
+ ),
}}
/>
) : null}{' '}
@@ -71,6 +78,8 @@ export const HashesMainRoot: FC = memo(() => {
+ {game.system && }
+
Date: Thu, 11 Dec 2025 20:37:28 +0000
Subject: [PATCH 2/2] wip
---
.../HashCheckerSection.test.tsx | 104 ++++++++++++++++++
.../HashesMainRoot/HashCheckerSection.tsx | 9 +-
.../HashesMainRoot/HashesMainRoot.test.tsx | 14 ++-
3 files changed, 125 insertions(+), 2 deletions(-)
create mode 100644 resources/js/features/games/components/HashesMainRoot/HashCheckerSection.test.tsx
diff --git a/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.test.tsx
new file mode 100644
index 0000000000..7da813c597
--- /dev/null
+++ b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.test.tsx
@@ -0,0 +1,104 @@
+import userEvent from '@testing-library/user-event';
+
+import { render, screen, waitFor } from '@/test';
+import { createGame, createGameHash } from '@/test/factories';
+
+import { HashCheckerSection } from './HashCheckerSection';
+
+const computeHashMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@/common/hooks/useRcheevos', () => ({
+ useRcheevos: () => ({
+ current: {
+ computeHash: computeHashMock,
+ },
+ }),
+}));
+
+describe('Component: HashCheckerSection', () => {
+ beforeEach(() => {
+ computeHashMock.mockReset();
+ computeHashMock.mockReturnValue('abc123');
+ });
+
+ it('renders without crashing', () => {
+ // ARRANGE
+ const { container } = render(
+ ,
+ {
+ pageProps: {
+ can: { manageGameHashes: false },
+ game: createGame(),
+ hashes: [createGameHash({ md5: 'abc123' })],
+ },
+ },
+ );
+
+ // ASSERT
+ expect(container).toBeTruthy();
+ });
+
+ it('computes the hash for an uploaded file and shows a success indicator when it matches', async () => {
+ // ARRANGE
+ render(, {
+ pageProps: {
+ can: { manageGameHashes: false },
+ game: createGame(),
+ hashes: [createGameHash({ md5: 'abc123' })],
+ },
+ });
+
+ const fileInput = screen.getByTestId('hash-file-input') as HTMLInputElement;
+ const file = new File(['dummy-content'], 'rom.bin', {
+ type: 'application/octet-stream',
+ });
+
+ // ACT
+ await userEvent.upload(fileInput, file);
+
+ // ASSERT
+ await waitFor(() => {
+ expect(computeHashMock).toHaveBeenCalledTimes(1);
+ });
+
+ expect(computeHashMock).toHaveBeenCalledWith(
+ 5,
+ new TextEncoder().encode('dummy-content').buffer,
+ );
+ expect(screen.getByText(/got hash:/i)).toBeVisible();
+ expect(screen.getByText('abc123')).toBeVisible();
+ expect(screen.getByText('✅')).toBeVisible();
+ });
+
+ it('shows a failure indicator when the computed hash is not recognized', async () => {
+ // ARRANGE
+ computeHashMock.mockReturnValue('deadbeef');
+
+ render(, {
+ pageProps: {
+ can: { manageGameHashes: false },
+ game: createGame(),
+ hashes: [createGameHash({ md5: 'abc123' })],
+ },
+ });
+
+ const fileInput = screen.getByTestId('hash-file-input') as HTMLInputElement;
+ const file = new File(['other-content'], 'rom2.bin', {
+ type: 'application/octet-stream',
+ });
+
+ // ACT
+ await userEvent.upload(fileInput, file);
+
+ // ASSERT
+ await waitFor(() => {
+ expect(screen.getByText('deadbeef')).toBeVisible();
+ });
+
+ expect(computeHashMock).toHaveBeenCalledWith(
+ 3,
+ new TextEncoder().encode('other-content').buffer,
+ );
+ expect(screen.getByText('❌')).toBeVisible();
+ });
+});
diff --git a/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx
index 61041a01b4..429c4fe2c1 100644
--- a/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx
+++ b/resources/js/features/games/components/HashesMainRoot/HashCheckerSection.tsx
@@ -28,7 +28,14 @@ export const HashCheckerSection: FC = memo(({ systemID }) =>
return (
-
+
{hash && (
diff --git a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx
index 9a527e8b86..6c842f94e9 100644
--- a/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx
+++ b/resources/js/features/games/components/HashesMainRoot/HashesMainRoot.test.tsx
@@ -3,6 +3,16 @@ import { createGame, createGameHash } from '@/test/factories';
import { HashesMainRoot } from './HashesMainRoot';
+vi.mock('rcheevos', () => {
+ const initialize = vi.fn(() => Promise.resolve(() => ({ initialized: true })));
+
+ return {
+ RCheevos: {
+ initialize,
+ },
+ };
+});
+
describe('Component: HashesMainRoot', () => {
it('renders without crashing', () => {
// ARRANGE
@@ -85,7 +95,9 @@ describe('Component: HashesMainRoot', () => {
});
// ASSERT
- const button = screen.queryByRole('button', { name: /other known hashes/i });
+ const button = screen.queryByRole('button', {
+ name: /other known hashes/i,
+ });
expect(button).toBeNull();
});