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(); });