diff --git a/docs/plan-battle-stage.md b/docs/plan-battle-stage.md new file mode 100644 index 00000000..955cbce9 --- /dev/null +++ b/docs/plan-battle-stage.md @@ -0,0 +1,229 @@ +# Plan: the battle stage + +`BattleStage` is the full-screen arena that opens when Start is pressed. It currently renders +a spinner, a flat list of taunt lines, an HP-bar scene, a scrolling strike log, and a result +sheet centred over all of it. It reads as a status page for a fight rather than the fight. + +Scope is `mobile/`. Three of the five items below need no other package. The two that touch +data are fixed in `useBattlePanel`, which is mobile's own file, not in `shared/`. + +--- + +## Three findings up front, because they decide the order + +**1. The panel throws away who is speaking.** `UseBattlePanel` exposes `taunts: string[]`, +built as `taunts.turns?.map((t) => t.text)`. The underlying `DialogueTurn` is +`{ speaker: 'attacker' | 'defender'; phase; text }`, so the side each line belongs to exists +and is discarded one layer before the component that needs it. Nothing can put a line beside +its avatar until this stops. This is the whole of the complaint about message history, and it +is a two-line change. + +**2. The panel throws away the shape of each strike, too.** `strikeLog: string[]` is built by +mapping `animation.history` through a formatter. `history` is `StrikeLogEntry[]`, which +carries `attacker: 1 | 2`, `damage`, `crit`, `isMagic`, `heal`, `elementMult`, +`furyTriggered`. No hit effect can know who was hit, how hard, or whether it crit while the +component only receives a sentence. + +**3. The two pets stop existing before the fight ends.** Publishing a receipt puts the fighter +on cooldown, which drops it out of `readyPets`, so `panel.fighter` is null by the time a +result is on screen, and `panel.opponent` can leave the matchmaking list the same way. The +panel already solves this for names with `personasRef`, a `BattlePersonas` captured at start. +`DialoguePetInput` holds `petId`, `name`, `level`, `rarity` and `dna`, which is enough to draw +an avatar. **Any avatar in the arena must read personas, not `fighter`/`opponent`, or it +vanishes at the moment of victory.** + +--- + +## 1. Two fighters, facing each other + +### Current state + +`BattleScene` renders two stacked `HpBar` rows, a flourish line and a scrolling log. There is +no pet on screen at any point. The one thing a player recognises a pet by, its art, is absent +from the only screen that is about two specific pets. + +### Proposed + +An arena band across the top: attacker on the left, defender on the right, each with + +- `PetArt` at 72, from the persona's `petId` and `dna` +- name and level under it +- an HP bar under that, keeping `HpBar`'s existing drain animation +- the loser dimmed to ~40% opacity once a result lands + +Between them, the `GlyphDivider` already used by Breed and Battle setup, with `VS`. + +### Work + +| file | change | +|---|---| +| `src/screens/parts/BattleStage.tsx` | the arena band | +| `src/screens/parts/BattleScene.tsx` | HP bars move into the band; the component keeps the log | +| `src/hooks/battle/useBattlePanel.ts` | expose `personas` so the arena can draw both sides | + +`PetArt` takes a `Pet`. A persona is not one, so either it gets a narrow prop type +(`Pick`, which is what it already reads) or +the panel exposes the two pets it captured rather than only their dialogue inputs. The second +is less work and keeps `PetArt` untouched. + +--- + +## 2. A line sits beside whoever said it + +### Current state + +```tsx +{panel.taunts.map((line, i) => ( + {line} +))} +``` + +Every line, both speakers, one column, no attribution. The result dialogue does better: it +renders `resultTurns` with the speaker's name in the speaker's colour. The taunts cannot, +because the speaker is gone by then (finding 1). + +### Proposed + +Taunts and result turns become the same thing: a speech bubble anchored to its speaker. + +- attacker lines left-aligned, tail pointing left, cyan border +- defender lines right-aligned, tail pointing right, magenta border +- max width ~78%, so a bubble never spans the arena and the side is obvious at a glance +- newest last, the column scrolls, and it auto-scrolls only when already at the bottom +- each bubble fades and rises 8px on arrival + +The tail is a rotated 8px square behind the bubble, not an image. There is no icon set here +and this needs no dependency. + +### Work + +| file | change | +|---|---| +| `src/hooks/battle/useBattlePanel.ts` | `taunts: DialogueTurn[]` instead of `string[]` | +| `src/screens/parts/SpeechBubble.tsx` *(new)* | one bubble, side and colour from the speaker | +| `src/screens/parts/BattleStage.tsx` | one column, taunts then result turns, in order | + +Two consumers change with the type: `BattleStage` and any test asserting on `panel.taunts`. +`useResultDialogue` already returns `DialogueTurn[]`, so the two sources become one list. + +--- + +## 3. Strikes should be visible, not narrated + +### Current state + +A strike changes two numbers and appends a sentence. `useLiveBattleAnimation` already paces +them one every 700ms, so the timing is there and nothing uses it. + +### Proposed + +Per strike, driven off `StrikeLogEntry`: + +- the striker lunges 12px toward its target and back, ~180ms +- the struck pet flashes its border and shakes 6px, ~120ms +- a damage number floats up 24px from the struck pet and fades, in `danger`, doubled in size + and in `warning` when `crit` +- `isMagic` tints the number `purple` rather than `danger` +- `heal > 0` floats a second number up from the striker in `success` +- `furyTriggered` pulses the striker's glow + +All of it is transform and opacity, so `useNativeDriver: true` throughout. The HP bar stays on +the JS driver because width is a layout property, which is already the case and already +commented. + +### Work + +| file | change | +|---|---| +| `src/hooks/battle/useBattlePanel.ts` | expose `currentStrike: StrikeLogEntry \| null` beside `flourish` | +| `src/screens/parts/StrikeEffects.tsx` *(new)* | the lunge, shake and floating numbers | +| `src/screens/parts/BattleScene.tsx` | keep the log, hand the arena the strike | + +Keep `flourish`. It is the line a player reads when they look away and back, and it is what +the screen reader gets. + +--- + +## 4. The verdict should not cover the fight + +### Current state + +An absolute overlay centred on the arena, holding title, rounds, HP left, XP, the whole result +dialogue and two buttons. It lands on top of the two pets the player was just watching, which +is the moment they most want to see. + +### Proposed + +- the verdict is a banner in the arena band, between the two pets, where `VS` was: `Victory` + or `Defeat`, plus `rounds · HP left · +XP` on one line +- the loser dims, the winner keeps its glow and pulses once +- the result dialogue joins the same bubble column as everything else, rather than being a + second list inside a sheet +- `Watch again` and `Close` move to a pinned row at the bottom, matching `ScreenActionBar` + everywhere else in the app +- the banner drops in and settles, ~220ms spring, and holds until dismissed + +This removes the nested-overlay layout entirely. The arena is one screen with a beginning, a +middle and an end, rather than a screen with a sheet on top of it. + +### Work + +| file | change | +|---|---| +| `src/screens/parts/BattleStage.tsx` | banner replaces the overlay; actions move to a pinned row | + +--- + +## 5. Reduced motion, and what the screen reader gets + +Everything above is decoration over information that must survive without it. + +- `useReduceMotion` already exists. Under it: no lunge, no shake, no float, no banner spring. + The damage number appears and disappears rather than travelling, and HP bars jump. +- the arena band gets an `accessibilityLabel` naming both fighters and their HP +- bubbles are read as "Rex says: …", which the speaker makes possible for the first time +- the verdict banner takes `accessibilityLiveRegion="polite"` so it is announced when it lands +- `GlyphDivider` is already hidden from screen readers and stays that way + +--- + +## Decisions needed + +| # | question | recommendation | +|---|---|---| +| 1 | Persona-shaped avatars, or expose the captured `Pet` objects from the panel? | **Expose the pets.** `PetArt` is untouched and the arena gets rarity and art for free | +| 2 | Do taunts and result lines share one column, or stay two lists? | **One column.** They are the same conversation, and one list is what makes the speaker rule uniform | +| 3 | Damage numbers on every strike, or only crits? | **Every strike.** A fight is 4 to 12 strikes, and only-on-crit reads as a bug the rest of the time | + +--- + +## Testing + +`BattleScreen.test.tsx` is 47 tests and already drives the arena through `openArena`. Four of +them assert on `panel.taunts` as strings and will need the turn shape. + +Worth adding: + +- a taunt from the defender renders on the defender's side, and one from the attacker does not +- the arena still names both fighters after the fighter leaves `readyPets`, which is finding 3 + and the one regression that would otherwise ship silently +- a crit renders a different damage treatment from a normal strike +- the verdict does not cover the two pets: both avatars are still mounted with the banner up +- under reduced motion, a strike still updates HP and still shows its damage number + +The existing replay tests already advance timers one strike at a time. Strike effects should +reuse that helper rather than introducing a second timing model. + +--- + +## Suggested order + +1. **§2 speech bubbles.** The complaint that started this, and the smallest change: a type on + the panel plus one new component. +2. **§1 the arena band.** Everything else hangs off having two pets on screen. +3. **§4 the verdict.** Once the band exists, the banner has somewhere to live, and this + deletes the overlay rather than adding to it. +4. **§3 strike effects.** The largest, and the only one that is pure decoration, so it goes + last and can be cut without leaving a gap. +5. **§5 reduced motion.** Alongside §3, not after it: retrofitting a motion flag across four + animations costs more than passing it in as each one lands. diff --git a/mobile/.eslintrc.js b/mobile/.eslintrc.js index 187894b6..3f3282fa 100644 --- a/mobile/.eslintrc.js +++ b/mobile/.eslintrc.js @@ -1,4 +1,8 @@ module.exports = { root: true, extends: '@react-native', + // Generated by `jest --coverage`. Already gitignored, but eslint has no view of + // that and its bundled report scripts trip the eslint-comments rules, so a + // coverage run would otherwise turn a clean lint into four warnings. + ignorePatterns: ['coverage/'], }; diff --git a/mobile/App.tsx b/mobile/App.tsx index bb96ae24..3b002b75 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,29 +1,75 @@ import React from 'react'; import "@walletconnect/react-native-compat"; -import { AppKitProvider } from '@reown/appkit-react-native'; +import { StatusBar } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { NavigationContainer } from '@react-navigation/native'; +import { AppKit, AppKitProvider } from '@reown/appkit-react-native'; import { WagmiProvider } from 'wagmi'; import { QueryClientProvider } from '@tanstack/react-query'; -import { queryClient, ApiClientProvider, AuthProvider } from '@shared/core'; +import { queryClient, ApiClientProvider, AuthProvider, PetsConfigProvider } from '@shared/core'; import { appKit, wagmiConfig } from './src/AppKitConfig'; -import AppRoot from './src/AppContent.tsx'; import { API_URL } from './config'; +import { useEvmPetsConfig } from './src/petsContractParams'; +import { ToastProvider } from './src/components/ui/Toast'; +import SignInErrorReporter from './src/components/SignInErrorReporter'; +import { RootNavigator } from './src/navigation/RootNavigator'; +import { neon } from './src/theme/neon'; import { SolanaAppKitAnchorBridge } from './src/solana/SolanaAppKitAnchorBridge'; +import { SolanaAuthSigner } from './src/solana/SolanaAuthSigner'; + +/** + * Provider order matches `frontend/src/AppProviders.tsx`, with `NavigationContainer` + * where its `BrowserRouter` sits. + * + * `SafeAreaProvider` is outermost so `ToastProvider` can measure a real bottom + * inset rather than assuming one. `AppKit` renders as a sibling of the navigator + * so its connect sheet is reachable from the landing screen and the tab shell + * alike. + * + * `SolanaAuthSigner` is a non-wrapping sibling inside `AppKitProvider`, matching + * where `AppProviders.tsx` puts its own: it registers rather than provides, and + * it has to sit above `AuthProvider`, which reads what it registers. + */ + +/** + * Supplies the contract config for whichever deployment chain the wallet is on. + * + * A component rather than a constant because the config now depends on + * `useAccount`, and hooks only run inside the tree. It has to sit under + * `WagmiProvider`, which it does. + */ +function PetsConfig({ children }: { children: React.ReactNode }) { + return {children}; +} export default function App() { return ( - - - - - - - - - - - - - + + + + + + + + + + + + {/* Inside ToastProvider and under AuthProvider, which is what it needs. */} + + + + + + + + + + + + + + ); } diff --git a/mobile/README.md b/mobile/README.md index ae3a840f..23b1b49a 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,105 +1,141 @@ -This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). +# CryptoPets mobile -# Getting Started +React Native client for the `do-not-stop` monorepo, sharing `@shared/core` with the web frontend. +Licensed PolyForm Noncommercial 1.0.0, like the rest of the app packages (see the root `LICENSE`). -> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. +## Setup -## Step 1: Start Metro - -First, you will need to run **Metro**, the JavaScript build tool for React Native. - -To start the Metro dev server, run the following command from the root of your React Native project: +Run everything from the repo root. The package manager is pnpm; `npm` and `yarn` will not resolve +the workspace links this package depends on. ```sh -# Using npm -npm start - -# OR using Yarn -yarn start +pnpm install:all # root + frontend + website + backend + mobile + contracts/ethereum +cp mobile/env.example mobile/.env ``` -## Step 2: Build and run your app +Fill in `REOWN_PROJECT_ID` from https://dashboard.reown.com and point `API_URL` at your backend. +On an Android emulator the host is `10.0.2.2`; on a physical device or LDPlayer it is your LAN IP. +Every other variable has a working default. -With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: +> **Editing `.env` needs a rebuild, not a reload.** `react-native-dotenv` inlines `@env` at Babel +> transform time and Metro caches transforms, so a Fast Refresh picks up nothing. Restart with +> `pnpm --filter mobile start --reset-cache` and rebuild. An installed APK has the old values +> bundled inside it, where a cache reset cannot reach them at all. -### Android +## Commands -```sh -# Using npm -npm run android - -# OR using Yarn -yarn android -``` +| Task | Command | +|---|---| +| Metro dev server | `pnpm --filter mobile start` | +| Run on Android | `pnpm --filter mobile android` | +| Run on iOS | `pnpm --filter mobile ios` | +| Lint | `pnpm --filter mobile lint` | +| Test | `pnpm --filter mobile test` | +| One test file | `pnpm --filter mobile exec jest __tests__/.test.tsx` | -### iOS +There is no `build` script. React Native has no equivalent, so "it works" means lint, jest, and the +app running on a device, never a successful compile. `tsc --noEmit` is useful but is not a gate: +it reports pre-existing errors in `shared/` that this package does not own. -For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). - -The first time you create a new project, run the Ruby bundler to install CocoaPods itself: +iOS also needs CocoaPods before its first run: ```sh -bundle install +cd mobile/ios && bundle install && bundle exec pod install ``` -Then, and every time you update your native dependencies, run: +## Which chain this targets -```sh -bundle exec pod install -``` +Two testnets carry a full deployment, **Base Sepolia (84532)** and **Sepolia (11155111)**. +`EVM_CHAIN_ID` picks which one the app starts on and defaults to Base Sepolia, matching the web +frontend. A player can switch between them in the app and the pet list follows, because contract +addresses are keyed by chain in `src/chains/ethereum/contracts.ts` and `useEvmPetsConfig` resolves +them from the wallet's current chain. -For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). +Addresses are built in, so **leave `PETCORE_ADDRESS` and friends unset** unless you are deliberately +overriding. They apply to `EVM_CHAIN_ID`'s chain only, since the names carry no chain of their own, +which makes a stale value worse than a missing one: it silently points the new target at the old +chain's proxy and every read returns an empty `0x` that looks like a decode bug. -```sh -# Using npm -npm run ios +Base Sepolia's roster started empty on 2026-08-06, so **mint a pet before expecting the gallery to +show anything**. An empty gallery on first run is correct, not a failure. -# OR using Yarn -yarn ios -``` +Solana is wired for devnet and reached through the same chain-blind hooks. It has not yet been +exercised end to end on a device. -If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. +## How it fits together -This is one way to run your app — you can also build it directly from Android Studio or Xcode. +`App.tsx` mounts the provider stack in the same order as the frontend's `AppProviders.tsx`, with +`NavigationContainer` where the web app has `BrowserRouter`: -## Step 3: Modify your app - -Now that you have successfully run the app, let's make changes! - -Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). - -When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: - -- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). -- **iOS**: Press R in iOS Simulator. - -## Congratulations! :tada: - -You've successfully run and modified your React Native App. :partying_face: - -### Now what? +``` +SafeAreaProvider > Wagmi > QueryClient > AppKit > [SolanaAuthSigner] + > SolanaAppKitAnchorBridge > ApiClient > Auth > PetsConfig > Toast > NavigationContainer +``` -- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). -- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). +Things worth knowing before changing any of it: + +- **Wallets go through Reown AppKit**, not Dynamic Labs as on web. Parity between the two apps is at + the level of behavior and configuration, not libraries. +- **Pet reads and writes are chain-blind.** Screens call `@shared/core` hooks (`usePetList`, + `useCreatePet`, `useBattlePets`, and so on) which resolve through the `ChainAdapter` layer. Do not + reach for wagmi or Anchor directly in a screen. +- **`useActiveChain` decides which adapter runs**, and it resolves Solana from the auth-signer store + and nothing else. `src/solana/SolanaAuthSigner.tsx` is what registers it; without that a connected + Solana wallet is invisible to every one of those hooks. +- **Navigation is five tabs plus seven stack routes.** `Gallery`, `Battle`, `Breed`, `Level Up` and + `Train` are tabs. `Marriage`, `Rename`, `Defense` and `Equip` are pushed over the shell from a + per-pet action, because each acts on one chosen pet. `Leaderboard`, `Inventory` and `Chat` are + pushed from the account sheet instead: they act on no single pet, and a bottom bar past five + entries truncates every label. +- **The landing screen is registered conditionally**, not redirected away from. While disconnected + only `Landing` exists, so there is no window where a tab screen renders against a wallet that is + not there. +- **`shared/` and `protocol/` are consumed as raw TypeScript**, with no build step, by this app and + the frontend at once. A change in either affects both. + +## Known gaps + +- No ERC-20 token balances. The target chain's popular-token list holds a single testnet LINK. + +Recently closed, so the older notes claiming otherwise are wrong: the battle replays +round by round from the verified receipt and plays the AI result dialogue after it; pet +art renders through `PetArt` when `IMAGE_SERVICE_URL` is set, falling back to the emoji +avatar; the leaderboard, the inventory, equipment and private chat all have screens. See +`docs/plan-mobile-frontend-parity.md` for what is left. ## Android package name -The app **display name** is **CryptoPets** (see `app.json`, Android `res/values/strings.xml`, and iOS `CFBundleDisplayName`). The Android **namespace** and **Kotlin/Java package** remain **`com.cryptozombies`** (e.g. `MainActivity` under `com/cryptozombies/`, `namespace` in `android/app/build.gradle`). +The app **display name** is **CryptoPets** (see `app.json`, Android `res/values/strings.xml`, and +iOS `CFBundleDisplayName`). The Android **namespace** and **Kotlin/Java package** remain +**`com.cryptozombies`** (e.g. `MainActivity` under `com/cryptozombies/`, `namespace` in +`android/app/build.gradle`). + +The Play Store **`applicationId`** is configured separately in `android/app/build.gradle` +(`defaultConfig.applicationId`); it can differ from the namespace, but the **native package / +namespace** is intentionally not rebranded to match CryptoPets yet. -The Play Store **`applicationId`** is configured separately in `android/app/build.gradle` (`defaultConfig.applicationId`); it can differ from the namespace, but the **native package / namespace** is intentionally not rebranded to match CryptoPets yet. +We are **not** renaming that Android namespace/package until React Native fixes autogenerated code +that can reference a **stale or wrong `BuildConfig` package** when renaming apps or using flavors. +See [facebook/react-native#52754](https://github.com/facebook/react-native/issues/52754). -We are **not** renaming that Android namespace/package until React Native fixes autogenerated code that can reference a **stale or wrong `BuildConfig` package** when renaming apps or using flavors—see [facebook/react-native#52754 — `ReactNativeApplicationEntryPoint.java` doesn’t generate correctly during release `.aab` build](https://github.com/facebook/react-native/issues/52754). +## Troubleshooting -# Troubleshooting +**A `.env` change had no effect.** See the rebuild note above. This is almost always the cause. -If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. +**`pnpm install` fails with `ERR_PNPM_ENOENT ... _tmp_`.** Metro is running and its +watcher holds handles in `node_modules` while pnpm renames temp directories. It fails on a different +package each attempt, so it looks transient and is not. Stop Metro and install again. A partial +failure rewrites `pnpm-lock.yaml` without recording the dependency in `mobile/package.json`, so +imports fail while the lockfile claims the package exists; add the specifier by hand and re-run. -# Learn More +**WebSocket 3000 "Unauthorized: origin not allowed".** The Reown dashboard project has an allowlist +that does not include this build's origin. Leave it empty while developing, or add the exact +`metadata.url` from `src/AppKitConfig.ts` plus your Metro origin. -To learn more about React Native, take a look at the following resources: +**Wrong network banner that will not clear.** The wallet approved a different chain set than the app +requested, which WalletConnect freezes at connect time. Reconnecting is the only reliable way to +widen it. `src/components/NetworkGate.tsx` explains which of the two cases you are in. -- [React Native Website](https://reactnative.dev) - learn more about React Native. -- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. -- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. -- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. -- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. +**A jest suite dies importing `@shared/core`.** The barrel re-exports the Solana adapter and drags +the whole Solana runtime in, ending at an unresolvable `rpc-websockets`. Stub the barrel with +`jest.mock` and pull the specific module in through `jest.requireActual` with a relative path. diff --git a/mobile/__tests__/App.test.tsx b/mobile/__tests__/App.test.tsx index 2bcd04de..a96c691c 100644 --- a/mobile/__tests__/App.test.tsx +++ b/mobile/__tests__/App.test.tsx @@ -13,21 +13,44 @@ import ReactTestRenderer from 'react-test-renderer'; const passthrough = ({children}: {children?: React.ReactNode}) => <>{children}; -jest.mock('@reown/appkit-react-native', () => ({AppKitProvider: passthrough})); -jest.mock('wagmi', () => ({WagmiProvider: passthrough})); +// `useProvider`/`useAccount` are here for `SolanaAuthSigner`, which App renders as a +// non-wrapping sibling. They became necessary when `SafeAreaProvider` started being mocked +// globally: the real one measures a native view and renders `null` until it has metrics, so +// under jest nothing below App's outermost provider had ever mounted and this test was +// checking that the imports resolve, not that the tree renders. +jest.mock('@reown/appkit-react-native', () => ({ + AppKitProvider: passthrough, + AppKit: () => null, + useProvider: () => ({provider: null}), + useAccount: () => ({address: undefined, isConnected: false, namespace: undefined, chainId: undefined}), +})); +jest.mock('wagmi', () => ({WagmiProvider: passthrough, useAccount: () => ({chainId: undefined})})); jest.mock('@tanstack/react-query', () => ({QueryClientProvider: passthrough})); jest.mock('@shared/core', () => ({ queryClient: {}, ApiClientProvider: passthrough, AuthProvider: passthrough, + PetsConfigProvider: passthrough, + useAuth: () => ({signInError: null}), + setSolanaAuthSigner: () => {}, })); jest.mock('../src/AppKitConfig', () => ({appKit: {}, wagmiConfig: {}})); jest.mock('../src/solana/SolanaAppKitAnchorBridge', () => ({ SolanaAppKitAnchorBridge: passthrough, })); -jest.mock('../src/AppContent.tsx', () => () => null); +jest.mock('@react-navigation/native', () => ({NavigationContainer: passthrough})); +jest.mock('../src/navigation/RootNavigator', () => ({RootNavigator: () => null})); // Reaches AsyncStorage (a native module) at import time, just to read API_URL. jest.mock('../config', () => ({API_URL: 'http://localhost:3001'})); +/** + * Without this, `SafeAreaProvider` measures a native view and renders `null`, so nothing below + * App's outermost provider mounts and the test below checks that the imports resolve rather + * than that the tree renders. The stubs above only matter because this makes it mount. + */ +jest.mock('react-native-safe-area-context', () => + require('react-native-safe-area-context/jest/mock').default, +); + import App from '../App'; diff --git a/mobile/__tests__/BattleScreen.test.tsx b/mobile/__tests__/BattleScreen.test.tsx new file mode 100644 index 00000000..10fbf975 --- /dev/null +++ b/mobile/__tests__/BattleScreen.test.tsx @@ -0,0 +1,990 @@ +/** + * Battle, over the real `useBattlePanel` with `@shared/core` stubbed. + * + * The things worth pinning are the ones that decide whether a battle is legal + * before a signature is asked for: only pets off cooldown can fight, the opponent + * must be cleared when the fighter changes (it was picked against a different + * level band), and `defenderOwner` must reach the mutation, since the backend needs it + * to find the defence authorization, and pet ids are not unique across owners on + * Solana. + */ + +import React from 'react'; +import { StyleSheet, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import type { OpponentPet, Pet } from '@shared/core'; +/** + * `useSafeAreaInsets` throws outside a `SafeAreaProvider`, and this suite renders a screen on + * its own. The library ships this mock for exactly that. Repeated per suite rather than + * registered globally: a global one needs a `setupFiles` entry pointing at a file whose name + * says nothing about what it does. + */ +jest.mock('react-native-safe-area-context', () => + require('react-native-safe-area-context/jest/mock').default, +); + + +const pet = (over: Partial = {}): Pet => ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 7n, + level: 5, + rarity: 2, + winCount: 3, + lossCount: 1, + readyAt: 0, + ...over, +}); + +const foe = (over: Partial = {}): OpponentPet => ({ + ...pet({ id: '9', name: 'Luna', level: 5 }), + owner: '0xrival', + ...over, +}); + +const mockState = { + pets: [pet()] as Pet[], + /** A pet the wallet owns whose record would not load, so it is not in `pets`. */ + petsError: null as Error | null, + /** What `useBattlePets` reports after a refused battle. */ + battleError: null as Error | null, + opponents: [foe()] as OpponentPet[], + opponentsLoading: false, + opponentsError: null as Error | null, + /** Which filter emptied the opponent list; the server names it. */ + emptyReason: null as string | null, + isAuthenticated: true, + /** The backend's own battle state, and why it stopped if it did. */ + phase: 'idle', + battleState: null as string | null, + failureReason: null as string | null, + isConnected: true, + turns: [] as { speaker: 'attacker' | 'defender'; phase: 'taunt'; text: string }[], + /** Post-fight reactions. `taunt` turns are filtered out before rendering. */ + dialogueTurns: [] as { speaker: string; phase: string; text: string }[], + dialogueLoading: false, + /** + * The client's own replay of the verified receipt, which is the only thing the + * scene animates. Null until a battle resolves, and absent entirely when a check + * failed — so an unverified fight has nothing to show rather than something + * unverified to show. + */ + liveReplay: null as { + log: Record[]; + startHp1: bigint; + startHp2: bigint; + } | null, +}; + +/** One strike, shaped as `StrikeLogEntry`. */ +const strike = (over: Record = {}) => ({ + round: 1, + attacker: 1, + isMagic: false, + damage: 10n, + heal: 0n, + crit: false, + elementMult: 100, + furyTriggered: false, + rebirthTriggered: false, + hp1After: 100n, + hp2After: 90n, + ...over, +}); + +const mockBattle = jest.fn(); +const mockRefetchPets = jest.fn(); +const mockRefetchOpponents = jest.fn(); +const mockTaunts = jest.fn(); +// `useCreateBattleRoom().createRoom` resolves to the room id itself, or null when +// it fails; it catches internally and never rejects. The mock returned a +// `{ roomId }` object before, which went unnoticed only because the value was +// discarded. +const mockCreateRoom = jest.fn, unknown[]>(async () => 'r1'); +/** Captures what the panel hands `useBattlePets`, which is where roomId matters. */ +const mockBattleOptions: { roomId?: string | null; roomSocketUrl?: string } = {}; +/** Captures what the result dialogue is asked for, including the personas fallback. */ +const mockDialogueArgs = jest.fn(); + +/** + * Rendered as a marker rather than nulled, so the opponent rows can be asserted to draw + * art. A stub returning null would let the art disappear again without a test noticing — + * which is exactly how the gallery went without avatars for the whole project. + */ +jest.mock('../src/components/PetArt', () => { + const { Text: RNText } = jest.requireActual('react-native'); + const React_ = jest.requireActual('react'); + return ({ pet: subject }: { pet: { id: string } }) => + React_.createElement(RNText, null, `[art:${subject.id}]`); +}); + +jest.mock('@shared/core', () => ({ + // `PetPicker` shows the selected pet's stats inline now, so anything rendering a picker + // reaches these. Real rather than stubbed: they are pure and dependency-free, and what a + // pet reads here has to be what it reads on the card and on the web app. + ...jest.requireActual('../../shared/src/utils/ethereum/petCard'), + ...jest.requireActual('../../shared/src/utils/pets/skills'), + ...jest.requireActual('../../shared/src/utils/pets/cosmetics'), + getReadyPetsUnified: (pets: Pet[]) => + pets.filter((p) => p.readyAt === 0).map((p) => ({ id: p.id, pet: p })), + usePetList: () => ({ + pets: mockState.pets, + isLoading: false, + error: mockState.petsError, + refetch: mockRefetchPets, + }), + useChainCapabilities: () => ({ + isConnected: mockState.isConnected, + activeKind: mockState.isConnected ? 'evm' : null, + }), + useOpponents: () => ({ + opponents: mockState.opponents, + isLoading: mockState.opponentsLoading, + error: mockState.opponentsError, + total: mockState.opponents.length, + emptyReason: mockState.emptyReason, + // One stable function, not a fresh `jest.fn()` per render. The real hook returns + // React Query's `refetch`, which is stable, and an effect depending on it would + // re-run on every render against a mock that is not. + refetch: mockRefetchOpponents, + }), + isBattleRejection: (...args: unknown[]) => + jest.requireActual('../../shared/src/utils/battleFailureMessage').isBattleRejection(...args), + isConsentFailure: (...args: unknown[]) => + jest.requireActual('../../shared/src/utils/battleFailureMessage').isConsentFailure(...args), + // The real wording for both: a new backend state or empty-reason must not need this + // screen edited to be sayable. + describeBattleStage: (...args: unknown[]) => + jest + .requireActual('../../shared/src/hooks/battle/useBattlePets') + .describeBattleStage(...args), + describeNoOpponents: (...args: unknown[]) => + jest + .requireActual('../../shared/src/hooks/battle/useOpponents') + .describeNoOpponents(...args), + useBattleTaunts: () => ({ + generate: mockTaunts, + reset: jest.fn(), + turns: mockState.turns, + isLoading: false, + }), + useAuth: () => ({ isAuthenticated: mockState.isAuthenticated }), + useCreateBattleRoom: () => ({ createRoom: mockCreateRoom, isLoading: false }), + // The real one: selection correctness is the thing under test, and a fake key + // function would let both the screen and the hook agree on a wrong shape. + opponentKey: (owner: string, id: string) => + jest.requireActual('../../shared/src/utils/battleMatchmaking').opponentKey(owner, id), + toDialoguePet: (subject: Pet | OpponentPet) => ({ + petId: subject.id, + name: subject.name, + level: subject.level, + rarity: subject.rarity, + dna: subject.dna.toString(), + winCount: subject.winCount, + lossCount: subject.lossCount, + }), + useBattleDialogue: (opts: Record) => { + mockDialogueArgs(opts); + return { turns: mockState.dialogueTurns, isLoading: mockState.dialogueLoading }; + }, + useBattlePets: (opts: { roomId?: string | null; roomSocketUrl?: string }) => { + mockBattleOptions.roomId = opts?.roomId; + mockBattleOptions.roomSocketUrl = opts?.roomSocketUrl; + return { + mutate: mockBattle, + isPending: false, + error: mockState.battleError, + phase: mockState.phase, + state: mockState.battleState, + failureReason: mockState.failureReason, + liveReplay: mockState.liveReplay, + }; + }, + // The real hook, not a stub: the replay's stepping and its done-gate are the + // behaviour under test, and a fake would only assert the fake. Pulled in by + // relative path because the barrel this factory replaces is what drags the Solana + // runtime into jest. + useLiveBattleAnimation: (...args: unknown[]) => + jest + .requireActual('../../shared/src/hooks/battle/useLiveBattleAnimation') + .useLiveBattleAnimation(...args), + describeMechanicalLogEntry: (...args: unknown[]) => + jest + .requireActual('../../shared/src/hooks/battle/useLiveBattleAnimation') + .describeMechanicalLogEntry(...args), +})); + +// Records its arguments so the message the player would actually see can be asserted. +// The third argument wins over the mutation error inside the real hook, which is the +// whole point of routing a rejection through it. +const mockPetErrorToast = jest.fn(); +jest.mock('../src/hooks/usePetErrorToast', () => ({ + usePetErrorToast: (...args: unknown[]) => mockPetErrorToast(...args), +})); + +const mockRouteParams: { petId?: string } = {}; +jest.mock('@react-navigation/native', () => ({ + useRoute: () => ({ params: mockRouteParams }), +})); + +import { BATTLE_ROOM_WS_URL } from '../src/constants/api'; +import BattleScreen from '../src/screens/BattleScreen'; + +import { textOfNode } from './support/harness'; + +/** + * Every tree rendered by a test, so `afterEach` can unmount them. + * + * Without this a finished test's component stays mounted and its replay timer keeps + * firing into the next one, re-rendering a dead tree *after* `jest.clearAllMocks()` has + * run. The symptom is a test that passes alone and fails in the file, because the last + * recorded call belongs to the previous test's component rather than this one's. + */ +const mounted: ReactTestRenderer.ReactTestRenderer[] = []; + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + mounted.push(tree); + return tree; +}; + +afterEach(async () => { + await ReactTestRenderer.act(async () => { + for (const tree of mounted.splice(0)) tree.unmount(); + }); +}); + + +const textOf = (tree: ReactTestRenderer.ReactTestRenderer) => textOfNode(tree.root); + +const pressWith = async (tree: ReactTestRenderer.ReactTestRenderer, label: string) => { + const target = tree.root + .findAllByType(TouchableOpacity) + .find((b) => textOfNode(b).includes(label)); + await ReactTestRenderer.act(async () => { + target?.props.onPress(); + }); +}; + +/** + * Opens the arena, which is where a fight is now watched. + * + * Start sets `arenaOpen` before it validates, so this needs no pet or opponent chosen: these + * tests are about what the arena shows once it is open, not about getting a battle accepted. + */ +const openArena = async (tree: ReactTestRenderer.ReactTestRenderer) => { + await pressWith(tree, 'Start Battle'); +}; + +beforeEach(() => { + mockState.pets = [pet()]; + mockState.petsError = null; + mockState.battleError = null; + mockRefetchPets.mockClear(); + mockRefetchOpponents.mockClear(); + mockPetErrorToast.mockClear(); + mockState.opponents = [foe()]; + mockState.opponentsLoading = false; + mockState.opponentsError = null; + mockState.emptyReason = null; + mockState.isAuthenticated = true; + mockState.phase = 'idle'; + mockState.battleState = null; + mockState.failureReason = null; + mockState.isConnected = true; + mockState.turns = []; + mockState.liveReplay = null; + mockState.dialogueTurns = []; + mockState.dialogueLoading = false; + delete mockRouteParams.petId; + jest.clearAllMocks(); +}); + +describe('BattleScreen', () => { + it('asks for a wallet before showing the arena', async () => { + mockState.isConnected = false; + const tree = await render(); + expect(textOf(tree)).toContain('Connect a wallet'); + }); + + it('offers only pets off cooldown', async () => { + // A pet that just fought cannot legally battle, so it must not be offered. + mockState.pets = [pet(), pet({ id: '2', name: 'Cooling', readyAt: 9_999_999_999 })]; + const tree = await render(); + expect(textOf(tree)).toContain('Rex'); + expect(textOf(tree)).not.toContain('Cooling'); + }); + + /* + * A pet whose record fails to load is filtered out before the picker ever sees it, so + * the list is one short and nothing explains why. That is indistinguishable from the + * pet not existing, and it sent someone hunting for a pet that had minted correctly. + * The list is right to omit it — there is nothing to draw — but the screen has to say so. + */ + it('says when a pet it cannot read is missing from the list', async () => { + mockState.pets = [pet({ id: '5', name: 'TIMON' })]; + mockState.petsError = new Error( + 'Could not load 1 of your 4 pets (id 12). They are still yours; this is a read that failed.', + ); + + const tree = await render(); + + expect(textOf(tree)).toContain('Could not load 1 of your 4 pets'); + expect(textOf(tree)).toContain('12'); + }); + + it('offers to read the pets again rather than leaving it there', async () => { + mockState.petsError = new Error('Could not load 1 of your 4 pets (id 12).'); + const tree = await render(); + + await pressWith(tree, 'Try again'); + + expect(mockRefetchPets).toHaveBeenCalled(); + }); + + it('stays quiet when every pet loaded', async () => { + mockState.pets = [pet(), pet({ id: '2', name: 'Momo' })]; + const tree = await render(); + expect(textOf(tree)).not.toContain('Could not load'); + expect(textOf(tree)).not.toContain('Try again'); + }); + + /* + * A refused battle used to reach the player as "Failed to start the battle. Please try + * again." while the server's actual sentence went only to the console. The two point + * opposite ways: "One of those pets is not on record yet" means wait for the indexer, + * and "please try again" means retry now, which cannot work. + */ + it('shows the server’s reason for a refused battle, not the fallback', async () => { + const { BattleRejectionError } = jest.requireActual( + '../../shared/src/utils/battleFailureMessage', + ); + mockState.battleError = new BattleRejectionError( + 'unknown-pet', + 'One of those pets is not on record yet.', + ); + + await render(); + + const [, , validationOrReason] = mockPetErrorToast.mock.calls.at(-1)!; + expect(validationOrReason).toBe('One of those pets is not on record yet.'); + }); + + it('leaves an ordinary failure to the fallback wording', async () => { + // Not every failure is an explained refusal. A dropped connection has no sentence + // worth repeating, and the caller's fallback is the better text. + mockState.battleError = new Error('socket hang up'); + + await render(); + + const [, , validationOrReason] = mockPetErrorToast.mock.calls.at(-1)!; + expect(validationOrReason).toBeNull(); + }); + + it('drops an opponent whose consent lapsed and re-reads the list', async () => { + const { BattleRejectionError } = jest.requireActual( + '../../shared/src/utils/battleFailureMessage', + ); + mockState.battleError = new BattleRejectionError('expired', 'That grant has expired.'); + + await render(); + + expect(mockRefetchOpponents).toHaveBeenCalled(); + }); + + it('keeps the opponent when the refusal is not about their consent', async () => { + // A level-band or unknown-pet refusal is about this attacker or the indexer, not + // the defender's willingness. Dropping them would be wrong. + const { BattleRejectionError } = jest.requireActual( + '../../shared/src/utils/battleFailureMessage', + ); + mockState.battleError = new BattleRejectionError('unknown-pet', 'Not on record yet.'); + + await render(); + + expect(mockRefetchOpponents).not.toHaveBeenCalled(); + }); + + it('preselects the pet a Gallery battle action arrived with', async () => { + mockState.pets = [pet(), pet({ id: '2', name: 'Momo' })]; + mockRouteParams.petId = '2'; + const tree = await render(); + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Start Battle'); + expect(mockBattle).toHaveBeenCalledWith( + expect.objectContaining({ petId1: '2', petId2: '9' }), + ); + }); + + it('follows a second Gallery pick, since the tab never unmounts', async () => { + /* + * `useState(initialPetId)` reads its argument once. Battle is a tab, mounted with + * the shell and never unmounted, so the pet a card asked for was honoured on the + * first tap and ignored on every one after: tapping Battle on Jane left the arena + * on whoever was picked first. + */ + mockState.pets = [pet({ id: '1', name: 'Rex' }), pet({ id: '2', name: 'Jane' })]; + mockRouteParams.petId = '1'; + const tree = await render(); + + // Arriving again from a different card, without remounting the screen. + mockRouteParams.petId = '2'; + await ReactTestRenderer.act(async () => { + tree.update(); + }); + + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Start Battle'); + expect(mockBattle).toHaveBeenCalledWith(expect.objectContaining({ petId1: '2' })); + }); + + it('sends defenderOwner, which the backend needs to find the consent grant', async () => { + const tree = await render(); + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Start Battle'); + expect(mockBattle).toHaveBeenCalledWith({ + petId1: '1', + petId2: '9', + defenderOwner: '0xrival', + }); + }); + + it('will not start without both sides chosen', async () => { + const tree = await render(); + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Start Battle'); + expect(mockBattle).not.toHaveBeenCalled(); + expect(textOf(tree)).toContain('Pick one of your pets and an opponent'); + }); + + it('clears the opponent when the fighter changes', async () => { + // The pick was made against a different level band, so keeping it would + // silently fight a match the player never chose. + mockState.pets = [pet(), pet({ id: '2', name: 'Momo', level: 20 })]; + const tree = await render(); + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Momo'); + await pressWith(tree, 'Start Battle'); + expect(mockBattle).not.toHaveBeenCalled(); + }); + + it('generates taunts before fighting, which also primes the result dialogue', async () => { + const tree = await render(); + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Start Battle'); + expect(mockTaunts).toHaveBeenCalledWith( + expect.objectContaining({ + chain: 'evm', + attacker: expect.objectContaining({ petId: '1', dna: '7' }), + defender: expect.objectContaining({ petId: '9' }), + }), + ); + }); + + it('mints a room, but fights anyway when that fails', async () => { + // The receipt settles a battle, not the room, so a failed mint must not + // block the fight. + mockCreateRoom.mockRejectedValueOnce(new Error('room service down')); + const tree = await render(); + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Start Battle'); + expect(mockCreateRoom).toHaveBeenCalled(); + expect(mockBattle).toHaveBeenCalled(); + }); + + it('links the battle to the room it minted', async () => { + // `accept` records roomId on the ledger row, and that is the only thing + // that makes the backend notify the room as the battle changes state. + // Minting a room without passing it here leaves it attached to nothing and + // every spectator holding the link uninformed. + const tree = await render(); + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Start Battle'); + + expect(mockBattleOptions.roomId).toBe('r1'); + expect(mockBattle).toHaveBeenCalled(); + }); + + it('subscribes to the room socket so updates arrive by push', async () => { + // Polling still carries the battle either way; this is what makes it prompt. + // + // Asserts pass-through, not the URL's shape. `BATTLE_ROOM_WS_URL` is derived from + // `API_URL`, which `react-native-dotenv` inlines at transform time from a gitignored + // `mobile/.env` — so matching it against a `wss?://` pattern here passes on a machine + // that has one and fails on CI, which does not. That is what it did. The derivation + // itself is `socketUrlFrom`, checked directly in `api.test.ts` against a known input. + const tree = await render(); + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Start Battle'); + + expect(mockBattleOptions.roomSocketUrl).toBe(BATTLE_ROOM_WS_URL); + }); + + it('does not hand a failed mint the previous battle’s room', async () => { + // Reusing it would push this fight's updates to a room full of the wrong + // spectators, which is worse than having no room at all. + const tree = await render(); + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Start Battle'); + expect(mockBattleOptions.roomId).toBe('r1'); + + mockCreateRoom.mockResolvedValueOnce(null); + await pressWith(tree, 'Start Battle'); + + expect(mockBattleOptions.roomId).toBeNull(); + }); + + it('labels the match by level gap', async () => { + mockState.opponents = [foe({ id: '9', name: 'Luna', level: 11 })]; + const tree = await render(); + await pressWith(tree, 'Rex'); + expect(textOf(tree)).toContain('+6 lv'); + }); + + it('shows the taunts once they arrive', async () => { + mockState.turns = [ + { speaker: 'attacker', phase: 'taunt', text: 'You call that a stance?' }, + ]; + const tree = await render(); + await openArena(tree); + expect(textOf(tree)).toContain('You call that a stance?'); + }); + + it('draws each opponent with its art, not a name alone', async () => { + mockState.opponents = [foe({ id: '9', name: 'Luna' }), foe({ id: '12', name: 'Momo' })]; + const rendered = textOf(await render()); + expect(rendered).toContain('[art:9]'); + expect(rendered).toContain('[art:12]'); + }); + + /** + * Pet ids are not unique across owners on Solana, so an id alone can name two pets. + * + * Selecting on the id resolved to whichever matched first, and `defenderOwner` then + * named the wrong wallet — the backend would look for a consent grant that wallet + * never signed. Invisible on EVM, where ERC-721 ids are globally unique, which is why + * it survived: this deployment is Base Sepolia. + * + * It has to be the second of the two that is selected. A lookup by bare id resolves to + * whichever matched first, so picking the first cannot tell a correct implementation + * from a broken one. + */ + it('picks the right pet when two owners hold the same id', async () => { + mockState.opponents = [ + foe({ id: '1', name: 'FirstOwnerPet', owner: '0xaaa' }), + foe({ id: '1', name: 'SecondOwnerPet', owner: '0xbbb' }), + ]; + const tree = await render(); + await pressWith(tree, 'Rex'); + await pressWith(tree, 'SecondOwnerPet'); + await pressWith(tree, 'Start Battle'); + + expect(mockBattle).toHaveBeenCalledWith( + expect.objectContaining({ petId2: '1', defenderOwner: '0xbbb' }), + ); + }); + + it('surfaces an opponent load failure', async () => { + mockState.opponentsError = new Error('backend unreachable'); + mockState.opponents = []; + const tree = await render(); + expect(textOf(tree)).toContain('backend unreachable'); + }); +}); + +/** + * The replay is presentation over a verified receipt, never a source of truth. + * + * `useBattlePets` only exposes `liveReplay` once every verification check has passed, + * so there is no state where the scene animates a fight the receipt does not commit to. + * What is worth pinning here is the other half: that the verdict waits for the fight to + * finish, and that a battle with nothing to animate still reports its result at once. + */ +describe('battle replay', () => { + /** + * Fake timers, because the first test asserts on what has *not* happened yet. + * + * `useLiveBattleAnimation` arms a 700ms `setTimeout` on mount. Under real timers the + * "no strike has played" assertion is a race against how long `render` plus `openArena` + * take: under 700ms it passes, over it the first strike has already landed and the bars + * are no longer full. That is fast enough locally to look reliable (it failed about one + * run in six) and slow enough on a cold CI runner to fail every time, which is exactly + * how it was found. + * + * The other two tests still advance time, they just do it explicitly. + */ + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * `useLiveBattleAnimation`'s strike interval. + * + * The third copy of this number: the hook owns it unexported, and `BattleScene` keeps its + * own `DRAIN_MS` to match. Worth exporting from the hook so all three read one value, but + * that is a `shared/` change and this is a mobile fix. + */ + const STRIKE_INTERVAL_MS = 700; + + /** One strike interval, plus enough to clear the boundary. */ + const playOneStrike = async () => { + await ReactTestRenderer.act(async () => { + jest.advanceTimersByTime(STRIKE_INTERVAL_MS + 50); + }); + }; + + const replay = (log: ReturnType[]) => ({ + log, + startHp1: 100n, + startHp2: 100n, + }); + + it('shows nothing to watch until a replay exists', async () => { + const tree = await render(); + expect(textOf(tree)).not.toContain('Bracing for the first strike'); + }); + + it('opens on full bars, before any strike has played', async () => { + mockState.liveReplay = replay([strike()]); + const tree = await render(); + await openArena(tree); + // Both fighters at 100%: the first strike has not landed yet. + expect(textOf(tree)).toContain('Bracing for the first strike'); + expect(textOf(tree)).toContain('100%'); + }); + + it('plays a strike, dropping the defender and narrating it', async () => { + mockState.liveReplay = replay([strike({ hp1After: 100n, hp2After: 60n })]); + const tree = await render(); + await openArena(tree); + + await playOneStrike(); + + const rendered = textOf(tree); + expect(rendered).toContain('60%'); + expect(rendered).toContain('lands a physical strike'); + // The mechanical log names both fighters, unlike the one-line flourish. + expect(rendered).toContain('Round 1'); + expect(rendered).toContain('Rex'); + }); + + it('reports the whole log as history, oldest first', async () => { + mockState.liveReplay = replay([ + strike({ round: 1, hp2After: 70n }), + strike({ round: 2, attacker: 2, crit: true, hp1After: 55n }), + ]); + const tree = await render(); + await openArena(tree); + + // One act per strike. The next timer is only armed by the effect that runs + // after React re-renders from the previous one, so a single long wait would + // play the first strike and never schedule the second. + for (let i = 0; i < 2; i++) { + await playOneStrike(); + } + + const rendered = textOf(tree); + expect(rendered.indexOf('Round 1')).toBeLessThan(rendered.indexOf('Round 2')); + expect(rendered).toContain('Crit!'); + }); +}); + +/** + * The result dialogue, and the reason it needs personas captured at battle start. + * + * Publishing a receipt puts the fighter on cooldown, so it leaves `readyPets` and + * `fighter` reads null exactly when the result is on screen. Anything naming the two + * afterwards has to fall back to what was captured when the fight began. + */ +describe('result dialogue', () => { + const startBattle = async (tree: ReactTestRenderer.ReactTestRenderer) => { + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Luna'); + await pressWith(tree, 'Start Battle'); + }; + + it('asks only for the post-fight phase, since taunts already played', async () => { + mockState.dialogueTurns = [ + { speaker: 'attacker', phase: 'taunt', text: 'Before the fight.' }, + { speaker: 'defender', phase: 'result', text: 'Well fought.' }, + ]; + const tree = await render(); + await startBattle(tree); + + // A taunt turn reaching the result sheet would replay pre-fight lines after it. + expect(textOf(tree)).not.toContain('Before the fight.'); + }); + + it('names both fighters from the captured personas once the fighter is on cooldown', async () => { + const tree = await render(); + await startBattle(tree); + + // The receipt has published, so the fighter is cooling down and out of the list. + mockState.pets = [pet({ readyAt: 9_999_999_999 })]; + mockState.opponents = []; + await ReactTestRenderer.act(async () => { + tree.update(); + }); + + const asked = mockDialogueArgs.mock.calls.at(-1)?.[0] as { + attacker: { name: string } | null; + defender: { name: string } | null; + }; + expect(asked.attacker?.name).toBe('Rex'); + expect(asked.defender?.name).toBe('Luna'); + }); + + it('narrates the strike log with those names too, not "Your pet"', async () => { + mockState.liveReplay = { log: [strike({ hp2After: 80n })], startHp1: 100n, startHp2: 100n }; + const tree = await render(); + await startBattle(tree); + + mockState.pets = [pet({ readyAt: 9_999_999_999 })]; + await ReactTestRenderer.act(async () => { + tree.update(); + }); + await ReactTestRenderer.act(async () => { + await new Promise((r) => setTimeout(r, 750)); + }); + + expect(textOf(tree)).toContain('Rex'); + expect(textOf(tree)).not.toContain('Your pet strikes'); + }); +}); + +/** + * Why the opponent list is empty. + * + * Four very different situations render as the same blank picker, and only some are the + * player's to act on. The server names which filter emptied it precisely so the client + * does not have to guess, and mobile discarded that until now — a roster nobody had + * indexed and a rival who had simply not allowed challenges both read as + * "No opponents available right now." + */ +describe('empty opponent list', () => { + beforeEach(() => { + mockState.opponents = []; + }); + + it('says an unindexed roster is not the player’s to fix', async () => { + mockState.emptyReason = 'roster-empty'; + const tree = await render(); + expect(textOf(tree)).toContain('server-side gap'); + }); + + it('points at the other player when nobody has allowed challenges', async () => { + mockState.emptyReason = 'no-consent'; + const tree = await render(); + expect(textOf(tree)).toContain('Allow Challenges'); + }); + + it('distinguishes consent signed under older rules from none at all', async () => { + mockState.emptyReason = 'consent-stale'; + const tree = await render(); + expect(textOf(tree)).toContain('older set of battle rules'); + }); + + it('tells a player on cooldown to come back, not that the game is empty', async () => { + mockState.emptyReason = 'all-on-cooldown'; + const tree = await render(); + expect(textOf(tree)).toContain('Try again shortly'); + }); + + it('falls back to a plain line when the server names no reason', async () => { + mockState.emptyReason = null; + mockState.isAuthenticated = true; + mockState.phase = 'idle'; + mockState.battleState = null; + mockState.failureReason = null; + const tree = await render(); + expect(textOf(tree)).toContain('No opponents available'); + }); +}); + +/** + * What a battle is waiting on. + * + * Six backend states rendered as the single word "Fighting…", so a fight stalled at + * `computed` — waiting on the independent Go verifier to agree — looked exactly like one + * about to finish. And a battle that ended badly said nothing at all: the overlay simply + * stopped changing, with the reason sitting unread on the server. + */ +describe('battle stage', () => { + it('says nothing while idle, because the arena has not been entered', async () => { + // The stage label moved into the arena when the arena started opening on Start. Until + // then there is no fight to narrate and nothing of it on screen. + const tree = await render(); + expect(textOf(tree)).not.toContain('Waiting for'); + }); + + it('names the stage rather than one word for six of them', async () => { + mockState.phase = 'resolving'; + mockState.battleState = 'computed'; + const tree = await render(); + await openArena(tree); + expect(textOf(tree)).toContain('independent verifier'); + }); + + it('distinguishes waiting on randomness from running the fight', async () => { + mockState.phase = 'awaiting-vrf'; + mockState.battleState = 'committed'; + const committed = await render(); + await openArena(committed); + expect(textOf(committed)).toContain('committed randomness round'); + + mockState.battleState = 'seeded'; + const seeded = await render(); + await openArena(seeded); + expect(textOf(seeded)).toContain('Running the fight'); + }); + + it('says why a battle stopped instead of leaving the screen frozen', async () => { + mockState.battleState = 'verification_failed'; + mockState.failureReason = 'engines disagreed'; + const tree = await render(); + await openArena(tree); + + expect(textOf(tree)).toContain('two engines disagreed'); + expect(textOf(tree)).toContain('engines disagreed'); + }); +}); + +describe('BattleScreen — versus mark', () => { + it('separates your fighter from the opponent', async () => { + // Your pet is a picker chip strip and the opponent is a list of rows. Both are pet + // rows under their own label, and nothing else on the screen says which side of the + // fight each one is. + const tree = await render(); + expect(textOf(tree)).toContain('VS'); + }); +}); + +describe('BattleScreen — chosen opponent', () => { + it('shows nothing until an opponent is chosen', async () => { + mockState.opponents = [foe({ id: '9', name: 'Luna', level: 11 })]; + expect(textOf(await render())).not.toContain('STR'); + }); + + it('shows the chosen opponent stats the chip has no room for', async () => { + // The chip is 80px of art, name and level. What decides a fight is underneath it. + mockState.opponents = [foe({ id: '9', name: 'Luna', level: 11 })]; + const tree = await render(); + await pressWith(tree, 'Luna'); + + expect(textOf(tree)).toContain('STR'); + }); + + it('follows the choice to another opponent', async () => { + // Both are mounted in the strip, so this is a real re-selection rather than a + // re-render with a different prop. + mockState.opponents = [ + foe({ id: '9', name: 'Luna', winCount: 4, lossCount: 1 }), + foe({ id: '12', name: 'Momo', winCount: 0, lossCount: 0 }), + ]; + const tree = await render(); + + await pressWith(tree, 'Luna'); + expect(textOf(tree)).toContain('80% wins'); + + await pressWith(tree, 'Momo'); + // An opponent with no record shows no rate at all, the same as on the card. + expect(textOf(tree)).not.toContain('% wins'); + }); +}); + +describe('the arena', () => { + const closeControl = (tree: ReactTestRenderer.ReactTestRenderer) => + tree.root.findAll((n) => n.props.accessibilityLabel === 'Leave the arena'); + + const isOpen = (tree: ReactTestRenderer.ReactTestRenderer) => closeControl(tree).length > 0; + + it('stays shut until Start is pressed', async () => { + const tree = await render(); + expect(isOpen(tree)).toBe(false); + + await openArena(tree); + expect(isOpen(tree)).toBe(true); + }); + + it('opens on the press, before there is anything to watch', async () => { + // Six backend states run before a replay exists, and a fight can fail on any of them. + // Waiting for the replay would leave the player on the setup screen with no sign the + // battle had started, and none when it stopped. + const tree = await render(); + await openArena(tree); + + expect(isOpen(tree)).toBe(true); + expect(textOf(tree)).not.toContain('Bracing for the first strike'); + }); + + it('closes again', async () => { + const tree = await render(); + await openArena(tree); + + await ReactTestRenderer.act(async () => closeControl(tree)[0].props.onPress()); + expect(isOpen(tree)).toBe(false); + }); +}); + +describe('who said it', () => { + /** A bubble's own label carries the speaker, which is also what a screen reader reads. */ + const bubbleNodes = (tree: ReactTestRenderer.ReactTestRenderer) => + tree.root.findAll( + // Host nodes only. `findAll` returns the composite element and the view it + // renders, so an unfiltered search counts every bubble twice. + (n) => typeof n.type === 'string' && String(n.props.accessibilityLabel ?? '').includes(' says: '), + ); + + const saidBy = (tree: ReactTestRenderer.ReactTestRenderer) => + bubbleNodes(tree).map((n) => n.props.accessibilityLabel as string); + + it('puts a line beside the pet that said it', async () => { + // The whole point. Both lines used to land in one column with nothing to tell them + // apart, because the panel flattened the turns to their text and dropped the speaker. + mockState.opponents = [foe({ id: '9', name: 'Luna' })]; + mockState.turns = [ + { speaker: 'attacker', phase: 'taunt', text: 'Nice stance.' }, + { speaker: 'defender', phase: 'taunt', text: 'Come and see.' }, + ]; + const tree = await render(); + // Both chosen, so the bubbles carry the pets' own names rather than the panel's + // "Your pet" / "Opponent" fallbacks. + await pressWith(tree, 'Rex'); + await pressWith(tree, 'Luna'); + await openArena(tree); + + expect(saidBy(tree)).toEqual([ + 'Rex says: Nice stance.', + 'Luna says: Come and see.', + ]); + }); + + it('draws the two sides in different colours', async () => { + mockState.turns = [ + { speaker: 'attacker', phase: 'taunt', text: 'Nice stance.' }, + { speaker: 'defender', phase: 'taunt', text: 'Come and see.' }, + ]; + const tree = await render(); + await openArena(tree); + + const bubbles = bubbleNodes(tree).map((n) => StyleSheet.flatten(n.props.style)); + + expect(bubbles).toHaveLength(2); + expect(bubbles[0].borderColor).not.toBe(bubbles[1].borderColor); + }); +}); diff --git a/mobile/__tests__/BreedScreen.test.tsx b/mobile/__tests__/BreedScreen.test.tsx new file mode 100644 index 00000000..8513fb1c --- /dev/null +++ b/mobile/__tests__/BreedScreen.test.tsx @@ -0,0 +1,385 @@ +/** + * Breeding, over the real `useBreedPanel` with `@shared/core` stubbed. The hook is + * the ported logic and the part worth testing: tab auto-switching, the cross-owner + * spouse path, and the guards that stop a doomed transaction being sent (relatives, + * a pending breed, a missing spouse). + */ + +import React from 'react'; +import { TextInput, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; +/** + * `useSafeAreaInsets` throws outside a `SafeAreaProvider`, and this suite renders a screen on + * its own. The library ships this mock for exactly that. Repeated per suite rather than + * registered globally: a global one needs a `setupFiles` entry pointing at a file whose name + * says nothing about what it does. + */ +jest.mock('react-native-safe-area-context', () => + require('react-native-safe-area-context/jest/mock').default, +); + + +const pet = (over: Partial = {}): Pet => ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 0n, + level: 5, + rarity: 2, + winCount: 0, + lossCount: 0, + readyAt: 0, + ...over, +}); + +const mockState = { + solanaPending: false, + solanaCanCancel: false, + pets: [pet(), pet({ id: '2', name: 'Momo' })] as Pet[], + areRelated: false, + pendingIds: [] as string[], + isMarried: false, + spouseId: undefined as bigint | undefined, + randomnessProvider: null as string | null, + isPending: false, + isAwaitingFulfillment: false, +}; + +const mockBreed = jest.fn(); + +jest.mock('../src/hooks/useTxErrorToast', () => ({ useTxErrorToast: () => {} })); + +jest.mock('../src/components/PetArt', () => () => null); + +const mockCancelSolana = jest.fn(async () => undefined); +const mockSettleBreed = jest.fn(async () => undefined); +const mockCancelBreed = jest.fn(async () => undefined); + +jest.mock('@shared/core', () => ({ + // `PetPicker` shows the selected pet's stats inline now, so anything rendering a picker + // reaches these. Real rather than stubbed: they are pure and dependency-free, and what a + // pet reads here has to be what it reads on the card and on the web app. + ...jest.requireActual('../../shared/src/utils/ethereum/petCard'), + ...jest.requireActual('../../shared/src/utils/pets/skills'), + ...jest.requireActual('../../shared/src/utils/pets/cosmetics'), + /** Solana holds one outstanding request per owner, not one per parent. */ + usePendingSolanaBreed: () => ({ + isPending: mockState.solanaPending, + canCancel: mockState.solanaCanCancel, + cancel: { run: mockCancelSolana, isPending: false, error: null }, + refetch: jest.fn(), + }), + useStudFees: () => ({ + amountLamports: null, + isLoading: false, + withdraw: { run: jest.fn(), isPending: false, error: null }, + refetch: jest.fn(), + }), + usePetList: () => ({ pets: mockState.pets, isLoading: false, error: null, refetch: jest.fn() }), + useChainCapabilities: () => ({ + randomness: { provider: mockState.randomnessProvider }, + activeKind: 'evm', + }), + useFees: () => ({ studFee: 500n, formatAmount: (v: bigint) => `${v} wei` }), + useMarriageInfo: () => ({ + isLoading: false, + isMarried: mockState.isMarried, + spouseId: mockState.spouseId, + }), + useBreedRelationCheck: () => ({ areRelated: mockState.areRelated }), + usePendingBreed: (id?: string) => ({ + isPending: id != null && mockState.pendingIds.includes(id), + // The way out of a stuck breed. Without these the screen can detect one and + // offer nothing, which is the bug this stub used to model faithfully. + settle: { run: mockSettleBreed, isPending: false, error: null }, + cancel: { run: mockCancelBreed, isPending: false, error: null }, + refetch: jest.fn(), + }), + useBreedPets: () => ({ + mutate: mockBreed, + isPending: mockState.isPending, + isAwaitingFulfillment: mockState.isAwaitingFulfillment, + error: null, + clearErrors: jest.fn(), + hash: '0xdeadbeefcafe', + lifecycle: {}, + }), +})); + +jest.mock('../src/hooks/usePetErrorToast', () => ({ usePetErrorToast: () => {} })); + +import BreedScreen from '../src/screens/BreedScreen'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +const press = async (tree: ReactTestRenderer.ReactTestRenderer, index: number) => { + await ReactTestRenderer.act(async () => { + tree.root.findAllByType(TouchableOpacity)[index].props.onPress(); + }); +}; + +const pressLabel = async (tree: ReactTestRenderer.ReactTestRenderer, label: string) => { + const node = tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === label); + await ReactTestRenderer.act(async () => node!.props.onPress()); +}; + +/** + * By `testID`, not by taking the last touchable. That held only while the breed button was + * the final thing in the scroll; it now lives in `ScreenActionBar`, and the next control + * added after the bar would have silently pressed the wrong one. + */ +const breedButton = (tree: ReactTestRenderer.ReactTestRenderer) => + tree.root.findAllByType(TouchableOpacity).find((n) => n.props.testID === 'action-primary')!; + +const pressBreed = async (tree: ReactTestRenderer.ReactTestRenderer) => { + await ReactTestRenderer.act(async () => { + breedButton(tree).props.onPress(); + }); +}; + +const type = async (tree: ReactTestRenderer.ReactTestRenderer, value: string) => { + await ReactTestRenderer.act(async () => { + tree.root.findByType(TextInput).props.onChangeText(value); + }); +}; + +/** + * Touchables are flat: two tab buttons, then the first picker's chips, then the + * second picker's. The second picker drops whichever pet the first selected, so + * its chips start after `pets.length` entries. + */ +const selectParent1 = async (tree: ReactTestRenderer.ReactTestRenderer) => press(tree, 2); +const selectParent2 = async (tree: ReactTestRenderer.ReactTestRenderer) => + press(tree, 2 + mockState.pets.length); + +beforeEach(() => { + mockState.solanaPending = false; + mockState.solanaCanCancel = false; + mockState.pets = [pet(), pet({ id: '2', name: 'Momo' })]; + mockState.areRelated = false; + mockState.pendingIds = []; + mockState.isMarried = false; + mockState.spouseId = undefined; + mockState.randomnessProvider = null; + mockState.isPending = false; + mockState.isAwaitingFulfillment = false; + jest.clearAllMocks(); +}); + +describe('BreedScreen — own pets', () => { + it('breeds two owned pets with the trimmed child name', async () => { + const tree = await render(); + await selectParent1(tree); // parent 1 → id 1 + await selectParent2(tree); // parent 2 list excludes id 1, so first chip is id 2 + await type(tree, ' Pup '); + await pressBreed(tree); + expect(mockBreed).toHaveBeenCalledWith({ parentId1: '1', parentId2: '2', name: 'Pup' }); + }); + + it('never offers the same pet as both parents', async () => { + const tree = await render(); + await selectParent1(tree); + // Second picker drops the chosen parent, so a pet cannot breed with itself. + const names = textOf(tree); + expect(names).toContain('Momo'); + await selectParent2(tree); + await type(tree, 'Pup'); + await pressBreed(tree); + const call = mockBreed.mock.calls[0][0]; + expect(call.parentId1).not.toEqual(call.parentId2); + }); + + it('blocks breeding relatives', async () => { + mockState.areRelated = true; + const tree = await render(); + await selectParent1(tree); + await selectParent2(tree); + await type(tree, 'Pup'); + await pressBreed(tree); + expect(mockBreed).not.toHaveBeenCalled(); + expect(textOf(tree)).toContain('These two are related'); + }); + + it('blocks while a breed is already pending for a parent', async () => { + // This guard is the button's `disabled`, not a check inside `onBreed` — + // same as frontend. Asserting `disabled` is what RN actually enforces; + // calling `onPress` directly bypasses it and would send the doomed tx. + mockState.pendingIds = ['1']; + const tree = await render(); + await selectParent1(tree); + await selectParent2(tree); + await type(tree, 'Pup'); + expect(breedButton(tree).props.disabled).toBe(true); + expect(textOf(tree)).toContain('A breed is already pending'); + }); + + it('requires a child name', async () => { + const tree = await render(); + await selectParent1(tree); + await selectParent2(tree); + await pressBreed(tree); + expect(mockBreed).not.toHaveBeenCalled(); + }); +}); + +describe('BreedScreen — pairing mark', () => { + it('sits between the two parents, and only where there are two', async () => { + // Both parent rows are identical chip strips under the same "Select Pet" label, so + // the mark is what says the second one is the other half of a pair rather than more + // of the first. The spouse tab picks one pet and has nothing to pair it with here. + expect(textOf(await render())).toContain('♥'); + + mockState.pets = [pet()]; + expect(textOf(await render())).not.toContain('♥'); + }); + + // Nothing here checks that the mark is not pressable, because `selectParent2` above + // already does: it presses by index, and one extra touchable between the pickers would + // retarget it. That is a better guard than a count, which would rot on the next button. +}); + +describe('BreedScreen — with spouse', () => { + it('auto-switches to the spouse tab when there is only one pet', async () => { + // The My Pets tab needs two, so landing there with one is a dead end. + mockState.pets = [pet()]; + const tree = await render(); + expect(textOf(tree)).toContain('Breed with Spouse'); + }); + + it('sends the cross-owner flag and the spouse id', async () => { + mockState.pets = [pet()]; + mockState.isMarried = true; + mockState.spouseId = 42n; + const tree = await render(); + await type(tree, 'Pup'); + await pressBreed(tree); + expect(mockBreed).toHaveBeenCalledWith({ + parentId1: '1', + parentId2: '42', + name: 'Pup', + crossOwner: true, + }); + }); + + it('will not breed an unmarried pet', async () => { + mockState.pets = [pet()]; + mockState.isMarried = false; + const tree = await render(); + await type(tree, 'Pup'); + await pressBreed(tree); + expect(mockBreed).not.toHaveBeenCalled(); + expect(textOf(tree)).toContain('not married'); + }); + + it('shows the stud fee once a marriage is confirmed', async () => { + mockState.pets = [pet()]; + mockState.isMarried = true; + mockState.spouseId = 42n; + const tree = await render(); + expect(textOf(tree)).toContain('Stud fee: 500 wei'); + }); +}); + +describe('BreedScreen — async randomness', () => { + it('names the wait differently on a Switchboard chain', async () => { + // Solana's commit/settle is visibly slower, so the label says why. + mockState.randomnessProvider = 'switchboard'; + mockState.isPending = true; + const tree = await render(); + expect(textOf(tree)).toContain('Generating randomness…'); + }); + + it('says "Submitting…" where randomness is not client-visible', async () => { + mockState.isPending = true; + const tree = await render(); + expect(textOf(tree)).toContain('Submitting…'); + }); + + it('explains that leaving during fulfillment is safe', async () => { + mockState.isAwaitingFulfillment = true; + const tree = await render(); + expect(textOf(tree)).toContain('Waiting for randomness'); + }); +}); + +/** + * Getting out of an interrupted breed. + * + * v2 breed is request then settle. If the settle never lands the parents stay pending + * and cannot breed again — and the screen used to say "settle or cancel it first" while + * offering neither, so the pets were stuck for good. + */ +describe('BreedScreen — recovering a stuck breed', () => { + it('offers settle and cancel rather than naming them and stopping', async () => { + mockState.pendingIds = ['1']; + const tree = await render(); + // The per-pet query only runs for a selected parent, so nothing is pending + // until one is chosen — the same shape the blocking test above relies on. + await selectParent1(tree); + + const labels = tree.root + .findAllByType(TouchableOpacity) + .map((n) => n.props.accessibilityLabel); + expect(labels).toContain('Settle pending breed'); + expect(labels).toContain('Cancel pending breed'); + }); + + it('settles on tap', async () => { + mockState.pendingIds = ['1']; + const tree = await render(); + await selectParent1(tree); + await pressLabel(tree, 'Settle pending breed'); + expect(mockSettleBreed).toHaveBeenCalled(); + }); + + it('cancels on tap', async () => { + mockState.pendingIds = ['1']; + const tree = await render(); + await selectParent1(tree); + await pressLabel(tree, 'Cancel pending breed'); + expect(mockCancelBreed).toHaveBeenCalled(); + }); + + it('offers no settle on Solana, which resumes instead', async () => { + // Solana's request has no settle step: a new attempt resumes it. Offering one + // would ask for a transaction the program does not have. + mockState.pendingIds = []; + mockState.solanaPending = true; + mockState.solanaCanCancel = false; + const tree = await render(); + + expect(textOf(tree)).toContain('will resume it'); + const labels = tree.root + .findAllByType(TouchableOpacity) + .map((n) => n.props.accessibilityLabel); + expect(labels).not.toContain('Settle pending breed'); + // Cancel only once the randomness has expired. + expect(labels).not.toContain('Cancel pending breed'); + }); + + it('offers cancel on Solana once the randomness has expired', async () => { + mockState.pendingIds = []; + mockState.solanaPending = true; + mockState.solanaCanCancel = true; + const tree = await render(); + + expect(textOf(tree)).toContain('Randomness has expired'); + await pressLabel(tree, 'Cancel pending breed'); + expect(mockCancelSolana).toHaveBeenCalled(); + }); +}); diff --git a/mobile/__tests__/ChatScreen.test.tsx b/mobile/__tests__/ChatScreen.test.tsx new file mode 100644 index 00000000..a5579885 --- /dev/null +++ b/mobile/__tests__/ChatScreen.test.tsx @@ -0,0 +1,256 @@ +/** + * Private chat, and the parts of it that are security properties rather than styling. + * + * Access is derived per request from live marriage state, never cached here. A thread + * leaving the list is a divorce landing, which is the feature working. And a + * non-participant gets 404, identical to a thread that does not exist, because a 403 + * would confirm a thread id to anyone probing — so a failed read must render one message + * for both and this screen must not try to explain which happened. + * + * `@shared/core` is stubbed — its barrel drags the Solana runtime into jest. + */ + +import React from 'react'; +import { TextInput, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +const SELF = '0xAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaa'; +const THEM = '0xBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbb'; + +const thread = (over: Record = {}) => ({ + threadId: 't1', + counterpart: THEM, + chain: 'ethereum', + pets: [ + { + petId: '1', + petName: 'Rex', + petDna: '1', + spousePetId: '9', + spouseName: 'Luna', + spouseDna: '2', + }, + ], + ...over, +}); + +const message = (over: Record = {}) => ({ + id: 1, + sender: THEM, + text: 'hello', + createdAt: '2026-08-11T00:00:00Z', + ...over, +}); + +const mockState = { + threads: [] as Record[], + threadsLoading: false, + threadsError: null as Error | null, + messages: [] as Record[], + messagesError: null as Error | null, + readUpTo: 0, + online: [] as string[], + isLive: true, + sendError: null as Error | null, +}; + +const mockSend = jest.fn(); +const mockReact = jest.fn(); +const mockMarkRead = jest.fn(); +const mockMessagesArgs = jest.fn(); + +jest.mock('../src/components/PetArt', () => () => null); + +/** + * Pass-through here: these suites are about what the screen draws once the session + * exists. The gate has its own suite, so re-exercising it five times would only make + * every fixture carry auth state it does not use. + */ +jest.mock('../src/components/SessionGate', () => { + const React_ = jest.requireActual('react'); + return ({ children }: { children: React.ReactNode }) => + React_.createElement(React_.Fragment, null, children); +}); + +jest.mock('@shared/core', () => ({ + CHAT_REACTIONS: ['👍', '❤️', '😂', '😮', '😢', '🙏', '👎'], + shortAddress: (a: string) => `${a.slice(0, 6)}...${a.slice(-4)}`, + sameAccount: (a: string, b: string) => a.toLowerCase() === b.toLowerCase(), + useChatThreads: () => ({ + threads: mockState.threads, + isLoading: mockState.threadsLoading, + error: mockState.threadsError, + }), + useChatMessages: (opts: unknown) => { + mockMessagesArgs(opts); + return { + messages: mockState.messages, + readUpTo: mockState.readUpTo, + markRead: mockMarkRead, + react: mockReact, + isLoading: false, + error: mockState.messagesError, + isLive: mockState.isLive, + online: mockState.online, + send: mockSend, + isSending: false, + sendError: mockState.sendError, + hasOlder: false, + isLoadingOlder: false, + loadOlder: jest.fn(), + }; + }, +})); + +jest.mock('wagmi', () => ({ useAccount: () => ({ address: SELF }) })); + +import ChatScreen from '../src/screens/ChatScreen'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +const press = async (tree: ReactTestRenderer.ReactTestRenderer, label: string) => { + const node = tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === label); + await ReactTestRenderer.act(async () => node!.props.onPress()); +}; + +const openThread = (tree: ReactTestRenderer.ReactTestRenderer) => + press(tree, `Open chat with ${THEM.slice(0, 6)}...${THEM.slice(-4)}`); + +beforeEach(() => { + mockState.threads = [thread()]; + mockState.threadsLoading = false; + mockState.threadsError = null; + mockState.messages = [message()]; + mockState.messagesError = null; + mockState.readUpTo = 0; + mockState.online = []; + mockState.isLive = true; + mockState.sendError = null; + jest.clearAllMocks(); +}); + +describe('thread list', () => { + it('names the counterpart and the married pairs the thread exists for', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('Rex ♥ Luna'); + }); + + it('explains an empty list rather than looking broken', async () => { + mockState.threads = []; + const tree = await render(); + expect(textOf(tree)).toContain('No conversations yet'); + }); +}); + +describe('access', () => { + it('falls back to the list when an open thread disappears, which is a divorce', async () => { + const tree = await render(); + await openThread(tree); + expect(tree.root.findAllByType(TextInput)).toHaveLength(1); + + mockState.threads = []; + await ReactTestRenderer.act(async () => { + tree.update(); + }); + + // Back on the list, not sitting in a transcript whose next read would fail. + expect(textOf(tree)).toContain('No conversations yet'); + expect(tree.root.findAllByType(TextInput)).toHaveLength(0); + }); + + it('gives one message for a failed read, never naming which case it was', async () => { + mockState.messagesError = new Error('Request failed with status code 404'); + const tree = await render(); + await openThread(tree); + + const rendered = textOf(tree); + expect(rendered).toContain('unavailable'); + // The distinction a 403 would have leaked must not be reconstructed here. + expect(rendered).not.toContain('404'); + expect(rendered).not.toContain('not a participant'); + }); +}); + +describe('conversation', () => { + it('marks the newest message read, moving this side of the watermark', async () => { + mockState.messages = [message({ id: 4 })]; + const tree = await render(); + await openThread(tree); + expect(mockMarkRead).toHaveBeenCalledWith(4); + }); + + it('shows a read receipt only on your own messages, by watermark', async () => { + mockState.messages = [message({ id: 1, sender: SELF }), message({ id: 2, sender: SELF })]; + mockState.readUpTo = 1; + const tree = await render(); + await openThread(tree); + // One tick: id 1 is at or below the watermark, id 2 is not. + expect(textOf(tree).match(/Read/g) ?? []).toHaveLength(1); + }); + + it('counts presence by identity, so the counterpart shows online', async () => { + mockState.online = [SELF.toLowerCase(), THEM.toLowerCase()]; + const tree = await render(); + await openThread(tree); + expect(textOf(tree)).toContain('online'); + }); + + it('says it is not live when the socket is down, without blocking reads', async () => { + mockState.isLive = false; + const tree = await render(); + await openThread(tree); + expect(textOf(tree)).toContain('not live'); + expect(textOf(tree)).toContain('hello'); + }); + + it('sends the trimmed draft', async () => { + const tree = await render(); + await openThread(tree); + await ReactTestRenderer.act(async () => { + tree.root.findByType(TextInput).props.onChangeText(' well fought '); + }); + await press(tree, 'Send message'); + expect(mockSend).toHaveBeenCalledWith('well fought'); + }); + + it('gives the words back when a send fails', async () => { + mockSend.mockRejectedValueOnce(new Error('marriage ended')); + const tree = await render(); + await openThread(tree); + await ReactTestRenderer.act(async () => { + tree.root.findByType(TextInput).props.onChangeText('hi'); + }); + await press(tree, 'Send message'); + // Restored rather than dropped: the message never arrived, so it is still theirs. + expect(tree.root.findByType(TextInput).props.value).toBe('hi'); + }); + + it('toggles an existing reaction through the server rather than guessing', async () => { + mockState.messages = [message({ reactions: [{ emoji: '👍', count: 1, mine: true }] })]; + const tree = await render(); + await openThread(tree); + await press(tree, 'React 👍'); + expect(mockReact).toHaveBeenCalledWith(1, '👍'); + }); + + it('passes the socket url through, so the thread can go live', async () => { + const tree = await render(); + await openThread(tree); + const asked = mockMessagesArgs.mock.calls.at(-1)?.[0] as { threadId: string }; + expect(asked.threadId).toBe('t1'); + }); +}); diff --git a/mobile/__tests__/ConnectButton.test.tsx b/mobile/__tests__/ConnectButton.test.tsx new file mode 100644 index 00000000..ecad2c29 --- /dev/null +++ b/mobile/__tests__/ConnectButton.test.tsx @@ -0,0 +1,186 @@ +/** + * The landing screen's wallet panel, and the only place a player can sign in + * before entering the tab shell. It has three states, not two: no wallet, wallet + * without a backend session, and both. + * + * The middle one is what matters. A connected wallet with no JWT looks finished + * from the outside, and every backend-served read stays empty until the player + * signs, so the sign-in call has to be reachable and its progress legible. + * + * `@shared/core` is stubbed, since its barrel drags the Solana runtime into jest. + */ + +import React from 'react'; +import { Text, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +const mockState = { + address: '0x1234567890abcdef1234567890abcdef12345678' as string | undefined, + chainId: 11155111 as number | undefined, + isConnected: true, + isAuthenticated: false, + isSigning: false, + isVerifying: false, + isNonceLoading: false, + user: null as { lastLogin: string } | null, +}; + +const mockOpen = jest.fn(); +const mockDisconnect = jest.fn(); +const mockSignAndLogin = jest.fn(); +const mockLogout = jest.fn(); + +jest.mock('@reown/appkit-react-native', () => ({ + useAppKit: () => ({ open: mockOpen, disconnect: mockDisconnect }), +})); + +jest.mock('wagmi', () => ({ + useAccount: () => ({ + address: mockState.address, + isConnected: mockState.isConnected, + chainId: mockState.chainId, + }), +})); + +jest.mock('@shared/core', () => ({ + useAuth: () => ({ + isAuthenticated: mockState.isAuthenticated, + user: mockState.user, + signAndLogin: mockSignAndLogin, + logout: mockLogout, + isSigning: mockState.isSigning, + isVerifying: mockState.isVerifying, + isNonceLoading: mockState.isNonceLoading, + }), +})); + +import ConnectButton from '../src/components/ConnectButton'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +/** Finds a button by the text it renders, since order shifts between states. */ +const buttonWith = (tree: ReactTestRenderer.ReactTestRenderer, label: string) => + tree.root + .findAllByType(TouchableOpacity) + .find((node) => + node.findAllByType(Text).some((t) => String(t.props.children).includes(label)), + ); + +beforeEach(() => { + mockState.address = '0x1234567890abcdef1234567890abcdef12345678'; + mockState.chainId = 11155111; + mockState.isConnected = true; + mockState.isAuthenticated = false; + mockState.isSigning = false; + mockState.isVerifying = false; + mockState.isNonceLoading = false; + mockState.user = null; + jest.clearAllMocks(); +}); + +describe('ConnectButton without a wallet', () => { + it('offers only the connect action', async () => { + mockState.isConnected = false; + const tree = await render(); + + expect(textOf(tree)).toContain('Connect Wallet'); + expect(tree.root.findAllByType(TouchableOpacity)).toHaveLength(1); + }); + + it('opens the wallet modal', async () => { + mockState.isConnected = false; + const tree = await render(); + + await ReactTestRenderer.act(async () => { + tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === 'Connect Wallet')! + .props.onPress(); + }); + expect(mockOpen).toHaveBeenCalled(); + }); +}); + +describe('ConnectButton with a wallet but no session', () => { + it('says so rather than looking finished', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('Not Authenticated'); + }); + + it('reaches signAndLogin', async () => { + const tree = await render(); + + await ReactTestRenderer.act(async () => { + buttonWith(tree, 'Sign & Login')!.props.onPress(); + }); + expect(mockSignAndLogin).toHaveBeenCalled(); + }); + + it.each([ + ['isNonceLoading', 'isNonceLoading' as const, 'Loading...'], + ['isSigning', 'isSigning' as const, 'Signing...'], + ['isVerifying', 'isVerifying' as const, 'Verifying...'], + ])('names the stage during %s', async (_label, flag, expected) => { + mockState[flag] = true; + const tree = await render(); + expect(textOf(tree)).toContain(expected); + }); + + it('blocks a second signature request while one is pending', async () => { + // Wallets queue duplicate personal_sign prompts rather than ignoring them, + // so a double tap leaves the player dismissing a stack of them. + mockState.isSigning = true; + const tree = await render(); + expect(buttonWith(tree, 'Signing...')!.props.disabled).toBe(true); + }); + + it('can still disconnect', async () => { + const tree = await render(); + + await ReactTestRenderer.act(async () => { + buttonWith(tree, 'Disconnect')!.props.onPress(); + }); + expect(mockDisconnect).toHaveBeenCalled(); + }); +}); + +describe('ConnectButton with a session', () => { + it('offers logout and no longer offers sign-in', async () => { + mockState.isAuthenticated = true; + const tree = await render(); + + expect(textOf(tree)).toContain('Authenticated'); + expect(textOf(tree)).not.toContain('Sign & Login'); + + await ReactTestRenderer.act(async () => { + buttonWith(tree, 'Logout')!.props.onPress(); + }); + expect(mockLogout).toHaveBeenCalled(); + }); + + it('shows the connected chain and address', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('11155111'); + expect(textOf(tree)).toContain(mockState.address as string); + }); + + it('omits the last-login line when the session carries no user', async () => { + // `user` is null between a restored token and the profile arriving, and + // formatting undefined there renders "Invalid Date". + mockState.isAuthenticated = true; + const tree = await render(); + expect(textOf(tree)).not.toContain('Last login'); + }); +}); diff --git a/mobile/__tests__/CreatePetModal.test.tsx b/mobile/__tests__/CreatePetModal.test.tsx new file mode 100644 index 00000000..593ae76a --- /dev/null +++ b/mobile/__tests__/CreatePetModal.test.tsx @@ -0,0 +1,266 @@ +/** + * The mint sheet. EVM minting spans three waits, not one: the request + * transaction, Pyth Entropy revealing, then the settle transaction. Each has its + * own label, and all three must keep the sheet locked, because a second mint + * fired during any of them costs the player a real fee for a pet they did not + * ask for. + * + * No `@shared/core` stub here. The component now imports one value from it, + * `parseContractError`, which is pure and dependency-free, so the real thing runs + * and there is nothing to fake. + */ + +import React from 'react'; +import { TextInput, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +import CreatePetModal from '../src/components/CreatePetModal'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' '); + +type CreatePetLike = React.ComponentProps['createPet']; + +const mockMutate = jest.fn(); +const mockReset = jest.fn(); + +const createPet = (over: Partial> = {}): CreatePetLike => + ({ + mutate: mockMutate, + reset: mockReset, + isPending: false, + isAwaitingFulfillment: false, + isSettling: false, + error: null, + hash: undefined, + ...over, + }) as unknown as CreatePetLike; + +const render = async (props: Partial> = {}) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + , + ); + }); + return tree; +}; + + +/** The submit button is the only TouchableOpacity in the sheet. */ +const submitButton = (tree: ReactTestRenderer.ReactTestRenderer) => + tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === 'Create pet')!; + +const typeName = async (tree: ReactTestRenderer.ReactTestRenderer, value: string) => { + await ReactTestRenderer.act(async () => { + tree.root.findAllByType(TextInput)[0].props.onChangeText(value); + }); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('CreatePetModal submission', () => { + it('will not submit an empty name', async () => { + const tree = await render(); + expect(submitButton(tree).props.disabled).toBe(true); + + await ReactTestRenderer.act(async () => { + submitButton(tree).props.onPress(); + }); + expect(mockMutate).not.toHaveBeenCalled(); + }); + + it('treats a whitespace-only name as empty', async () => { + const tree = await render(); + await typeName(tree, ' '); + expect(submitButton(tree).props.disabled).toBe(true); + }); + + it('trims the name before minting', async () => { + // The name goes on chain, so leading space is permanent and unfixable + // without a rename fee. + const tree = await render(); + await typeName(tree, ' Rex '); + + await ReactTestRenderer.act(async () => { + submitButton(tree).props.onPress(); + }); + + expect(mockMutate).toHaveBeenCalledWith({ name: 'Rex' }); + }); + + it('caps the name at the on-chain limit', async () => { + const tree = await render(); + expect(tree.root.findAllByType(TextInput)[0].props.maxLength).toBe(20); + }); + + it('clears the name and any prior error when reopened', async () => { + // Reopening after a failure should not present the last attempt's error + // as if it applied to a fresh one. + const tree = await render(); + await typeName(tree, 'Rex'); + + await ReactTestRenderer.act(() => { + tree.update( + , + ); + }); + await ReactTestRenderer.act(() => { + tree.update( + , + ); + }); + + expect(tree.root.findAllByType(TextInput)[0].props.value).toBe(''); + expect(mockReset).toHaveBeenCalled(); + }); +}); + +describe('CreatePetModal three-phase mint', () => { + it.each([ + ['the request transaction', { isPending: true }, 'Confirm in wallet'], + ['randomness', { isAwaitingFulfillment: true }, 'Rolling traits'], + ['the settle transaction', { isSettling: true }, 'Minting'], + ])('names the wait it is in: %s', async (_label, state, expected) => { + const tree = await render({ createPet: createPet(state) }); + expect(textOf(tree)).toContain(expected); + }); + + it.each([ + ['isPending', { isPending: true }], + ['isAwaitingFulfillment', { isAwaitingFulfillment: true }], + ['isSettling', { isSettling: true }], + ])('locks the sheet during %s', async (_label, state) => { + // A second mint fired mid-flight is a second fee for a pet nobody asked + // for, so every phase has to block the button and the input alike. + const tree = await render({ createPet: createPet(state) }); + expect(submitButton(tree).props.disabled).toBe(true); + expect(tree.root.findAllByType(TextInput)[0].props.editable).toBe(false); + }); + + it('will not close mid-mint', async () => { + const onClose = jest.fn(); + const tree = await render({ createPet: createPet({ isSettling: true }), onClose }); + + // Backdrop and close button both drop their handler while busy. + const pressables = tree.root.findAll((node) => { + const type = node.type as { displayName?: string; name?: string }; + if (typeof type === 'string' || !type) return false; + return (type.displayName ?? type.name) === 'Pressable'; + }); + for (const p of pressables) { + expect(p.props.onPress).toBeUndefined(); + } + expect(onClose).not.toHaveBeenCalled(); + }); + + it('offers the plain label when idle', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('Create pet'); + }); +}); + +describe('CreatePetModal feedback', () => { + it('shows the failure reason', async () => { + const tree = await render({ + createPet: createPet({ error: new Error('insufficient funds') }), + }); + expect(textOf(tree)).toContain('insufficient funds'); + }); + + // A wallet refusal arrives as a viem error whose `message` is the whole request + // dump — chain, calldata, gas, a docs URL and a version banner — which used to be + // rendered verbatim into this small sheet. It is also the one failure carrying no + // information the player can act on, so it is the one that gets rewritten. + it.each([ + ['a non-Error rejection', 'user rejected'], + ['a viem rejection dump', new Error('User rejected the request.\nDocs: https://viem.sh/')], + ])('replaces %s with wording a player can act on', async (_label, error) => { + const tree = await render({ createPet: createPet({ error }) }); + + expect(textOf(tree)).toContain('Cancelled in your wallet'); + expect(textOf(tree)).not.toContain('viem.sh'); + }); + + it('reports a submitted transaction while still working', async () => { + const tree = await render({ + createPet: createPet({ hash: '0xabc', isAwaitingFulfillment: true }), + }); + expect(textOf(tree)).toContain('Transaction submitted'); + }); + + it('reports completion once the waits are over', async () => { + const tree = await render({ createPet: createPet({ hash: '0xabc' }) }); + expect(textOf(tree)).toContain('refreshing list'); + }); + + it('does not claim a transaction succeeded when it errored', async () => { + const tree = await render({ + createPet: createPet({ hash: '0xabc', error: new Error('reverted') }), + }); + expect(textOf(tree)).toContain('reverted'); + expect(textOf(tree)).not.toContain('refreshing list'); + }); +}); + +describe('CreatePetModal open-only reset', () => { + /** + * The adapter rebuilds its `lifecycle` object every render, so `reset` is a + * fresh identity each time. An effect keyed on it re-runs on every render, + * and its own `reset()` causes the next one: on device that surfaced as + * "Maximum update depth exceeded" the moment the sheet opened. + */ + it('does not re-reset when only the reset identity changes', async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + , + ); + }); + expect(mockReset).toHaveBeenCalledTimes(1); + + for (let i = 0; i < 3; i++) { + await ReactTestRenderer.act(() => { + tree.update( + mockReset() })} + />, + ); + }); + } + + expect(mockReset).toHaveBeenCalledTimes(1); + }); + + it('resets again when the sheet is reopened', async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + , + ); + }); + await ReactTestRenderer.act(() => { + tree.update( + , + ); + }); + await ReactTestRenderer.act(() => { + tree.update(); + }); + + expect(mockReset).toHaveBeenCalledTimes(2); + }); +}); diff --git a/mobile/__tests__/DefenseScreen.test.tsx b/mobile/__tests__/DefenseScreen.test.tsx new file mode 100644 index 00000000..bf842263 --- /dev/null +++ b/mobile/__tests__/DefenseScreen.test.tsx @@ -0,0 +1,457 @@ +/** + * Standing defence consent (§D). The parts worth pinning are what reaches `grant`: + * a wrong scope here either exposes every pet a player owns or silently authorizes + * none, and neither is visible in the UI afterwards. + * + * `@shared/core` is stubbed — its barrel drags the Solana runtime into jest. + */ + +import React from 'react'; +import { TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; +/** + * `useSafeAreaInsets` throws outside a `SafeAreaProvider`, and this suite renders a screen on + * its own. The library ships this mock for exactly that. Repeated per suite rather than + * registered globally: a global one needs a `setupFiles` entry pointing at a file whose name + * says nothing about what it does. + */ +jest.mock('react-native-safe-area-context', () => + require('react-native-safe-area-context/jest/mock').default, +); + + +const pet = (over: Partial = {}): Pet => ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 0n, + level: 5, + rarity: 2, + winCount: 0, + lossCount: 0, + readyAt: 0, + ...over, +}); + +/** A live delegation, expiring a full day out so the hours-left line is stable. */ +const sessionKeyFixture = () => ({ + address: '0xkey', + expiresAt: Math.floor(Date.now() / 1000) + 24 * 60 * 60, +}); + +const mockState = { + /** A stored session key, or null when none has been approved. */ + sessionKey: null as Record | null, + sessionSupported: true, + /** What `useBattleSession` reports after a failed approve. */ + sessionError: null as Error | null, + /** What the consent read reports; `unknown` renders no card at all. */ + consent: { kind: 'unknown' } as Record, + pets: [pet(), pet({ id: '2', name: 'Momo' })] as Pet[], + isConnected: true, + isPending: false, + /** `useBattleSession().isPending` — a session approval waiting on the wallet. */ + sessionPending: false, + error: null as Error | null, +}; + +const mockRefreshConsent = jest.fn(); +const mockApproveSession = jest.fn(async () => ({ address: '0xkey' })); +const mockRevokeSession = jest.fn(async () => undefined); +const mockGrant = jest.fn(async () => '0xhash'); +const mockRevoke = jest.fn(async () => true); + +/** + * Pass-through here: these suites are about what the screen draws once the session + * exists. The gate has its own suite, so re-exercising it five times would only make + * every fixture carry auth state it does not use. + */ +jest.mock('../src/components/SessionGate', () => { + const React_ = jest.requireActual('react'); + return ({ children }: { children: React.ReactNode }) => + React_.createElement(React_.Fragment, null, children); +}); + +jest.mock('@shared/core', () => ({ + // The real parser rather than a stand-in. It is pure and dependency-free, and a copy + // here would drift from the branch the screen actually takes on a wallet refusal. + parseContractError: jest.requireActual('@shared/core').parseContractError, + /** Delegated battle signing: a separate signature from the consent grant. */ + useBattleSession: () => ({ + key: mockState.sessionKey, + supported: mockState.sessionSupported, + isPending: mockState.sessionPending, + error: mockState.sessionError, + approve: mockApproveSession, + revoke: mockRevokeSession, + discardLocalKey: jest.fn(), + }), + useDefenseAuthorizations: () => ({ + status: mockState.consent, + isLoading: false, + error: null, + refresh: mockRefreshConsent, + }), + usePetList: () => ({ pets: mockState.pets, isLoading: false, error: null, refetch: jest.fn() }), + useChainCapabilities: () => ({ isConnected: mockState.isConnected }), + useDefenseAuthorization: () => ({ + grant: mockGrant, + revoke: mockRevoke, + isPending: mockState.isPending, + error: mockState.error, + }), +})); + +const mockNotify = jest.fn(); +jest.mock('../src/hooks/useNotifyError', () => ({ useNotifyError: () => mockNotify })); + +const mockRouteParams: { petId?: string } = {}; +jest.mock('@react-navigation/native', () => ({ + useRoute: () => ({ params: mockRouteParams }), +})); + +import DefenseScreen from '../src/screens/DefenseScreen'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +/** + * Found by `testID`, not by position. + * + * These used to index from the end of the touchable list, which held only while the action + * buttons were the last things `ActionScreenLayout` rendered. They are in a fixed bar outside + * the scroll now, so "last two touchables" stopped being true and every one of these lookups + * silently pointed at a checkbox row instead. + */ +/** Every touchable's accessibility label, for asserting which controls are offered. */ +const labelsOf = (tree: ReactTestRenderer.ReactTestRenderer): unknown[] => + tree.root.findAllByType(TouchableOpacity).map((n) => n.props.accessibilityLabel); + +const pressLabel = async (tree: ReactTestRenderer.ReactTestRenderer, label: string) => { + const node = tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === label); + await ReactTestRenderer.act(async () => node!.props.onPress()); +}; + +const press = async (tree: ReactTestRenderer.ReactTestRenderer, index: number) => { + await ReactTestRenderer.act(async () => { + tree.root.findAllByType(TouchableOpacity)[index].props.onPress(); + }); +}; + +const byTestId = (tree: ReactTestRenderer.ReactTestRenderer, id: string) => + tree.root.findAllByType(TouchableOpacity).find((n) => n.props.testID === id); + +const pressAllow = async (tree: ReactTestRenderer.ReactTestRenderer) => { + const node = byTestId(tree, 'action-primary'); + await ReactTestRenderer.act(async () => node!.props.onPress()); +}; + +const pressWithdraw = async (tree: ReactTestRenderer.ReactTestRenderer) => { + const node = byTestId(tree, 'action-secondary'); + await ReactTestRenderer.act(async () => node!.props.onPress()); +}; + +beforeEach(() => { + mockState.sessionKey = null; + mockState.sessionSupported = true; + mockState.sessionError = null; + mockRevokeSession.mockResolvedValue(undefined); + mockApproveSession.mockResolvedValue({ address: '0xkey' }); + mockState.consent = { kind: 'unknown' }; + mockState.pets = [pet(), pet({ id: '2', name: 'Momo' })]; + mockState.isConnected = true; + mockState.isPending = false; + mockState.sessionPending = false; + mockState.error = null; + delete mockRouteParams.petId; + jest.clearAllMocks(); +}); + +describe('DefenseScreen', () => { + it('defaults to covering every pet, including future ones', async () => { + const tree = await render(); + await pressAllow(tree); + expect(mockGrant).toHaveBeenCalledWith({ allPets: true }); + }); + + it('hides the per-pet list until the blanket scope is turned off', async () => { + const tree = await render(); + expect(textOf(tree)).not.toContain('Momo'); + await press(tree, 0); + expect(textOf(tree)).toContain('Momo'); + }); + + it('grants only the chosen pets once narrowed', async () => { + const tree = await render(); + await press(tree, 0); // turn off "all pets" + await press(tree, 2); // second pet row (row 1 is the all-pets toggle) + await pressAllow(tree); + expect(mockGrant).toHaveBeenCalledWith({ petIds: ['2'] }); + }); + + it('narrows to the pet a Gallery action arrived with, rather than granting for all', async () => { + // Coming in from one pet's Defend button must not silently authorize the + // whole wallet, which is what the default scope would do. + mockRouteParams.petId = '2'; + const tree = await render(); + await pressAllow(tree); + expect(mockGrant).toHaveBeenCalledWith({ petIds: ['2'] }); + }); + + it('reports the scope it actually granted', async () => { + const tree = await render(); + await pressAllow(tree); + expect(textOf(tree)).toContain('Every pet you own can now be challenged.'); + }); + + /* + * A wallet holds one `eth_signTypedData` at a time. Both signing controls here go + * through the same connection, and each used to gate only on its own hook's + * `isPending`, so the second tap reached the wallet as + * `-32002 ... already pending for origin`. The window is wide on Android: the first + * tap looks inert until the wallet finishes coming to the foreground. + */ + describe('while a wallet signature is outstanding', () => { + const sessionButton = (tree: ReactTestRenderer.ReactTestRenderer, label: string) => + tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === label); + + const allowButton = (tree: ReactTestRenderer.ReactTestRenderer) => + byTestId(tree, 'action-primary')!; + + it('will not let a consent grant start a session signature too', async () => { + mockState.isPending = true; + const tree = await render(); + expect(sessionButton(tree, 'Approve battle session')!.props.disabled).toBe(true); + }); + + it('will not let a session approval start a consent signature too', async () => { + mockState.sessionPending = true; + const tree = await render(); + expect(allowButton(tree).props.disabled).toBe(true); + }); + + it('still lets an existing session be ended, since that signs nothing', async () => { + // `revoke` is a plain DELETE. Blocking it would only make the screen feel stuck + // while a grant is waiting on the wallet. + mockState.sessionKey = sessionKeyFixture(); + mockState.isPending = true; + const tree = await render(); + expect(sessionButton(tree, 'End battle session')!.props.disabled).toBe(false); + }); + + it('offers both controls when nothing is pending', async () => { + const tree = await render(); + expect(sessionButton(tree, 'Approve battle session')!.props.disabled).toBe(false); + expect(allowButton(tree).props.disabled).toBe(false); + }); + }); + + it('withdraws consent', async () => { + const tree = await render(); + await pressWithdraw(tree); + expect(mockRevoke).toHaveBeenCalled(); + expect(textOf(tree)).toContain('Consent withdrawn.'); + }); + + it('notifies rather than signing when disconnected', async () => { + mockState.isConnected = false; + const tree = await render(); + await pressAllow(tree); + expect(mockGrant).not.toHaveBeenCalled(); + expect(mockNotify).toHaveBeenCalledWith( + 'Please connect your wallet first', + undefined, + 'defense-validation', + ); + }); + + it('surfaces a signing failure', async () => { + mockState.error = new Error('User rejected the signature'); + const tree = await render(); + expect(textOf(tree)).toContain('User rejected the signature'); + }); + + it('says so when there is nothing to authorize', async () => { + mockState.pets = []; + const tree = await render(); + await press(tree, 0); + expect(textOf(tree)).toContain('No pets to authorize yet.'); + }); +}); + +/** + * What is currently granted, which is the half of the consent API that used to be + * missing from both clients. + * + * Being challenged is passive: a defender never discovers their consent has lapsed by + * trying something and failing, their pets simply stop being challengeable, and the only + * person who sees an error is the attacker, who cannot fix it. So the screen has to say + * it unprompted, and it has to distinguish two states that ask for the same action. + */ +describe('consent status', () => { + it('shows nothing while the answer is unknown, rather than guessing "not allowed"', async () => { + mockState.consent = { kind: 'unknown' }; + const tree = await render(); + const rendered = textOf(tree); + expect(rendered).not.toContain('Challenges allowed'); + expect(rendered).not.toContain('Not allowed'); + expect(rendered).not.toContain('Needs re-signing'); + }); + + it('reports an active grant', async () => { + mockState.consent = { kind: 'active', authorizations: [{}, {}] }; + const tree = await render(); + expect(textOf(tree)).toContain('Challenges allowed'); + expect(textOf(tree)).toContain('2 active grants'); + }); + + it('says nobody can challenge when nothing is granted', async () => { + mockState.consent = { kind: 'none' }; + const tree = await render(); + expect(textOf(tree)).toContain('Not allowed'); + }); + + it('distinguishes a lapsed grant from never having granted one', async () => { + // Both ask the player to sign again, but "you never allowed challenges" when the + // rules simply moved reads as the app having forgotten. + mockState.consent = { kind: 'stale', authorizations: [{}] }; + const tree = await render(); + const rendered = textOf(tree); + expect(rendered).toContain('Needs re-signing'); + expect(rendered).toContain('rules changed'); + expect(rendered).not.toContain('Not allowed'); + }); + + it('re-reads after a grant, or the summary contradicts what just happened', async () => { + mockState.consent = { kind: 'none' }; + const tree = await render(); + await pressAllow(tree); + expect(mockRefreshConsent).toHaveBeenCalled(); + }); +}); + +/** + * Delegated battle signing, and the reason it sits on this screen without being the same + * thing as the consent above it. + * + * Consent lets *other* players challenge you. A session lets *you* start battles without + * a wallet prompt each time. Both are wallet signatures; only this one replaces future + * ones, and confusing them would have a player approve the wrong thing. + */ +describe('battle session', () => { + it('offers to approve one when none is held', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('stop confirming every fight'); + expect(labelsOf(tree)).toContain('Approve battle session'); + }); + + it('confirms the approval where the button is', async () => { + const tree = await render(); + await pressLabel(tree, 'Approve battle session'); + expect(mockApproveSession).toHaveBeenCalled(); + expect(textOf(tree)).toContain('Approved'); + }); + + it('offers to end an existing one instead of approving a second', async () => { + mockState.sessionKey = sessionKeyFixture(); + const tree = await render(); + + expect(textOf(tree)).toContain('no wallet prompt each time'); + expect(labelsOf(tree)).toContain('End battle session'); + expect(labelsOf(tree)).not.toContain('Approve battle session'); + + await pressLabel(tree, 'End battle session'); + expect(mockRevokeSession).toHaveBeenCalled(); + }); + + it('says how much time is left on a held session', async () => { + mockState.sessionKey = sessionKeyFixture(); + const tree = await render(); + expect(textOf(tree)).toContain('Active for another 24 hours'); + }); + + /* + * Every one of these used to render nothing at all. `approve` swallows its failure + * into `session.error` and resolves null, and that error was wired nowhere, so a + * refused signature and a successful one looked identical: the button said "Signing…" + * and then went back to saying "Approve session". + */ + it('reports a signature refused in the wallet', async () => { + mockApproveSession.mockResolvedValue(null); + mockState.sessionError = new Error('User rejected the request.\nDocs: https://viem.sh/'); + const tree = await render(); + + await pressLabel(tree, 'Approve battle session'); + + expect(textOf(tree)).toContain('refused in your wallet'); + expect(textOf(tree)).not.toContain('viem.sh'); + }); + + it('reports a failure that is not a refusal, with its reason', async () => { + mockApproveSession.mockResolvedValue(null); + mockState.sessionError = new Error('delegation scope not accepted'); + const tree = await render(); + + await pressLabel(tree, 'Approve battle session'); + expect(textOf(tree)).toContain('delegation scope not accepted'); + }); + + it('does not let a stale success outlive a later failure', async () => { + // The note is set by a callback and the error is read at render, so the two can + // disagree. A failure has to win, or a refused re-approval reads as approved. + const tree = await render(); + await pressLabel(tree, 'Approve battle session'); + expect(textOf(tree)).toContain('Approved'); + + mockState.sessionError = new Error('delegation scope not accepted'); + mockApproveSession.mockResolvedValue(null); + await pressLabel(tree, 'Approve battle session'); + + expect(textOf(tree)).not.toContain('Approved.'); + expect(textOf(tree)).toContain('Not approved'); + }); + + it('confirms the session ended', async () => { + mockState.sessionKey = sessionKeyFixture(); + const tree = await render(); + + await pressLabel(tree, 'End battle session'); + expect(textOf(tree)).toContain('Session ended.'); + }); + + it('says a revoke stopped signing here even when the server call failed', async () => { + // The hook clears the local key first and does not catch, so this rejects. Signing + // has still stopped on this device, which is the part the player cares about. + mockState.sessionKey = sessionKeyFixture(); + mockRevokeSession.mockRejectedValue(new Error('network down')); + const tree = await render(); + + await pressLabel(tree, 'End battle session'); + expect(textOf(tree)).toContain('Session ended on this device'); + }); + + it('renders nothing on a chain that cannot delegate', async () => { + // Solana has no session support here, and an approve button that cannot work is + // worse than no button: it invites a signature that buys nothing. + mockState.sessionSupported = false; + const tree = await render(); + expect(textOf(tree)).not.toContain('battle session'); + expect(labelsOf(tree)).not.toContain('Approve battle session'); + }); +}); diff --git a/mobile/__tests__/EquipScreen.test.tsx b/mobile/__tests__/EquipScreen.test.tsx new file mode 100644 index 00000000..e9cee3c7 --- /dev/null +++ b/mobile/__tests__/EquipScreen.test.tsx @@ -0,0 +1,255 @@ +/** + * Gearing a pet (roadmap section 4). + * + * Three slots are always drawn, filled or not: an empty slot is information, and + * rendering only what is equipped makes a bare pet look like a pet with no slots. + * + * The choices per slot come from the bag rather than a second query, and the filter is + * the part worth pinning: a consumable, a slotless item and a spent stack all have to + * stay out, or the player is offered something the contract will reject. + * + * `@shared/core` is stubbed — its barrel drags the Solana runtime into jest. + */ + +import React from 'react'; +import { TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +const gear = (over: Record = {}) => ({ + itemType: '10', + key: 'rusty_dagger', + category: 'equipment', + slot: 0, + rarity: 1, + effect: { kind: 'stat_bonus', hp: 0, atk: 4, def: 0, int: 0, mdef: 0 }, + name: 'Rusty Dagger', + description: 'A dagger.', + ...over, +}); + +const mockState = { + entries: [] as { item: ReturnType; quantity: string }[], + bySlot: new Map }>(), + slotsLoading: false, + canEquip: true, + isConnected: true, +}; + +const mockEquip = jest.fn(); +const mockUnequip = jest.fn(); +const mockNotify = jest.fn(); +/** Which pet the slots are read for; the whole point of following the route param. */ +const mockEquipmentPetId = jest.fn(); + +/** + * A marker rather than null, so the bag can be asserted to draw item art. Nulling it + * would let the art vanish again unnoticed, which is how the gallery shipped without + * pet avatars for the whole project. + */ +jest.mock('../src/components/ItemArt', () => { + const { Text: RNText } = jest.requireActual('react-native'); + const React_ = jest.requireActual('react'); + return ({ item }: { item: { itemType: string } }) => + React_.createElement(RNText, null, `[item-art:${item.itemType}]`); +}); + +jest.mock('../src/components/PetArt', () => () => null); + +/** + * Pass-through here: these suites are about what the screen draws once the session + * exists. The gate has its own suite, so re-exercising it five times would only make + * every fixture carry auth state it does not use. + */ +jest.mock('../src/components/SessionGate', () => { + const React_ = jest.requireActual('react'); + return ({ children }: { children: React.ReactNode }) => + React_.createElement(React_.Fragment, null, children); +}); + +jest.mock('@shared/core', () => ({ + // `PetPicker` shows the selected pet's stats inline now, so anything rendering a picker + // reaches these. Real rather than stubbed: they are pure and dependency-free, and what a + // pet reads here has to be what it reads on the card and on the web app. + ...jest.requireActual('../../shared/src/utils/ethereum/petCard'), + ...jest.requireActual('../../shared/src/utils/pets/skills'), + ...jest.requireActual('../../shared/src/utils/pets/cosmetics'), + SLOT: { weapon: 0, armor: 1, trinket: 2 }, + useChainCapabilities: () => ({ + activeKind: 'ethereum', + isConnected: mockState.isConnected, + }), + usePetList: () => ({ + // `dna` and `rarity` are not decoration: the picker now renders the selected pet's + // stats, and those are derived from `dna`. Without it the real helper is handed + // `undefined % 10000n`, which throws rather than returning a wrong number. + pets: [{ id: '1', name: 'Rex', level: 2, dna: 0n, rarity: 1 }], + }), + useInventory: () => ({ entries: mockState.entries }), + usePetEquipment: (opts: { petId: string | null }) => { + mockEquipmentPetId(opts.petId); + return { + equipped: [...mockState.bySlot.values()], + bySlot: mockState.bySlot, + isLoading: mockState.slotsLoading, + isSuccess: true, + error: null, + refetch: jest.fn(), + }; + }, + useEquipItem: () => ({ + canEquip: mockState.canEquip, + equip: mockEquip, + unequip: mockUnequip, + equipLifecycle: { error: null }, + unequipLifecycle: { error: null }, + isPending: false, + }), + getRarityColor: () => '#ffffff', + describeItemEffect: () => '+4 ATK', +})); + +jest.mock('../src/hooks/useNotifyError', () => ({ useNotifyError: () => mockNotify })); +jest.mock('../src/hooks/useTxErrorToast', () => ({ useTxErrorToast: () => {} })); +const mockRouteParams: { petId?: string } = { petId: '1' }; +jest.mock('@react-navigation/native', () => ({ useRoute: () => ({ params: mockRouteParams }) })); + +import EquipScreen from '../src/screens/EquipScreen'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +const press = async (tree: ReactTestRenderer.ReactTestRenderer, label: string) => { + const node = tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === label); + await ReactTestRenderer.act(async () => node!.props.onPress()); +}; + +const labels = (tree: ReactTestRenderer.ReactTestRenderer): unknown[] => + tree.root.findAllByType(TouchableOpacity).map((n) => n.props.accessibilityLabel); + +beforeEach(() => { + mockState.entries = [{ item: gear(), quantity: '1' }]; + mockState.bySlot = new Map(); + mockState.slotsLoading = false; + mockState.canEquip = true; + mockState.isConnected = true; + mockRouteParams.petId = '1'; + mockEquipmentPetId.mockClear(); + jest.clearAllMocks(); +}); + +describe('slots', () => { + it('draws all three whether filled or not', async () => { + const tree = await render(); + const rendered = textOf(tree); + expect(rendered).toContain('Weapon'); + expect(rendered).toContain('Armor'); + expect(rendered).toContain('Trinket'); + }); + + it('offers removal for a filled slot and nothing to equip into it', async () => { + mockState.bySlot = new Map([[0, { slot: 0, item: gear() }]]); + const tree = await render(); + expect(labels(tree)).toContain('Unequip Weapon'); + expect(labels(tree)).not.toContain('Equip Weapon'); + }); + + it('draws art for both a worn item and the choices offered', async () => { + mockState.bySlot = new Map([[0, { slot: 0, item: gear({ itemType: '10' }) }]]); + mockState.entries = [{ item: gear({ itemType: '11', slot: 1, name: 'Hide Vest' }), quantity: '1' }]; + const tree = await render(); + + expect(textOf(tree)).toContain('[item-art:10]'); // worn + expect(textOf(tree)).toContain('[item-art:11]'); // offered + }); + + it('says an empty slot has nothing that fits, distinct from having no slot', async () => { + mockState.entries = []; + const tree = await render(); + expect(textOf(tree)).toContain('nothing in the bag fits this slot'); + }); +}); + +describe('what can go in a slot', () => { + it('keeps consumables and slotless items out', async () => { + mockState.entries = [ + { item: gear(), quantity: '1' }, + { item: gear({ itemType: '11', name: 'Potion', category: 'consumable', slot: null }), quantity: '5' }, + { item: gear({ itemType: '12', name: 'Badge', category: 'collectible', slot: null }), quantity: '1' }, + ]; + const tree = await render(); + expect(labels(tree)).toContain('Choose Rusty Dagger'); + expect(labels(tree)).not.toContain('Choose Potion'); + expect(labels(tree)).not.toContain('Choose Badge'); + }); + + it('keeps a spent stack out, since zero is written rather than deleted', async () => { + mockState.entries = [{ item: gear(), quantity: '0' }]; + const tree = await render(); + expect(labels(tree)).not.toContain('Choose Rusty Dagger'); + expect(textOf(tree)).toContain('nothing in the bag fits this slot'); + }); +}); + +describe('committing', () => { + it('equips the chosen item into its slot', async () => { + const tree = await render(); + await press(tree, 'Choose Rusty Dagger'); + await press(tree, 'Equip Weapon'); + expect(mockEquip).toHaveBeenCalledWith(0, '10'); + }); + + it('unequips by slot', async () => { + mockState.bySlot = new Map([[0, { slot: 0, item: gear() }]]); + const tree = await render(); + await press(tree, 'Unequip Weapon'); + expect(mockUnequip).toHaveBeenCalledWith(0); + }); + + it('refuses while disconnected rather than sending a call that cannot be signed', async () => { + mockState.isConnected = false; + const tree = await render(); + await press(tree, 'Choose Rusty Dagger'); + await press(tree, 'Equip Weapon'); + expect(mockEquip).not.toHaveBeenCalled(); + expect(mockNotify).toHaveBeenCalledWith( + 'Connect your wallet first', + undefined, + 'equip-validation', + ); + }); + + it('follows a second pet arriving from another card', async () => { + // Navigating to a screen already on the stack reuses the mounted instance, so a + // `useState` initializer never sees the new pet. Battle had the same bug with + // worse reach, because a tab never unmounts at all. + const tree = await render(); + mockRouteParams.petId = '2'; + await ReactTestRenderer.act(async () => { + tree.update(); + }); + + // The slots are read for pet 2 now. Asserting the argument rather than the + // rendering is what makes this fail when the param is ignored: the screen looks + // identical either way, it just describes the wrong pet. + expect(mockEquipmentPetId).toHaveBeenLastCalledWith('2'); + }); + + it('says why on a chain with no item contract, rather than showing a dead button', async () => { + mockState.canEquip = false; + const tree = await render(); + expect(textOf(tree)).toContain('no item contract'); + }); +}); diff --git a/mobile/__tests__/GalleryScreen.test.tsx b/mobile/__tests__/GalleryScreen.test.tsx new file mode 100644 index 00000000..6b95ea3f --- /dev/null +++ b/mobile/__tests__/GalleryScreen.test.tsx @@ -0,0 +1,323 @@ +/** + * Gallery screen over a stubbed `usePetGallery`. The composite hook is where the + * real wiring lives (chain adapter, API client, navigation), so the screen is + * checked as what it is: a pure view over that hook's return value. + * + * `usePetCooldowns` is exercised directly further down, since its tick and label + * logic is the part with actual behaviour. + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; + +// `@shared/core`'s barrel re-exports the Solana adapter, so importing anything from +// it drags @solana/web3.js and its transitive runtime into jest. The only thing +// needed here is the two cooldown utils, which are dependency-free, so they are +// pulled from their own module and the barrel is stubbed. +jest.mock('@shared/core', () => ({ + ...jest.requireActual('../../shared/src/utils/ethereum/petReadyTime'), + // The real card helpers, not fakes: what the card must show is the same number the + // web app shows, and both read these. A stub here would assert the stub, and the + // whole point of the card carrying stats is that the two clients agree. + ...jest.requireActual('../../shared/src/utils/ethereum/petCard'), + ...jest.requireActual('../../shared/src/utils/pets/skills'), + getRarityColor: (r: number) => (r === 2 ? '#C0C0C0' : '#8B4513'), + // No image service in tests, so the badge falls back to its rarity-tinted initial. + // That fallback is the interesting path anyway: it is what a deployment without + // IMAGE_SERVICE_URL shows, and it still says a slot is filled. + itemArtUrl: () => null, + getRarityName: (r: number) => (r === 2 ? 'Uncommon' : 'Common'), +})); + +/** A marker rather than null, so the card can be asserted to draw art at all. */ +jest.mock('../src/components/PetArt', () => { + const { Text: RNText } = jest.requireActual('react-native'); + const React_ = jest.requireActual('react'); + return ({ pet }: { pet: { id: string } }) => + React_.createElement(RNText, null, `[art:${pet.id}]`); +}); + +import { + getGeneration, + getPetClass, + getPetProperties, + getXpNumbers, +} from '../../shared/src/utils/ethereum/petCard'; +import { getPetSkill } from '../../shared/src/utils/pets/skills'; + +const mockGallery = jest.fn(); +jest.mock('../src/hooks/pet-gallery/usePetGallery', () => ({ + usePetGallery: () => mockGallery(), +})); +jest.mock('../src/components/CreatePetModal', () => () => null); +// Both sheets reach for chain hooks this file's `@shared/core` stub does not +// carry, and both have their own suites. This one is about the gallery view. +jest.mock('../src/components/SendPetModal', () => () => null); + +import GalleryScreen from '../src/screens/GalleryScreen'; +import { usePetCooldowns } from '../src/hooks/usePetCooldowns'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const pet = (over: Partial = {}): Pet => ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 0n, + level: 3, + rarity: 2, + winCount: 4, + lossCount: 1, + readyAt: 0, + ...over, +}); + +const readyStatus = { + onCooldown: false, + battleReady: true, + battleOnCooldown: false, + breedOnCooldown: false, + trainOnCooldown: false, + battleLabel: '', + breedLabel: '', + trainLabel: '', +}; + +const galleryValue = (over: Record = {}) => ({ + pets: [] as Pet[], + isLoading: false, + error: null, + totalWins: 0, + statusFor: () => readyStatus, + /** The batched equipment read; a pet with no gear simply has no entry. */ + equippedFor: () => undefined, + refreshing: false, + onRefresh: jest.fn(), + createPet: {}, + createModalOpen: false, + onOpenCreateModal: jest.fn(), + onCloseCreateModal: jest.fn(), + onBattle: jest.fn(), + onRename: jest.fn(), + onDefend: jest.fn(), + ...over, +}); + + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + +describe('GalleryScreen', () => { + it('shows the pet and win totals', async () => { + mockGallery.mockReturnValue( + galleryValue({ pets: [pet(), pet({ id: '2', winCount: 6 })], totalWins: 10 }), + ); + const rendered = textOf(await render()); + expect(rendered).toContain('Pets'); + expect(rendered).toContain('10'); + }); + + it('renders a card per pet with its rarity and record', async () => { + mockGallery.mockReturnValue(galleryValue({ pets: [pet()], totalWins: 4 })); + const rendered = textOf(await render()); + expect(rendered).toContain('Rex'); + expect(rendered).toContain('Uncommon'); + expect(rendered).toContain('ID #1'); + expect(rendered).toContain('Level 3'); + }); + + /** + * What the card draws, checked against the shared helpers rather than against + * literals. + * + * The card knew all of this and drew none of it: art, stats, skill and class were + * one render away the whole time. Asserting against `getPetProperties` and friends + * rather than hardcoded numbers is what makes this a parity test — if the card ever + * reads the wrong field, the expectation moves with the helper and the test still + * catches it. + */ + it('shows the DNA stat tiles, from the same helper the web app uses', async () => { + const subject = pet(); + mockGallery.mockReturnValue(galleryValue({ pets: [subject] })); + const rendered = textOf(await render()); + + const props = getPetProperties(subject); + for (const [label, value] of [ + ['STR', props.attack], + ['INT', props.intelligence], + ['DEF', props.defense], + ['VIT', props.life], + ] as const) { + expect(rendered).toContain(label); + expect(rendered).toContain(String(value)); + } + // AGI is deliberately absent: nothing in the data model backs it. + expect(rendered).not.toContain('AGI'); + }); + + it('names the species skill and the pet class', async () => { + const subject = pet({ speciesId: 3 }); + mockGallery.mockReturnValue(galleryValue({ pets: [subject] })); + const rendered = textOf(await render()); + + expect(rendered).toContain(getPetSkill(3)!.name); + expect(rendered).toContain(getPetClass(subject.dna)); + expect(rendered).toContain(`Gen ${subject.generation ?? getGeneration(subject.dna)}`); + }); + + it('omits the skill block for a pet with no species, rather than showing an empty one', async () => { + // Solana pets and older EVM rows carry no speciesId, and `getPetSkill` returns + // null for them. A bordered empty block would read as a missing value. + const subject = pet(); + expect(subject.speciesId).toBeUndefined(); + mockGallery.mockReturnValue(galleryValue({ pets: [subject] })); + + const rendered = textOf(await render()); + expect(rendered).toContain('Rex'); + expect(rendered).not.toContain(getPetSkill(0)!.name); + }); + + it('shows XP as current over max rather than a bare number', async () => { + const subject = pet(); + mockGallery.mockReturnValue(galleryValue({ pets: [subject] })); + const rendered = textOf(await render()); + + const xp = getXpNumbers(subject); + expect(rendered).toContain(`${xp.xpCurrent}/${xp.xpMax}`); + }); + + it('shows a win rate only once the pet has fought', async () => { + mockGallery.mockReturnValue(galleryValue({ pets: [pet({ winCount: 3, lossCount: 1 })] })); + expect(textOf(await render())).toContain('75% win rate'); + + mockGallery.mockReturnValue(galleryValue({ pets: [pet({ winCount: 0, lossCount: 0 })] })); + // 0% would read as a losing record rather than as no record at all. + expect(textOf(await render())).not.toContain('win rate'); + }); + + it('draws the pet art, which the card omitted entirely until now', async () => { + mockGallery.mockReturnValue(galleryValue({ pets: [pet({ id: '7' })] })); + expect(textOf(await render())).toContain('[art:7]'); + }); + + it('shows a badge per equipped slot, and nothing for a bare pet', async () => { + // `usePetEquipmentForPets` had no mobile caller at all, so a geared pet was + // indistinguishable from a bare one everywhere outside the equip screen. + const gear = (slot: number, name: string) => ({ + slot, + item: { itemType: String(slot), name, rarity: 2, category: 'equipment', slot, key: '', effect: null, description: '' }, + }); + mockGallery.mockReturnValue( + galleryValue({ + pets: [pet({ id: '7' })], + equippedFor: () => [gear(1, 'Hide Vest'), gear(0, 'Iron Fang')], + }), + ); + + // One label for the strip, ordered by slot so icons never reshuffle between + // renders: weapon (0) before armor (1), whatever order the server sent. + const tree = await render(); + const labels = tree.root + .findAll((n) => typeof n.props.accessibilityLabel === 'string') + .map((n) => n.props.accessibilityLabel as string); + expect(labels).toContain('Wearing Iron Fang, Hide Vest'); + + mockGallery.mockReturnValue(galleryValue({ pets: [pet({ id: '7' })] })); + const bare = await render(); + const bareLabels = bare.root + .findAll((n) => typeof n.props.accessibilityLabel === 'string') + .map((n) => n.props.accessibilityLabel as string); + expect(bareLabels.some((l) => l.startsWith('Wearing'))).toBe(false); + }); + + it('pages through the roster instead of stacking it', async () => { + // The wiring, not the pager: `carousel.test.tsx` covers what the pager does. Without + // this, reverting `PetList` to its vertical stack would leave every test above green, + // since they only ever look at one pet's card. + mockGallery.mockReturnValue( + galleryValue({ pets: [pet(), pet({ id: '2' }), pet({ id: '3' })] }), + ); + expect(textOf(await render())).toContain('1 / 3'); + }); + + it('surfaces the empty state rather than an empty list', async () => { + mockGallery.mockReturnValue(galleryValue()); + expect(textOf(await render())).toContain('No pets yet'); + }); + + it('shows a load failure instead of pretending the roster is empty', async () => { + mockGallery.mockReturnValue(galleryValue({ error: new Error('rpc down') })); + const rendered = textOf(await render()); + expect(rendered).toContain('Could not load pets'); + expect(rendered).toContain('rpc down'); + }); + + it('renders cooldown countdowns when a pet is not ready', async () => { + mockGallery.mockReturnValue( + galleryValue({ + pets: [pet()], + statusFor: () => ({ + ...readyStatus, + onCooldown: true, + battleReady: false, + battleOnCooldown: true, + battleLabel: '2h 5m', + }), + }), + ); + expect(textOf(await render())).toContain('Battle ready in 2h 5m'); + }); +}); + +describe('usePetCooldowns', () => { + const Probe = ({ pets, onStatus }: { pets: Pet[]; onStatus: (s: unknown) => void }) => { + const { anyCooldown, statusFor } = usePetCooldowns(pets); + onStatus({ anyCooldown, status: pets[0] ? statusFor(pets[0]) : null }); + return null; + }; + + const probe = async (pets: Pet[]) => { + const seen: { anyCooldown: boolean; status: { onCooldown: boolean } | null }[] = []; + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + seen.push(s as never)} />, + ); + }); + // A pet on cooldown starts a 1s interval. Without unmounting, the hook's + // cleanup never runs and jest hangs after the assertions pass. + await ReactTestRenderer.act(() => { + tree.unmount(); + }); + return seen[seen.length - 1]; + }; + + it('reports a ready pet as off cooldown', async () => { + const result = await probe([pet({ readyAt: 0 })]); + expect(result.anyCooldown).toBe(false); + expect(result.status?.onCooldown).toBe(false); + }); + + it('reports a future readyAt as on cooldown', async () => { + const future = Math.floor(Date.now() / 1000) + 3600; + const result = await probe([pet({ readyAt: future })]); + expect(result.anyCooldown).toBe(true); + expect(result.status?.onCooldown).toBe(true); + }); + + it('treats an absent breed/train cooldown as ready, not as zero', async () => { + // breedReadyAt/trainReadyAt are optional on Pet; a missing one must not read + // as epoch 0 and it must not read as blocked either. + const result = await probe([pet({ readyAt: 0, breedReadyAt: undefined })]); + expect(result.status?.onCooldown).toBe(false); + }); +}); diff --git a/mobile/__tests__/InventoryScreen.test.tsx b/mobile/__tests__/InventoryScreen.test.tsx new file mode 100644 index 00000000..a3d7b6db --- /dev/null +++ b/mobile/__tests__/InventoryScreen.test.tsx @@ -0,0 +1,239 @@ +/** + * The bag, and the two things about it that are easy to get wrong. + * + * Quantity zero is a value rather than an absence: `indexer-go` resumes from an + * `updatedAt` watermark, so a spent stack is written as `quantity 0` instead of being + * deleted. A row reading zero has to disappear from the bag, not show as a held item. + * + * A pending drop is not an item yet. Nothing on chain reflects one until its claim + * lands, so it cannot be offered anywhere an item can be spent. + * + * `@shared/core` is stubbed — its barrel drags the Solana runtime into jest. + */ + +import React from 'react'; +import { TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +const item = (over: Record = {}) => ({ + itemType: '1', + key: 'xp_potion_i', + category: 'consumable', + slot: null, + rarity: 1, + effect: { kind: 'grant_xp', amount: 50 }, + name: 'XP Potion', + description: 'A small potion.', + ...over, +}); + +const mockState = { + entries: [] as { item: ReturnType; quantity: string }[], + pending: [] as Record[], + isLoading: false, + error: null as Error | null, + claimingId: null as string | null, +}; + +const mockClaim = jest.fn(); +const mockSpend = jest.fn(); +const mockRefetch = jest.fn(); +const mockNotify = jest.fn(); + +/** + * A marker rather than null, so the bag can be asserted to draw item art. Nulling it + * would let the art vanish again unnoticed, which is how the gallery shipped without + * pet avatars for the whole project. + */ +jest.mock('../src/components/ItemArt', () => { + const { Text: RNText } = jest.requireActual('react-native'); + const React_ = jest.requireActual('react'); + return ({ item: subject }: { item: { itemType: string } }) => + React_.createElement(RNText, null, `[item-art:${subject.itemType}]`); +}); + +jest.mock('../src/components/PetArt', () => () => null); + +/** + * Pass-through here: these suites are about what the screen draws once the session + * exists. The gate has its own suite, so re-exercising it five times would only make + * every fixture carry auth state it does not use. + */ +jest.mock('../src/components/SessionGate', () => { + const React_ = jest.requireActual('react'); + return ({ children }: { children: React.ReactNode }) => + React_.createElement(React_.Fragment, null, children); +}); + +jest.mock('@shared/core', () => ({ + // `PetPicker` shows the selected pet's stats inline now, so anything rendering a picker + // reaches these. Real rather than stubbed: they are pure and dependency-free, and what a + // pet reads here has to be what it reads on the card and on the web app. + ...jest.requireActual('../../shared/src/utils/ethereum/petCard'), + ...jest.requireActual('../../shared/src/utils/pets/skills'), + ...jest.requireActual('../../shared/src/utils/pets/cosmetics'), + useChainCapabilities: () => ({ activeKind: 'ethereum', isConnected: true }), + useInventory: () => ({ + entries: mockState.entries, + isLoading: mockState.isLoading, + error: mockState.error, + refetch: mockRefetch, + }), + usePendingItems: () => ({ + pending: mockState.pending, + isLoading: false, + error: null, + claim: mockClaim, + claimingId: mockState.claimingId, + claimError: null, + }), + usePetList: () => ({ + // `dna` and `rarity` are not decoration: the picker now renders the selected pet's + // stats, and those are derived from `dna`. Without it the real helper is handed + // `undefined % 10000n`, which throws rather than returning a wrong number. + pets: [{ id: '1', name: 'Rex', level: 2, dna: 0n, rarity: 1 }], + }), + useSpendItem: () => ({ spend: mockSpend, isPending: false, error: null, reset: jest.fn() }), + getRarityColor: () => '#ffffff', + describeItemEffect: () => 'Grants 50 XP', + explainItem: () => 'Used on one of your pets.', + itemStats: () => [{ label: 'XP', value: 50 }], + SLOT_NAMES: { 0: 'weapon', 1: 'armor', 2: 'trinket' }, +})); + +jest.mock('../src/hooks/useNotifyError', () => ({ useNotifyError: () => mockNotify })); + +import InventoryScreen from '../src/screens/InventoryScreen'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +const press = async (tree: ReactTestRenderer.ReactTestRenderer, label: string) => { + const node = tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === label); + await ReactTestRenderer.act(async () => node!.props.onPress()); +}; + +beforeEach(() => { + mockState.entries = [{ item: item(), quantity: '3' }]; + mockState.pending = []; + mockState.isLoading = false; + mockState.error = null; + mockState.claimingId = null; + mockSpend.mockResolvedValue({ burnTxHash: '0x', level: 3, xp: 120, readyAt: 0, leveledUp: true }); + jest.clearAllMocks(); +}); + +describe('the bag', () => { + it('lists a held stack with its quantity', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('XP Potion'); + expect(textOf(tree)).toContain('×3'); + }); + + it('draws each item with its art, not a coloured tile alone', async () => { + // Without it a legendary sword and a crafting shard look alike: both are a name + // on a rarity-tinted rectangle. + mockState.entries = [{ item: item({ itemType: '42' }), quantity: '1' }]; + const tree = await render(); + expect(textOf(tree)).toContain('[item-art:42]'); + }); + + it('hides a spent stack, since zero is written rather than deleted', async () => { + mockState.entries = [{ item: item(), quantity: '0' }]; + const tree = await render(); + expect(textOf(tree)).not.toContain('XP Potion'); + expect(textOf(tree)).toContain('Nothing here yet'); + }); + + it('reports an error instead of an empty bag', async () => { + mockState.error = new Error('backend unreachable'); + const tree = await render(); + expect(textOf(tree)).toContain('backend unreachable'); + expect(textOf(tree)).not.toContain('Nothing here yet'); + }); +}); + +describe('unclaimed drops', () => { + it('keeps them out of the bag, since nothing on chain reflects one yet', async () => { + mockState.entries = []; + mockState.pending = [ + { entitlementId: 'e1', item: item({ name: 'Rusty Dagger' }), quantity: 1, source: 'battle_drop', sourceRef: '7', createdAt: '' }, + ]; + const tree = await render(); + + expect(textOf(tree)).toContain('Rusty Dagger'); + expect(textOf(tree)).toContain('battle #7'); + // The bag itself is still empty: a pending drop is not a held item. + expect(textOf(tree)).toContain('Nothing here yet'); + }); + + it('claims by entitlement id', async () => { + mockState.pending = [ + { entitlementId: 'e1', item: item({ name: 'Rusty Dagger' }), quantity: 1, source: 'admin_grant', sourceRef: '', createdAt: '' }, + ]; + const tree = await render(); + await press(tree, 'Claim Rusty Dagger'); + expect(mockClaim).toHaveBeenCalledWith('e1'); + }); + + it('surfaces a failed claim rather than leaving the row looking slow', async () => { + mockClaim.mockRejectedValueOnce(new Error('out of gas')); + mockState.pending = [ + { entitlementId: 'e1', item: item({ name: 'Rusty Dagger' }), quantity: 1, source: 'admin_grant', sourceRef: '', createdAt: '' }, + ]; + const tree = await render(); + await press(tree, 'Claim Rusty Dagger'); + expect(mockNotify).toHaveBeenCalledWith( + 'Could not claim Rusty Dagger', + expect.any(Error), + 'inventory-claim', + ); + }); +}); + +describe('spending a consumable', () => { + it('refuses without a pet rather than sending a call that cannot work', async () => { + const tree = await render(); + await press(tree, 'Open XP Potion'); + await press(tree, 'Use item'); + expect(mockSpend).not.toHaveBeenCalled(); + expect(mockNotify).toHaveBeenCalledWith( + 'Pick a pet to use this on', + undefined, + 'inventory-validation', + ); + }); + + it('spends on the chosen pet and refreshes the bag', async () => { + const tree = await render(); + await press(tree, 'Open XP Potion'); + + // PetPicker renders one chip per pet; the first is Rex. + const chip = tree.root + .findAllByType(TouchableOpacity) + .find((n) => textOf({ root: n } as never).includes('Rex')); + await ReactTestRenderer.act(async () => chip!.props.onPress()); + await press(tree, 'Use item'); + + expect(mockSpend).toHaveBeenCalledWith({ + chain: 'ethereum', + petId: '1', + itemType: '1', + }); + expect(mockRefetch).toHaveBeenCalled(); + expect(textOf(tree)).toContain('Level 3'); + }); +}); diff --git a/mobile/__tests__/LeaderboardScreen.test.tsx b/mobile/__tests__/LeaderboardScreen.test.tsx new file mode 100644 index 00000000..3f57dd21 --- /dev/null +++ b/mobile/__tests__/LeaderboardScreen.test.tsx @@ -0,0 +1,242 @@ +/** + * The leaderboard renders the page the backend ranked and never re-sorts it. + * + * The trap worth a test is the medal: it belongs to a rank, not to a position in the + * page, so page two grows no medals and a search that turns up the leader still shows + * it as the leader. Ranks arrive absolute for exactly this reason. + * + * `@shared/core` is stubbed — its barrel drags the Solana runtime into jest. + */ + +import React from 'react'; +import { TextInput, TouchableOpacity, View } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +const mockState = { + walletAddress: '0xAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaa' as string | null, + petEntries: [] as Record[], + playerEntries: [] as Record[], + total: 0, + isLoading: false, + error: null as Error | null, + rank: null as Record | null, +}; + +/** + * Pass-through here: these suites are about what the screen draws once the session + * exists. The gate has its own suite, so re-exercising it five times would only make + * every fixture carry auth state it does not use. + */ +jest.mock('../src/components/SessionGate', () => { + const React_ = jest.requireActual('react'); + return ({ children }: { children: React.ReactNode }) => + React_.createElement(React_.Fragment, null, children); +}); + +jest.mock('@shared/core', () => ({ + getRarityColor: () => '#ffffff', + shortAddress: (a: string) => `${a.slice(0, 6)}...${a.slice(-4)}`, + sameAccount: (a: string, b: string) => a.toLowerCase() === b.toLowerCase(), + useChainCapabilities: () => ({ + activeKind: 'ethereum', + walletAddress: mockState.walletAddress, + }), + useLeaderboard: ({ enabled }: { enabled: boolean }) => ({ + entries: enabled ? mockState.petEntries : [], + total: enabled ? mockState.total : 0, + pageSize: 20, + isLoading: mockState.isLoading, + error: mockState.error, + }), + usePlayerLeaderboard: ({ enabled }: { enabled: boolean }) => ({ + entries: enabled ? mockState.playerEntries : [], + total: enabled ? mockState.total : 0, + pageSize: 20, + isLoading: mockState.isLoading, + error: mockState.error, + }), + usePlayerRank: () => ({ rank: mockState.rank, isLoading: false }), +})); + +jest.mock('../src/components/PetArt', () => () => null); + +import LeaderboardScreen from '../src/screens/LeaderboardScreen'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const petEntry = (over: Record = {}) => ({ + rank: 1, + id: '1', + chain: 'ethereum', + owner: '0xBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbbBBbb', + name: 'caipet', + dna: '5565590272533216', + level: 3, + rarity: 1, + winCount: 4, + lossCount: 1, + asset: '', + ...over, +}); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +/** + * Found by accessibility label rather than by serializing the subtree: a rendered pet + * carries a BigInt dna, which `JSON.stringify` refuses outright. + */ +const showPlayers = async (tree: ReactTestRenderer.ReactTestRenderer) => { + const tab = tree.root + .findAllByType(TouchableOpacity) + .find((node) => node.props.accessibilityLabel === 'Show Players board'); + await ReactTestRenderer.act(async () => tab!.props.onPress()); +}; + +/** Every row's border colour, in render order. Medals show up here. */ +const rowBorders = (tree: ReactTestRenderer.ReactTestRenderer): unknown[] => + tree.root + .findAllByType(View) + .map((node) => { + const style = node.props.style; + const flat = Array.isArray(style) ? Object.assign({}, ...style.filter(Boolean)) : style; + return flat?.borderColor; + }) + .filter(Boolean); + +beforeEach(() => { + mockState.walletAddress = '0xAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaaAAaa'; + mockState.petEntries = []; + mockState.playerEntries = []; + mockState.total = 0; + mockState.isLoading = false; + mockState.error = null; + mockState.rank = null; +}); + +describe('empty and loading states', () => { + it('tells a player with no battles that the board fills up, not that it is broken', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('No battles on record yet'); + }); + + it('says nothing matched rather than reusing the no-battles copy', async () => { + const tree = await render(); + await ReactTestRenderer.act(async () => { + tree.root.findByType(TextInput).props.onChangeText('zzz'); + }); + // The 300 ms debounce has to elapse before the term reaches the query, and it + // has to do so in its own act: the effect that arms the timer only runs after + // the render the keystroke caused. + await ReactTestRenderer.act(async () => { + await new Promise((r) => setTimeout(r, 350)); + }); + expect(textOf(tree)).toContain('matches "zzz"'); + expect(textOf(tree)).not.toContain('No battles on record yet'); + }); + + it('reports an error instead of an empty board', async () => { + mockState.error = new Error('backend unreachable'); + const tree = await render(); + expect(textOf(tree)).toContain('backend unreachable'); + }); +}); + +describe('your standing', () => { + it('calls an unranked player unranked, which is a real state and not an error', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('Unranked'); + }); + + it('shows the rank the backend gave, not one counted from the page', async () => { + mockState.rank = { rank: 42, owner: '0xAA', winCount: 7, lossCount: 3, petCount: 2 }; + const tree = await render(); + expect(textOf(tree)).toContain('#42'); + expect(textOf(tree)).toContain('7W 3L'); + }); +}); + +describe('ranking', () => { + it('medals by absolute rank, so page two grows none', async () => { + // Ranks 21-23: the first rows of page two, and nothing here is a medal. + mockState.petEntries = [21, 22, 23].map((rank) => petEntry({ rank, id: String(rank) })); + mockState.total = 60; + const tree = await render(); + + const medals = ['#ffd45e', '#c9d4e4', '#d08a52']; + expect(rowBorders(tree).filter((c) => medals.includes(c as string))).toHaveLength(0); + expect(textOf(tree)).toContain('21'); + }); + + it('medals the top three when they are the ones on screen', async () => { + mockState.petEntries = [1, 2, 3, 4].map((rank) => petEntry({ rank, id: String(rank) })); + mockState.total = 4; + const tree = await render(); + + const medals = ['#ffd45e', '#c9d4e4', '#d08a52']; + expect(rowBorders(tree).filter((c) => medals.includes(c as string))).toHaveLength(3); + }); + + it('renders rows in the order given, never re-sorted locally', async () => { + // Deliberately not in win order: the backend ranks on the merged record, so a + // local sort could only disagree with the rank printed beside each row. + mockState.petEntries = [ + petEntry({ rank: 1, id: '1', name: 'first', winCount: 2, lossCount: 0 }), + petEntry({ rank: 2, id: '2', name: 'second', winCount: 9, lossCount: 9 }), + ]; + mockState.total = 2; + const tree = await render(); + expect(textOf(tree).indexOf('first')).toBeLessThan(textOf(tree).indexOf('second')); + }); +}); + +describe('boards', () => { + it('starts on pets and switches to players', async () => { + mockState.petEntries = [petEntry({ name: 'caipet' })]; + mockState.playerEntries = [ + { + rank: 1, + owner: '0xCCccCCccCCccCCccCCccCCccCCccCCccCCccCCcc', + winCount: 5, + lossCount: 2, + petCount: 3, + }, + ]; + mockState.total = 1; + + const tree = await render(); + expect(textOf(tree)).toContain('caipet'); + + await showPlayers(tree); + + expect(textOf(tree)).toContain('3 pets'); + expect(textOf(tree)).not.toContain('caipet'); + }); + + it('marks the connected wallet on the player board', async () => { + mockState.playerEntries = [ + { + rank: 1, + owner: mockState.walletAddress!.toLowerCase(), + winCount: 1, + lossCount: 0, + petCount: 1, + }, + ]; + mockState.total = 1; + + const tree = await render(); + await showPlayers(tree); + + expect(textOf(tree)).toContain('you'); + }); +}); diff --git a/mobile/__tests__/MarriageScreen.test.tsx b/mobile/__tests__/MarriageScreen.test.tsx new file mode 100644 index 00000000..fc1bb4a0 --- /dev/null +++ b/mobile/__tests__/MarriageScreen.test.tsx @@ -0,0 +1,484 @@ +/** + * Marriage, over the real `useMarriagePanel` with `@shared/core` stubbed. What is + * worth pinning is the chain filtering (a marriage cannot cross chains), the + * accept confirmation step, and the cache invalidation after a write — without + * that last one every row keeps showing pre-write state. + */ + +import React from 'react'; +import { TextInput, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; +/** + * `useSafeAreaInsets` throws outside a `SafeAreaProvider`, and this suite renders a screen on + * its own. The library ships this mock for exactly that. Repeated per suite rather than + * registered globally: a global one needs a `setupFiles` entry pointing at a file whose name + * says nothing about what it does. + */ +jest.mock('react-native-safe-area-context', () => + require('react-native-safe-area-context/jest/mock').default, +); + + +const pet = (over: Partial = {}): Pet => ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 0n, + level: 5, + rarity: 2, + winCount: 0, + lossCount: 0, + readyAt: 0, + ...over, +}); + +/** The default pet's id, so a test can say "this one is married" without repeating it. */ +const MARRIED_ID = '1'; + +const proposal = (over: Record = {}) => ({ + proposerPetId: '9', + proposerPetName: 'Luna', + proposerOwner: '0xabc', + targetPetId: '1', + expiry: 0, + ...over, +}); + +const mockState = { + pets: [pet()] as Pet[], + kind: 'evm' as 'evm' | 'solana' | 'none', + proposals: [] as ReturnType[], + proposalsLoading: false, + /** Which of `pets` `useMarriedPets` reports as married. */ + marriedIds: [] as string[], + marriagesLoading: false, + /** Bulk roster from `useAllPets`; a spouse is only sometimes in it. */ + roster: [{ id: '9', name: 'Luna' }] as { id: string; name: string }[], + /** What a direct spouse lookup returns when the roster map has no answer. */ + fetchedSpouse: {} as { name?: string; level?: number }, + /** What `searchPets` returns for the partner field; someone else's pets. */ + searchResults: [] as { id: string; name: string; level: number; dna: bigint }[], +}; + +/** Every `useSpousePet` call the card made, to check it skips when it can. */ +const mockSpouseLookups: { id: string; skip: boolean }[] = []; + +const mockMutations = { + propose: jest.fn(async () => undefined), + accept: jest.fn(async () => undefined), + cancel: jest.fn(async () => undefined), + divorce: jest.fn(async () => undefined), +}; +const mockInvalidate = jest.fn(); +const mockRefetch = jest.fn(); +const mockRefetchProposals = jest.fn(); +const mockIncomingArgs = jest.fn(); + +jest.mock('@shared/core', () => ({ + // `PetPicker` shows the selected pet's stats inline now, so anything rendering a picker + // reaches these. Real rather than stubbed: they are pure and dependency-free, and what a + // pet reads here has to be what it reads on the card and on the web app. + ...jest.requireActual('../../shared/src/utils/ethereum/petCard'), + ...jest.requireActual('../../shared/src/utils/pets/skills'), + ...jest.requireActual('../../shared/src/utils/pets/cosmetics'), + // The real formatter, taken from its own module rather than the package barrel: the + // barrel drags in `queryClient` and the rest of the surface this suite mocks away. It + // is pure, and a stub would let the wording drift from the rows frontend renders. + formatExpiry: jest.requireActual('../../shared/src/utils/common/time').formatExpiry, + usePetList: () => ({ pets: mockState.pets, isLoading: false, error: null, refetch: mockRefetch }), + useChainCapabilities: () => ({ + kind: mockState.kind, + activeKind: mockState.kind === 'none' ? null : mockState.kind, + walletAddress: '0xme', + // Stands in for the chain's own error parser. The real one pulls the revert reason + // off a viem/ethers error; this one just proves the panel routes through it rather + // than discarding the error and reporting its own line. + parseError: (err: unknown, fallback: string) => ({ + message: err instanceof Error ? err.message : fallback, + isUserRejection: false, + }), + }), + useAllPets: () => ({ pets: mockState.roster }), + useSearchPets: (query: string) => ({ + results: query.trim() ? mockState.searchResults : [], + isLoading: false, + error: null, + refetch: jest.fn(), + }), + getPetAvatar: () => '🐾', + petArtUrl: () => null, + useIncomingProposals: (...args: unknown[]) => { + mockIncomingArgs(...args); + return { + proposals: mockState.proposals, + isLoading: mockState.proposalsLoading, + refetch: mockRefetchProposals, + }; + }, + useMarriage: () => ({ + propose: { mutateAsync: mockMutations.propose, isPending: false }, + accept: { mutateAsync: mockMutations.accept, isPending: false }, + cancel: { mutateAsync: mockMutations.cancel, isPending: false }, + divorce: { mutateAsync: mockMutations.divorce, isPending: false }, + }), + // Resolves a spouse the bulk roster does not hold. `skip` is what the card + // passes when the map already answered, so honouring it here is what proves + // the card is not firing a redundant request per married pet. + useSpousePet: (_chain: unknown, id: string, opts?: { skip?: boolean }) => { + mockSpouseLookups.push({ id, skip: Boolean(opts?.skip) }); + return opts?.skip ? {} : mockState.fetchedSpouse; + }, +})); + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: mockInvalidate }), +})); + +/** + * The married set, stubbed at the mobile hook rather than at wagmi's multicall. + * + * `useMarriedPets` has its own suite for the multicall shape and the chain split. Driving it + * from here would put a tuple of contract results between this file and the thing it is + * about, which is the screen. + */ +jest.mock('../src/hooks/marriage/useMarriedPets', () => ({ + useMarriedPets: (_chain: unknown, pets: { id: string }[]) => ({ + marriedPets: pets + .filter((candidate) => mockState.marriedIds.includes(candidate.id)) + .map((candidate) => ({ pet: candidate, spouseId: '9' })), + isLoading: mockState.marriagesLoading, + }), +})); + +const mockNotify = jest.fn(); +jest.mock('../src/hooks/useNotifyError', () => ({ useNotifyError: () => mockNotify })); + +import MarriageScreen from '../src/screens/MarriageScreen'; + +import { allText, type Tree, textOfNode } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +/** + * Every tree is unmounted after its test. + * + * The Incoming tab polls on an interval, and an interval belonging to a component that is + * never unmounted keeps Node's event loop alive: the suite passes and then jest hangs + * instead of exiting, which reads as a broken test run rather than a leak. + */ +const mounted: ReactTestRenderer.ReactTestRenderer[] = []; + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + mounted.push(tree); + return tree; +}; + +afterEach(async () => { + await ReactTestRenderer.act(async () => { + mounted.splice(0).forEach((tree) => tree.unmount()); + }); +}); + + +const pressWith = async (tree: ReactTestRenderer.ReactTestRenderer, label: string) => { + const target = tree.root + .findAllByType(TouchableOpacity) + .find((b) => textOfNode(b).includes(label)); + await ReactTestRenderer.act(async () => { + target?.props.onPress(); + }); +}; + + +const type = async (tree: ReactTestRenderer.ReactTestRenderer, value: string) => { + await ReactTestRenderer.act(async () => { + tree.root.findByType(TextInput).props.onChangeText(value); + }); +}; + +/** + * Pick the partner the way a player does now: search, then tap a result. + * + * This screen used to take the partner's numeric id typed straight in, so these tests + * typed one. A proposal still names an exact pet; what changed is that finding it no + * longer requires already knowing its id. + */ +const choosePartner = async (tree: ReactTestRenderer.ReactTestRenderer, name: string) => { + await type(tree, name); + const row = tree.root + .findAllByType(TouchableOpacity) + .find((node) => node.props.accessibilityLabel === `Choose ${name}`); + await ReactTestRenderer.act(async () => row!.props.onPress()); +}; + +beforeEach(() => { + mockState.pets = [pet()]; + mockState.kind = 'evm'; + mockState.proposals = []; + mockState.proposalsLoading = false; + mockState.marriedIds = []; + mockState.marriagesLoading = false; + mockState.roster = [{ id: '9', name: 'Luna' }]; + mockState.fetchedSpouse = {}; + mockState.searchResults = [{ id: '42', name: 'Nia', level: 3, dna: 1n }]; + mockSpouseLookups.length = 0; + jest.clearAllMocks(); +}); + +describe('MarriageScreen', () => { + it('asks for a wallet before anything else', async () => { + mockState.kind = 'none'; + const tree = await render(); + expect(textOf(tree)).toContain('Connect a wallet'); + }); + + it('offers only pets on the active chain, since a marriage cannot cross chains', async () => { + mockState.pets = [pet(), pet({ id: '2', name: 'Sol', chain: 'solana' })]; + const tree = await render(); + const rendered = textOf(tree); + expect(rendered).toContain('Rex'); + expect(rendered).not.toContain('Sol'); + }); + + it('queries proposals for the active chain and its pet ids', async () => { + mockState.pets = [pet(), pet({ id: '2', name: 'Momo' })]; + await render(); + expect(mockIncomingArgs).toHaveBeenCalledWith('evm', ['1', '2']); + }); + + it('sends a proposal for the chosen pet and the partner found by search', async () => { + const tree = await render(); + await pressWith(tree, 'Rex'); + await choosePartner(tree, 'Nia'); + await pressWith(tree, 'Send Proposal'); + expect(mockMutations.propose).toHaveBeenCalledWith({ petIdA: '1', petIdB: '42' }); + }); + + it('keeps the player’s own pick out of the partner results', async () => { + // Marrying a pet to itself is not a proposal anyone means to send, and the + // search covers the whole roster, own pets included. + mockState.searchResults = [ + { id: '1', name: 'Rex', level: 2, dna: 1n }, + { id: '42', name: 'Nia', level: 3, dna: 1n }, + ]; + const tree = await render(); + await pressWith(tree, 'Rex'); + await type(tree, 'e'); + + const labels = tree.root + .findAllByType(TouchableOpacity) + .map((node) => node.props.accessibilityLabel); + expect(labels).toContain('Choose Nia'); + expect(labels).not.toContain('Choose Rex'); + }); + + it('refreshes contract reads after a write, or every row shows stale state', async () => { + const tree = await render(); + await pressWith(tree, 'Rex'); + await choosePartner(tree, 'Nia'); + await pressWith(tree, 'Send Proposal'); + expect(mockRefetch).toHaveBeenCalled(); + const keys = mockInvalidate.mock.calls.map((c) => c[0].queryKey[0]); + expect(keys).toEqual( + expect.arrayContaining(['readContract', 'readContracts', 'incomingProposals']), + ); + }); + + it('reports the chain’s own reason, not a line of its own', async () => { + /* + * `PetCore.divorce` reverts with "Not the owner of this pet"; marry and accept are + * the same shape. Those tell a player what to do about it, and they used to be + * replaced with "Marriage action failed" and logged to a console nobody reads. + */ + mockMutations.propose.mockRejectedValueOnce(new Error('Not the owner of this pet')); + const tree = await render(); + await pressWith(tree, 'Rex'); + await choosePartner(tree, 'Nia'); + await pressWith(tree, 'Send Proposal'); + + expect(mockNotify).toHaveBeenCalledWith( + 'Not the owner of this pet', + expect.any(Error), + 'marriage', + ); + expect(textOf(tree)).not.toContain('Proposal sent!'); + }); + + /* + * `proposalTTL` is 60 seconds on this deployment (GameConfig's dev default; the source + * notes prod is 7 days). A proposal can therefore lapse between opening the screen and + * reaching for Accept, and the list only ever holds live ones, so without the window on + * screen it just disappears and reads as never having arrived. That is exactly how a + * real proposal, correctly written on chain, was reported as missing. + */ + /* + * A proposal that arrives while the tab is open used to be unreachable: the shared hook + * caches for 15s and schedules nothing, and this panel dropped its `refetch`, so the + * list only changed if you left the screen and came back. At a 60s expiry that is the + * whole window. + */ + it('keeps re-reading proposals while the Incoming tab is open', async () => { + // The timer is spied rather than faked: `act` is async here, and fake timers + // deadlock against it. What matters is that a repeating read is scheduled at all, + // and that its callback re-reads, so drive the callback directly. + const setSpy = jest.spyOn(global, 'setInterval'); + try { + const tree = await render(); + await pressWith(tree, 'Incoming'); + + const scheduled = setSpy.mock.calls.find(([, ms]) => ms === 10_000); + expect(scheduled).toBeDefined(); + + mockRefetchProposals.mockClear(); + await ReactTestRenderer.act(async () => { + (scheduled![0] as () => void)(); + }); + expect(mockRefetchProposals).toHaveBeenCalled(); + } finally { + setSpy.mockRestore(); + } + }); + + it('does not poll while the Propose tab is showing', async () => { + // One multicall across the whole roster per tick, on a screen nobody is reading. + const setSpy = jest.spyOn(global, 'setInterval'); + try { + await render(); + expect(setSpy.mock.calls.some(([, ms]) => ms === 10_000)).toBe(false); + } finally { + setSpy.mockRestore(); + } + }); + + it('stops polling when the tab is left', async () => { + const clearSpy = jest.spyOn(global, 'clearInterval'); + try { + const tree = await render(); + await pressWith(tree, 'Incoming'); + clearSpy.mockClear(); + await pressWith(tree, 'Propose'); + + expect(clearSpy).toHaveBeenCalled(); + } finally { + clearSpy.mockRestore(); + } + }); + + it('shows how long an incoming proposal has left', async () => { + mockState.proposals = [proposal({ expiry: Math.floor(Date.now() / 1000) + 45 })]; + const tree = await render(); + await pressWith(tree, 'Incoming'); + expect(textOf(tree)).toContain('Expires in 1m'); + }); + + it('says a proposal has expired rather than showing a bare countdown', async () => { + mockState.proposals = [proposal({ expiry: 1 })]; + const tree = await render(); + await pressWith(tree, 'Incoming'); + expect(textOf(tree)).toContain('Expired'); + }); + + it('confirms before accepting, rather than marrying on one tap', async () => { + mockState.proposals = [proposal()]; + const tree = await render(); + await pressWith(tree, 'Incoming'); + await pressWith(tree, 'Accept'); + expect(mockMutations.accept).not.toHaveBeenCalled(); + expect(textOf(tree)).toContain('Accept proposal?'); + }); + + it('accepts with the proposer and the targeted pet once confirmed', async () => { + mockState.proposals = [proposal()]; + const tree = await render(); + await pressWith(tree, 'Incoming'); + await pressWith(tree, 'Accept'); + // The dialog's Accept is the confirm; the row's opened it. + // The dialog's confirm, by label. Filtering on the word "Accept" also matched the + // row button that opened the dialog, so this took the last of two and depended on + // render order to tell them apart. + const confirm = tree.root + .findAllByType(TouchableOpacity) + .find((b) => b.props.accessibilityLabel === 'Confirm accept'); + await ReactTestRenderer.act(async () => confirm!.props.onPress()); + expect(mockMutations.accept).toHaveBeenCalledWith({ petIdA: '9', petIdB: '1' }); + }); + + it('lists a married pet with its spouse and hides single ones', async () => { + mockState.marriedIds = [MARRIED_ID]; + const tree = await render(); + expect(textOf(tree)).toContain('married to Luna'); + + mockState.marriedIds = []; + const single = await render(); + expect(textOf(single)).not.toContain('married to'); + }); + + it('gives a page to each marriage, not to each pet', async () => { + // The whole reason `useMarriedPets` exists. The card used to decide for itself + // whether it was a marriage and render null if not, which a stacked list absorbed + // silently; a pager allocates the page first, so three pets and one marriage would + // be two blank screens to swipe past. The counter is what gives that away. + mockState.pets = [pet(), pet({ id: '2' }), pet({ id: '3' })]; + mockState.marriedIds = [MARRIED_ID]; + + const tree = await render(); + expect(textOf(tree)).toContain('1 / 1'); + expect(textOf(tree)).not.toContain('1 / 3'); + }); + + it('says there are no marriages rather than showing an empty pager', async () => { + mockState.marriedIds = []; + expect(textOf(await render())).toContain('No active marriages'); + }); + + it('does not claim there are none while it is still reading', async () => { + // The read is a multicall across the roster. Showing the empty state until it lands + // tells a player with four marriages they have none, every time the screen opens. + mockState.marriagesLoading = true; + expect(textOf(await render())).not.toContain('No active marriages'); + }); + + it('does not look up a spouse the roster already named', async () => { + // One redundant request per married pet otherwise, on every render. + mockState.marriedIds = [MARRIED_ID]; + mockSpouseLookups.length = 0; + await render(); + expect(mockSpouseLookups.every((l) => l.skip)).toBe(true); + }); + + it('names a spouse the roster does not hold', async () => { + // The usual case: a spouse is someone else's pet, and `useAllPets` only + // fetched a page. Without the direct lookup the card shows "pet #9", which + // is the id the player already could not do anything with. + mockState.marriedIds = [MARRIED_ID]; + mockState.roster = []; + mockState.fetchedSpouse = { name: 'Momo', level: 4 }; + mockSpouseLookups.length = 0; + + const tree = await render(); + + expect(mockSpouseLookups.some((l) => l.id === '9' && !l.skip)).toBe(true); + expect(textOf(tree)).toContain('married to Momo'); + }); + + it('falls back to the id when nothing can name the spouse', async () => { + mockState.marriedIds = [MARRIED_ID]; + mockState.roster = []; + mockState.fetchedSpouse = {}; + const tree = await render(); + expect(textOf(tree)).toContain('married to pet #9'); + }); + + it('divorces the chosen pet', async () => { + mockState.marriedIds = [MARRIED_ID]; + const tree = await render(); + await pressWith(tree, 'Divorce'); + expect(mockMutations.divorce).toHaveBeenCalledWith({ petId: '1' }); + }); +}); diff --git a/mobile/__tests__/PetSearchField.test.tsx b/mobile/__tests__/PetSearchField.test.tsx new file mode 100644 index 00000000..710f801b --- /dev/null +++ b/mobile/__tests__/PetSearchField.test.tsx @@ -0,0 +1,174 @@ +/** + * Finding another player's pet by name, rather than typing its id from memory. + * + * The states worth pinning are the ones that look alike and are not: an idle field + * showing nothing, a searched field that matched nothing, and a failed request. Only + * the middle one should say "no pets match". + * + * `@shared/core` is stubbed — its barrel drags the Solana runtime into jest. + */ + +import React from 'react'; +import { TextInput, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +const mockState = { + results: [] as { id: string; name: string; level: number; dna: bigint }[], + isLoading: false, + error: null as Error | null, +}; + +const mockSearchArgs = jest.fn(); + +jest.mock('@shared/core', () => ({ + useSearchPets: (query: string, options: unknown) => { + mockSearchArgs(query, options); + return { + results: query.trim() ? mockState.results : [], + isLoading: mockState.isLoading, + error: mockState.error, + refetch: jest.fn(), + }; + }, +})); + +jest.mock('../src/components/PetArt', () => () => null); + +import PetSearchField from '../src/components/PetSearchField'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const onChange = jest.fn(); + +const render = async (props: Partial> = {}) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + , + ); + }); + return tree; +}; + + +const type = async (tree: ReactTestRenderer.ReactTestRenderer, value: string) => { + await ReactTestRenderer.act(async () => { + tree.root.findByType(TextInput).props.onChangeText(value); + }); +}; + +const labels = (tree: ReactTestRenderer.ReactTestRenderer): unknown[] => + tree.root.findAllByType(TouchableOpacity).map((node) => node.props.accessibilityLabel); + +beforeEach(() => { + mockState.results = [{ id: '42', name: 'Nia', level: 3, dna: 1n }]; + mockState.isLoading = false; + mockState.error = null; + jest.clearAllMocks(); +}); + +describe('states that look alike', () => { + it('shows nothing at all before anything is typed', async () => { + const tree = await render(); + expect(textOf(tree)).not.toContain('No pets match'); + expect(labels(tree)).toHaveLength(0); + }); + + it('says nothing matched only once something was searched for', async () => { + mockState.results = []; + const tree = await render(); + await type(tree, 'zzz'); + expect(textOf(tree)).toContain('No pets match'); + }); + + it('treats an all-spaces term as idle, not as a miss', async () => { + mockState.results = []; + const tree = await render(); + await type(tree, ' '); + expect(textOf(tree)).not.toContain('No pets match'); + }); + + it('reports an error rather than claiming nothing matched', async () => { + mockState.error = new Error('backend unreachable'); + const tree = await render(); + await type(tree, 'nia'); + expect(textOf(tree)).toContain('backend unreachable'); + expect(textOf(tree)).not.toContain('No pets match'); + }); +}); + +describe('choosing', () => { + it('reports the chosen pet id and shows it instead of the field', async () => { + const tree = await render(); + await type(tree, 'nia'); + + const row = tree.root + .findAllByType(TouchableOpacity) + .find((node) => node.props.accessibilityLabel === 'Choose Nia'); + await ReactTestRenderer.act(async () => row!.props.onPress()); + + expect(onChange).toHaveBeenCalledWith('42'); + expect(textOf(tree)).toContain('Nia'); + expect(textOf(tree)).toContain('#42'); + expect(tree.root.findAllByType(TextInput)).toHaveLength(0); + }); + + it('drops excluded ids, so a pet cannot be proposed to itself', async () => { + mockState.results = [ + { id: '1', name: 'Rex', level: 2, dna: 1n }, + { id: '42', name: 'Nia', level: 3, dna: 1n }, + ]; + const tree = await render({ excludeIds: ['1'] }); + await type(tree, 'e'); + + expect(labels(tree)).toContain('Choose Nia'); + expect(labels(tree)).not.toContain('Choose Rex'); + }); + + it('resets when the parent clears the value after a successful proposal', async () => { + const tree = await render(); + await type(tree, 'nia'); + const row = tree.root + .findAllByType(TouchableOpacity) + .find((node) => node.props.accessibilityLabel === 'Choose Nia'); + await ReactTestRenderer.act(async () => row!.props.onPress()); + expect(tree.root.findAllByType(TextInput)).toHaveLength(0); + + // The parent owns the value: it stores what onChange reported, then clears it + // once the proposal lands. Both halves have to be replayed or the clear is + // indistinguishable from the initial empty render. + await ReactTestRenderer.act(async () => { + tree.update(); + }); + await ReactTestRenderer.act(async () => { + tree.update(); + }); + + // Back to a searchable field rather than still naming a pet it no longer reports. + expect(tree.root.findAllByType(TextInput)).toHaveLength(1); + expect(textOf(tree)).not.toContain('#42'); + }); +}); + +describe('query wiring', () => { + it('stops searching once a pet is chosen, so the list cannot reopen under it', async () => { + const tree = await render(); + await type(tree, 'nia'); + const row = tree.root + .findAllByType(TouchableOpacity) + .find((node) => node.props.accessibilityLabel === 'Choose Nia'); + await ReactTestRenderer.act(async () => row!.props.onPress()); + + const last = mockSearchArgs.mock.calls.at(-1); + expect((last?.[1] as { enabled: boolean }).enabled).toBe(false); + }); + + it('passes the chain through, since a proposal cannot cross chains', async () => { + await render({ chain: 'solana' }); + const last = mockSearchArgs.mock.calls.at(-1); + expect((last?.[1] as { chain: string }).chain).toBe('solana'); + }); +}); diff --git a/mobile/__tests__/SendPetModal.test.tsx b/mobile/__tests__/SendPetModal.test.tsx new file mode 100644 index 00000000..d437da94 --- /dev/null +++ b/mobile/__tests__/SendPetModal.test.tsx @@ -0,0 +1,219 @@ +/** + * Transferring a pet is the one action in the app that cannot be undone from + * inside it: ownership moves on chain and this wallet has no claim afterwards. + * So the tests weigh what happens *before* a signature is requested. + * + * The self-send rule carries the most weight. Sending to your own address is a + * perfectly valid transfer, so the chain accepts it, charges gas, and changes + * nothing. Only the client can catch it. + */ + +import React from 'react'; +import { TextInput, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; + +import { validateTransferRecipient } from '../src/utils/validateTransferRecipient'; + +const EVM_WALLET = '0x1111111111111111111111111111111111111111'; +const EVM_OTHER = '0x2222222222222222222222222222222222222222'; + +const pet = (over: Partial = {}): Pet => ({ + id: '7', + chain: 'evm', + name: 'Rex', + dna: 0n, + level: 3, + rarity: 2, + winCount: 0, + lossCount: 0, + readyAt: 0, + ...over, +}); + +const mockState = { + walletAddress: EVM_WALLET as string | null, + chainLabel: 'Ethereum', + isPending: false, + error: null as Error | null, +}; + +const mockTransfer = jest.fn(); +const mockReset = jest.fn(); + +jest.mock('@shared/core', () => ({ + useChainCapabilities: () => ({ + walletAddress: mockState.walletAddress, + chainLabel: mockState.chainLabel, + address: { + label: 'Recipient Address:', + placeholder: '0x...', + isValid: (v: string) => /^0x[0-9a-fA-F]{40}$/.test(v), + }, + }), + useTransferPet: () => ({ + mutate: mockTransfer, + isPending: mockState.isPending, + error: mockState.error, + reset: mockReset, + }), +})); + +import SendPetModal from '../src/components/SendPetModal'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' '); + +const render = async (target: Pet | null = pet()) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + , + ); + }); + return tree; +}; + + +const type = async (tree: ReactTestRenderer.ReactTestRenderer, value: string) => { + await ReactTestRenderer.act(async () => { + tree.root.findAllByType(TextInput)[0].props.onChangeText(value); + }); +}; + +/** + * The send button by label, not by position. Its own text changes to "Confirm in wallet…" + * while a transfer is pending, so the label is the only stable handle on it. + */ +const sendButton = (tree: ReactTestRenderer.ReactTestRenderer) => + tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === 'Send pet'); + +const send = async (tree: ReactTestRenderer.ReactTestRenderer) => { + await ReactTestRenderer.act(async () => { + sendButton(tree)!.props.onPress(); + }); +}; + +beforeEach(() => { + mockState.walletAddress = EVM_WALLET; + mockState.chainLabel = 'Ethereum'; + mockState.isPending = false; + mockState.error = null; + jest.clearAllMocks(); +}); + +describe('validateTransferRecipient', () => { + const isValid = (v: string) => /^0x[0-9a-fA-F]{40}$/.test(v); + const base = { isValid, chainLabel: 'Ethereum', walletAddress: EVM_WALLET }; + + it('accepts a well-formed address that is not the sender', () => { + expect(validateTransferRecipient({ ...base, raw: EVM_OTHER })).toBeNull(); + }); + + it('rejects an empty or whitespace-only address', () => { + expect(validateTransferRecipient({ ...base, raw: '' })).toMatch(/enter a recipient/i); + expect(validateTransferRecipient({ ...base, raw: ' ' })).toMatch(/enter a recipient/i); + }); + + it('names the chain when the address is malformed', () => { + expect(validateTransferRecipient({ ...base, raw: 'not-an-address' })).toBe( + 'Please enter a valid Ethereum address', + ); + }); + + it('rejects sending to yourself regardless of casing', () => { + // The chain would accept this, charge gas, and change nothing. + expect(validateTransferRecipient({ ...base, raw: EVM_WALLET })).toMatch(/yourself/i); + expect( + validateTransferRecipient({ ...base, raw: EVM_WALLET.toUpperCase().replace('0X', '0x') }), + ).toMatch(/yourself/i); + }); + + it('trims before checking, so a pasted address with spaces still works', () => { + expect(validateTransferRecipient({ ...base, raw: ` ${EVM_OTHER} ` })).toBeNull(); + }); + + it('does not treat a missing wallet address as a self-send', () => { + expect( + validateTransferRecipient({ ...base, raw: EVM_OTHER, walletAddress: null }), + ).toBeNull(); + }); +}); + +describe('SendPetModal', () => { + it('names the pet it is about to send', async () => { + const tree = await render(pet({ name: 'Momo' })); + expect(textOf(tree)).toContain('Send Momo'); + }); + + it('warns that the transfer is final', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('cannot be undone'); + }); + + it('uses the chain-supplied label and placeholder', async () => { + // Chain-blind: the same sheet must serve a Solana base58 address. + const tree = await render(); + expect(textOf(tree)).toContain('Recipient Address:'); + expect(tree.root.findAllByType(TextInput)[0].props.placeholder).toBe('0x...'); + }); + + it('will not send to a malformed address', async () => { + const tree = await render(); + await type(tree, 'nonsense'); + await send(tree); + + expect(mockTransfer).not.toHaveBeenCalled(); + expect(textOf(tree)).toContain('valid Ethereum address'); + }); + + it('will not send the pet to its current owner', async () => { + const tree = await render(); + await type(tree, EVM_WALLET); + await send(tree); + + expect(mockTransfer).not.toHaveBeenCalled(); + expect(textOf(tree)).toContain('yourself'); + }); + + it('sends the trimmed address with the pet id', async () => { + const tree = await render(pet({ id: '42' })); + await type(tree, ` ${EVM_OTHER} `); + await send(tree); + + expect(mockTransfer).toHaveBeenCalledWith({ to: EVM_OTHER, petId: '42' }); + }); + + it('locks the sheet while the wallet is deciding', async () => { + mockState.isPending = true; + const tree = await render(); + expect(sendButton(tree)!.props.disabled).toBe(true); + expect(tree.root.findAllByType(TextInput)[0].props.editable).toBe(false); + expect(textOf(tree)).toContain('Confirm in wallet'); + }); + + it('surfaces a failed transfer rather than closing quietly', async () => { + mockState.error = new Error('insufficient funds for gas'); + const tree = await render(); + expect(textOf(tree)).toContain('insufficient funds for gas'); + }); + + it('clears the previous recipient when opened for another pet', async () => { + // Reusing it would aim this pet at the last one's destination. + const tree = await render(pet({ id: '1' })); + await type(tree, EVM_OTHER); + + await ReactTestRenderer.act(() => { + tree.update( + , + ); + }); + + expect(tree.root.findAllByType(TextInput)[0].props.value).toBe(''); + expect(mockReset).toHaveBeenCalled(); + }); +}); diff --git a/mobile/__tests__/SessionGate.test.tsx b/mobile/__tests__/SessionGate.test.tsx new file mode 100644 index 00000000..be56df38 --- /dev/null +++ b/mobile/__tests__/SessionGate.test.tsx @@ -0,0 +1,135 @@ +/** + * A connected wallet is not a session, and telling them apart is the whole point. + * + * Thirteen shared hooks are disabled until `isAuthenticated`, so without a session they + * return empty and never even reach a loading state. Every screen built on them then + * rendered its own empty copy — "No battles on record yet", "Nothing here yet", "No + * conversations yet" — all of which are *wrong* when nobody has been asked yet, and none + * of which name the one action that would fix it. + * + * `@shared/core` is stubbed — its barrel drags the Solana runtime into jest. + */ + +import React from 'react'; +import { Text, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +const mockState = { + isConnected: true, + isAuthenticated: false, + isNonceLoading: false, + isSigning: false, + isVerifying: false, +}; + +const mockSignAndLogin = jest.fn(); + +jest.mock('@shared/core', () => ({ + useChainCapabilities: () => ({ isConnected: mockState.isConnected }), + useAuth: () => ({ + isAuthenticated: mockState.isAuthenticated, + signAndLogin: mockSignAndLogin, + isNonceLoading: mockState.isNonceLoading, + isSigning: mockState.isSigning, + isVerifying: mockState.isVerifying, + }), +})); + +import SessionGate from '../src/components/SessionGate'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + + the real screen + , + ); + }); + return tree; +}; + + +beforeEach(() => { + mockState.isConnected = true; + mockState.isAuthenticated = false; + mockState.isNonceLoading = false; + mockState.isSigning = false; + mockState.isVerifying = false; + jest.clearAllMocks(); +}); + +describe('which step is missing', () => { + it('asks for a wallet when there is none, and offers no sign-in button', async () => { + mockState.isConnected = false; + const tree = await render(); + + expect(textOf(tree)).toContain('Connect your wallet'); + expect(textOf(tree)).not.toContain('the real screen'); + // Nothing to sign with yet, so a sign-in button could only fail. + expect(tree.root.findAllByType(TouchableOpacity)).toHaveLength(0); + }); + + it('asks for a signature when the wallet is connected but the session is not', async () => { + const tree = await render(); + + expect(textOf(tree)).toContain('Sign in to see the rankings'); + expect(textOf(tree)).toContain('Sign in to Play'); + expect(textOf(tree)).not.toContain('the real screen'); + }); + + it('renders the screen once both are true', async () => { + mockState.isAuthenticated = true; + const tree = await render(); + + expect(textOf(tree)).toContain('the real screen'); + expect(textOf(tree)).not.toContain('Sign in'); + }); + + it('keeps the screen title in every state, so it does not lose its identity', async () => { + mockState.isConnected = false; + expect(textOf(await render())).toContain('Leaderboard'); + + mockState.isConnected = true; + expect(textOf(await render())).toContain('Leaderboard'); + }); +}); + +describe('signing in', () => { + it('signs in on tap', async () => { + const tree = await render(); + const button = tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === 'Sign in to play'); + await ReactTestRenderer.act(async () => button!.props.onPress()); + expect(mockSignAndLogin).toHaveBeenCalled(); + }); + + it.each([ + ['isNonceLoading', 'Getting nonce…'], + ['isSigning', 'Check your wallet…'], + ['isVerifying', 'Verifying…'], + ] as const)('names the stage it is waiting on: %s', async (flag, label) => { + mockState[flag] = true; + const tree = await render(); + expect(textOf(tree)).toContain(label); + }); + + it('disables the button while signing, since wallets queue duplicate prompts', async () => { + mockState.isSigning = true; + const tree = await render(); + const button = tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.accessibilityLabel === 'Sign in to play'); + expect(button!.props.disabled).toBe(true); + }); +}); diff --git a/mobile/__tests__/accountSheet.test.tsx b/mobile/__tests__/accountSheet.test.tsx new file mode 100644 index 00000000..b2bbe67e --- /dev/null +++ b/mobile/__tests__/accountSheet.test.tsx @@ -0,0 +1,361 @@ +/** + * The account sheet exists for one thing AppKit's own modal cannot do: backend + * sign-in. Auth is nonce → wallet signature → JWT, and every backend-served read + * needs that token, so a header that cannot reach `signAndLogin` leaves a + * connected player unable to battle at all. + * + * `@shared/core` is stubbed — its barrel drags the Solana runtime into jest. + */ + +import React from 'react'; +import { AccessibilityInfo, Modal, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { SafeAreaInsetsContext } from 'react-native-safe-area-context'; +import ReactTestRenderer from 'react-test-renderer'; + +const mockState = { + // Inlined rather than imported from `wagmi/chains`: a `jest.mock` factory is + // hoisted above the file's imports and may only reach names matching /^mock/i. + chainId: 11155111, + address: '0x1234567890abcdef1234567890abcdef12345678' as string | undefined, + isAuthenticated: false, + isNonceLoading: false, + isSigning: false, + isVerifying: false, + balance: { value: 1234567890123456789n } as { value: bigint } | undefined, + balanceLoading: false, + balanceError: null as Error | null, +}; + +const mockSignAndLogin = jest.fn(); +const mockLogout = jest.fn(); +const mockOpen = jest.fn(); +const mockDisconnect = jest.fn(); +jest.mock('wagmi', () => ({ + useAccount: () => ({ address: mockState.address, chainId: mockState.chainId }), + useBalance: () => ({ + data: mockState.balance, + isLoading: mockState.balanceLoading, + error: mockState.balanceError, + }), +})); + +jest.mock('@reown/appkit-react-native', () => ({ + useAppKit: () => ({ open: mockOpen, disconnect: mockDisconnect }), +})); + +/** + * The sheet pads itself clear of the home indicator, so it calls `useSafeAreaInsets`, which + * throws outside a `SafeAreaProvider`. The library ships this mock for exactly that. + */ +jest.mock('react-native-safe-area-context', () => + require('react-native-safe-area-context/jest/mock').default, +); + +jest.mock('@shared/core', () => ({ + useAuth: () => ({ + isAuthenticated: mockState.isAuthenticated, + signAndLogin: mockSignAndLogin, + logout: mockLogout, + isSigning: mockState.isSigning, + isVerifying: mockState.isVerifying, + isNonceLoading: mockState.isNonceLoading, + }), +})); + +import AccountSheet from '../src/components/AccountSheet'; +import NativeBalance from '../src/components/NativeBalance'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' '); + +/** + * The act callback is `async` on purpose. A sync one closes the scope the moment effects have + * flushed, so a mount effect that asks the OS something — `useReduceMotion` here — sets its + * state one microtask later, outside any scope, and React reports the update as unwrapped. + */ +const render = async (node: React.ReactElement) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create(node); + }); + return tree; +}; + + +/** + * Every control here is reached by its accessibility label, never by position. + * + * An index-based press retargets silently when the list changes: the test still passes, + * against a different button. That has happened in this codebase more than once. + */ +const byLabel = (tree: ReactTestRenderer.ReactTestRenderer, label: string) => + tree.root.findAllByType(TouchableOpacity).find((n) => n.props.accessibilityLabel === label); + +const openSheet = async (tree: ReactTestRenderer.ReactTestRenderer) => { + await ReactTestRenderer.act(async () => byLabel(tree, 'Account')!.props.onPress()); +}; + +/** + * Runs the sheet's close animation out. + * + * Closing is animated, so `setIsOpen(false)` lands in an animation callback ~120ms after the + * press rather than during it. Left pending, that timer fires after jest has torn the + * environment down and the run reports `You are trying to import a file after the Jest + * environment has been torn down` against a component render — which points at the wrong + * file entirely, since the test that walked away from the timer is not the one named. + */ +const settle = async () => { + await ReactTestRenderer.act(async () => { + jest.advanceTimersByTime(300); + }); +}; + +const isSheetOpen = (tree: ReactTestRenderer.ReactTestRenderer): boolean => + tree.root.findAllByType(Modal)[0].props.visible; + +beforeEach(() => { + jest.useFakeTimers(); + mockState.address = '0x1234567890abcdef1234567890abcdef12345678'; + mockState.isAuthenticated = false; + mockState.isNonceLoading = false; + mockState.isSigning = false; + mockState.isVerifying = false; + mockState.balance = { value: 1234567890123456789n }; + mockState.balanceLoading = false; + mockState.balanceError = null; + jest.clearAllMocks(); +}); + +afterEach(() => { + jest.useRealTimers(); + // `clearAllMocks` above keeps implementations, so a `spyOn` in one test would still be + // answering in the next. + jest.restoreAllMocks(); +}); + +describe('AccountSheet trigger', () => { + it('shows a truncated address', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('0x1234...5678'); + }); + + it('falls back to "Connected" before an address resolves', async () => { + mockState.address = undefined; + const tree = await render(); + expect(textOf(tree)).toContain('Connected'); + }); +}); + +describe('AccountSheet auth actions', () => { + it('reaches signAndLogin, which nothing in the tab shell could before', async () => { + const tree = await render(); + await openSheet(tree); + expect(textOf(tree)).toContain('Sign message & login'); + + await ReactTestRenderer.act(async () => byLabel(tree, 'Sign in')!.props.onPress()); + expect(mockSignAndLogin).toHaveBeenCalled(); + }); + + it('offers logout once authenticated, and not sign-in', async () => { + mockState.isAuthenticated = true; + const tree = await render(); + await openSheet(tree); + expect(textOf(tree)).toContain('Logout'); + expect(textOf(tree)).not.toContain('Sign message & login'); + + await ReactTestRenderer.act(async () => byLabel(tree, 'Logout')!.props.onPress()); + expect(mockLogout).toHaveBeenCalled(); + }); + + it.each([ + ['isNonceLoading', 'Getting nonce...'], + ['isSigning', 'Approve the signature in your wallet...'], + ['isVerifying', 'Verifying...'], + ] as const)('names the stage it is waiting on: %s', async (flag, label) => { + mockState[flag] = true; + const tree = await render(); + await openSheet(tree); + expect(textOf(tree)).toContain(label); + }); + + it('disables the auth button while a signature is pending', async () => { + // Wallets queue duplicate personal_sign prompts rather than ignoring them. + mockState.isSigning = true; + const tree = await render(); + await openSheet(tree); + expect(byLabel(tree, 'Sign in')!.props.disabled).toBe(true); + }); + + it('shows the full address for a long-press copy', async () => { + const tree = await render(); + await openSheet(tree); + expect(textOf(tree)).toContain('0x1234567890abcdef1234567890abcdef12345678'); + }); + + // Both close the sheet before acting, which unmounts the modal — so each gets + // its own render rather than reusing a node list that is gone by then. + // + // Found by accessibility label, not by index: the sheet's action list grows as + // screens are added, and an index-based press silently retargets when it does. + const pressAction = async (tree: ReactTestRenderer.ReactTestRenderer, label: string) => { + const button = tree.root + .findAllByType(TouchableOpacity) + .find((node) => node.props.accessibilityLabel === label); + await ReactTestRenderer.act(async () => button!.props.onPress()); + await settle(); + }; + + it('leaves wallet-level actions to AppKit', async () => { + const tree = await render(); + await openSheet(tree); + await pressAction(tree, 'Wallet'); + expect(mockOpen).toHaveBeenCalled(); + }); + + it('disconnects', async () => { + const tree = await render(); + await openSheet(tree); + await pressAction(tree, 'Disconnect'); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it('carries no navigation, which is the drawer’s job now', async () => { + // Five rows to Allow Challenges, Marriage, Leaderboard, Messages and Inventory used + // to live here, which made the wallet control the only way to reach half the app. + // `appDrawer.test.tsx` is where those destinations are checked now. + const tree = await render(); + await openSheet(tree); + + const labels = tree.root + .findAllByType(TouchableOpacity) + .map((node) => node.props.accessibilityLabel); + expect(labels).toEqual(expect.arrayContaining(['Wallet', 'Disconnect'])); + expect(labels).not.toContain('Marriage'); + expect(labels).not.toContain('Leaderboard'); + }); +}); + +describe('AccountSheet as a bottom sheet', () => { + const sheetStyle = (tree: ReactTestRenderer.ReactTestRenderer) => + StyleSheet.flatten( + tree.root + .findAllByType(View) + .find((n) => n.props.testID === 'account-sheet')!.props.style, + ); + + it('sits against the bottom edge rather than the middle of the screen', async () => { + // The whole difference between this and the centred card it replaced. A pager or a + // refactor that restores `justifyContent: center` puts it back in the middle with the + // slide animation still running, which reads as the sheet arriving from nowhere. + const tree = await render(); + await openSheet(tree); + + const root = tree.root + .findAllByType(View) + .map((n) => StyleSheet.flatten(n.props.style)) + .find((style) => style?.flex === 1 && style?.justifyContent); + expect(root?.justifyContent).toBe('flex-end'); + }); + + it('clears whatever the device puts along the bottom edge', async () => { + // A sheet flush to the bottom puts its last button under the home indicator. + // + // The inset is injected rather than left to the shipped mock, which reports zero on + // every edge. Against that, `insets.bottom + 20` and a bare `20` are the same number + // and this test passes with the safe-area handling deleted. + const gestureBar = await render( + + + , + ); + await openSheet(gestureBar); + expect(sheetStyle(gestureBar).paddingBottom).toBe(34 + 20); + + const noInset = await render( + + + , + ); + await openSheet(noInset); + expect(sheetStyle(noInset).paddingBottom).toBe(20); + }); + + it('squares off the corners that sit on the screen edge', async () => { + // Rounding all four leaves two slivers of scrim in the bottom corners of the display. + const tree = await render(); + await openSheet(tree); + + const style = sheetStyle(tree); + expect(style.borderTopLeftRadius).toBeGreaterThan(0); + expect(style.borderRadius).toBeUndefined(); + }); +}); + +describe('AccountSheet open and close animation', () => { + /** + * `setIsOpen(false)` runs from an animation callback now, not from the press handler. If + * that callback never arrives — a bad config, a driver that does not run — every button + * in the sheet still fires its action and the sheet stays up over the screen it just + * navigated to. The action tests above would not notice: they assert the handler was + * called, which happens either way. + */ + it('finishes closing rather than leaving the sheet over the screen', async () => { + const tree = await render(); + await openSheet(tree); + expect(isSheetOpen(tree)).toBe(true); + + const close = tree.root + .findAllByType(TouchableOpacity) + .find((node) => node.props.accessibilityLabel === 'Disconnect'); + await ReactTestRenderer.act(async () => close!.props.onPress()); + await settle(); + + expect(isSheetOpen(tree)).toBe(false); + }); + + it('closes instantly when the OS asks for reduced motion', async () => { + // Not a preference about polish: the animation is a 120ms delay before the sheet + // goes, and the reduced-motion path has to skip the wait, not just the movement. + jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValue(true); + + const tree = await render(); + await openSheet(tree); + + const close = tree.root + .findAllByType(TouchableOpacity) + .find((node) => node.props.accessibilityLabel === 'Disconnect'); + await ReactTestRenderer.act(async () => close!.props.onPress()); + + // No `settle()`: with reduced motion there is no animation to wait on. + expect(isSheetOpen(tree)).toBe(false); + }); +}); + +describe('NativeBalance', () => { + it('formats wei to four decimals with the chain symbol', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('1.2346'); + expect(textOf(tree)).toContain('ETH'); + }); + + it('renders zero rather than hiding it', async () => { + // A funded-looking wallet that is actually empty is worse than a plain 0. + mockState.balance = { value: 0n }; + const tree = await render(); + expect(textOf(tree)).toContain('0.0000'); + }); + + it('says so when the read fails instead of showing a zero', async () => { + mockState.balanceError = new Error('wrong network'); + const tree = await render(); + expect(textOf(tree)).toContain('Balance unavailable'); + }); + + it('renders nothing without an address', async () => { + mockState.address = undefined; + const tree = await render(); + expect(tree.toJSON()).toBeNull(); + }); +}); diff --git a/mobile/__tests__/actionScreens.test.tsx b/mobile/__tests__/actionScreens.test.tsx new file mode 100644 index 00000000..8d658dc9 --- /dev/null +++ b/mobile/__tests__/actionScreens.test.tsx @@ -0,0 +1,339 @@ +/** + * Level Up, Train and Rename: the three single-mutation screens. Each is checked + * for the parts that are easy to get wrong and invisible until a wallet is + * attached — the level-scaled fee in the button label, the validation gate, and + * what reaches `mutate`. + * + * `@shared/core` is stubbed rather than imported: its barrel re-exports the Solana + * adapter and drags an unparseable runtime into jest (see GalleryScreen.test.tsx). + */ + +import React from 'react'; +import { ScrollView, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; +import { SafeAreaInsetsContext } from 'react-native-safe-area-context'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; +/** + * `useSafeAreaInsets` throws outside a `SafeAreaProvider`, and this suite renders a screen on + * its own. The library ships this mock for exactly that. Repeated per suite rather than + * registered globally: a global one needs a `setupFiles` entry pointing at a file whose name + * says nothing about what it does. + */ +jest.mock('react-native-safe-area-context', () => + require('react-native-safe-area-context/jest/mock').default, +); + + +const pet = (over: Partial = {}): Pet => ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 0n, + level: 5, + rarity: 2, + winCount: 0, + lossCount: 0, + readyAt: 0, + ...over, +}); + +const mockState = { + pets: [pet()] as Pet[], + isConnected: true, + renameMinLevel: 1, + levelUpFee: 1000n as bigint | null, + trainFee: 1000n as bigint | null, +}; + +const mockMutations = { + levelUp: jest.fn(), + train: jest.fn(), + rename: jest.fn(), +}; + +const mutationResult = (mutate: jest.Mock) => ({ + mutate, + isPending: false, + error: null, + reset: jest.fn(), + lifecycle: {}, +}); + +jest.mock('../src/components/PetArt', () => () => null); + +jest.mock('@shared/core', () => ({ + // `PetPicker` shows the selected pet's stats inline now, so anything rendering a picker + // reaches these. Real rather than stubbed: they are pure and dependency-free, and what a + // pet reads here has to be what it reads on the card and on the web app. + ...jest.requireActual('../../shared/src/utils/ethereum/petCard'), + ...jest.requireActual('../../shared/src/utils/pets/skills'), + ...jest.requireActual('../../shared/src/utils/pets/cosmetics'), + useSyncMetadata: () => ({ sync: jest.fn(), isPending: false, error: null }), + getReadyPetsUnified: (pets: Pet[]) => pets.map((p) => ({ id: p.id, pet: p })), + usePetList: () => ({ pets: mockState.pets, isLoading: false, error: null, refetch: jest.fn() }), + useChainCapabilities: () => ({ + isConnected: mockState.isConnected, + renameMinLevel: mockState.renameMinLevel, + }), + useFees: () => ({ + levelUpFee: mockState.levelUpFee, + trainFee: mockState.trainFee, + // Mirrors the real formatter closely enough to assert the scaling maths. + formatAmount: (v: bigint) => `${v.toString()} wei`, + }), + useLevelUpPet: () => mutationResult(mockMutations.levelUp), + useTrainPet: () => mutationResult(mockMutations.train), + useRenamePet: () => mutationResult(mockMutations.rename), +})); + +const mockNotify = jest.fn(); +jest.mock('../src/hooks/useNotifyError', () => ({ useNotifyError: () => mockNotify })); +jest.mock('../src/hooks/useTxErrorToast', () => ({ useTxErrorToast: () => {} })); + +const mockRouteParams: { petId?: string } = {}; +jest.mock('@react-navigation/native', () => ({ + useRoute: () => ({ params: mockRouteParams }), +})); + +import LevelUpScreen from '../src/screens/LevelUpScreen'; +import TrainScreen from '../src/screens/TrainScreen'; +import RenameScreen from '../src/screens/RenameScreen'; + +import { allText, textOfNode, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const render = async (Screen: React.ComponentType) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +/** + * Found by `testID` rather than by position. It used to take the last touchable, which held + * only because these three screens have no secondary button — Defense does, and the same + * helper there was already pointing at the wrong control once the action bar moved out of + * the scroll. + */ +const pressAction = async (tree: ReactTestRenderer.ReactTestRenderer) => { + const node = tree.root + .findAllByType(TouchableOpacity) + .find((n) => n.props.testID === 'action-primary'); + await ReactTestRenderer.act(() => node!.props.onPress()); +}; + +/** + * The first pet chip, by the name it renders. + * + * It was "the first touchable", which held only while the picker was the first thing on the + * screen. It is not any more: `PetPicker` now draws a detail strip under the chips, and the + * pinned action bar is a sibling of the scroll. + */ +const selectFirstPet = async (tree: ReactTestRenderer.ReactTestRenderer) => { + const chip = tree.root + .findAllByType(TouchableOpacity) + .find((n) => textOfNode(n).includes('Rex')); + await ReactTestRenderer.act(() => chip!.props.onPress()); +}; + +beforeEach(() => { + mockState.pets = [pet()]; + mockState.isConnected = true; + mockState.renameMinLevel = 1; + mockState.levelUpFee = 1000n; + mockState.trainFee = 1000n; + delete mockRouteParams.petId; + jest.clearAllMocks(); +}); + +describe('LevelUpScreen', () => { + it('scales the fee by (level-1)² and shows it on the button', async () => { + // level 5 → 100 + 4² = 116 → 1000 * 116 / 100 = 1160 + const tree = await render(LevelUpScreen); + await selectFirstPet(tree); + expect(textOf(tree)).toContain('Level Up (1160 wei)'); + }); + + it('omits the cost until a pet is chosen, since the fee depends on its level', async () => { + const tree = await render(LevelUpScreen); + expect(textOf(tree)).toContain('Level Up'); + expect(textOf(tree)).not.toContain('wei'); + }); + + it('passes the selected pet to the mutation', async () => { + const tree = await render(LevelUpScreen); + await selectFirstPet(tree); + await pressAction(tree); + expect(mockMutations.levelUp).toHaveBeenCalledWith({ petId: '1' }); + }); + + it('notifies rather than mutating when disconnected', async () => { + mockState.isConnected = false; + const tree = await render(LevelUpScreen); + await selectFirstPet(tree); + await pressAction(tree); + expect(mockMutations.levelUp).not.toHaveBeenCalled(); + expect(mockNotify).toHaveBeenCalledWith( + 'Please connect your wallet first', + undefined, + 'level-up-validation', + ); + }); +}); + +describe('TrainScreen', () => { + it('scales the fee by 2·level, a different curve from level-up', async () => { + // level 5 → 100 + 10 = 110 → 1000 * 110 / 100 = 1100 + const tree = await render(TrainScreen); + await selectFirstPet(tree); + expect(textOf(tree)).toContain('Train (1100 wei)'); + }); + + it('still offers the action when the fee has not loaded', async () => { + mockState.trainFee = null; + const tree = await render(TrainScreen); + await selectFirstPet(tree); + expect(textOf(tree)).toContain('Train'); + }); +}); + +describe('RenameScreen', () => { + it('rejects a name below the minimum length', async () => { + const tree = await render(RenameScreen); + await selectFirstPet(tree); + await ReactTestRenderer.act(() => { + tree.root.findByType(TextInput).props.onChangeText('a'); + }); + expect(textOf(tree)).toContain('○ Min 2 characters'); + }); + + it('trims before sending, so trailing spaces do not reach the chain', async () => { + const tree = await render(RenameScreen); + await selectFirstPet(tree); + await ReactTestRenderer.act(() => { + tree.root.findByType(TextInput).props.onChangeText(' Blaze '); + }); + await pressAction(tree); + expect(mockMutations.rename).toHaveBeenCalledWith({ petId: '1', name: 'Blaze' }); + }); + + it('preselects the pet a Gallery action arrived with', async () => { + mockState.pets = [pet(), pet({ id: '2', name: 'Momo' })]; + mockRouteParams.petId = '2'; + const tree = await render(RenameScreen); + await ReactTestRenderer.act(() => { + tree.root.findByType(TextInput).props.onChangeText('Blaze'); + }); + await pressAction(tree); + expect(mockMutations.rename).toHaveBeenCalledWith({ petId: '2', name: 'Blaze' }); + }); + + it('hides pets below the chain minimum level', async () => { + mockState.renameMinLevel = 10; + mockState.pets = [pet({ level: 5 })]; + const tree = await render(RenameScreen); + expect(textOf(tree)).toContain('level 10 or above'); + }); +}); + +describe('PetPicker empty states', () => { + /** + * A wallet with no pets and a wallet whose pets are all busy look identical + * to the picker. On device an empty roster read "No pets are off cooldown + * right now", which tells a player with nothing to their name that their + * pets are resting. + */ + it('sends a player with no pets to the Gallery rather than blaming cooldown', async () => { + mockState.pets = []; + const tree = await render(LevelUpScreen); + expect(textOf(tree)).toContain('Mint one from the Gallery'); + expect(textOf(tree)).not.toContain('off cooldown'); + }); + + it('still blames the filter when the wallet does hold pets', async () => { + mockState.renameMinLevel = 10; + mockState.pets = [pet({ level: 5 })]; + const tree = await render(RenameScreen); + expect(textOf(tree)).toContain('level 10 or above'); + expect(textOf(tree)).not.toContain('Mint one from the Gallery'); + }); + + it('applies to Train too, not just Level Up', async () => { + mockState.pets = []; + const tree = await render(TrainScreen); + expect(textOf(tree)).toContain('Mint one from the Gallery'); + expect(textOf(tree)).not.toContain('off cooldown'); + }); +}); + +/** + * The action bar's whole point is that it does not move. + * + * Rendered after `children` inside the `ScrollView`, the button's position tracked the height + * of whatever the screen put above it: different on every screen, and below the fold on the + * longer ones. These assert the structure that fixes it, because nothing else would notice it + * sliding back in — every behavioural test above passes either way. + */ +describe('the fixed action bar', () => { + const scrolls = (tree: ReactTestRenderer.ReactTestRenderer) => + tree.root.findAllByType(ScrollView); + + const primary = (tree: ReactTestRenderer.ReactTestRenderer) => + tree.root.findAllByType(TouchableOpacity).find((n) => n.props.testID === 'action-primary'); + + it('keeps the action outside every scrollable region', async () => { + const tree = await render(LevelUpScreen); + const button = primary(tree); + + expect(button).toBeDefined(); + for (const scroll of scrolls(tree)) { + expect(scroll.findAll((n) => n === button)).toHaveLength(0); + } + }); + + /** + * Pinning the bar is what creates this problem: inside the scroll the content inset + * handled it, and a bar sitting on the window's bottom edge does not. Both cases are + * asserted because only the pair distinguishes a real inset read from a constant that + * happens to match one device. + */ + it('pads itself clear of whatever the device puts along the bottom edge', async () => { + const gestureBar = await renderWithInsets(LevelUpScreen, 34); + expect(paddingBottomOfBar(gestureBar)).toBe(34 + 12); + + const noInset = await renderWithInsets(LevelUpScreen, 0); + expect(paddingBottomOfBar(noInset)).toBe(12); + }); + + it('renders the action even when the screen has no content of its own', async () => { + mockState.pets = []; + const tree = await render(LevelUpScreen); + expect(primary(tree)).toBeDefined(); + }); +}); + +/** + * The global mock reads `SafeAreaInsetsContext` before falling back to zeroes, so a provider + * is enough to stand in for a device with a gesture bar. No `SafeAreaProvider`: that measures + * a native view, which does not exist here. + */ +const renderWithInsets = async (Screen: React.ComponentType, bottom: number) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + + + , + ); + }); + return tree; +}; + +const paddingBottomOfBar = (tree: ReactTestRenderer.ReactTestRenderer): number | undefined => { + const bar = tree.root.findAllByType(View).find((n) => n.props.testID === 'action-bar'); + return StyleSheet.flatten(bar!.props.style).paddingBottom as number | undefined; +}; diff --git a/mobile/__tests__/api.test.ts b/mobile/__tests__/api.test.ts new file mode 100644 index 00000000..d57f7dd0 --- /dev/null +++ b/mobile/__tests__/api.test.ts @@ -0,0 +1,51 @@ +/** + * How a backend WebSocket URL is derived from `API_URL`. + * + * Tested through `socketUrlFrom` rather than through `BATTLE_ROOM_WS_URL` and + * `CHAT_WS_URL` themselves, for the reason `ethereumNetworks.test.ts` records about + * `resolveTargetChainId`: `react-native-dotenv` inlines `@env` at Babel transform time, so + * those two constants are literals baked in from whichever `.env` built them. `mobile/.env` + * is gitignored, so a machine with one and CI without it disagree about their value, and an + * assertion on either tests the file rather than the rule. + * + * That is not hypothetical. `BattleScreen.test.tsx` matched `roomSocketUrl` against + * `/^wss?:\/\/.+\/ws\/battle-room$/`, which passed on every developer machine and failed on + * CI with `undefined`, where the app has no backend configured at all. + */ + +import { socketUrlFrom } from '../src/constants/api'; + +describe('socketUrlFrom', () => { + it('swaps http for ws and appends the channel path', () => { + expect(socketUrlFrom('http://localhost:4000', '/ws/battle-room')).toBe( + 'ws://localhost:4000/ws/battle-room', + ); + }); + + it('keeps a TLS endpoint secure', () => { + // `ws://` against an `https://` backend is refused by the browser and by App + // Transport Security on iOS, so the scheme has to carry the `s` across. + expect(socketUrlFrom('https://api.cryptopets.app', '/ws/chat')).toBe( + 'wss://api.cryptopets.app/ws/chat', + ); + }); + + it('leaves a host containing "http" alone when the scheme is not http', () => { + // What the `^` anchor is for. `API_URL` is always http(s) today, so this input + // cannot arrive from `@env` and the anchor is defensive rather than load-bearing. + // It is pinned anyway because dropping the anchor reads as a harmless + // simplification: on a base that does start with http the two forms agree + // exactly, so nothing else in this file can tell them apart. + expect(socketUrlFrom('wss://http-proxy.internal', '/ws/chat')).toBe( + 'wss://http-proxy.internal/ws/chat', + ); + }); + + it('is undefined with no backend configured, rather than a bad URL', () => { + // This is the CI case. Returning `ws:/ws/battle-room` here would have the socket + // hook reconnecting against a nonsense address forever; undefined disables the + // subscription and leaves polling to carry the battle. + expect(socketUrlFrom(undefined, '/ws/battle-room')).toBeUndefined(); + expect(socketUrlFrom('', '/ws/battle-room')).toBeUndefined(); + }); +}); diff --git a/mobile/__tests__/appDrawer.test.tsx b/mobile/__tests__/appDrawer.test.tsx new file mode 100644 index 00000000..2df556fa --- /dev/null +++ b/mobile/__tests__/appDrawer.test.tsx @@ -0,0 +1,249 @@ +/** + * The drawer that took the five account-level destinations off the wallet sheet. + * + * `navigation.test.tsx` accepts membership of `DRAWER_ITEMS` as proof a route is reachable, + * because the drawer navigates with a loop variable and its source-scan cannot see that. This + * file is what makes that acceptable: it presses every row and checks where each one goes. + */ + +import React from 'react'; +import { Modal, PanResponder, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +/** + * `useSafeAreaInsets` throws outside a `SafeAreaProvider`, and this suite renders the drawer + * on its own. The library ships this mock for exactly that. Repeated per suite rather than + * registered globally: a global one needs a `setupFiles` entry pointing at a file whose name + * says nothing about what it does. + */ +jest.mock('react-native-safe-area-context', () => + require('react-native-safe-area-context/jest/mock').default, +); + +import AppDrawer from '../src/components/AppDrawer'; +import DrawerHost, { shouldCloseFromDrag, shouldOpenFromEdge } from '../src/components/DrawerHost'; +import { DRAWER_ITEMS, STACK_TITLES } from '../src/navigation/routes'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create( + + + , + ); + }); + return tree; +}; + +const byLabel = (tree: ReactTestRenderer.ReactTestRenderer, label: string) => + tree.root.findAllByType(TouchableOpacity).find((n) => n.props.accessibilityLabel === label); + +const press = async (node: ReactTestRenderer.ReactTestInstance | undefined) => { + await ReactTestRenderer.act(async () => node!.props.onPress()); +}; + +/** Runs the close animation out; see `accountSheet.test.tsx` for why this is not optional. */ +const settle = async () => { + await ReactTestRenderer.act(async () => { + jest.advanceTimersByTime(300); + }); +}; + +const isOpen = (tree: ReactTestRenderer.ReactTestRenderer): boolean => + tree.root.findAllByType(Modal)[0].props.visible; + +const open = async (tree: ReactTestRenderer.ReactTestRenderer) => { + await press(byLabel(tree, 'Menu')); +}; + + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); +}); + +afterEach(() => { + jest.useRealTimers(); + // `clearAllMocks` keeps implementations, so a `spyOn` would still be in place next test. + jest.restoreAllMocks(); +}); + +describe('AppDrawer', () => { + it('stays shut until the menu button is pressed', async () => { + const tree = await render(); + expect(isOpen(tree)).toBe(false); + + await open(tree); + expect(isOpen(tree)).toBe(true); + }); + + it('lists every drawer destination, labelled as the screen titles itself', async () => { + // Labels come from `STACK_TITLES`, so a screen is named the same in the menu and in + // the header it pushes. Two of the five differ from their route name — Defense is + // "Allow Challenges" and Chat is "Messages" — which is exactly where a hand-written + // second copy of the labels would drift. + const tree = await render(); + await open(tree); + + const rendered = textOf(tree); + DRAWER_ITEMS.forEach((route) => { + expect(rendered).toContain(STACK_TITLES[route]); + }); + }); + + it.each(DRAWER_ITEMS as readonly string[])( + 'goes to %s and closes behind itself', + async (route) => { + const tree = await render(); + await open(tree); + + await press(byLabel(tree, STACK_TITLES[route as keyof typeof STACK_TITLES])); + expect(mockNavigate).toHaveBeenCalledWith(route); + + // Navigates first, then closes: the drawer has to reveal where you are going + // rather than the screen you left. Both have to actually happen. + await settle(); + expect(isOpen(tree)).toBe(false); + }, + ); + + it('closes on a tap outside, without navigating', async () => { + const tree = await render(); + await open(tree); + + const scrim = tree.root.findAll((n) => n.props.accessibilityLabel === 'Close menu')[0]; + await ReactTestRenderer.act(async () => scrim.props.onPress()); + await settle(); + + expect(isOpen(tree)).toBe(false); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('offers no per-pet screen, because a menu has no pet to offer', async () => { + // Rename and Equip arrive from a tapped pet card carrying its id. Listing them here + // would mean a screen that opens with nothing selected. + const tree = await render(); + await open(tree); + + const rendered = textOf(tree); + expect(rendered).not.toContain(STACK_TITLES.Rename); + expect(rendered).not.toContain(STACK_TITLES.Equip); + }); +}); + +describe('edge swipe', () => { + /** + * `startX` is where the finger went down; the predicate derives it as `pageX - dx`, + * because `gesture.x0` is only meaningful once the responder has been granted and being + * granted is what it is deciding. + */ + const swipe = (startX: number, dx: number, dy = 0) => + shouldOpenFromEdge( + { nativeEvent: { pageX: startX + dx } } as never, + { dx, dy } as never, + ); + + it('opens on a small drag right from the left edge', async () => { + expect(swipe(4, 20)).toBe(true); + }); + + it('ignores a drag that starts away from the edge', async () => { + // Where a pet card is. Without this the first swipe of the Gallery pager opens the + // menu instead of showing the next pet. + expect(swipe(200, 60)).toBe(false); + }); + + it('ignores a vertical scroll that drifts right near the edge', async () => { + // The common case, not the rare one: every screen here scrolls vertically, and a + // thumb travelling down the left side wanders sideways as it goes. + expect(swipe(4, 20, 200)).toBe(false); + }); + + it('ignores a leftward drag from the edge', async () => { + expect(swipe(4, -40)).toBe(false); + }); + + it('ignores a touch that has barely moved', async () => { + // A tap on something in the leftmost strip registers a pixel or two of travel. + expect(swipe(4, 3)).toBe(false); + }); + + it('wires that predicate to the gesture, and the gesture to opening', async () => { + // The two halves the cases above cannot reach. Asserting the config identity rather + // than firing a synthetic touch: `PanResponder`'s grant path reads RN's event plugin + // registry, so a hand-made event tests React Native rather than this component. + const create = jest.spyOn(PanResponder, 'create'); + const tree = await render(); + // By identity, not by index: there are two responders now, the host's and the + // panel's, and which renders first is React's business rather than this test's. + const config = create.mock.calls + .map(([c]) => c) + .find((c) => c.onMoveShouldSetPanResponderCapture === shouldOpenFromEdge)!; + expect(config).toBeDefined(); + + expect(isOpen(tree)).toBe(false); + await ReactTestRenderer.act(async () => { + config.onPanResponderGrant!(null as never, null as never); + }); + expect(isOpen(tree)).toBe(true); + }); +}); + +describe('swipe to close', () => { + const drag = (dx: number, dy = 0) => shouldCloseFromDrag({} as never, { dx, dy } as never); + + it('closes on a push back to the left', async () => { + expect(drag(-40)).toBe(true); + }); + + it('ignores a drag the other way, which is the gesture that opened it', async () => { + expect(drag(40)).toBe(false); + }); + + it('ignores a vertical drag down the panel', async () => { + expect(drag(-20, 200)).toBe(false); + }); + + it('ignores a touch that has barely moved', async () => { + // Otherwise the wobble in a tap on a menu row dismisses the menu instead of + // navigating, which is the worst possible outcome for a row press. + expect(drag(-3)).toBe(false); + }); + + it('is wired to the panel, and really closes', async () => { + // The host's responder cannot do this job: the drawer is a Modal, its own native + // window, so the host's surface is behind it and never sees the touch. + const create = jest.spyOn(PanResponder, 'create'); + const tree = await render(); + await open(tree); + expect(isOpen(tree)).toBe(true); + + const config = create.mock.calls + .map(([c]) => c) + .find((c) => c.onMoveShouldSetPanResponderCapture === shouldCloseFromDrag)!; + expect(config).toBeDefined(); + + // That the responder was created is not that it was attached. An earlier version of + // this test stopped at the line above and passed with the handlers left off the + // panel, which is the original bug: the drawer opened by swipe and would not close. + const panel = tree.root.findAll((n) => n.props.testID === 'drawer-panel').at(-1)!; + expect(typeof panel.props.onMoveShouldSetResponderCapture).toBe('function'); + + await ReactTestRenderer.act(async () => { + config.onPanResponderGrant!(null as never, null as never); + }); + await settle(); + expect(isOpen(tree)).toBe(false); + }); +}); diff --git a/mobile/__tests__/battleEvidenceStore.test.ts b/mobile/__tests__/battleEvidenceStore.test.ts new file mode 100644 index 00000000..27f0cb2c --- /dev/null +++ b/mobile/__tests__/battleEvidenceStore.test.ts @@ -0,0 +1,150 @@ +/** + * `shared`'s battleEvidence module falls back to a no-op store when it finds no + * Web Storage, which React Native has none of — so mobile was dropping the signed + * commitment that proves the drand round was chosen before the randomness existed + * (§E, §J). The point of a local copy is that the player's evidence does not + * depend on the backend continuing to serve it, so "silently kept nothing" is the + * failure this store exists to prevent. + * + * The real `saveBattleEvidence` / `readBattleEvidence` run here: a fake would test + * the fake, and the contract under test is precisely that shared can use this + * store through its synchronous interface. + */ + +const mockDisk = new Map(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getAllKeys: jest.fn(async () => [...mockDisk.keys()]), + multiGet: jest.fn(async (keys: string[]) => + keys.map((k) => [k, mockDisk.get(k) ?? null]), + ), + setItem: jest.fn(async (k: string, v: string) => { + mockDisk.set(k, v); + }), + removeItem: jest.fn(async (k: string) => { + mockDisk.delete(k); + }), + }, +})); + +jest.mock('@shared/core', () => jest.requireActual('../../shared/src/utils/battleEvidence')); + +import { + forgetBattleEvidence, + listBattleEvidenceIds, + readBattleEvidence, + saveBattleEvidence, + setEvidenceStore, + type BattleEvidence, +} from '../../shared/src/utils/battleEvidence'; +import { + battleEvidenceStore, + hydrateBattleEvidence, + resetBattleEvidenceCache, +} from '../src/utils/battleEvidenceStore'; + +const evidence = (battleId: string): BattleEvidence => ({ + battleId, + commitmentHash: `0xhash-${battleId}`, + signature: `0xsig-${battleId}`, + signingKeyId: 'key-1', + commitment: { round: 42, battleId }, + storedAt: 1_700_000_000_000, +}); + +/** AsyncStorage writes are fire-and-forget; let their microtasks land. */ +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +beforeEach(() => { + mockDisk.clear(); + resetBattleEvidenceCache(); + setEvidenceStore(battleEvidenceStore); +}); + +afterAll(() => { + setEvidenceStore(null); +}); + +describe('battleEvidenceStore', () => { + it('round-trips evidence through shared, which a no-op store cannot', async () => { + saveBattleEvidence(evidence('battle-1')); + expect(readBattleEvidence('battle-1')).toMatchObject({ + battleId: 'battle-1', + commitmentHash: '0xhash-battle-1', + }); + expect(listBattleEvidenceIds()).toEqual(['battle-1']); + }); + + it('reads synchronously, because shared has no async path to await', () => { + // The whole reason for the memory layer: `getItem` must answer immediately. + saveBattleEvidence(evidence('battle-2')); + expect(battleEvidenceStore.getItem('cryptopets.battle-evidence.battle-2')).toContain( + 'battle-2', + ); + }); + + it('persists to AsyncStorage so evidence survives a relaunch', async () => { + saveBattleEvidence(evidence('battle-3')); + await settle(); + + // Simulate a fresh launch: memory gone, disk intact. + resetBattleEvidenceCache(); + expect(readBattleEvidence('battle-3')).toBeNull(); + + await hydrateBattleEvidence(); + expect(readBattleEvidence('battle-3')).toMatchObject({ battleId: 'battle-3' }); + expect(listBattleEvidenceIds()).toEqual(['battle-3']); + }); + + it('does not let hydration overwrite evidence saved since launch', async () => { + mockDisk.set( + 'cryptopets.battle-evidence.battle-4', + JSON.stringify(evidence('battle-4')), + ); + resetBattleEvidenceCache(); + + // A battle accepted before hydration finished: this copy is the newer one. + const fresh = { ...evidence('battle-4'), commitmentHash: '0xfresher' }; + saveBattleEvidence(fresh); + await hydrateBattleEvidence(); + + expect(readBattleEvidence('battle-4')?.commitmentHash).toBe('0xfresher'); + }); + + it('forgets evidence on both layers', async () => { + saveBattleEvidence(evidence('battle-5')); + await settle(); + + forgetBattleEvidence('battle-5'); + await settle(); + + expect(readBattleEvidence('battle-5')).toBeNull(); + expect(listBattleEvidenceIds()).toEqual([]); + expect(mockDisk.has('cryptopets.battle-evidence.battle-5')).toBe(false); + }); + + it('ignores unrelated AsyncStorage keys when hydrating', async () => { + mockDisk.set('authToken', 'not-evidence'); + mockDisk.set('cryptopets.battle-evidence.battle-6', JSON.stringify(evidence('battle-6'))); + resetBattleEvidenceCache(); + + await hydrateBattleEvidence(); + + expect(readBattleEvidence('battle-6')).not.toBeNull(); + expect(battleEvidenceStore.getItem('authToken')).toBeNull(); + }); + + it('survives a failing disk rather than breaking the battle', async () => { + const AsyncStorage = jest.requireMock( + '@react-native-async-storage/async-storage', + ).default; + AsyncStorage.setItem.mockRejectedValueOnce(new Error('disk full')); + + expect(() => saveBattleEvidence(evidence('battle-7'))).not.toThrow(); + await settle(); + // The in-memory copy still answers for this session. + expect(readBattleEvidence('battle-7')).toMatchObject({ battleId: 'battle-7' }); + }); +}); diff --git a/mobile/__tests__/battleSplash.test.tsx b/mobile/__tests__/battleSplash.test.tsx new file mode 100644 index 00000000..be661eda --- /dev/null +++ b/mobile/__tests__/battleSplash.test.tsx @@ -0,0 +1,125 @@ +/** + * The card that announces a fight. + * + * It is decoration, so what is worth pinning is not how it looks but that it says who is + * fighting, that it always gets out of the way, and that it does so without movement when the + * OS has asked for none. + */ + +import React from 'react'; +import { AccessibilityInfo } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; + +jest.mock('@shared/core', () => ({ + getPetAvatar: () => '🐾', + petArtUrl: () => null, +})); + +import BattleSplash from '../src/screens/parts/BattleSplash'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const pet = (over: Partial = {}): Pet => + ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 0n, + level: 3, + rarity: 2, + winCount: 0, + lossCount: 0, + readyAt: 0, + ...over, + }) as Pet; + +const onDone = jest.fn(); + +const render = async (attacker: Pet | null = pet(), defender: Pet | null = pet({ id: '2' })) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create( + , + ); + }); + return tree; +}; + + +/** Long enough for the whole timeline: wipe, slam, impact, hold and clear. */ +const playOut = async () => { + await ReactTestRenderer.act(async () => { + jest.advanceTimersByTime(4000); + }); +}; + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe('BattleSplash', () => { + it('names both fighters and the fight', async () => { + const tree = await render(); + const shown = textOf(tree); + expect(shown).toContain('Rex'); + expect(shown).toContain('Luna'); + expect(shown).toContain('VS'); + }); + + it('reads as one announcement to a screen reader, not three labels', async () => { + const tree = await render(); + const labels = tree.root + .findAll((n) => typeof n.props.accessibilityLabel === 'string') + .map((n) => n.props.accessibilityLabel); + expect(labels).toContain('Rex versus Luna'); + }); + + it('always gets out of the way', async () => { + // It covers the arena, so a card that never finishes is a fight the player can hear + // happening and cannot see. + await render(); + expect(onDone).not.toHaveBeenCalled(); + + await playOut(); + expect(onDone).toHaveBeenCalled(); + }); + + it('still announces the fight when the OS asks for no motion', async () => { + jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValue(true); + const tree = await render(); + + // Composed and still. Who is fighting whom is the point of the card, and that is + // exactly the part that does not need movement. + expect(textOf(tree)).toContain('Rex'); + expect(textOf(tree)).toContain('VS'); + + await playOut(); + expect(onDone).toHaveBeenCalled(); + }); + + it('falls back to the name when a pet has left the ready list', async () => { + // `fighter` and `opponent` go null once a receipt publishes and the pet drops onto + // cooldown. The card is entered before that, but a re-entry is not. + const tree = await render(null, null); + const shown = textOf(tree); + expect(shown).toContain('Rex'); + expect(shown).toContain('Luna'); + expect(shown).toContain('?'); + }); +}); diff --git a/mobile/__tests__/carousel.test.tsx b/mobile/__tests__/carousel.test.tsx new file mode 100644 index 00000000..d1352d3c --- /dev/null +++ b/mobile/__tests__/carousel.test.tsx @@ -0,0 +1,124 @@ +/** + * The paged list behind the Gallery, and behind the marriage list after it. + * + * Exercised directly rather than through a screen: the interesting behaviour is arithmetic + * over a measured width and a scroll offset, and driving it through `GalleryScreen` would put + * a pet fixture between the test and the thing being tested. + */ + +import React from 'react'; +import { FlatList, Text, View } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +import Carousel from '../src/components/ui/Carousel'; + +const items = (count: number) => Array.from({ length: count }, (_, i) => ({ id: String(i) })); + +const carousel = (count: number) => ( + item.id} + itemLabel="Pet" + renderItem={(item) => {`page:${item.id}`}} + /> +); + +/** + * Every tree is unmounted after its test. + * + * `VirtualizedList` keeps deciding which cells to render after the render that mounted it, + * on its own timers. A tree left standing does that once the test has moved on, and React + * reports it as an update outside `act(...)` blamed on whichever test is running then. + */ +const mounted: ReactTestRenderer.ReactTestRenderer[] = []; + +const renderCarousel = async (count: number) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create(carousel(count)); + }); + mounted.push(tree); + return tree; +}; + +afterEach(async () => { + await ReactTestRenderer.act(async () => { + mounted.splice(0).forEach((tree) => tree.unmount()); + }); +}); + +const indicator = (tree: ReactTestRenderer.ReactTestRenderer): string => + tree.root + .findAllByType(Text) + .filter((node) => node.props.testID === 'carousel-indicator') + .map((node) => (node.props.children as unknown[]).join('')) + .join(''); + +/** Stands in for the layout pass, which never runs under `react-test-renderer`. */ +const measure = async (tree: ReactTestRenderer.ReactTestRenderer, width: number) => { + const root = tree.root.findAllByType(View).find((node) => node.props.testID === 'carousel'); + await ReactTestRenderer.act(async () => { + root!.props.onLayout({ nativeEvent: { layout: { width } } }); + }); +}; + +const scrollTo = async (tree: ReactTestRenderer.ReactTestRenderer, x: number) => { + const list = tree.root.findByType(FlatList); + await ReactTestRenderer.act(async () => { + list.props.onMomentumScrollEnd({ nativeEvent: { contentOffset: { x } } }); + }); +}; + +describe('Carousel', () => { + it('counts the whole set, not the pages it has mounted', async () => { + // The distinction is the point of windowing: twelve pets exist, roughly one is + // mounted, and a counter reading "1 / 1" would be worse than no counter at all. + const tree = await renderCarousel(12); + expect(indicator(tree)).toBe('1 / 12'); + }); + + it('reports the page you land on', async () => { + const tree = await renderCarousel(12); + await measure(tree, 360); + await scrollTo(tree, 720); + expect(indicator(tree)).toBe('3 / 12'); + }); + + it('ignores a scroll that arrives before it has been measured', async () => { + // Width is 0 until the layout pass, and dividing the offset by it gives Infinity, + // which the clamp then turns into the last page. So a scroll landing in that window + // would report the roster's end no matter where the player actually was. + const tree = await renderCarousel(12); + await scrollTo(tree, 720); + expect(indicator(tree)).toBe('1 / 12'); + }); + + it('clamps to the last page when the set shrinks under it', async () => { + const tree = await renderCarousel(5); + await measure(tree, 360); + await scrollTo(tree, 360 * 4); + expect(indicator(tree)).toBe('5 / 5'); + + // A pet sent away while you were looking at the end of the roster. + await ReactTestRenderer.act(async () => { + tree.update(carousel(2)); + }); + expect(indicator(tree)).toBe('2 / 2'); + }); + + it('mounts a page at a time rather than the whole set', async () => { + // The other half of why this is a FlatList: every pet card fetches its own art, so + // the vertical list it replaced put twenty image requests on screen at once. + // + // The bound is loose because it is a claim about roughly how many, not exactly: RN + // mounts one here today, and a version that also mounted a neighbour would still be + // doing the right thing. `initialNumToRender`'s default of 10 would not be, and is + // what this catches. + const tree = await renderCarousel(12); + const pages = tree.root + .findAllByType(Text) + .filter((node) => String(node.props.children).startsWith('page:')); + expect(pages.length).toBeGreaterThan(0); + expect(pages.length).toBeLessThanOrEqual(3); + }); +}); diff --git a/mobile/__tests__/chatPanel.test.tsx b/mobile/__tests__/chatPanel.test.tsx new file mode 100644 index 00000000..eff85460 --- /dev/null +++ b/mobile/__tests__/chatPanel.test.tsx @@ -0,0 +1,112 @@ +/** + * The one piece of chat state worth a controller: which thread is open, and what happens when + * it stops existing underneath the reader. + * + * Access is derived per request rather than stored, so a divorce closes a conversation with + * no revocation step. That makes "the thread vanished while you were reading it" a normal + * path, not an edge case, and it was previously unreachable from any test. + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import type { ChatThread } from '@shared/core'; + +const mockState = { + threads: [] as ChatThread[], +}; + +jest.mock('@shared/core', () => ({ + useChatThreads: () => ({ threads: mockState.threads, isLoading: false, error: null }), + useChatMessages: () => ({ messages: [], online: [], markRead: jest.fn() }), + sameAccount: (a: string, b: string) => a.toLowerCase() === b.toLowerCase(), +})); + +jest.mock('wagmi', () => ({ useAccount: () => ({ address: '0xME' }) })); + +import { useChatPanel, type UseChatPanel } from '../src/hooks/chat/useChatPanel'; + +const thread = (id: string): ChatThread => + ({ threadId: id, counterpart: '0xthem', pets: [] }) as unknown as ChatThread; + +let panel!: UseChatPanel; + +const Probe = () => { + panel = useChatPanel(); + return null; +}; + +const mount = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + +const rerender = async (tree: ReactTestRenderer.ReactTestRenderer) => { + await ReactTestRenderer.act(async () => tree.update()); +}; + +beforeEach(() => { + mockState.threads = [thread('a'), thread('b')]; +}); + +describe('useChatPanel', () => { + it('opens nothing until a thread is chosen', async () => { + await mount(); + expect(panel.openThread).toBeNull(); + }); + + it('opens the thread it was given', async () => { + const tree = await mount(); + await ReactTestRenderer.act(async () => panel.onOpen('b')); + expect(panel.openThread?.threadId).toBe('b'); + await rerender(tree); + }); + + it('falls back to the list when the open thread disappears', async () => { + // A divorce landing mid-conversation. Keeping it open would show a transcript whose + // next read is going to fail, so the honest response is to go back. + const tree = await mount(); + await ReactTestRenderer.act(async () => panel.onOpen('b')); + expect(panel.openThread?.threadId).toBe('b'); + + mockState.threads = [thread('a')]; + await rerender(tree); + + expect(panel.openThread).toBeNull(); + }); + + it('does not re-open a thread that came back', async () => { + // What the effect is actually for. `openThread` is derived, so a vanished thread + // already reads as null without it; what it clears is the remembered id. Leave that + // set and a thread reappearing in a later read yanks the player back into a + // conversation they had been dropped out of. + const tree = await mount(); + await ReactTestRenderer.act(async () => panel.onOpen('b')); + + mockState.threads = [thread('a')]; + await rerender(tree); + expect(panel.openThread).toBeNull(); + + mockState.threads = [thread('a'), thread('b')]; + await rerender(tree); + expect(panel.openThread).toBeNull(); + }); + + it('keeps the thread open while it is still there', async () => { + // The other half: a list that merely re-reads must not bounce the reader out. + const tree = await mount(); + await ReactTestRenderer.act(async () => panel.onOpen('b')); + + mockState.threads = [thread('a'), thread('b')]; + await rerender(tree); + + expect(panel.openThread?.threadId).toBe('b'); + }); + + it('carries the caller’s own address, for deciding which side a message sits on', async () => { + await mount(); + expect(panel.selfAddress).toBe('0xME'); + }); +}); diff --git a/mobile/__tests__/contracts.test.ts b/mobile/__tests__/contracts.test.ts new file mode 100644 index 00000000..564a2acc --- /dev/null +++ b/mobile/__tests__/contracts.test.ts @@ -0,0 +1,51 @@ +/** + * Guards the ABI copy. The JSONs are copied verbatim from `frontend/src/chains/ethereum/` + * rather than regenerated, so nothing mechanical keeps them in step with that source; a + * truncated or stale copy would break every read at runtime with a decode error rather + * than at build time. + * + * Addresses are asserted by shape, not value: `react-native-dotenv` inlines `@env` at + * Babel transform time, so the values here come from whichever `.env` this machine has. + */ + +import type { AbiFunction } from 'viem'; + +import { evmContracts } from '../src/chains/ethereum/contracts'; + +const fnNames = (abi: readonly unknown[]): string[] => + (abi as AbiFunction[]).filter((e) => e.type === 'function').map((e) => e.name); + +describe('evmContracts', () => { + it('exposes the three v2 units', () => { + expect(Object.keys(evmContracts)).toEqual(['petCore', 'gameLogic', 'gameConfig']); + }); + + it.each(['petCore', 'gameLogic', 'gameConfig'] as const)('%s has an address and an abi', (key) => { + const contract = evmContracts[key]; + expect(contract.address).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(contract.abi.length).toBeGreaterThan(0); + }); + + it('carries the PetCore surface the pet hooks read', () => { + expect(fnNames(evmContracts.petCore.abi)).toEqual( + expect.arrayContaining(['getByOwner', 'getPet', 'totalPets', 'levelUp', 'changeName']), + ); + }); + + it('carries the entropy-era GameLogic surface', () => { + // The older Sepolia stack's GameLogic predates this and reverts on entropy(). + // If these ever go missing, the ABI has been copied from a pre-entropy build. + expect(fnNames(evmContracts.gameLogic.abi)).toEqual( + expect.arrayContaining(['entropy', 'requestMintStarter', 'settleMint', 'requestCreateFromDNA']), + ); + }); + + it('has no battleFee, because battles left the chain', () => { + // §L Phase 6 retired GameConfig.battleFee with the on-chain battle path. + // Its return would mean the ABI came from a pre-retirement build. + expect(fnNames(evmContracts.gameConfig.abi)).not.toContain('battleFee'); + expect(fnNames(evmContracts.gameConfig.abi)).toEqual( + expect.arrayContaining(['breedFee', 'levelUpFee', 'trainFee', 'baseMintFee']), + ); + }); +}); diff --git a/mobile/__tests__/createReownSolanaWallet.test.ts b/mobile/__tests__/createReownSolanaWallet.test.ts new file mode 100644 index 00000000..83b2d267 --- /dev/null +++ b/mobile/__tests__/createReownSolanaWallet.test.ts @@ -0,0 +1,138 @@ +/** + * The signing wallet Anchor drives for every Solana pet action, and the one place + * mobile serializes a transaction itself. + * + * The contract that matters is `requireAllSignatures: false`. `Transaction.serialize()` + * throws by default when signatures are missing, which is exactly the state an + * unsigned transaction is in, so getting this wrong means nothing can ever be + * signed. Real `@solana/web3.js` objects are used rather than mocks, because a + * mocked `Transaction` would happily serialize anything and prove nothing. + */ + +import bs58 from 'bs58'; +import { Keypair, PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; + +import { createReownSolanaWallet } from '../src/solana/createReownSolanaWallet'; + +const payer = Keypair.generate(); +const recipient = Keypair.generate(); + +/** + * The provider's argument shape, declared so `mock.calls[0]` is a real tuple. + * `jest.fn(async () => …)` infers a zero-parameter function, which types every + * recorded call as an empty array and makes the assertions below unwritable. + */ +type RequestArgs = [{ method: string; params?: unknown }, string]; + +/** A realistic unsigned transfer: blockhash and fee payer set, no signatures. */ +const unsignedTx = (): Transaction => { + const tx = new Transaction(); + tx.add( + SystemProgram.transfer({ + fromPubkey: payer.publicKey, + toPubkey: recipient.publicKey, + lamports: 1, + }), + ); + tx.recentBlockhash = bs58.encode(Buffer.alloc(32, 3)); + tx.feePayer = payer.publicKey; + return tx; +}; + +/** What a wallet hands back: the same transaction, signed. */ +const signedBase58 = (tx: Transaction): string => { + const copy = Transaction.from( + tx.serialize({ requireAllSignatures: false, verifySignatures: false }), + ); + copy.sign(payer); + return bs58.encode(copy.serialize()); +}; + +describe('createReownSolanaWallet', () => { + it('exposes the address as a PublicKey', () => { + const wallet = createReownSolanaWallet(jest.fn(), payer.publicKey.toBase58(), 'devnet'); + expect(wallet.publicKey).toBeInstanceOf(PublicKey); + expect(wallet.publicKey.toBase58()).toBe(payer.publicKey.toBase58()); + }); + + it('serializes an unsigned transaction and returns the signed one', async () => { + const tx = unsignedTx(); + const request = jest.fn, RequestArgs>(async () => ({ + transaction: signedBase58(tx), + })); + const wallet = createReownSolanaWallet( + request, + payer.publicKey.toBase58(), + 'solana:devnet', + ); + + const signed = await wallet.signTransaction(tx); + + const [args, chain] = request.mock.calls[0]; + expect(chain).toBe('solana:devnet'); + expect(args.method).toBe('solana_signTransaction'); + // Round-trips only if it was serialized without requiring signatures. + const sent = Transaction.from( + bs58.decode((args.params as { transaction: string }).transaction), + ); + expect(sent.instructions).toHaveLength(1); + expect(signed.signatures.some((s) => s.signature !== null)).toBe(true); + }); + + it('normalizes a bare cluster id to a CAIP-2 reference', async () => { + const tx = unsignedTx(); + const request = jest.fn, RequestArgs>(async () => ({ + transaction: signedBase58(tx), + })); + const wallet = createReownSolanaWallet(request, payer.publicKey.toBase58(), 'devnet'); + + await wallet.signTransaction(tx); + + expect(request.mock.calls[0][1]).toBe('solana:devnet'); + }); + + it('signs a batch, preserving order', async () => { + const txs = [unsignedTx(), unsignedTx()]; + const request = jest.fn, RequestArgs>(async () => ({ + transactions: txs.map(signedBase58), + })); + const wallet = createReownSolanaWallet( + request, + payer.publicKey.toBase58(), + 'solana:devnet', + ); + + const signed = await wallet.signAllTransactions(txs); + + const [args] = request.mock.calls[0]; + expect(args.method).toBe('solana_signAllTransactions'); + expect((args.params as { transactions: string[] }).transactions).toHaveLength(2); + expect(signed).toHaveLength(2); + }); + + it('refuses a response with no signed transaction', async () => { + // Returning the input would hand Anchor a transaction it believes is + // signed, and the failure would surface much later as a rejected send. + const wallet = createReownSolanaWallet( + jest.fn(async () => ({})), + payer.publicKey.toBase58(), + 'solana:devnet', + ); + + await expect(wallet.signTransaction(unsignedTx())).rejects.toThrow( + 'Wallet did not return a signed transaction', + ); + }); + + it('refuses an empty batch response', async () => { + const wallet = createReownSolanaWallet( + jest.fn(async () => ({ transactions: [] })), + payer.publicKey.toBase58(), + 'solana:devnet', + ); + + await expect(wallet.signAllTransactions([unsignedTx()])).rejects.toThrow( + 'Wallet did not return signed transactions', + ); + }); +}); diff --git a/mobile/__tests__/devLogFilters.test.ts b/mobile/__tests__/devLogFilters.test.ts new file mode 100644 index 00000000..7ea00235 --- /dev/null +++ b/mobile/__tests__/devLogFilters.test.ts @@ -0,0 +1,220 @@ +/** + * The failure mode of a log filter is a pattern that does not match the line it was + * written for: the red screen keeps appearing and the filter looks installed. So these + * assert against the message text verbatim, exactly as WalletConnect emitted it. + */ + +import { + IGNORED_DEV_LOG_PATTERNS, + installDevLogFilters, + shouldIgnoreConsoleLine, + shouldIgnoreRejection, +} from '../src/devLogFilters'; + +const isIgnored = (line: string) => IGNORED_DEV_LOG_PATTERNS.some((p) => p.test(line)); + +describe('dev log filters', () => { + it('ignores a late session_request with no listener left', () => { + expect( + isIgnored( + '{"context":"client"} Error: emitting session_request:1786630141078297 without any listeners', + ), + ).toBe(true); + }); + + it('covers the other session events that orphan the same way', () => { + expect(isIgnored('emitting session_ping:123 without any listeners')).toBe(true); + expect(isIgnored('emitting session_event:456 without any listeners')).toBe(true); + }); + + /* + * Two throw sites append a number after the message, and one passes no id at all + * (`session_connect`), so the id and its separator both have to be optional. + */ + it('covers the throw sites that shape the line differently', () => { + expect(isIgnored('emitting session_connect without any listeners, 954')).toBe(true); + expect(isIgnored('emitting session_ping:2176 without any listeners 2176')).toBe(true); + }); + + /* + * The bug this file exists for, twice over: the pattern originally required a space + * after the colon and so matched nothing. `engineEvent` in @walletconnect/utils returns + * `${event}${id ? `:${id}` : ''}`, and the spaced form only ever appeared in the pasted + * report. Both are accepted rather than betting on which one a reader will hand us. + */ + it('accepts the separator with or without a space', () => { + expect(isIgnored('emitting session_request:123 without any listeners')).toBe(true); + expect(isIgnored('emitting session_request: 123 without any listeners')).toBe(true); + }); + + /* + * The relay refusing to publish is why sign-in fails. It stays visible: the player + * gets a toast, and whoever is debugging needs to see that the relay is unhealthy. + */ + it('does not hide a failed publish', () => { + expect( + isIgnored( + '{"context":"client"} Failed to publish payload, please try again. id: 1786629317758309888 tag:1108', + ), + ).toBe(false); + }); + + it('does not hide anything from our own code', () => { + expect(isIgnored('Signing failed: UnknownRpcError: An unknown RPC error occurred.')).toBe( + false, + ); + expect(isIgnored('[pet-action] mutation error: BattleRejectionError')).toBe(false); + }); +}); + + +/** + * `LogBox.ignoreLogs` hides the red overlay and nothing else. The same line still reaches + * Metro and the debugger console, where it reads as an app error and gets reported as one. + * That is why the first version of this filter was installed correctly and the message + * kept appearing: only one of the two surfaces was covered. + */ +describe('shouldIgnoreConsoleLine', () => { + it('matches the line as a reader sees it, across separate arguments', () => { + // pino passes the context object and the message separately, so testing only the + // first argument would never match the thing this exists for. + expect( + shouldIgnoreConsoleLine([ + '{"context":"client"}', + 'Error: emitting session_request:1786660970108296 without any listeners', + ]), + ).toBe(true); + }); + + it('matches a single pre-joined argument too', () => { + expect( + shouldIgnoreConsoleLine([ + '{"context":"client"} Error: emitting session_request:123 without any listeners', + ]), + ).toBe(true); + }); + + /* + * Reproduces the arguments as they actually arrive, rather than as the red box renders + * them. `@walletconnect/sign-client` throws inside `onRelayMessage`, catches it, and + * calls `this.client.logger.error(err)`, so pino hands console.error the child logger's + * bindings object and a real Error — not the string in the bug report. Passing a string + * here is what let the broken pattern look correct. + */ + it('matches the arguments WalletConnect actually passes', () => { + const id = 1786660970108296; + const thrown = new Error(`emitting ${`session_request:${id}`} without any listeners`); + + expect(shouldIgnoreConsoleLine([{ context: 'client' }, thrown])).toBe(true); + }); + + it('leaves a failed publish alone', () => { + expect( + shouldIgnoreConsoleLine(['Failed to publish payload, please try again. tag:1108']), + ).toBe(false); + }); + + it('leaves our own logs alone', () => { + expect(shouldIgnoreConsoleLine(['[pet-action] mutation error:', new Error('boom')])).toBe( + false, + ); + expect(shouldIgnoreConsoleLine(['[sign-in]', new Error('relay down')])).toBe(false); + }); + + it('survives a non-string argument', () => { + expect(shouldIgnoreConsoleLine([undefined, null, 42, { a: 1 }])).toBe(false); + }); +}); + +/** + * A different surface from the two above: React Native reports unhandled rejections through + * `ExceptionsManager.handleException`, so neither `LogBox.ignoreLogs` nor the `console.error` + * wrapper sees them. + * + * The one being filtered is `@reown/appkit-wagmi-react-native` calling an async + * `connectWagmi` without awaiting it, inside a `try/catch` that therefore catches nothing. + * On restart with a session that has no accounts left, it surfaces as a startup crash for a + * failure whose outcome — stay disconnected, offer Connect — is already correct and visible. + */ +describe('shouldIgnoreRejection', () => { + /** How viem builds it: the detail line lives in `message`. */ + const appKitRejection = () => { + const error = new Error( + 'User rejected the request.\n\nDetails: No accounts found or user rejected connection via AppKit.\nVersion: viem@2.38.4', + ); + error.name = 'UserRejectedRequestError'; + return error; + }; + + it('ignores the unawaited AppKit connect from app startup', () => { + expect(shouldIgnoreRejection(appKitRejection())).toBe(true); + }); + + /* + * The case that keeps this filter honest. Refusing a transaction in the wallet throws + * the same error class with the same first line, and that one is the player's own + * action — it has to keep surfacing. + */ + it('does not ignore a wallet refusing a transaction', () => { + const error = new Error( + 'User rejected the request.\n\nRequest Arguments:\n from: 0xEb43\n to: 0x4B89\nVersion: viem@2.38.4', + ); + error.name = 'UserRejectedRequestError'; + expect(shouldIgnoreRejection(error)).toBe(false); + }); + + it('does not ignore anything from our own code', () => { + expect(shouldIgnoreRejection(new Error('BattleRejectionError: not on record yet'))).toBe( + false, + ); + }); + + it('survives a rejection that is not an Error', () => { + expect(shouldIgnoreRejection(undefined)).toBe(false); + expect(shouldIgnoreRejection('No accounts found or user rejected connection via AppKit')).toBe( + true, + ); + }); +}); + +describe('installDevLogFilters', () => { + it('installs once, so Fast Refresh cannot wrap an already-wrapped console', () => { + const before = console.error; + installDevLogFilters(); + const afterFirst = console.error; + installDevLogFilters(); + + expect(console.error).toBe(afterFirst); + console.error = before; + }); +}); + +/** + * The ordering is the whole fix, and it is invisible at runtime until someone reports the + * noise again. + * + * pino captures `console.error` by reference when the WalletConnect logger is created, and + * that happens while `./App` is imported. `import` statements are hoisted above the entry + * module's body, so a wrapper installed by a call in that body is always too late. The + * first version did exactly that: correct filter, correctly registered, never consulted. + */ +describe('entry module ordering', () => { + const entry = require('fs').readFileSync( + require('path').join(__dirname, '..', 'index.js'), + 'utf8', + ); + + it('imports the log filters before anything that builds a logger', () => { + const filters = entry.indexOf("'./src/devLogFilters'"); + const app = entry.indexOf("'./App'"); + + expect(filters).toBeGreaterThan(-1); + expect(app).toBeGreaterThan(-1); + expect(filters).toBeLessThan(app); + }); + + it('installs by importing, not by a call in the module body', () => { + // A call below the imports runs after every import has already been evaluated. + expect(entry).not.toMatch(/^\s*installDevLogFilters\(\);/m); + }); +}); diff --git a/mobile/__tests__/ethereumNetworkSwitcher.test.tsx b/mobile/__tests__/ethereumNetworkSwitcher.test.tsx new file mode 100644 index 00000000..24ec4d24 --- /dev/null +++ b/mobile/__tests__/ethereumNetworkSwitcher.test.tsx @@ -0,0 +1,159 @@ +/** + * The switcher is the way back from a wrong network, so the two things worth + * pinning are that it stays visible on one and that it cannot send a player + * somewhere unplayable. + * + * Both were real defects before Phase 5.1: it keyed off `chain`, which wagmi + * leaves undefined on any chain the app is not configured for, so it hid itself + * exactly when it was needed; and it listed a wider set than `CHAINS`, which put + * mainnet, where nothing is deployed, one tap away. + */ + +import React from 'react'; +import { Text } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +const mockState = { + chainId: 11155111 as number | undefined, + isConnected: true, + isPending: false, + switchError: null as Error | null, +}; + +const mockSwitchChain = jest.fn(); + +jest.mock('wagmi', () => ({ + useAccount: () => ({ chainId: mockState.chainId, isConnected: mockState.isConnected }), + useSwitchChain: () => ({ + switchChain: mockSwitchChain, + isPending: mockState.isPending, + error: mockState.switchError, + }), +})); + +import EthereumNetworkSwitcher from '../src/components/EthereumNetworkSwitcher'; +import { CHAINS } from '../src/constants/ethereumNetworks'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +/** + * Found by `displayName`, not by type identity: React Native exports `Pressable` + * as a memo around a forwardRef, so the rendered node's type is not the symbol + * this file would import and `findAllByType` matches nothing. + */ +const pressables = (tree: ReactTestRenderer.ReactTestRenderer) => + tree.root.findAll((node) => { + const type = node.type as { displayName?: string; name?: string }; + if (typeof type === 'string' || !type) return false; + return (type.displayName ?? type.name) === 'Pressable'; + }); + +/** The trigger, by label. It used to be "the first Pressable", which the modal's own + * backdrop and rows sit behind in render order and could have overtaken. */ +const trigger = (tree: ReactTestRenderer.ReactTestRenderer) => + pressables(tree).find((n) => n.props.accessibilityLabel === 'Switch network'); + +const openModal = async (tree: ReactTestRenderer.ReactTestRenderer) => { + await ReactTestRenderer.act(async () => trigger(tree)!.props.onPress()); +}; + +beforeEach(() => { + mockState.chainId = 11155111; + mockState.isConnected = true; + mockState.isPending = false; + mockState.switchError = null; + mockSwitchChain.mockClear(); +}); + +describe('EthereumNetworkSwitcher', () => { + it('renders nothing when no wallet is connected', async () => { + mockState.isConnected = false; + const tree = await render(); + expect(tree.toJSON()).toBeNull(); + }); + + it('names the current chain', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('Sepolia'); + }); + + it('stays visible on an unsupported chain, which is when it is needed', async () => { + // Keyed off `chainId`, not `chain`: wagmi leaves `chain` undefined here, and + // keying off it hid the only control that could get the player back. + mockState.chainId = 1; + const tree = await render(); + expect(tree.toJSON()).not.toBeNull(); + expect(textOf(tree)).toContain('Wrong network'); + }); + + it('still renders before a chain id resolves', async () => { + mockState.chainId = undefined; + const tree = await render(); + expect(tree.toJSON()).not.toBeNull(); + }); + + it('offers only chains with a deployment', async () => { + const tree = await render(); + await openModal(tree); + const listed = textOf(tree); + + for (const { name } of CHAINS) { + expect(listed).toContain(name); + } + // Mainnet rides along in the WalletConnect proposal so testnet-less wallets + // can approve something. Listing it here would let a player switch to a + // chain where every contract read silently fails. + expect(listed).not.toContain('Ethereum'); + }); + + it('switches to the chosen chain and closes', async () => { + const tree = await render(); + await openModal(tree); + + // By label rather than index: the trigger, backdrop and close button all + // precede the rows, and counting them is the kind of assumption that + // breaks silently the next time the header gains a control. + // Skipping the trigger is the one positional assumption kept, because it + // renders the current chain's name too and would otherwise match first. + // Everything after it lives inside the modal. + const target = CHAINS[0]; + const row = pressables(tree) + .slice(1) + .find((node) => + node + .findAllByType(Text) + .some((t) => String(t.props.children) === target.name), + ); + + await ReactTestRenderer.act(async () => { + row!.props.onPress(); + }); + + expect(mockSwitchChain).toHaveBeenCalledWith({ chainId: target.chain.id }); + }); + + it('surfaces a switch failure instead of failing silently', async () => { + mockState.switchError = new Error('unrecognized chain'); + const tree = await render(); + expect(textOf(tree)).toContain('unrecognized chain'); + }); + + it('says it is switching and blocks a second tap while pending', async () => { + mockState.isPending = true; + const tree = await render(); + expect(textOf(tree)).toContain('Switching...'); + expect(trigger(tree)!.props.disabled).toBe(true); + }); +}); diff --git a/mobile/__tests__/ethereumNetworks.test.ts b/mobile/__tests__/ethereumNetworks.test.ts new file mode 100644 index 00000000..f1d16d4a --- /dev/null +++ b/mobile/__tests__/ethereumNetworks.test.ts @@ -0,0 +1,156 @@ +/** + * `react-native-dotenv` inlines `@env` at Babel transform time, so `TARGET_CHAIN_ID` + * is a literal baked in from whichever `.env` this machine has and asserting a value + * for it would only test the local file. `resolveTargetChainId` is the parsing it + * wraps, and that is checked directly. + */ + +import { baseSepolia, mainnet, sepolia } from 'wagmi/chains'; + +import { + CHAINS, + TARGET_CHAIN_ID, + WC_FALLBACK_CHAINS, + getAppKitEvmNetworks, + getChainConfig, + getNativeTokenSymbol, + getTargetChainName, + isSupportedChain, + resolveTargetChainId, +} from '../src/constants/ethereumNetworks'; +import { withRpcUrl } from '../src/ethereumChains'; + +/** + * Unset, viem reads through the chain's built-in public endpoint, which for Base Sepolia + * is the shared `https://sepolia.base.org`. This app's reads are not light there: the pet + * list is one Multicall3 `aggregate3` over every pet the wallet owns, and incoming + * proposals another over the whole roster. Both were timing out, and a timed-out multicall + * drops pets from the list rather than failing loudly. + * + * `withRpcUrl` takes the URL as an argument rather than reading `@env` itself, because + * `react-native-dotenv` inlines that at transform time and a test could only assert + * whatever `.env` this machine happens to have. + */ +describe('withRpcUrl', () => { + it('leaves a chain untouched when no url is configured', () => { + expect(withRpcUrl(baseSepolia, undefined)).toBe(baseSepolia); + expect(withRpcUrl(baseSepolia, '')).toBe(baseSepolia); + expect(withRpcUrl(baseSepolia, ' ')).toBe(baseSepolia); + }); + + it('reads through the configured url instead of the public default', () => { + const custom = withRpcUrl(baseSepolia, 'https://my-node.example/v2/key'); + expect(custom.rpcUrls.default.http).toEqual(['https://my-node.example/v2/key']); + expect(baseSepolia.rpcUrls.default.http[0]).not.toBe('https://my-node.example/v2/key'); + }); + + it('changes nothing a wallet matches a network on', () => { + // id, currency and explorer are what a wallet uses to recognise or add a network. + // Rewriting them would offer a network the wallet cannot place. + const custom = withRpcUrl(baseSepolia, 'https://my-node.example'); + expect(custom.id).toBe(baseSepolia.id); + expect(custom.name).toBe(baseSepolia.name); + expect(custom.nativeCurrency).toEqual(baseSepolia.nativeCurrency); + expect(custom.blockExplorers).toEqual(baseSepolia.blockExplorers); + }); + + it('does not mutate the shared chain object', () => { + const before = [...baseSepolia.rpcUrls.default.http]; + withRpcUrl(baseSepolia, 'https://my-node.example'); + expect(baseSepolia.rpcUrls.default.http).toEqual(before); + }); +}); + +describe('resolveTargetChainId', () => { + it('parses a chain id', () => { + expect(resolveTargetChainId('11155111')).toBe(sepolia.id); + expect(resolveTargetChainId('31337')).toBe(31337); + }); + + it('falls back to Base Sepolia when unset or empty', () => { + expect(resolveTargetChainId(undefined)).toBe(baseSepolia.id); + expect(resolveTargetChainId('')).toBe(baseSepolia.id); + }); + + it('falls back rather than returning NaN for a malformed value', () => { + expect(resolveTargetChainId('base-sepolia')).toBe(baseSepolia.id); + expect(resolveTargetChainId('1.5')).toBe(baseSepolia.id); + }); +}); + +describe('TARGET_CHAIN_ID', () => { + it('is a chain the app has contracts on', () => { + expect(isSupportedChain(TARGET_CHAIN_ID)).toBe(true); + }); +}); + +describe('CHAINS', () => { + it('lists both deployment chains, so a player can switch between them', () => { + const ids = CHAINS.map((c) => c.chain.id); + expect(ids).toContain(baseSepolia.id); + expect(ids).toContain(sepolia.id); + expect(CHAINS[0].chain.id).toBe(baseSepolia.id); + }); + + it('omits chains with no deployment, including the handshake fallback', () => { + // Mainnet is offered in the WalletConnect proposal so testnet-less wallets + // can approve something, but it has no contracts. It must not be listed as + // somewhere to play, or the switcher could strand a player there. + expect(WC_FALLBACK_CHAINS.some((c) => c.id === mainnet.id)).toBe(true); + expect(CHAINS.some((c) => c.chain.id === mainnet.id)).toBe(false); + expect(isSupportedChain(mainnet.id)).toBe(false); + }); + + it('does not treat an unknown chain as supported', () => { + expect(isSupportedChain(137)).toBe(false); + expect(isSupportedChain(undefined)).toBe(false); + }); +}); + +describe('getChainConfig / getTargetChainName', () => { + it('resolves a playable chain and names the target', () => { + expect(getChainConfig(sepolia.id)?.name).toBe('Sepolia'); + expect(getTargetChainName(sepolia.id)).toBe('Sepolia'); + }); + + it('does not resolve the handshake fallback', () => { + expect(getChainConfig(mainnet.id)).toBeUndefined(); + }); + + it('names an unknown chain by id rather than rendering "undefined"', () => { + expect(getTargetChainName(137)).toBe('chain 137'); + }); +}); + +describe('getAppKitEvmNetworks', () => { + it('puts the target first so defaultNetwork lands on it', () => { + expect(getAppKitEvmNetworks(sepolia.id)[0].id).toBe(sepolia.id); + }); + + it('trails the handshake fallback behind every playable chain', () => { + const ids = getAppKitEvmNetworks(sepolia.id).map((c) => c.id); + expect(ids).toContain(mainnet.id); + expect(ids.indexOf(mainnet.id)).toBe(ids.length - 1); + }); + + it('lists each chain once', () => { + const ids = getAppKitEvmNetworks(sepolia.id).map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('still leads with a playable chain when the target has no config', () => { + // A typo'd EVM_CHAIN_ID must not put wagmi's default on the fallback. + // 137 rather than 84532: Base Sepolia is a real deployment chain now, so + // it would pass this trivially while testing nothing. + expect(getAppKitEvmNetworks(137)[0].id).toBe(CHAINS[0].chain.id); + }); +}); + +describe('getNativeTokenSymbol', () => { + it('resolves known chains and defaults to ETH', () => { + expect(getNativeTokenSymbol(sepolia.id)).toBe('ETH'); + expect(getNativeTokenSymbol(31337)).toBe('ETH'); + expect(getNativeTokenSymbol(999999)).toBe('ETH'); + expect(getNativeTokenSymbol(undefined)).toBe('ETH'); + }); +}); diff --git a/mobile/__tests__/evmContracts.test.ts b/mobile/__tests__/evmContracts.test.ts new file mode 100644 index 00000000..7b433a02 --- /dev/null +++ b/mobile/__tests__/evmContracts.test.ts @@ -0,0 +1,107 @@ +/** + * Contract addresses are keyed by chain because the app is playable on more than + * one deployment. The failure this guards against is silent: a single shared set + * sends reads for the chain the wallet is on to the *other* chain's proxy, which + * returns an empty `0x` that reads like a decode bug rather than a wrong address. + * That is exactly how the frontend ended up querying Sepolia addresses on Base + * Sepolia. + */ + +import { baseSepolia, sepolia } from 'wagmi/chains'; + +import { + evmContractsFor, + hasEvmDeployment, + resolveEvmDeployment, +} from '../src/chains/ethereum/contracts'; +import { CHAINS, TARGET_CHAIN_ID, isSupportedChain } from '../src/constants/ethereumNetworks'; + +describe('the configured build', () => { + it('targets a chain that actually has contracts', () => { + // `EVM_CHAIN_ID` is inlined from `.env` at transform time, so a typo or a + // chain id copied from another network produces a build where every read + // resolves to an undefined address: an empty gallery and no error. This is + // the only check that fails loudly instead. + expect(hasEvmDeployment(TARGET_CHAIN_ID)).toBe(true); + expect(isSupportedChain(TARGET_CHAIN_ID)).toBe(true); + }); + + it('offers no chain it cannot play on', () => { + // The switcher lists CHAINS verbatim, so anything here without addresses + // is a switch that silently reads some other chain's contracts. + for (const { chain, name } of CHAINS) { + expect([name, hasEvmDeployment(chain.id)]).toEqual([name, true]); + } + }); +}); + +describe('resolveEvmDeployment', () => { + it('knows the Sepolia proxies', () => { + const d = resolveEvmDeployment(sepolia.id); + expect(d.petCore).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(d.gameLogic).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(d.gameLogic).not.toBe(d.petCore); + }); + + it('returns nothing for a chain with no deployment', () => { + expect(resolveEvmDeployment(1)).toEqual({}); + }); + + it('does not borrow another chain’s addresses', () => { + // The whole point of the map. If Base Sepolia ever silently answers with + // Sepolia's proxies, pet reads there hit a contract that does not exist. + const base = resolveEvmDeployment(baseSepolia.id); + const eth = resolveEvmDeployment(sepolia.id); + if (base.petCore) { + expect(base.petCore).not.toBe(eth.petCore); + expect(base.gameLogic).not.toBe(eth.gameLogic); + } + }); +}); + +describe('hasEvmDeployment', () => { + it('is true only where both required proxies are known', () => { + expect(hasEvmDeployment(sepolia.id)).toBe(true); + expect(hasEvmDeployment(1)).toBe(false); + }); + + it('tracks whatever the map says for Base Sepolia', () => { + // Base Sepolia's proxies were never created: the 2026-08-06 deploy stalled + // after the implementations. This asserts the map and the predicate agree, + // rather than pinning a value that changes the day the deploy completes. + const d = resolveEvmDeployment(baseSepolia.id); + expect(hasEvmDeployment(baseSepolia.id)).toBe(Boolean(d.petCore && d.gameLogic)); + }); + + it('ignores GameConfig, which only degrades fee display', () => { + const d = resolveEvmDeployment(sepolia.id); + expect(Boolean(d.petCore && d.gameLogic)).toBe(true); + expect(hasEvmDeployment(sepolia.id)).toBe(true); + }); +}); + +describe('evmContractsFor', () => { + it('carries the same ABIs to every chain', () => { + // Proxies differ per chain; the interface does not. + const a = evmContractsFor(sepolia.id); + const b = evmContractsFor(baseSepolia.id); + expect(a.petCore.abi).toBe(b.petCore.abi); + expect(a.gameLogic.abi).toBe(b.gameLogic.abi); + }); + + it('leaves the address undefined where there is no deployment', () => { + // `PetsEvmConfig.address` is optional and read hooks stay disabled without + // it, so an unknown chain degrades instead of pointing somewhere wrong. + const c = evmContractsFor(1); + expect(c.petCore.address).toBeUndefined(); + expect(c.gameLogic.address).toBeUndefined(); + expect(c.petCore.abi).toBeDefined(); + }); + + it('exposes a real PetCore read surface', () => { + const names = (evmContractsFor(sepolia.id).petCore.abi as unknown as { name?: string }[]) + .map((e) => e.name) + .filter(Boolean); + expect(names).toEqual(expect.arrayContaining(['ownerOf', 'balanceOf', 'getPet'])); + }); +}); diff --git a/mobile/__tests__/leaderboardPanel.test.tsx b/mobile/__tests__/leaderboardPanel.test.tsx new file mode 100644 index 00000000..4eb00159 --- /dev/null +++ b/mobile/__tests__/leaderboardPanel.test.tsx @@ -0,0 +1,122 @@ +/** + * The leaderboard's state machine, now that it is one. + * + * None of this was reachable while it lived inside a 512-line screen: the debounce and the + * two page resets could each be deleted with every test still green, which is how they were + * found. Driven through a probe rather than the screen, because what is being pinned is when + * the query changes, not what the rows look like. + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; + +const mockState = { + petsArgs: [] as { page: number; search: string; enabled: boolean }[], + playersArgs: [] as { page: number; search: string; enabled: boolean }[], +}; + +const board = (args: { page: number; search: string; enabled: boolean }, into: unknown[]) => { + into.push(args); + return { entries: [], total: 0, pageSize: 25, isLoading: false, error: null }; +}; + +jest.mock('@shared/core', () => ({ + useChainCapabilities: () => ({ activeKind: 'evm', walletAddress: '0xme' }), + useLeaderboard: (a: never) => board(a, mockState.petsArgs), + usePlayerLeaderboard: (a: never) => board(a, mockState.playersArgs), + usePlayerRank: () => ({ rank: null }), + getRarityColor: () => '#fff', + sameAccount: (a: string, b: string) => a.toLowerCase() === b.toLowerCase(), + shortAddress: (a: string) => a, +})); + +import { useLeaderboardPanel, type UseLeaderboardPanel } from '../src/hooks/leaderboard/useLeaderboardPanel'; + +let panel!: UseLeaderboardPanel; + +const Probe = () => { + panel = useLeaderboardPanel(); + return null; +}; + +const mount = async () => { + await ReactTestRenderer.act(async () => { + ReactTestRenderer.create(); + }); +}; + +/** Runs the 300ms debounce out. */ +const debounce = async () => { + await ReactTestRenderer.act(async () => { + jest.advanceTimersByTime(400); + }); +}; + +const act = async (fn: () => void) => { + await ReactTestRenderer.act(async () => fn()); +}; + +/** What the active board was last asked for. */ +const lastQuery = () => mockState.petsArgs[mockState.petsArgs.length - 1]; + +beforeEach(() => { + jest.useFakeTimers(); + mockState.petsArgs = []; + mockState.playersArgs = []; +}); + +afterEach(() => jest.useRealTimers()); + +describe('useLeaderboardPanel', () => { + it('waits before searching, so a ranked query is not run per keystroke', async () => { + await mount(); + await act(() => panel.onTermChange('Rex')); + + // The field updates at once; the query does not. + expect(panel.term).toBe('Rex'); + expect(lastQuery().search).toBe(''); + + await debounce(); + expect(lastQuery().search).toBe('Rex'); + }); + + it('trims the term, so trailing space is not part of the search', async () => { + await mount(); + await act(() => panel.onTermChange(' Rex ')); + await debounce(); + expect(lastQuery().search).toBe('Rex'); + }); + + it('goes back to the first page when the search changes', async () => { + // A term that narrows the board renumbers which page anything is on, so a search made + // from page three would otherwise land on a page that no longer has rows. + await mount(); + await act(() => panel.onPage(3)); + expect(panel.page).toBe(3); + + await act(() => panel.onTermChange('Rex')); + await debounce(); + expect(panel.page).toBe(0); + }); + + it('goes back to the first page when the board changes', async () => { + // The two boards are different lengths, so page three of one need not exist on the + // other. + await mount(); + await act(() => panel.onPage(3)); + await act(() => panel.onBoardChange('players')); + + expect(panel.page).toBe(0); + }); + + it('asks only the board that is showing', async () => { + // Both hooks are called on every render because hooks must be; `enabled` is what + // stops the hidden one issuing a query. + await mount(); + expect(lastQuery().enabled).toBe(true); + expect(mockState.playersArgs[mockState.playersArgs.length - 1].enabled).toBe(false); + + await act(() => panel.onBoardChange('players')); + expect(mockState.playersArgs[mockState.playersArgs.length - 1].enabled).toBe(true); + }); +}); diff --git a/mobile/__tests__/navigation.test.tsx b/mobile/__tests__/navigation.test.tsx new file mode 100644 index 00000000..9dea34c0 --- /dev/null +++ b/mobile/__tests__/navigation.test.tsx @@ -0,0 +1,268 @@ +/** + * Navigator smoke test: the tab shell mounts, every route in the table is + * reachable, and the initial screen renders. Screens are placeholders until + * Phase 4, so this checks wiring rather than content. + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import { NavigationContainer } from '@react-navigation/native'; + +const mockIsConnected = jest.fn(() => true); +jest.mock('wagmi', () => ({ useAccount: () => ({ isConnected: mockIsConnected() }) })); + +// The screens behind the gate are the app's real ones; each imports the +// `@shared/core` barrel, which drags the Solana runtime into jest and fails to +// parse. Stub every screen the navigator mounts — a new real screen replacing a +// placeholder is what breaks this suite next. +jest.mock('../src/screens/GalleryScreen', () => () => null); +jest.mock('../src/screens/LevelUpScreen', () => () => null); +jest.mock('../src/screens/TrainScreen', () => () => null); +jest.mock('../src/screens/RenameScreen', () => () => null); +jest.mock('../src/screens/DefenseScreen', () => () => null); +jest.mock('../src/screens/BreedScreen', () => () => null); +jest.mock('../src/screens/MarriageScreen', () => () => null); +jest.mock('../src/screens/BattleScreen', () => () => null); +jest.mock('../src/screens/LeaderboardScreen', () => () => null); +jest.mock('../src/screens/InventoryScreen', () => () => null); +jest.mock('../src/screens/EquipScreen', () => () => null); +jest.mock('../src/screens/ChatScreen', () => () => null); +jest.mock('../src/components/AppHeader', () => () => null); +jest.mock('../src/screens/LandingScreen', () => { + const { Text: RNText } = jest.requireActual('react-native'); + const React_ = jest.requireActual('react'); + return () => React_.createElement(RNText, null, 'Connect your wallet'); +}); + +import { RootNavigator } from '../src/navigation/RootNavigator'; +import { DRAWER_ITEMS, STACK_TITLES, TAB_ITEMS } from '../src/navigation/routes'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + + + , + ); + }); + return tree; +}; + +beforeEach(() => { + mockIsConnected.mockReturnValue(true); +}); + + +describe('routes', () => { + it('has five tabs, not the seven routed sidebar entries', () => { + // Past five, a bottom tab bar truncates labels past readability. If this + // changes, the entry also needs moving out of RootStackParamList. + expect(TAB_ITEMS).toHaveLength(5); + expect(TAB_ITEMS.map((t) => t.name)).toEqual([ + 'Gallery', + 'Battle', + 'Breed', + 'LevelUp', + 'Train', + ]); + }); + + it('keeps the per-pet actions on the stack', () => { + // Leaderboard is on the stack for a different reason than the other three: it + // acts on no pet at all, but a five-slot tab bar has no room for a read-only + // screen without truncating the labels of the four that do. + expect(Object.keys(STACK_TITLES)).toEqual([ + 'Marriage', + 'Rename', + 'Defense', + 'Leaderboard', + 'Inventory', + 'Equip', + 'Chat', + ]); + }); + + it('does not route the deferred features', () => { + // Inventory left this list when roadmap section 4 landed on mobile. Shard Forge has + // no implementation on either client, so it stays absent rather than shown + // disabled: a tab bar has no room to advertise what does not work yet. + const everyRoute = [...TAB_ITEMS.map((t) => t.name), ...Object.keys(STACK_TITLES)]; + expect(everyRoute).not.toContain('ShardForge'); + }); +}); + +describe('RootNavigator', () => { + it('mounts the tab shell and renders the first tab', async () => { + const tree = await render(); + expect(textOf(tree)).toContain('Gallery'); + }); + + it('renders a tab bar entry for every item in the table', async () => { + const tree = await render(); + const rendered = textOf(tree); + TAB_ITEMS.forEach((item) => { + expect(rendered).toContain(item.label); + }); + }); + + const routeNames = async (connected: boolean) => { + mockIsConnected.mockReturnValue(connected); + const ref = React.createRef>(); + await ReactTestRenderer.act(() => { + ReactTestRenderer.create( + + + , + ); + }); + return ref.current?.getRootState().routeNames ?? []; + }; + + it('exposes every in-app route once connected', async () => { + expect(await routeNames(true)).toEqual( + expect.arrayContaining(['Main', 'Marriage', 'Rename', 'Defense']), + ); + }); + + it('shows only Landing while disconnected', async () => { + // Registered conditionally, not redirected to: with Main absent there is no + // window where a tab screen renders against a disconnected wallet. + expect(await routeNames(false)).toEqual(['Landing']); + }); + + it('leaves no back route into Landing once connected', async () => { + // The point of the conditional split: reconnecting must not leave a stale + // Landing entry on the stack for the back gesture to return to. + expect(await routeNames(true)).not.toContain('Landing'); + }); + + it('renders the landing screen while disconnected', async () => { + mockIsConnected.mockReturnValue(false); + const tree = await render(); + expect(textOf(tree)).toContain('Connect your wallet'); + }); +}); + +/** + * Every stack route has to be reachable from somewhere. + * + * `Marriage` was registered in the navigator, titled, typed and fully implemented, and + * for weeks nothing navigated to it. No other test could see that: the navigator mounts + * it happily, its own suite renders it directly, and a player simply had no way in. + * + * So this scans the source for a `navigate('Route')` on each one. Crude on purpose — a + * real navigation graph would need the app running — but it fails loudly the moment a + * screen is added without a door, which is the only failure mode that mattered here. + */ +describe('reachability', () => { + const fs = jest.requireActual('fs') as typeof import('fs'); + const path = jest.requireActual('path') as typeof import('path'); + + /** + * Every source file except the route table itself. + * + * The navigator and `routes.ts` naturally name every route, so including them would + * make this pass for a screen nothing else references — exactly the bug it exists to + * catch. + */ + const sourceText = (() => { + const root = path.join(__dirname, '..', 'src'); + const skip = [path.join('navigation', 'routes.ts'), path.join('navigation', 'RootNavigator.tsx')]; + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (/\.tsx?$/.test(entry.name) && !skip.some((s) => full.endsWith(s))) { + files.push(full); + } + } + }; + walk(root); + return files.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); + })(); + + /** + * The route named as the first argument of some call — `navigate('X')`, `go('X')`, + * `navigate('X', { petId })`, whichever. + * + * Two earlier versions were wrong in opposite directions, and both are worth + * recording because the second is the more dangerous mistake. + * + * Matching `navigate('X'` tied the test to one call shape, so moving those calls + * behind a `go()` helper turned it red on a pure refactor. Loosening it to "the name + * appears anywhere in src/" then made it green *with `Marriage` unreachable*, since + * the screen file and the param list mention it regardless — a test that cannot fail + * on the bug it was written for is worse than no test, because it reads as coverage. + * + * Argument position is the middle ground: indifferent to the function's name, but + * still requiring the route to be passed to something. + */ + const reachedByACall = (route: string) => + [`('${route}'`, `("${route}"`, `(\`${route}\``].some((call) => sourceText.includes(call)); + + /** + * `AppDrawer` maps over `DRAWER_ITEMS` and navigates with the loop variable, so none of + * its five destinations appears as a literal argument anywhere in `src/` — the scan above + * cannot see a door that is built from a table. + * + * Membership is accepted as a door instead, which is only honest because + * `appDrawer.test.tsx` renders the drawer and presses each row: without that, this would + * be the "the name appears somewhere" mistake the comment above warns about, one level up. + */ + const reachedByTheDrawer = (route: string) => + (DRAWER_ITEMS as readonly string[]).includes(route); + + it.each(Object.keys(STACK_TITLES))('has a way into %s', (route) => { + expect(reachedByACall(route) || reachedByTheDrawer(route)).toBe(true); + }); +}); + +/** + * Screens opened from the drawer push without a transition. + * + * The drawer is a Modal that slides out while the default push slides in over ~350ms, so the + * screen behind stays visible through it — the Gallery flashes on the way to the Leaderboard. + * Ordering the calls so the push starts first does not help, because the two still animate + * together; the destination has to be painted before the drawer starts to leave. + * + * Read off `DRAWER_ITEMS` rather than listed again, which is the same array `RootNavigator` + * derives the no-transition set from. A separate copy here would pass while the navigator and + * the menu disagreed. + */ +describe('drawer transitions', () => { + const fromSheet: readonly string[] = DRAWER_ITEMS; + + const optionsFor = async (route: string) => { + const ref = React.createRef>(); + await ReactTestRenderer.act(() => { + ReactTestRenderer.create( + + + , + ); + }); + await ReactTestRenderer.act(async () => { + ref.current?.navigate(route as never); + }); + const state = ref.current?.getRootState(); + return state?.routes.find((r) => r.name === route); + }; + + it.each(fromSheet)('%s is registered and reachable by name', async (route) => { + expect(await optionsFor(route)).toBeDefined(); + }); + + it('leaves the per-pet screens their slide', () => { + // Reached by tapping a pet card, with no modal in the way, where the slide reads + // as moving deeper into that pet rather than as a flash. + expect(fromSheet).not.toContain('Rename'); + expect(fromSheet).not.toContain('Equip'); + }); +}); diff --git a/mobile/__tests__/networkGate.test.tsx b/mobile/__tests__/networkGate.test.tsx new file mode 100644 index 00000000..84053ea3 --- /dev/null +++ b/mobile/__tests__/networkGate.test.tsx @@ -0,0 +1,330 @@ +/** + * WalletConnect freezes a session's approved chain set at handshake, and AppKit + * pins the provider to `defaultNetwork` regardless of what was approved. The two + * together produce a session that looks connected and fails at signature time, + * inside the sign client, without the wallet ever being asked. + * + * These tests are weighted towards the decision that avoids that: which chain the + * session should be repaired to, and whether the player is told the truth about + * why signing would fail. + */ + +import React from 'react'; +import { TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import { mainnet, sepolia } from 'wagmi/chains'; + +import { pickRequestChainId } from '../src/utils/sessionChain'; +import { TARGET_CHAIN_ID, getTargetChainName } from '../src/constants/ethereumNetworks'; +import { parseApprovedEvmChainIds } from '../src/hooks/useApprovedEvmChains'; + +const mockState = { + isConnected: true, + chainId: sepolia.id as number | undefined, + approved: null as number[] | null, +}; + +const mockSwitchChainAsync = jest.fn(async () => undefined); +const mockOpen = jest.fn(async () => undefined); +const mockDisconnect = jest.fn(async () => undefined); +const mockToast = { + show: jest.fn(), + error: jest.fn(), + info: jest.fn(), + success: jest.fn(), +}; + +jest.mock('wagmi', () => ({ + useAccount: () => ({ isConnected: mockState.isConnected, chainId: mockState.chainId }), + useSwitchChain: () => ({ switchChainAsync: mockSwitchChainAsync }), +})); + +jest.mock('@reown/appkit-react-native', () => ({ + useAppKit: () => ({ open: mockOpen, disconnect: mockDisconnect }), +})); + +jest.mock('../src/components/ui/Toast', () => ({ + useToast: () => mockToast, +})); + +jest.mock('../src/hooks/useApprovedEvmChains', () => ({ + ...jest.requireActual('../src/hooks/useApprovedEvmChains'), + useApprovedEvmChains: () => mockState.approved, +})); + +import NetworkGate from '../src/components/NetworkGate'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' '); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + + +/** + * A gate button by its label, matched on a prefix because the primary names the chain + * it is switching to. + * + * These used to be `[0]` and `[1]`, and which one each index meant depended on + * `targetAuthorized`: when the target is already approved the secondary is not rendered at + * all, so `[1]` in one test and `[1]` in the next were different buttons. + */ +const byLabel = (tree: ReactTestRenderer.ReactTestRenderer, prefix: string) => + tree.root + .findAllByType(TouchableOpacity) + .find((n) => String(n.props.accessibilityLabel ?? '').startsWith(prefix)); + +beforeEach(() => { + mockState.isConnected = true; + mockState.chainId = TARGET_CHAIN_ID; + mockState.approved = null; + // `mockReset` rather than `mockClear`: the failure tests below install a + // persistent rejection, and `useEvmSessionChain`'s repair attempt is a real + // call that would otherwise carry it into the next test. + mockSwitchChainAsync.mockReset(); + mockSwitchChainAsync.mockResolvedValue(undefined); + mockOpen.mockClear(); + mockDisconnect.mockClear(); + mockToast.error.mockClear(); + mockToast.info.mockClear(); +}); + +describe('pickRequestChainId', () => { + const configured = [sepolia.id, 31337, mainnet.id]; + + it('does not repair when the approved set is unknown', () => { + // `null` is "no session yet, or not WalletConnect" — it cannot rule the + // target out, and switching on a guess would fight the wallet. + expect( + pickRequestChainId({ + approved: null, + current: mainnet.id, + target: sepolia.id, + configured, + }), + ).toBeNull(); + }); + + it('does not repair when the target itself is approved', () => { + // The ordinary wrong-network switch handles this; the gate's button works. + expect( + pickRequestChainId({ + approved: [sepolia.id, mainnet.id], + current: mainnet.id, + target: sepolia.id, + configured, + }), + ).toBeNull(); + }); + + it('does not repair when the current chain is already approved', () => { + expect( + pickRequestChainId({ + approved: [mainnet.id], + current: mainnet.id, + target: sepolia.id, + configured, + }), + ).toBeNull(); + }); + + it('moves to an approved chain when pinned to one that was never approved', () => { + // The case that breaks signing: provider pinned to Sepolia, wallet + // approved only mainnet, so every request dies inside the sign client. + expect( + pickRequestChainId({ + approved: [mainnet.id], + current: sepolia.id, + target: sepolia.id, + configured, + }), + ).toBe(mainnet.id); + }); + + it('prefers the first configured chain the wallet approved', () => { + // `configured` is target-first, so a wallet that approved several gets the + // one closest to where the app wants to be. + expect( + pickRequestChainId({ + approved: [mainnet.id, 31337], + current: 84532, + target: 84532, + configured, + }), + ).toBe(31337); + }); + + it('will not pick a chain wagmi is not configured for', () => { + expect( + pickRequestChainId({ + approved: [137], + current: sepolia.id, + target: 84532, + configured, + }), + ).toBeNull(); + }); +}); + +describe('parseApprovedEvmChainIds', () => { + it('reads the eip155 chains array', () => { + expect( + parseApprovedEvmChainIds({ eip155: { chains: ['eip155:1', 'eip155:11155111'] } }), + ).toEqual([1, 11155111]); + }); + + it('reads accounts too, because CAIP-25 makes `chains` optional', () => { + expect( + parseApprovedEvmChainIds({ eip155: { accounts: ['eip155:8453:0xabc'] } }), + ).toEqual([8453]); + }); + + it('de-duplicates across both sources', () => { + expect( + parseApprovedEvmChainIds({ + eip155: { chains: ['eip155:1'], accounts: ['eip155:1:0xabc'] }, + }), + ).toEqual([1]); + }); + + it('ignores other namespaces and malformed entries', () => { + expect(parseApprovedEvmChainIds({ solana: { chains: ['solana:xyz'] } })).toEqual([]); + expect(parseApprovedEvmChainIds({ eip155: { chains: ['eip155:abc', 42] } })).toEqual([]); + expect(parseApprovedEvmChainIds(undefined)).toEqual([]); + }); +}); + +describe('NetworkGate', () => { + it('renders nothing when no wallet is connected', async () => { + mockState.isConnected = false; + mockState.chainId = mainnet.id; + const tree = await render(); + expect(tree.toJSON()).toBeNull(); + }); + + it('renders nothing on the target chain with approvals unknown', async () => { + const tree = await render(); + expect(tree.toJSON()).toBeNull(); + }); + + it('warns about the network when the wallet sits on an unplayable chain', async () => { + mockState.chainId = mainnet.id; + mockState.approved = [mainnet.id, TARGET_CHAIN_ID]; + const tree = await render(); + expect(textOf(tree)).toContain(`CryptoPets runs on ${getTargetChainName()}`); + expect(textOf(tree)).toContain(`Switch to ${getTargetChainName()}`); + }); + + it('warns about the session when the target was never approved', async () => { + // The distinction matters: the player is on the right chain by wagmi's + // reckoning, and nothing looks wrong until a signature is refused. + mockState.chainId = TARGET_CHAIN_ID; + mockState.approved = [mainnet.id]; + const tree = await render(); + expect(textOf(tree)).toContain(`Wallet did not approve ${getTargetChainName()}`); + expect(textOf(tree)).toContain('signing will fail'); + }); + + it('leads with reconnect when the target was never approved', async () => { + // A session's chain set is frozen at handshake, so a new proposal is the + // only path that reliably widens it. Switching leads only when the target + // is already approved, where it is a local provider call. + mockState.chainId = TARGET_CHAIN_ID; + mockState.approved = [mainnet.id]; + const tree = await render(); + + expect(textOf(tree)).toContain('Reconnect wallet'); + + await ReactTestRenderer.act(async () => { + byLabel(tree, 'Reconnect wallet')!.props.onPress(); + }); + + expect(mockDisconnect).toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalled(); + expect(mockSwitchChainAsync).not.toHaveBeenCalledWith({ chainId: TARGET_CHAIN_ID }); + }); + + it('still offers the add attempt for wallets that honour it', async () => { + // `wallet_addEthereumChain` does widen a live session in some wallets, so + // the path stays reachable rather than being removed for everyone. + mockState.chainId = TARGET_CHAIN_ID; + mockState.approved = [mainnet.id]; + const tree = await render(); + + await ReactTestRenderer.act(async () => { + byLabel(tree, 'Ask this wallet to add')!.props.onPress(); + }); + + expect(mockSwitchChainAsync).toHaveBeenCalledWith({ chainId: TARGET_CHAIN_ID }); + }); + + it('explains a wallet that ends the session instead of adding the chain', async () => { + // Rabby's behaviour: the request goes out, no prompt appears, and the + // session dies. The gate unmounts with it, so the message has to survive + // as a toast or the player is returned to Landing with no reason given. + mockState.chainId = TARGET_CHAIN_ID; + mockState.approved = [mainnet.id]; + mockSwitchChainAsync.mockRejectedValue( + new Error('An unknown RPC error occurred.\n\nDetails: User disconnected.'), + ); + const tree = await render(); + + await ReactTestRenderer.act(async () => { + byLabel(tree, 'Ask this wallet to add')!.props.onPress(); + }); + + expect(textOf(tree)).toContain('ended the session'); + expect(mockToast.error).toHaveBeenCalledWith(expect.stringContaining('ended the session')); + }); + + it('repairs a session pinned to a chain it never approved', async () => { + // Mounted on Sepolia with only mainnet approved: every request would die + // in the sign client, including the `wallet_addEthereumChain` the switch + // button needs. Moving to mainnet is what makes the button reach a wallet. + mockState.chainId = TARGET_CHAIN_ID; + mockState.approved = [mainnet.id]; + await render(); + expect(mockSwitchChainAsync).toHaveBeenCalledWith({ chainId: mainnet.id }); + }); + + it('does not repair a session that already approved the target', async () => { + mockState.chainId = mainnet.id; + mockState.approved = [mainnet.id, TARGET_CHAIN_ID]; + await render(); + expect(mockSwitchChainAsync).not.toHaveBeenCalled(); + }); + + it('explains a rejected switch rather than echoing the raw error', async () => { + mockState.chainId = mainnet.id; + mockState.approved = [mainnet.id, TARGET_CHAIN_ID]; + mockSwitchChainAsync.mockRejectedValue(Object.assign(new Error('nope'), { code: 4001 })); + const tree = await render(); + + await ReactTestRenderer.act(async () => { + byLabel(tree, 'Switch to')!.props.onPress(); + }); + + expect(textOf(tree)).toContain('You dismissed the request in your wallet'); + }); + + it('tells a refusing wallet to reconnect when the target was never approved', async () => { + mockState.chainId = TARGET_CHAIN_ID; + mockState.approved = [mainnet.id]; + mockSwitchChainAsync.mockRejectedValue(new Error('unsupported chain')); + const tree = await render(); + + // The add attempt is the secondary action now; reconnect leads. + await ReactTestRenderer.act(async () => { + byLabel(tree, 'Ask this wallet to add')!.props.onPress(); + }); + + expect(textOf(tree)).toContain('disconnect and reconnect'); + }); +}); diff --git a/mobile/__tests__/petPreview.test.tsx b/mobile/__tests__/petPreview.test.tsx new file mode 100644 index 00000000..eac19b79 --- /dev/null +++ b/mobile/__tests__/petPreview.test.tsx @@ -0,0 +1,187 @@ +/** + * The two ways a picker shows what a pet actually is, outside the Gallery. + * + * Selecting one puts its numbers inline under the chips; holding one opens the full card over + * the screen. Both are driven through `PetPicker` rather than through their own components, + * because what is worth pinning is which gesture does which, and that a tap still selects. + */ + +import React from 'react'; +import { TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; + +// The barrel drags the Solana runtime into jest; the card reads these helpers for real, +// because what it shows has to be the number the web app shows. +jest.mock('@shared/core', () => ({ + ...jest.requireActual('../../shared/src/utils/ethereum/petCard'), + ...jest.requireActual('../../shared/src/utils/pets/skills'), + getRarityColor: () => '#C0C0C0', + getRarityName: () => 'Uncommon', + itemArtUrl: () => null, +})); + +jest.mock('../src/components/PetArt', () => { + const { Text: RNText } = jest.requireActual('react-native'); + const React_ = jest.requireActual('react'); + return ({ pet }: { pet: { id: string } }) => + React_.createElement(RNText, null, `[art:${pet.id}]`); +}); + +import PetPicker from '../src/components/PetPicker'; + +import { allText, type Tree } from './support/harness'; + +/** Every string on screen. The walk itself lives in the shared harness. */ +const textOf = (tree: Tree) => allText(tree, ' | '); + +const pet = (over: Partial = {}): Pet => ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 0n, + level: 3, + rarity: 2, + winCount: 4, + lossCount: 1, + readyAt: 0, + ...over, +}); + +const onSelect = jest.fn(); + +const render = async (pets: Pet[]) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create( + ({ id: p.id, pet: p }))} + selectedId="" + onSelect={onSelect} + emptyHint="none" + />, + ); + }); + return tree; +}; + +/** The chips, in order. The preview's own touchables are not chips. */ +const chips = (tree: ReactTestRenderer.ReactTestRenderer) => + tree.root.findAllByType(TouchableOpacity); + + +beforeEach(() => jest.clearAllMocks()); + +describe('holding a pet chip', () => { + it('opens that pet, with the stats the chip has no room for', async () => { + const tree = await render([pet()]); + // The chip alone shows art, a name and a level. None of this is on it. + expect(textOf(tree)).not.toContain('STR'); + + await ReactTestRenderer.act(async () => chips(tree)[0].props.onLongPress()); + + const shown = textOf(tree); + expect(shown).toContain('STR'); + expect(shown).toContain('Uncommon'); + expect(shown).toContain('80% win rate'); + }); + + it('opens the pet that was held, not the first one', async () => { + const tree = await render([pet(), pet({ id: '2', name: 'Momo' })]); + await ReactTestRenderer.act(async () => chips(tree)[1].props.onLongPress()); + + // On `ID #`, which only the card renders. Asserting on the art marker passed against + // a version that always previewed the first pet, because both chips draw their own + // art and the marker was in the tree either way. + const shown = textOf(tree); + expect(shown).toContain('ID #2'); + expect(shown).not.toContain('ID #1'); + }); + + it('waits two seconds, so scrolling the chips does not open one', async () => { + // RN's default is 500ms. A chip is small and sits in a horizontal scroller, so a + // finger resting on one before flicking sideways is ordinary rather than rare. + const tree = await render([pet()]); + expect(chips(tree)[0].props.delayLongPress).toBe(2000); + }); + + it('still selects on a tap', async () => { + const tree = await render([pet()]); + await ReactTestRenderer.act(async () => chips(tree)[0].props.onPress()); + + expect(onSelect).toHaveBeenCalledWith('1'); + // On the modal specifically. Stats alone would be ambiguous now that selecting a pet + // also shows them inline, and a tap has to select without covering the screen. + expect(tree.root.findAll((n) => n.props.accessibilityLabel === 'Close pet card')).toHaveLength(0); + }); + + it('offers no action that would navigate away', async () => { + // The preview opens from the middle of something else: choosing a breeding parent, + // setting up a battle. Rename or Send from here would lose whatever was half-filled. + const tree = await render([pet()]); + await ReactTestRenderer.act(async () => chips(tree)[0].props.onLongPress()); + + const shown = textOf(tree); + for (const action of ['Battle', 'Rename', 'Allow', 'Equip', 'Send']) { + expect(shown).not.toContain(action); + } + }); + + it('closes again', async () => { + const tree = await render([pet()]); + await ReactTestRenderer.act(async () => chips(tree)[0].props.onLongPress()); + expect(textOf(tree)).toContain('STR'); + + const close = tree.root.findAll((n) => n.props.accessibilityLabel === 'Close pet card')[0]; + await ReactTestRenderer.act(async () => close.props.onPress()); + expect(textOf(tree)).not.toContain('STR'); + }); +}); + +describe('selecting a pet', () => { + /** Selection is a prop, so the picker is re-rendered with it rather than tapped. */ + const withSelection = async (id: string) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create( + ({ id: p.id, pet: p }), + )} + selectedId={id} + onSelect={onSelect} + emptyHint="none" + />, + ); + }); + return tree; + }; + + it('shows nothing until one is chosen', async () => { + expect(textOf(await withSelection(''))).not.toContain('STR'); + }); + + it('shows the chosen pet stats the chip has no room for', async () => { + const shown = textOf(await withSelection('1')); + expect(shown).toContain('STR'); + expect(shown).toContain('Uncommon'); + expect(shown).toContain('80% wins'); + }); + + it('follows the selection to another pet', async () => { + // The unfought pet: no rate at all rather than a number. + expect(textOf(await withSelection('2'))).not.toContain('% wins'); + }); + + it('says nothing about a rate for a pet that has never fought', async () => { + // Not "0% wins", which reads as a losing record, and not "no record" either: the + // "0W / 0L" sitting beside it already says that, and `PetCard` omits it too. The two + // used to disagree, which meant one pet reading two ways a screen apart. + // The card draws the same distinction, and the two are read one after the other. + const shown = textOf(await withSelection('2')); + expect(shown).toContain('0W'); + // On '% wins' specifically: the strip also shows an HP percentage, so a bare '%' + // asserts nothing about the win rate. + expect(shown).not.toContain('% wins'); + }); +}); diff --git a/mobile/__tests__/petStats.test.ts b/mobile/__tests__/petStats.test.ts new file mode 100644 index 00000000..3cae7c66 --- /dev/null +++ b/mobile/__tests__/petStats.test.ts @@ -0,0 +1,71 @@ +/** + * The two derivations `PetCard` and `PetDetailStrip` share. + * + * Both had their own copy before, under different names, and they disagreed about a pet that + * had never fought. That is the kind of divergence a screen test does not catch: each surface + * passed its own assertions while describing the same pet two ways. + */ + +import type { Pet } from '@shared/core'; + +jest.mock('@shared/core', () => ({ + // The real derivation. Stubbing it would leave this asserting the stub, and the point of + // the tiles is that a pet reads the same here as on the web client, which reads this too. + ...jest.requireActual('../../shared/src/utils/ethereum/petCard'), +})); + +import { getPetProperties } from '../../shared/src/utils/ethereum/petCard'; +import { statTiles, winPercent } from '../src/utils/petStats'; + +const pet = (over: Partial = {}): Pet => + ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 90210n, + level: 3, + rarity: 2, + winCount: 0, + lossCount: 0, + readyAt: 0, + ...over, + }) as Pet; + +describe('statTiles', () => { + it('pairs each label with its own stat', async () => { + // By pair, not by presence. Two of the four stats can hold the same number, so a test + // that only checks the values appear somewhere passes with two fields swapped. + const subject = pet(); + const props = getPetProperties(subject); + + expect(statTiles(subject)).toEqual([ + { label: 'STR', value: props.attack }, + { label: 'INT', value: props.intelligence }, + { label: 'DEF', value: props.defense }, + { label: 'VIT', value: props.life }, + ]); + }); + + it('offers no AGI, because nothing in the data model backs one', async () => { + expect(statTiles(pet()).map((t) => t.label)).not.toContain('AGI'); + }); +}); + +describe('winPercent', () => { + it('rounds the share of fights won', async () => { + expect(winPercent(pet({ winCount: 4, lossCount: 1 }))).toBe(80); + expect(winPercent(pet({ winCount: 1, lossCount: 2 }))).toBe(33); + }); + + it('answers null for a pet that has never fought, not zero', async () => { + // Zero percent reads as a losing record, and never fighting is not losing. Returning + // the number and letting each surface decide how to say it is what stopped the card + // and the strip disagreeing. + expect(winPercent(pet({ winCount: 0, lossCount: 0 }))).toBeNull(); + }); + + it('answers zero for a pet that has only lost', async () => { + // The case `null` must not swallow: this pet really is at zero percent. + expect(winPercent(pet({ winCount: 0, lossCount: 3 }))).toBe(0); + }); +}); diff --git a/mobile/__tests__/signInFailure.test.tsx b/mobile/__tests__/signInFailure.test.tsx new file mode 100644 index 00000000..c902f19e --- /dev/null +++ b/mobile/__tests__/signInFailure.test.tsx @@ -0,0 +1,108 @@ +/** + * A sign-in that never reached the wallet. + * + * The WalletConnect relay refusing to publish surfaces as viem's + * `UnknownRpcError: An unknown RPC error occurred`, with "Failed to publish payload" + * only in the detail. No prompt ever appears in the wallet, because the request never + * arrived — so the advice is to reconnect, not to go and approve something. + * + * Before this, every leg of sign-in was logged to the console and dropped. The button + * went back to idle and nothing was said, which is indistinguishable from a player + * changing their mind. + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; + +import { describeSignInFailure } from '../src/utils/signInFailure'; + +const mockAuth = { signInError: null as Error | null }; +const mockToast = { show: jest.fn(), error: jest.fn(), info: jest.fn(), success: jest.fn() }; + +jest.mock('@shared/core', () => ({ useAuth: () => mockAuth })); +jest.mock('../src/components/ui/Toast', () => ({ useToast: () => mockToast })); + +import SignInErrorReporter from '../src/components/SignInErrorReporter'; + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockAuth.signInError = null; +}); + +describe('describeSignInFailure', () => { + it('separates a wallet that never got the request from one that refused', () => { + const unreachable = describeSignInFailure( + new Error( + 'An unknown RPC error occurred.\n\nDetails: Failed to publish payload, please try again. id: 1786629317758309888 tag:1108', + ), + ); + + expect(unreachable?.isUnreachable).toBe(true); + expect(unreachable?.message).toContain('never showed you the request'); + }); + + it('does not call a refusal a connection problem', () => { + const refused = describeSignInFailure(new Error('User rejected the request.')); + expect(refused?.isUnreachable).toBe(false); + expect(refused?.message).toContain('cancelled'); + }); + + it('still says something useful for a failure it does not recognise', () => { + const other = describeSignInFailure(new Error('boom')); + expect(other?.message).toBe('Could not sign you in. Try again.'); + expect(other?.isUnreachable).toBe(false); + }); + + it('reads the message off a raw object, not just an Error', () => { + expect( + describeSignInFailure({ message: 'Failed to publish payload, please try again.' }) + ?.isUnreachable, + ).toBe(true); + }); + + it('has nothing to say about a sign-in that worked', () => { + expect(describeSignInFailure(null)).toBeNull(); + }); +}); + +describe('SignInErrorReporter', () => { + it('says nothing while sign-in is fine', async () => { + await render(); + expect(mockToast.error).not.toHaveBeenCalled(); + }); + + it('reports a failure once, not on every render', async () => { + mockAuth.signInError = new Error('Failed to publish payload, please try again.'); + const tree = await render(); + + await ReactTestRenderer.act(async () => { + tree.update(); + tree.update(); + }); + + expect(mockToast.error).toHaveBeenCalledTimes(1); + expect(mockToast.error).toHaveBeenCalledWith( + expect.stringContaining('never showed you the request'), + ); + }); + + it('reports a second, different failure', async () => { + mockAuth.signInError = new Error('Failed to publish payload, please try again.'); + const tree = await render(); + + mockAuth.signInError = new Error('User rejected the request.'); + await ReactTestRenderer.act(async () => { + tree.update(); + }); + + expect(mockToast.error).toHaveBeenCalledTimes(2); + }); +}); diff --git a/mobile/__tests__/solanaAuthSigner.test.tsx b/mobile/__tests__/solanaAuthSigner.test.tsx new file mode 100644 index 00000000..12e80b34 --- /dev/null +++ b/mobile/__tests__/solanaAuthSigner.test.tsx @@ -0,0 +1,171 @@ +/** + * Registering the Solana signer is what makes Solana reachable at all. + * `useActiveChain` resolves `kind: 'solana'` from this store and nothing else, + * and `useChainAdapter` selects the Solana adapter only on that basis — so an + * unregistered signer means a connected Solana wallet is invisible to every + * chain-blind pet hook, and `signAndLogin` has nothing to sign with. + * + * The store is the real one from `@shared/core`; only AppKit is faked, since the + * registration contract is the thing under test. + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import bs58 from 'bs58'; + +const mockState = { + address: 'B1aZe6PubKeyPlaceholder11111111111111111111' as string | undefined, + isConnected: true, + namespace: 'solana' as string | undefined, + chainId: 'solana:devnet' as string | number | undefined, + provider: undefined as unknown, +}; + +const mockRequest = jest.fn(); + +jest.mock('@reown/appkit-react-native', () => ({ + useProvider: () => ({ provider: mockState.provider }), + useAccount: () => ({ + address: mockState.address, + isConnected: mockState.isConnected, + namespace: mockState.namespace, + chainId: mockState.chainId, + }), +})); + +// The barrel is stubbed (it re-exports the Solana adapter and drags the whole +// Solana runtime in), but the store itself is the real one: a fake would test the +// fake. `@shared/core` exports only `.` and `./node`, so the specific modules come +// in by relative path, as elsewhere in this suite. +jest.mock('@shared/core', () => ({ + ...jest.requireActual('../../shared/src/auth/solanaAuthStore'), + coerceSolanaEd25519SignatureBytes: jest.requireActual( + '../../shared/src/utils/solana/signatureAuthCodec', + ).coerceSolanaEd25519SignatureBytes, +})); + +import { + getSolanaAuthAddress, + getSolanaAuthSigner, + setSolanaAuthSigner, +} from '../../shared/src/auth/solanaAuthStore'; +import { coerceSolanaEd25519SignatureBytes } from '../../shared/src/utils/solana/signatureAuthCodec'; + +import { SolanaAuthSigner } from '../src/solana/SolanaAuthSigner'; +import { solanaProviderChainRef } from '../src/utils/solanaProviderChainRef'; + +const SIGNATURE = new Uint8Array(64).fill(7); + +const render = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + return tree; +}; + +beforeEach(() => { + mockState.address = 'B1aZe6PubKeyPlaceholder11111111111111111111'; + mockState.isConnected = true; + mockState.namespace = 'solana'; + mockState.chainId = 'solana:devnet'; + mockRequest.mockReset(); + mockRequest.mockResolvedValue(bs58.encode(SIGNATURE)); + mockState.provider = { request: mockRequest }; + setSolanaAuthSigner(null); +}); + +describe('solanaProviderChainRef', () => { + it('passes through a CAIP-2 reference unchanged', () => { + expect(solanaProviderChainRef('solana:mainnet')).toBe('solana:mainnet'); + }); + + it('namespaces a bare cluster id', () => { + expect(solanaProviderChainRef('devnet')).toBe('solana:devnet'); + expect(solanaProviderChainRef(101)).toBe('solana:101'); + }); + + it('falls back to the build target rather than producing "solana:undefined"', () => { + expect(solanaProviderChainRef(undefined)).toBe('solana:devnet'); + expect(solanaProviderChainRef('')).toBe('solana:devnet'); + }); +}); + +describe('SolanaAuthSigner', () => { + it('registers the connected wallet, which is what makes useActiveChain see Solana', async () => { + await render(); + expect(getSolanaAuthAddress()).toBe(mockState.address); + }); + + it('registers nothing for an EVM session', async () => { + // Both namespaces share one AppKit session; registering here would make a + // pure EVM wallet look like a Solana one to every chain-blind hook. + mockState.namespace = 'eip155'; + await render(); + expect(getSolanaAuthSigner()).toBeNull(); + }); + + it('registers nothing while disconnected or without an address', async () => { + mockState.isConnected = false; + await render(); + expect(getSolanaAuthSigner()).toBeNull(); + + mockState.isConnected = true; + mockState.address = undefined; + await render(); + expect(getSolanaAuthSigner()).toBeNull(); + }); + + it('clears the registration on unmount, so a stale signer cannot outlive it', async () => { + const tree = await render(); + expect(getSolanaAuthSigner()).not.toBeNull(); + await ReactTestRenderer.act(() => { + tree.unmount(); + }); + expect(getSolanaAuthSigner()).toBeNull(); + }); + + it('signs over the wire as base58 on the session chain', async () => { + await render(); + const message = new TextEncoder().encode('nonce-to-sign'); + + const signature = await getSolanaAuthSigner()!.signMessage(message); + + expect(mockRequest).toHaveBeenCalledWith( + { + method: 'solana_signMessage', + params: { message: bs58.encode(message), pubkey: mockState.address }, + }, + 'solana:devnet', + ); + expect(signature).toEqual(SIGNATURE); + }); + + it('still signs when the session reports no chain id', async () => { + mockState.chainId = undefined; + await render(); + + await getSolanaAuthSigner()!.signMessage(new Uint8Array([1, 2, 3])); + + expect(mockRequest.mock.calls[0][1]).toBe('solana:devnet'); + }); + + it.each([ + ['a bare base58 string', () => bs58.encode(SIGNATURE)], + ['a { signature } wrapper', () => ({ signature: bs58.encode(SIGNATURE) })], + ['raw bytes', () => SIGNATURE], + ])('accepts %s, because wallets disagree on the shape', async (_label, make) => { + mockRequest.mockResolvedValue(make()); + await render(); + const signature = await getSolanaAuthSigner()!.signMessage(new Uint8Array([1])); + expect(coerceSolanaEd25519SignatureBytes(signature)).toEqual(SIGNATURE); + }); + + it('propagates a refusal rather than registering a broken signer', async () => { + mockRequest.mockRejectedValue(new Error('User rejected')); + await render(); + await expect( + getSolanaAuthSigner()!.signMessage(new Uint8Array([1])), + ).rejects.toThrow('User rejected'); + }); +}); diff --git a/mobile/__tests__/support/harness.tsx b/mobile/__tests__/support/harness.tsx new file mode 100644 index 00000000..0e00ec9a --- /dev/null +++ b/mobile/__tests__/support/harness.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Text, TouchableOpacity } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +export type Tree = ReactTestRenderer.ReactTestRenderer; + +/** + * Render a component for a test. + * + * The `act` callback is `async` on purpose. A sync one closes the scope the moment effects + * have flushed, so a mount effect that asks the OS something (`useReduceMotion` is the one + * here) sets its state a microtask later, outside any scope, and React reports the update as + * unwrapped. + */ +export const renderTree = async (node: React.ReactElement): Promise => { + let tree!: Tree; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create(node); + }); + return tree; +}; + +/** + * Every string on screen, in tree order. + * + * Twenty-one suites carried their own copy of this walk, with twenty-three `textOf` wrappers + * around it. It was the largest duplication in the project. + * + * The shape is preserved exactly rather than replaced with a query. A node whose children are + * `[1, ' / ', 3]` reads as `1 / 3` here and as three separate nodes to any per-node query, so + * `toContain('1 / 3')` passes against this and would fail against the other. Translating + * those case by case changes what the tests assert; moving the helper does not. + * + * `separator` is a parameter and not a constant because the suites never agreed: fourteen + * joined on `' | '` and ten on `' '`, and an assertion spanning two nodes depends on which. + */ +export const allText = (tree: Tree, separator = ' | '): string => + tree.root + .findAllByType(Text) + .map((node) => { + const walk = (child: unknown): string => + typeof child === 'string' || typeof child === 'number' + ? String(child) + : Array.isArray(child) + ? child.map(walk).join('') + : ''; + return walk(node.props.children); + }) + .join(separator); + +/** + * The text inside one node, for asserting on a single control rather than the whole screen. + * + * Same walk as `allText`, so a control whose label is composed from several children reads + * the same either way. A simplified version that only handled string children silently + * returned nothing for those, which is the kind of empty string an assertion passes against. + */ +export const textOfNode = (node: ReactTestRenderer.ReactTestInstance): string => + node + .findAllByType(Text) + .map((n) => { + const walk = (child: unknown): string => + typeof child === 'string' || typeof child === 'number' + ? String(child) + : Array.isArray(child) + ? child.map(walk).join('') + : ''; + return walk(n.props.children); + }) + .join(' '); + +/** + * The touchable carrying this accessibility label. + * + * The preferred lookup. Index-based ones have broken here repeatedly and always silently: the + * press lands on a different control and the test still passes. + */ +export const byLabel = (tree: Tree, label: string) => + tree.root.findAllByType(TouchableOpacity).find((n) => n.props.accessibilityLabel === label); + +/** The touchable whose own text contains this string. */ +export const byText = (tree: Tree, text: string) => + tree.root.findAllByType(TouchableOpacity).find((n) => textOfNode(n).includes(text)); + +export const pressLabel = async (tree: Tree, label: string): Promise => { + const target = byLabel(tree, label); + if (!target) throw new Error(`No touchable labelled "${label}"`); + await ReactTestRenderer.act(async () => target.props.onPress()); +}; + +export const pressText = async (tree: Tree, text: string): Promise => { + const target = byText(tree, text); + if (!target) throw new Error(`No touchable containing "${text}"`); + await ReactTestRenderer.act(async () => target.props.onPress()); +}; diff --git a/mobile/__tests__/toast.test.tsx b/mobile/__tests__/toast.test.tsx new file mode 100644 index 00000000..8a67f47b --- /dev/null +++ b/mobile/__tests__/toast.test.tsx @@ -0,0 +1,200 @@ +/** + * Covers the toast provider and the three app-local hooks bound to it. The point of + * the RN provider is that `useNotifyError` / `usePetErrorToast` / `useTxErrorToast` + * port over from frontend unchanged, so these assert the wiring rather than the + * error parsing: `usePetError` and `useTxError` live in `@shared/core` and are + * mocked here, since reaching the real ones means booting the chain adapter. + */ + +import React from 'react'; +import { Text } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; + +// The viewport measures a bottom inset, which needs a SafeAreaProvider and a real +// frame. Neither is what these tests are about. +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); + +const mockUsePetError = jest.fn(); +const mockUseTxError = jest.fn(); +jest.mock('@shared/core', () => ({ + usePetError: (...args: unknown[]) => mockUsePetError(...args), + useTxError: (...args: unknown[]) => mockUseTxError(...args), +})); + +import { ToastProvider, useToast } from '../src/components/ui/Toast'; +import { useNotifyError } from '../src/hooks/useNotifyError'; +import { usePetErrorToast } from '../src/hooks/usePetErrorToast'; +import { useTxErrorToast } from '../src/hooks/useTxErrorToast'; + +/** Every string rendered in the tree, so assertions do not depend on layout. */ +const textOf = (tree: ReactTestRenderer.ReactTestRenderer): string => { + const walk = (node: unknown): string => { + if (typeof node === 'string') return node; + if (Array.isArray(node)) return node.map(walk).join(' '); + if (node && typeof node === 'object' && 'children' in node) { + return walk((node as { children: unknown }).children); + } + return ''; + }; + return walk(tree.toJSON()); +}; + +const renderInProvider = async (Component: React.FC) => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create( + + + , + ); + }); + return tree; +}; + +beforeEach(() => { + // Fake timers throughout: the 5200ms auto-dismiss otherwise outlives the test + // that started it and fires a setState into a later one, which surfaces as an + // act() warning and a failure in whichever test happens to be running. + jest.useFakeTimers(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + mockUsePetError.mockReset(); + mockUseTxError.mockReset(); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe('ToastProvider', () => { + it('throws when useToast is called outside it', () => { + const Orphan = () => { + useToast(); + return null; + }; + expect(() => + ReactTestRenderer.act(() => { + ReactTestRenderer.create(); + }), + ).toThrow('useToast must be used within ToastProvider'); + }); + + it('renders a toast for each tone', async () => { + const Fixture = () => { + const toast = useToast(); + return ( + { + toast.error('went wrong'); + toast.info('heads up'); + toast.success('all good'); + }} + > + go + + ); + }; + const tree = await renderInProvider(Fixture); + expect(textOf(tree)).not.toContain('went wrong'); + + await ReactTestRenderer.act(() => { + tree.root.findByType(Text).props.onPress(); + }); + + const rendered = textOf(tree); + expect(rendered).toContain('went wrong'); + expect(rendered).toContain('heads up'); + expect(rendered).toContain('all good'); + }); + + it('auto-dismisses', async () => { + const Fixture = () => { + const toast = useToast(); + return toast.error('temporary')}>go; + }; + const tree = await renderInProvider(Fixture); + await ReactTestRenderer.act(() => { + tree.root.findByType(Text).props.onPress(); + }); + expect(textOf(tree)).toContain('temporary'); + + await ReactTestRenderer.act(() => { + jest.advanceTimersByTime(5200); + }); + expect(textOf(tree)).not.toContain('temporary'); + }); +}); + +describe('useNotifyError', () => { + it('shows the message and logs the raw error', async () => { + const raw = new Error('revert 0x123'); + const Fixture = () => { + const notify = useNotifyError(); + return notify('Could not level up', raw, 'level-up')}>go; + }; + const tree = await renderInProvider(Fixture); + await ReactTestRenderer.act(() => { + tree.root.findByType(Text).props.onPress(); + }); + + expect(textOf(tree)).toContain('Could not level up'); + expect(console.error).toHaveBeenCalledWith('[level-up]', raw); + }); +}); + +describe('usePetErrorToast', () => { + it('fires a toast when a pet action fails', async () => { + mockUsePetError.mockReturnValue({ + message: 'Not enough ETH', + isUserRejection: false, + isContractError: true, + }); + const Fixture = () => { + usePetErrorToast(new Error('insufficient funds'), null, null, 'fallback'); + return null; + }; + const tree = await renderInProvider(Fixture); + expect(textOf(tree)).toContain('Not enough ETH'); + }); + + it('stays quiet when there is no error', async () => { + mockUsePetError.mockReturnValue({ + message: null, + isUserRejection: false, + isContractError: false, + }); + const Fixture = () => { + usePetErrorToast(null, null, null, 'fallback'); + return null; + }; + const tree = await renderInProvider(Fixture); + expect(textOf(tree)).toBe(''); + }); +}); + +describe('useTxErrorToast', () => { + it('fires a toast when a write fails', async () => { + mockUseTxError.mockReturnValue({ message: 'Transaction failed', isUserRejection: false }); + const Fixture = () => { + useTxErrorToast(new Error('reverted')); + return null; + }; + const tree = await renderInProvider(Fixture); + expect(textOf(tree)).toContain('Transaction failed'); + }); + + it('routes a user rejection to info rather than error', async () => { + // Tone is what separates "you cancelled" from "something broke"; a rejection + // shown in the error tone reads as a fault the player has to act on. + mockUseTxError.mockReturnValue({ message: 'You rejected the request', isUserRejection: true }); + const Fixture = () => { + useTxErrorToast(new Error('User rejected')); + return null; + }; + const tree = await renderInProvider(Fixture); + expect(textOf(tree)).toContain('You rejected the request'); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/mobile/__tests__/useEvmChainSync.test.tsx b/mobile/__tests__/useEvmChainSync.test.tsx new file mode 100644 index 00000000..779f44dc --- /dev/null +++ b/mobile/__tests__/useEvmChainSync.test.tsx @@ -0,0 +1,137 @@ +/** + * The wrong-network write failure, and why it cannot be fixed at the write. + * + * Every EVM write pins `chainId` from `useEvmPetsConfig`, which reads + * `useAccount().chainId`. When the wallet moves and wagmi does not hear about it, that + * value is stale, and the wallet rejects each attempt with -32602 "active chainId is + * different than the one provided". Retrying re-sends the same stale chain, so the repair + * has to happen before the write, against the connector rather than against wagmi's state. + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import { baseSepolia, mainnet, sepolia } from 'wagmi/chains'; + +import { CHAIN_MISMATCH_MESSAGE, isChainMismatchError } from '../src/utils/chainMismatch'; + +const mockState = { + isConnected: true, + /** What wagmi believes. */ + chainId: baseSepolia.id as number | undefined, + /** What the wallet is really on. */ + actualChainId: baseSepolia.id as number, + hasConnector: true, +}; + +const mockSwitchChainAsync = jest.fn(async () => undefined); +const mockGetChainId = jest.fn(async () => mockState.actualChainId); + +jest.mock('wagmi', () => ({ + useAccount: () => ({ + isConnected: mockState.isConnected, + chainId: mockState.chainId, + connector: mockState.hasConnector ? { getChainId: mockGetChainId } : undefined, + }), + useSwitchChain: () => ({ switchChainAsync: mockSwitchChainAsync }), +})); + +import { useEvmChainSync } from '../src/hooks/useEvmChainSync'; + +function Probe() { + useEvmChainSync(); + return null; +} + +const mount = async () => { + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + tree = ReactTestRenderer.create(); + }); + // Let the async reconcile settle. + await ReactTestRenderer.act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + return tree; +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockState.isConnected = true; + mockState.chainId = baseSepolia.id; + mockState.actualChainId = baseSepolia.id; + mockState.hasConnector = true; +}); + +describe('useEvmChainSync', () => { + it('does nothing while wagmi and the wallet agree', async () => { + await mount(); + expect(mockSwitchChainAsync).not.toHaveBeenCalled(); + }); + + it('follows the wallet when it has moved to another playable chain', async () => { + // The player switched to Sepolia in the wallet; wagmi still says Base Sepolia, so + // every write would name Base Sepolia and be refused. + mockState.chainId = baseSepolia.id; + mockState.actualChainId = sepolia.id; + + await mount(); + + expect(mockSwitchChainAsync).toHaveBeenCalledWith({ chainId: sepolia.id }); + }); + + it('leaves a chain with no deployment to NetworkGate', async () => { + // Following the wallet onto mainnet would point every read at contracts that are + // not there, and silently. The gate is the thing that can ask the player to move. + mockState.chainId = baseSepolia.id; + mockState.actualChainId = mainnet.id; + + await mount(); + + expect(mockSwitchChainAsync).not.toHaveBeenCalled(); + }); + + it('does not ask a disconnected wallet where it is', async () => { + mockState.isConnected = false; + await mount(); + expect(mockGetChainId).not.toHaveBeenCalled(); + }); + + it('survives a connector that will not answer', async () => { + mockState.actualChainId = sepolia.id; + mockGetChainId.mockRejectedValueOnce(new Error('session closed')); + + await expect(mount()).resolves.toBeDefined(); + expect(mockSwitchChainAsync).not.toHaveBeenCalled(); + }); +}); + +describe('isChainMismatchError', () => { + it('recognises the wallet refusal by message', () => { + expect( + isChainMismatchError( + new Error('Invalid parameters: active chainId is different than the one provided.'), + ), + ).toBe(true); + }); + + it('recognises it from a raw JSON-RPC object', () => { + expect( + isChainMismatchError({ + code: -32602, + message: 'Invalid parameters: active chainId is different than the one provided.', + }), + ).toBe(true); + }); + + it('does not claim unrelated failures', () => { + expect(isChainMismatchError(new Error('User rejected the request.'))).toBe(false); + expect(isChainMismatchError(new Error('insufficient funds'))).toBe(false); + expect(isChainMismatchError(null)).toBe(false); + }); + + it('tells the player to move the wallet rather than to retry', () => { + expect(CHAIN_MISMATCH_MESSAGE).toContain('different network'); + expect(CHAIN_MISMATCH_MESSAGE).not.toMatch(/^Transaction failed/); + }); +}); diff --git a/mobile/__tests__/useMarriedPets.test.tsx b/mobile/__tests__/useMarriedPets.test.tsx new file mode 100644 index 00000000..805c0f4e --- /dev/null +++ b/mobile/__tests__/useMarriedPets.test.tsx @@ -0,0 +1,157 @@ +/** + * The batch `marriageOf` read that replaced a per-card `useMarriageInfo`. + * + * Driven through a probe rather than through `MarriageScreen`, because the thing worth + * pinning is how a multicall's result array maps back onto the roster it was built from, and + * a screen between the two only hides that. + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; + +const mockState = { + /** One entry per pet, in roster order, as `useReadContracts` returns them. */ + results: undefined as { status: string; result?: readonly [bigint, string] }[] | undefined, + isLoading: false, + hasConfig: true, +}; + +const mockReadContracts = jest.fn(); + +jest.mock('wagmi', () => ({ + useReadContracts: (args: unknown) => { + mockReadContracts(args); + return { data: mockState.results, isLoading: mockState.isLoading }; + }, +})); + +jest.mock('@shared/core', () => ({ + usePetsConfig: () => ({ + evm: mockState.hasConfig + ? { petCore: { address: '0xpetcore', abi: [] }, chainId: 84532 } + : undefined, + }), +})); + +import { useMarriedPets, type MarriedPet } from '../src/hooks/marriage/useMarriedPets'; + +const pet = (id: string, over: Partial = {}): Pet => ({ + id, + chain: 'evm', + name: `Pet ${id}`, + dna: 0n, + level: 1, + rarity: 1, + winCount: 0, + lossCount: 0, + readyAt: 0, + ...over, +}); + +const married = (spouseId: bigint) => ({ + status: 'success', + result: [spouseId, '0xowner'] as const, +}); +const single = married(0n); +const failed = { status: 'failure' as const }; + +/** Renders the hook and hands back what it returned. */ +const run = async (chain: 'evm' | 'solana' | null, pets: Pet[]) => { + let seen!: { marriedPets: MarriedPet[]; isLoading: boolean }; + const Probe = () => { + seen = useMarriedPets(chain, pets); + return null; + }; + await ReactTestRenderer.act(async () => { + ReactTestRenderer.create(); + }); + return seen; +}; + +beforeEach(() => { + mockState.results = undefined; + mockState.isLoading = false; + mockState.hasConfig = true; + jest.clearAllMocks(); +}); + +describe('useMarriedPets on EVM', () => { + it('keeps the pets with a spouse and drops the rest', async () => { + mockState.results = [married(7n), single]; + const { marriedPets } = await run('evm', [pet('1'), pet('2')]); + + expect(marriedPets).toEqual([{ pet: pet('1'), spouseId: '7' }]); + }); + + it('pairs each result with the pet it was read for', async () => { + // The multicall answers positionally. Mapping over the successes alone, or over the + // married ones alone, shifts every later pet onto someone else's spouse — a card + // naming the wrong pet, with nothing in the UI to say so. + // + // Both kinds of gap are in here on purpose: a single pet at index 0 and a failed + // read at index 1. A fixture with only one of the two proves only half of it. + mockState.results = [single, failed, married(7n), married(8n)]; + const { marriedPets } = await run('evm', [pet('1'), pet('2'), pet('3'), pet('4')]); + + expect(marriedPets).toEqual([ + { pet: pet('3'), spouseId: '7' }, + { pet: pet('4'), spouseId: '8' }, + ]); + }); + + it('loses one unreadable pet rather than the whole list', async () => { + mockState.results = [failed, married(7n)]; + const { marriedPets } = await run('evm', [pet('1'), pet('2')]); + + expect(marriedPets).toEqual([{ pet: pet('2'), spouseId: '7' }]); + }); + + it('asks for marriageOf once per pet, not three reads per card', async () => { + // `useMarriageInfo` read marriageOf, marriageProposal and marriageCooldownUntil for + // every pet and the card used the first of the three. Twenty pets was sixty reads. + await run('evm', [pet('1'), pet('2'), pet('3')]); + + const { contracts } = mockReadContracts.mock.calls[0][0]; + expect(contracts).toHaveLength(3); + expect( + contracts.every((c: { functionName: string }) => c.functionName === 'marriageOf'), + ).toBe(true); + }); + + it('reports loading rather than an empty roster while the read is out', async () => { + mockState.isLoading = true; + const { marriedPets, isLoading } = await run('evm', [pet('1')]); + + expect(isLoading).toBe(true); + expect(marriedPets).toEqual([]); + }); +}); + +describe('useMarriedPets on Solana', () => { + it('reads the spouse off the pet, without a contract call', async () => { + // The pet account carries it, and `usePetList` has already fetched that. + const { marriedPets, isLoading } = await run('solana', [ + pet('1', { chain: 'solana', spouseId: 9 }), + pet('2', { chain: 'solana' }), + ]); + + expect(marriedPets).toEqual([ + { pet: pet('1', { chain: 'solana', spouseId: 9 }), spouseId: '9' }, + ]); + expect(isLoading).toBe(false); + }); + + it('treats spouse 0 as single, not as married to pet zero', async () => { + const { marriedPets } = await run('solana', [pet('1', { chain: 'solana', spouseId: 0 })]); + expect(marriedPets).toEqual([]); + }); +}); + +describe('useMarriedPets with no chain', () => { + it('reports nothing rather than reading', async () => { + const { marriedPets, isLoading } = await run(null, [pet('1')]); + expect(marriedPets).toEqual([]); + expect(isLoading).toBe(false); + }); +}); diff --git a/mobile/__tests__/usePetGallery.test.tsx b/mobile/__tests__/usePetGallery.test.tsx new file mode 100644 index 00000000..9ba953aa --- /dev/null +++ b/mobile/__tests__/usePetGallery.test.tsx @@ -0,0 +1,268 @@ +/** + * The gallery screen is a pure view over this hook, so every decision the gallery + * makes is made here and `GalleryScreen.test.tsx` stubs it out entirely. + * + * The parts worth pinning are the ones that are invisible when wrong: a + * disconnected wallet still showing pets, a pull-to-refresh that never stops + * spinning, and a mint that reports success without re-reading the list. + * + * `@shared/core` is stubbed, since its barrel drags the Solana runtime into jest. + * `usePetCooldowns` runs for real, so every render here must be unmounted or its + * interval outlives the test. + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import type { Pet } from '@shared/core'; + +const pet = (over: Partial = {}): Pet => ({ + id: '1', + chain: 'evm', + name: 'Rex', + dna: 0n, + level: 3, + rarity: 2, + winCount: 0, + lossCount: 0, + readyAt: 0, + ...over, +}); + +const mockState = { + pets: [pet()] as Pet[], + isLoading: false, + error: null as Error | null, + isConnected: true, + /** Pet id to its filled slots. A pet with no gear has no entry, by design. */ + equippedByPet: new Map(), +}; + +const mockEquipmentArgs = jest.fn(); +const mockRefetch = jest.fn(async () => undefined); +const mockNotify = jest.fn(); +const mockNavigate = jest.fn(); +/** Captures the options `useCreatePet` was constructed with, to fire onSuccess. */ +const mockCreatePetOptions: { onSuccess?: () => void } = {}; + +jest.mock('@shared/core', () => ({ + // `usePetCooldowns` runs for real and reaches for these through the barrel. + // They are dependency-free, so they come from their own module rather than + // being faked. + ...jest.requireActual('../../shared/src/utils/ethereum/petReadyTime'), + /** Captured so the batched read can be asserted to ask for every pet, once. */ + usePetEquipmentForPets: (opts: { petIds: string[] }) => { + mockEquipmentArgs(opts); + return { byPet: mockState.equippedByPet, isLoading: false, error: null, refetch: jest.fn() }; + }, + usePetList: () => ({ + pets: mockState.pets, + isLoading: mockState.isLoading, + error: mockState.error, + refetch: mockRefetch, + }), + useChainCapabilities: () => ({ isConnected: mockState.isConnected }), + useCreatePet: (opts: { onSuccess?: () => void }) => { + mockCreatePetOptions.onSuccess = opts?.onSuccess; + return { mutate: jest.fn(), isPending: false, error: null, reset: jest.fn() }; + }, +})); + +jest.mock('../src/hooks/useNotifyError', () => ({ useNotifyError: () => mockNotify })); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +import { usePetGallery, type UsePetGallery } from '../src/hooks/pet-gallery/usePetGallery'; + +/** + * Renders the hook and hands back its latest value plus the renderer, so each + * test can unmount. `usePetCooldowns` starts a 1s interval for a pet on cooldown + * and without unmounting the cleanup never runs: assertions pass and then jest + * hangs with no output, which reads like a parse failure rather than a timer. + */ +const renderHook = async () => { + const seen: { current: UsePetGallery } = { current: null as unknown as UsePetGallery }; + const Probe = () => { + seen.current = usePetGallery(); + return null; + }; + let tree!: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(() => { + tree = ReactTestRenderer.create(); + }); + // Unmounting is itself a React update, so it needs act() too. Without it every + // test prints an "update was not wrapped in act(...)" warning and real ones + // are lost in the noise. + const unmount = async () => { + await ReactTestRenderer.act(() => { + tree.unmount(); + }); + }; + return { seen, unmount }; +}; + +beforeEach(() => { + mockState.pets = [pet()]; + mockState.isLoading = false; + mockState.error = null; + mockState.isConnected = true; + jest.clearAllMocks(); +}); + +describe('usePetGallery', () => { + it('hides pets while no wallet is connected', async () => { + // The list can outlive a disconnect in the query cache, and showing someone + // else's pets to a disconnected wallet is worse than showing none. + mockState.isConnected = false; + const { seen, unmount } = await renderHook(); + expect(seen.current.pets).toEqual([]); + await unmount(); + }); + + it('passes pets through once connected', async () => { + const { seen, unmount } = await renderHook(); + expect(seen.current.pets).toHaveLength(1); + await unmount(); + }); + + it('totals wins across pets, tolerating a missing count', async () => { + mockState.pets = [ + pet({ id: '1', winCount: 3 }), + pet({ id: '2', winCount: 4 }), + pet({ id: '3', winCount: undefined as unknown as number }), + ]; + const { seen, unmount } = await renderHook(); + expect(seen.current.totalWins).toBe(7); + await unmount(); + }); + + it('reports a load failure once rather than on every render', async () => { + mockState.error = new Error('rpc down'); + const { unmount } = await renderHook(); + expect(mockNotify).toHaveBeenCalledTimes(1); + expect(mockNotify).toHaveBeenCalledWith( + 'Failed to load pet data. Please try again.', + mockState.error, + 'pet-list', + ); + await unmount(); + }); + + it('does not report anything when the load succeeds', async () => { + const { unmount } = await renderHook(); + expect(mockNotify).not.toHaveBeenCalled(); + await unmount(); + }); + + it('refetches on pull-to-refresh and clears the spinner', async () => { + const { seen, unmount } = await renderHook(); + expect(seen.current.refreshing).toBe(false); + + await ReactTestRenderer.act(async () => { + await seen.current.onRefresh(); + }); + + expect(mockRefetch).toHaveBeenCalled(); + expect(seen.current.refreshing).toBe(false); + await unmount(); + }); + + it('clears the spinner even when the refetch fails', async () => { + // Two things at once: without the `finally` the control spins forever, and + // without the `catch` the rejection escapes as an unhandled one, because + // `RefreshControl` discards what `onRefresh` returns. + mockRefetch.mockRejectedValueOnce(new Error('offline')); + const { seen, unmount } = await renderHook(); + + await ReactTestRenderer.act(async () => { + seen.current.onRefresh(); + }); + + expect(seen.current.refreshing).toBe(false); + await unmount(); + }); + + it('re-reads the list only once the mint settles', async () => { + // EVM minting is requestMintStarter then settleMint after Pyth Entropy + // reveals, so refetching on submit would read the roster before the pet + // exists. + const { seen, unmount } = await renderHook(); + + await ReactTestRenderer.act(async () => { + seen.current.onOpenCreateModal(); + }); + expect(seen.current.createModalOpen).toBe(true); + + await ReactTestRenderer.act(async () => { + mockCreatePetOptions.onSuccess?.(); + }); + + expect(seen.current.createModalOpen).toBe(false); + expect(mockRefetch).toHaveBeenCalledTimes(1); + await unmount(); + }); + + it('opens and closes the create modal', async () => { + const { seen, unmount } = await renderHook(); + await ReactTestRenderer.act(async () => { + seen.current.onOpenCreateModal(); + }); + expect(seen.current.createModalOpen).toBe(true); + + await ReactTestRenderer.act(async () => { + seen.current.onCloseCreateModal(); + }); + expect(seen.current.createModalOpen).toBe(false); + await unmount(); + }); + + it('routes each per-pet action to the screen that acts on that pet', async () => { + const { seen, unmount } = await renderHook(); + const target = pet({ id: '42' }); + + await ReactTestRenderer.act(async () => { + seen.current.onBattle(target); + seen.current.onRename(target); + seen.current.onDefend(target); + }); + + // Battle is a tab, so it is reached through the tab navigator; Rename and + // Defense are stack routes pushed over the shell (plan 3.1). + expect(mockNavigate).toHaveBeenNthCalledWith(1, 'Main', { + screen: 'Battle', + params: { petId: '42' }, + }); + expect(mockNavigate).toHaveBeenNthCalledWith(2, 'Rename', { petId: '42' }); + expect(mockNavigate).toHaveBeenNthCalledWith(3, 'Defense', { petId: '42' }); + await unmount(); + }); +}); + +/** + * Gear is read for the whole roster in one request. + * + * `usePetEquipmentForPets` exists precisely so a gallery does not fire one query per + * card, and it had no mobile caller at all until now. Asserting the id list rather than + * the call count is what pins that: a per-card hook would ask for one id at a time. + */ +describe('usePetGallery equipment', () => { + it('asks for every pet at once, not one query per card', async () => { + mockState.pets = [pet({ id: '1' }), pet({ id: '2' }), pet({ id: '3' })]; + const { unmount } = await renderHook(); + + const asked = mockEquipmentArgs.mock.calls.at(-1)?.[0] as { petIds: string[] }; + expect(asked.petIds).toEqual(['1', '2', '3']); + await unmount(); + }); + + it('reports undefined for a pet wearing nothing, which is how the read omits it', async () => { + mockState.pets = [pet({ id: '1' }), pet({ id: '2' })]; + mockState.equippedByPet = new Map([['1', [{ slot: 0 }]]]); + const { seen, unmount } = await renderHook(); + + expect(seen.current.equippedFor('1')).toHaveLength(1); + expect(seen.current.equippedFor('2')).toBeUndefined(); + await unmount(); + }); +}); diff --git a/mobile/babel.config.js b/mobile/babel.config.js index 30cefb12..8b74aaa2 100644 --- a/mobile/babel.config.js +++ b/mobile/babel.config.js @@ -16,5 +16,8 @@ module.exports = { verbose: false, }, ], + // `@switchboard-xyz/common`'s ESM build uses `export * as ns from`, which + // the React Native preset does not transform. + '@babel/plugin-transform-export-namespace-from', ], }; diff --git a/mobile/config.ts b/mobile/config.ts index 3ab022a4..fd5177e0 100644 --- a/mobile/config.ts +++ b/mobile/config.ts @@ -1,7 +1,8 @@ -import { setTokenSuccessCallback, setStorageAdapter } from '@shared/core'; +import { setEvidenceStore, setTokenSuccessCallback, setStorageAdapter } from '@shared/core'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { API_URL as ENV_API_URL } from '@env'; +import { battleEvidenceStore, hydrateBattleEvidence } from './src/utils/battleEvidenceStore'; export const API_URL = ENV_API_URL; @@ -19,3 +20,10 @@ setStorageAdapter({ removeToken: () => AsyncStorage.removeItem('authToken'), }); +// Battle evidence (§E, §J). Without this, `shared` finds no Web Storage on React +// Native and falls back to a no-op, so the player's signed commitment is dropped +// the moment it arrives. Hydration is deliberately not awaited: it only decides +// whether an earlier launch's evidence is visible, and nothing reads it at import. +setEvidenceStore(battleEvidenceStore); +hydrateBattleEvidence().catch(() => undefined); + diff --git a/mobile/env.d.ts b/mobile/env.d.ts index 46c038d2..160b6444 100644 --- a/mobile/env.d.ts +++ b/mobile/env.d.ts @@ -1,13 +1,37 @@ declare module '@env' { export const REOWN_PROJECT_ID: string; export const API_URL: string; - /** Deployed CryptoPets contract (same as frontend VITE_CONTRACT_ADDRESS). */ - export const CONTRACT_ADDRESS: string; + /** + * Chain id the contracts are deployed on. Unset falls back to Sepolia; see + * `src/constants/ethereumNetworks.ts`. + */ + export const EVM_CHAIN_ID: string | undefined; + /** + * v2 contract addresses. Each falls back to the live Sepolia deployment in + * `src/chains/ethereum/contracts.ts`, the same one frontend defaults to. + */ + export const PETCORE_ADDRESS: string | undefined; + export const GAMELOGIC_ADDRESS: string | undefined; + export const GAMECONFIG_ADDRESS: string | undefined; /** * Optional Hardhat/Anvil JSON-RPC URL for chain 31337 (e.g. `http://192.168.1.5:8545` on a physical device). * If unset: Android emulator uses `10.0.2.2`; iOS simulator uses `127.0.0.1`. */ export const HARDHAT_RPC_URL: string | undefined; + /** + * Optional JSON-RPC URL for the target EVM chain (`EVM_CHAIN_ID`). + * + * Unset, viem falls back to the chain's built-in public endpoint, which for Base + * Sepolia is `https://sepolia.base.org`. That endpoint is shared and rate-limited, and + * this app's reads are not light: the pet list is one Multicall3 `aggregate3` across + * every pet the wallet owns, and incoming marriage proposals are another across every + * pet in the roster. Both time out there under load, and a timed-out multicall drops + * pets from the list rather than failing loudly. + * + * Point this at your own endpoint (Alchemy, QuickNode and similar have free tiers) and + * those reads stop competing with every other project on the public node. + */ + export const EVM_RPC_URL: string | undefined; /** Same program id as frontend `VITE_CRYPTOPETS_PROGRAM_ID` (Anchor devnet deploy). */ export const CRYPTOPETS_PROGRAM_ID: string | undefined; /** Optional custom RPC; default is public Solana devnet if unset. */ diff --git a/mobile/env.example b/mobile/env.example index a0685039..be18957f 100644 --- a/mobile/env.example +++ b/mobile/env.example @@ -1,3 +1,7 @@ +# Copy to `.env`. Changes need `pnpm --prefix mobile start --reset-cache` AND a +# rebuild: react-native-dotenv inlines `@env` at Babel transform time, so a +# reload alone picks up nothing. An installed APK has the old values bundled in. + # WalletConnect Project ID # Get your project ID from https://dashboard.reown.com/ REOWN_PROJECT_ID=your_project_id_here @@ -9,12 +13,38 @@ REOWN_PROJECT_ID=your_project_id_here # Metro dev server (http://localhost:8081, http://127.0.0.1:8081, http://YOUR_LAN_IP:8081), # and native bundle IDs if the dashboard asks for them (e.g. com.cryptopets on Android). -# CryptoPets Solana (same program as web `VITE_CRYPTOPETS_PROGRAM_ID`) +# Backend API. Android emulator → host is 10.0.2.2. Physical device → your LAN IP. +API_URL=http://localhost:3001 + +# --- EVM (v2) --- +# Which deployment the app starts on: 84532 Base Sepolia, 11155111 Sepolia, +# 31337 Hardhat. Both testnets carry a full stack and a player can switch +# between them in the app, so this only picks the default. +EVM_CHAIN_ID=84532 + +# Addresses are built into src/chains/ethereum/contracts.ts, keyed by chain, for +# Base Sepolia and Sepolia alike. Leave the variables below unset unless you are +# deliberately overriding a deployment. +# +# They apply to EVM_CHAIN_ID's chain only, because the names carry no chain of +# their own. That makes a stale value actively harmful now rather than merely +# redundant: setting EVM_CHAIN_ID=11155111 while these still hold Base Sepolia +# addresses points Sepolia at Base Sepolia's proxies, and every read comes back +# as an empty 0x that looks like a decode bug. +# PETCORE_ADDRESS= +# GAMELOGIC_ADDRESS= +# GAMECONFIG_ADDRESS= + +# Optional Hardhat/Anvil RPC override for chain 31337. Unset: Android emulator +# uses 10.0.2.2, iOS simulator uses 127.0.0.1. +# HARDHAT_RPC_URL=http://192.168.1.5:8545 + +# --- Solana --- +# Same program id as frontend `VITE_CRYPTOPETS_PROGRAM_ID` CRYPTOPETS_PROGRAM_ID= # Optional; default public devnet RPC if empty # CRYPTOPETS_SOLANA_RPC=https://api.devnet.solana.com -API_URL=http://localhost:3001 CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 # Pet art service (image-generator), same as frontend VITE_IMAGE_SERVICE_URL. diff --git a/mobile/index.js b/mobile/index.js index 0c64d0be..d39a389f 100644 --- a/mobile/index.js +++ b/mobile/index.js @@ -6,6 +6,19 @@ if (__DEV__) { require("./ReactotronConfig"); } +/* + * First, and a side-effect import rather than a call below. + * + * `import` statements are hoisted and run before anything in this module body, so a call + * down there happens *after* `./App` has already been evaluated. That import reaches + * `AppKitConfig`, which builds the WalletConnect client, whose pino logger captures + * `console.error` by reference the moment it is created (`write.apply(proto, args)` in + * pino's browser build). A wrapper installed after that point is never consulted, which is + * exactly how the filter came to be installed correctly and change nothing. + */ +import './src/devLogFilters'; + +import './src/shims/installPolyfills'; import { AppRegistry } from 'react-native'; import App from './App'; import { name as appName } from './app.json'; diff --git a/mobile/jest.config.js b/mobile/jest.config.js index 80b7ab06..dc23e14d 100644 --- a/mobile/jest.config.js +++ b/mobile/jest.config.js @@ -1,8 +1,64 @@ +/** + * Solana v2 packages ship `dist/index.node.cjs` alongside their `.mjs` builds. + * Generated from what is actually installed: + * ls node_modules/@solana | while read n; do + * [ -f "$n/dist/index.node.cjs" ] && echo "$n"; done | paste -sd'|' - + * Enumerated rather than matched with a wildcard because `@solana/buffer-layout`, + * a web3.js v1 dependency, has no such build and a blanket pattern maps it to a + * file that does not exist. + */ +const SOLANA_V2_PACKAGES = + 'accounts|addresses|assertions|codecs|codecs-core|codecs-data-structures|codecs-numbers|' + + 'codecs-strings|errors|fast-stable-stringify|functional|instructions|keys|kit|options|' + + 'programs|promises|rpc|rpc-api|rpc-parsed-types|rpc-spec|rpc-spec-types|rpc-subscriptions|' + + 'rpc-subscriptions-api|rpc-subscriptions-channel-websocket|rpc-subscriptions-spec|' + + 'rpc-transformers|rpc-transport-http|rpc-types|signers|subscribable|sysvars|' + + 'transaction-confirmation|transaction-messages|transactions'; + module.exports = { preset: 'react-native', + // v8, not the default babel provider. `babel-plugin-istanbul` rewrites the + // module body before `react-native-dotenv` gets to replace the `@env` import, + // and that plugin then dies on the moved node with `ReferenceError: Container + // is falsy`. It takes down every suite reaching a file that reads `@env`, and + // the failure is reported as 0% coverage on those files rather than as a + // broken run, so `jest --coverage` looks like it worked and quietly measured + // nothing. v8 instruments at runtime and never touches the AST. + coverageProvider: 'v8', + // Jest's 5s default is measured per test, and the first test in a suite also + // pays for transforming everything that suite imports. With a warm cache that + // is invisible; with a cold one, which is every CI run and every run after a + // config change, half the suites time out and the failure reads as a hang + // rather than as a slow first pass. Verified by `jest --clearCache`. + testTimeout: 30000, + testPathIgnorePatterns: ['/node_modules/', '/__tests__/support/'], moduleNameMapper: { // Side-effect-only polyfills shipped as ESM importing a `.ts` path: Metro bundles // it, jest cannot parse it, and nothing under test reads from it. '^@walletconnect/react-native-compat$': '/__mocks__/walletconnectCompat.js', + + // The two redirects below are what make `@solana/web3.js` importable here. + // Both are resolution problems, not transform ones, so `transformIgnorePatterns` + // cannot reach either. + // + // `rpc-websockets` declares only `browser`, `node` and `types` export + // conditions. The react-native resolver asks for `react-native`, matches + // nothing, and there is no fallback, so it reports the package as missing + // even though it is installed. + '^rpc-websockets$': '/../node_modules/rpc-websockets/dist/index.cjs', + // Solana v2 packages do carry a `react-native` condition, pointing at an + // `.mjs` build that this transform never runs over. Their CJS build is + // equivalent for tests and needs no transform at all. + [`^@solana/(${SOLANA_V2_PACKAGES})$`]: + '/../node_modules/@solana/$1/dist/index.node.cjs', }, + // wagmi, viem and React Navigation ship ESM only. Metro handles that; jest does + // not, and the react-native preset's default pattern skips everything in + // node_modules except react-native itself, so importing any of them dies on + // `export *`. `react-native-*` covers the navigation native peers (screens, + // safe-area-context) as well as react-native itself. `uuid` is here because + // web3.js reaches its ESM browser build. + transformIgnorePatterns: [ + 'node_modules/(?!(?:@react-native|react-native|react-native-.*|@react-navigation|wagmi|@wagmi|viem|ox|abitype|uuid)/)', + ], }; diff --git a/mobile/metro.config.js b/mobile/metro.config.js index 42d745c7..ab6c7cc6 100644 --- a/mobile/metro.config.js +++ b/mobile/metro.config.js @@ -1,6 +1,38 @@ const path = require('path'); const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +/** + * `@switchboard-xyz/on-demand` reaches for anchor's NodeWallet behind a + * try/catch, for the Node-only path it never takes here. Metro resolves + * statically, so the catch does not save the bundle: it fails outright. + * + * The package already ships the answer, mapping that specifier to a throwing + * stub in its `browser` field, but Metro resolves that relative redirect + * against the scope directory rather than the package, looking for + * `@switchboard-xyz/dist/...` with the `on-demand` segment dropped. Pointing at + * the same stub by absolute path is what makes the bundle build. + */ +const SWITCHBOARD_NODEWALLET = '@coral-xyz/anchor-31/dist/cjs/nodewallet'; + +/** + * Node built-ins the same package reaches for, which React Native does not + * have. `crypto` is one `createHash('sha256')` in Surge's auth, a feature + * nothing here uses, but Metro has to resolve it to build the graph at all. + */ +const nodeModuleShims = { + [SWITCHBOARD_NODEWALLET]: + '@switchboard-xyz/on-demand/dist/esm/shims/nodewallet.js', + crypto: './src/shims/nodeCrypto.js', + https: './src/shims/nodeHttps.js', +}; + +const shimPaths = Object.fromEntries( + Object.entries(nodeModuleShims).map(([name, target]) => [ + name, + require.resolve(target), + ]) +); + /** * Metro configuration * https://reactnative.dev/docs/metro @@ -22,6 +54,10 @@ const config = { path.join(__dirname, 'node_modules', String(name)), } ), + resolveRequest: (context, moduleName, platform) => + shimPaths[moduleName] + ? { type: 'sourceFile', filePath: shimPaths[moduleName] } + : context.resolveRequest(context, moduleName, platform), }, }; diff --git a/mobile/package.json b/mobile/package.json index a8b758f4..fb9fea4c 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -15,9 +15,15 @@ "@babel/runtime": "^7.28.4", "@shared/core": "workspace:*", "@coral-xyz/anchor": "0.32.1", + "@noble/hashes": "^1.8.0", + "@ungap/structured-clone": "^1.3.0", + "buffer": "^6.0.3", "@solana/web3.js": "^1.95.2", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^11.4.1", + "@react-navigation/bottom-tabs": "^7.18.14", + "@react-navigation/native": "^7.3.14", + "@react-navigation/native-stack": "^7.18.6", "@reown/appkit-react-native": "^2.0.1", "@reown/appkit-solana-react-native": "^2.0.1", "@reown/appkit-wagmi-react-native": "^2.0.1", @@ -28,13 +34,14 @@ "react-native-dotenv": "^3.4.11", "react-native-get-random-values": "^2.0.0", "react-native-safe-area-context": "^5.5.2", - "react-native-svg": "^15.14.0", + "react-native-screens": "^4.26.2", "bs58": "^6.0.0", "viem": "~2.38.3", "wagmi": "^2.18.2" }, "devDependencies": { "@babel/core": "^7.25.2", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@babel/preset-env": "^7.25.3", "@react-native-community/cli": "20.0.0", "@react-native-community/cli-platform-android": "20.0.0", diff --git a/mobile/src/AppContent.tsx b/mobile/src/AppContent.tsx deleted file mode 100644 index c438d288..00000000 --- a/mobile/src/AppContent.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { - ActivityIndicator, - StatusBar, - StyleSheet, - ScrollView, - View, - Text, - TouchableOpacity, -} from 'react-native'; -import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { AppKit } from '@reown/appkit-react-native'; -import { useAuth, usePetsContract } from '@shared/core'; -import { useAccount } from 'wagmi'; -import ConnectButton from './components/ConnectButton'; -import EthereumNetworkSwitcher from './components/EthereumNetworkSwitcher'; -import CreatePetModal from './components/CreatePetModal'; -import PetList from './components/PetList'; -import { petsContractParams } from './petsContractParams'; -import { neon, neonGlow } from './theme/neon'; - -function AppRoot() { - return ( - - - - - ); -} - -function AppContent() { - const { isAuthenticated } = useAuth(); - const { isConnected } = useAccount(); - const pets = usePetsContract(petsContractParams); - const [refreshing, setRefreshing] = useState(false); - const [createModalVisible, setCreateModalVisible] = useState(false); - const insets = useSafeAreaInsets(); - - const handleRefreshPets = useCallback(async () => { - setRefreshing(true); - try { - await pets.refetchPetIds(); - } finally { - setRefreshing(false); - } - }, [pets]); - - const closeCreateModal = useCallback(() => { - setCreateModalVisible(false); - }, []); - - return ( - - {/* Header */} - - Do Not Stop - {(isAuthenticated || isConnected) && ( - - - {isConnected ? : null} - - - - )} - - - {/* Main: avoid nesting ScrollView with PetList’s own scroll + pull-to-refresh */} - {isAuthenticated || isConnected ? ( - isConnected ? ( - - Welcome back! - {pets.isContractConfigured ? ( - - setCreateModalVisible(true)} - activeOpacity={0.85} - > - Create - - - {refreshing ? ( - - ) : ( - Refresh - )} - - - ) : null} - - - - ) : ( - - Welcome back! - Connect a wallet to load your on-chain pets. - - ) - ) : ( - - - ON-CHAIN COLLECTION - Do Not Stop - - - Connect your wallet to mint, battle, and breed — same universe as the web app, in your - pocket. - - - - Create pets - Mint unique companions on-chain. - - - Battles - Prove strength in the arena. - - - Breeding - Combine traits for the next gen. - - - - - - - - )} - - {/* AppKit UI component for wallet connection */} - - - ); -} - -const styles = StyleSheet.create({ - mainContainer: { - flex: 1, - backgroundColor: neon.bgDeep, - }, - header: { - backgroundColor: neon.bgPanel, - borderBottomWidth: 1, - borderBottomColor: neon.border, - paddingHorizontal: 16, - paddingBottom: 16, - ...neonGlow(neon.cyan, 8, 0.2), - }, - headerTitle: { - fontSize: 28, - fontWeight: '800', - textAlign: 'center', - color: neon.text, - letterSpacing: 2, - textShadowColor: neon.cyan, - textShadowOffset: { width: 0, height: 0 }, - textShadowRadius: 12, - }, - walletSection: { - marginTop: 12, - alignItems: 'center', - }, - walletRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - flexWrap: 'wrap', - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 16, - paddingTop: 24, - paddingBottom: 32, - }, - welcomeSection: { - alignItems: 'center', - }, - heroKicker: { - fontSize: 11, - fontWeight: '700', - letterSpacing: 4, - color: neon.magenta, - marginBottom: 8, - textShadowColor: neon.magenta, - textShadowOffset: { width: 0, height: 0 }, - textShadowRadius: 8, - }, - heroTitle: { - fontSize: 36, - fontWeight: '900', - color: neon.text, - letterSpacing: 1, - marginBottom: 4, - textShadowColor: neon.cyan, - textShadowOffset: { width: 0, height: 0 }, - textShadowRadius: 16, - }, - heroGlowLine: { - width: 120, - height: 3, - backgroundColor: neon.cyan, - marginBottom: 20, - borderRadius: 2, - opacity: 0.95, - ...neonGlow(neon.cyan, 8, 0.75), - }, - welcomeText: { - fontSize: 16, - color: neon.textMuted, - textAlign: 'center', - marginBottom: 28, - maxWidth: 600, - lineHeight: 24, - }, - features: { - width: '100%', - maxWidth: 900, - }, - feature: { - backgroundColor: neon.bgCard, - borderRadius: 16, - padding: 20, - marginBottom: 16, - borderWidth: 1, - }, - featureCyan: { - borderColor: 'rgba(0, 245, 255, 0.45)', - ...neonGlow(neon.cyan, 12, 0.25), - }, - featureMagenta: { - borderColor: 'rgba(255, 45, 166, 0.45)', - ...neonGlow(neon.magenta, 12, 0.25), - }, - featurePurple: { - borderColor: 'rgba(192, 132, 252, 0.45)', - ...neonGlow(neon.purple, 12, 0.22), - }, - featureTitle: { - fontSize: 18, - fontWeight: '800', - color: neon.text, - letterSpacing: 0.5, - marginBottom: 6, - }, - featureSub: { - fontSize: 14, - color: neon.textDim, - lineHeight: 20, - }, - connectButtonContainer: { - alignItems: 'center', - marginTop: 8, - }, - authenticatedMain: { - flex: 1, - paddingHorizontal: 16, - paddingTop: 24, - paddingBottom: 16, - width: '100%', - }, - walletHint: { - marginTop: 16, - fontSize: 16, - color: neon.textMuted, - textAlign: 'center', - }, - authenticatedText: { - fontSize: 24, - fontWeight: '700', - color: neon.text, - textShadowColor: neon.purple, - textShadowOffset: { width: 0, height: 0 }, - textShadowRadius: 10, - }, - actionsRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 16, - flexWrap: 'wrap', - }, - createBtn: { - backgroundColor: neon.bgCard, - paddingHorizontal: 20, - paddingVertical: 10, - borderRadius: 12, - marginRight: 12, - marginBottom: 4, - minWidth: 100, - alignItems: 'center', - borderWidth: 1, - borderColor: neon.cyan, - ...neonGlow(neon.cyan, 10, 0.4), - }, - createBtnText: { - color: neon.cyan, - fontSize: 16, - fontWeight: '700', - letterSpacing: 0.5, - }, - refreshBtn: { - borderWidth: 1, - borderColor: neon.magenta, - backgroundColor: neon.bgPanel, - paddingHorizontal: 20, - paddingVertical: 10, - borderRadius: 12, - marginBottom: 4, - minWidth: 100, - alignItems: 'center', - justifyContent: 'center', - minHeight: 42, - ...neonGlow(neon.magenta, 8, 0.25), - }, - refreshBtnDisabled: { - opacity: 0.5, - }, - refreshBtnText: { - color: neon.magenta, - fontSize: 16, - fontWeight: '700', - }, -}); - -export default AppRoot; diff --git a/mobile/src/AppKitConfig.ts b/mobile/src/AppKitConfig.ts index 88cdc44e..2c9de96b 100644 --- a/mobile/src/AppKitConfig.ts +++ b/mobile/src/AppKitConfig.ts @@ -1,13 +1,21 @@ import { createAppKit, solana } from '@reown/appkit-react-native'; import { WagmiAdapter } from '@reown/appkit-wagmi-react-native'; import { SolanaAdapter } from '@reown/appkit-solana-react-native'; -import { mainnet, sepolia } from 'wagmi/chains'; import { storage } from './StorageUtil'; import { REOWN_PROJECT_ID } from '@env'; -import { hardhatLocal } from './ethereumChains'; +import { getAppKitEvmNetworks } from './constants/ethereumNetworks'; const reownProjectId = REOWN_PROJECT_ID; +/** + * Target chain first, handshake fallbacks last — see `getAppKitEvmNetworks`. + * + * Derived rather than listed here so `useEvmSessionChain` can read the same set + * when deciding what it is allowed to switch to. A hand-kept second copy is how + * the provider ends up pinned to a chain wagmi cannot switch away from. + */ +const evmNetworks = getAppKitEvmNetworks(); + /** WalletConnect explorer IDs — featured so they still appear when custom chains (e.g. Hardhat) narrow the API wallet list. */ const FEATURED_WALLET_IDS = [ 'c57ca95b47569778a828d19178114f4db188b89b763c899ba0be274e97267d96', // MetaMask @@ -17,7 +25,7 @@ const FEATURED_WALLET_IDS = [ // Create Wagmi adapter for Ethereum chains const wagmiAdapter = new WagmiAdapter({ projectId: reownProjectId, - networks: [hardhatLocal, mainnet, sepolia], + networks: evmNetworks, }); // Export wagmiConfig for App.tsx @@ -29,8 +37,13 @@ const solanaAdapter = new SolanaAdapter(); // Create AppKit instance with both Ethereum and Solana support export const appKit = createAppKit({ projectId: reownProjectId, - networks: [hardhatLocal, mainnet, sepolia, solana], - defaultNetwork: mainnet, + networks: [...evmNetworks, solana], + /** + * The target chain, not mainnet. AppKit pins the provider here after every + * connect regardless of what the wallet approved, so naming an unplayable + * chain guaranteed a wrong-network session on first launch. + */ + defaultNetwork: evmNetworks[0], adapters: [wagmiAdapter, solanaAdapter], featuredWalletIds: FEATURED_WALLET_IDS, storage, diff --git a/mobile/src/chains/ethereum/contracts.ts b/mobile/src/chains/ethereum/contracts.ts new file mode 100644 index 00000000..f338a8e7 --- /dev/null +++ b/mobile/src/chains/ethereum/contracts.ts @@ -0,0 +1,137 @@ +import type { Abi } from 'viem'; +import { baseSepolia, sepolia } from 'wagmi/chains'; +import { PETCORE_ADDRESS, GAMELOGIC_ADDRESS, GAMECONFIG_ADDRESS } from '@env'; + +import { TARGET_CHAIN_ID } from '../../constants/ethereumNetworks'; +import petCoreAbi from './petCoreAbi.json'; +import gameLogicAbi from './gameLogicAbi.json'; +import gameConfigAbi from './gameConfigAbi.json'; + +/** + * v2 EVM contract surface, mirroring `frontend/src/chains/ethereum/contracts.ts`. + * The monolithic v1 contract is split into three units: + * - PetCore (proxy) - ERC-721 storage, mint, rename, level/XP, cooldowns, marriage. + * - GameLogic (proxy) - async breed/mint (request then settle) + entropy wiring. + * - GameConfig - tunable fees / cooldowns / XP-curve / skill params (read for UI). + * + * CombatSim is deliberately absent: battles are resolved by the backend and replayed + * from the signed receipt (§L Phase 6), so no client ever calls the on-chain sim. + * + * The ABI JSONs are copied verbatim from frontend rather than regenerated, so the + * two apps decode identical call data. All deployments share them: the proxies + * differ per chain, the interface does not. + * + * **Addresses are keyed by chain**, because the app is playable on more than one. + * A single set would send reads for whichever chain the wallet is on to the other + * chain's proxy, which is how the frontend ended up querying Sepolia addresses on + * Base Sepolia and getting an empty `0x` that reads like a decode bug. + */ + +/** Proxy addresses for one chain. Absent means "no deployment here yet". */ +export interface EvmDeployment { + petCore?: `0x${string}`; + gameLogic?: `0x${string}`; + gameConfig?: `0x${string}`; +} + +/** + * Sepolia (11155111), verified on-chain 2026-08-05: all three live, PetCore answers + * name()="CryptoPets", GameLogic.petCore()/gameConfig() point back at the other two, + * and GameLogic.entropy() resolves to Pyth Entropy V2. + * + * Not the older Sepolia stack at 0x0BB0e0…9d33 / 0xaDEC55…56ee, which holds 5 pets + * but whose GameLogic predates the entropy wiring, so minting a starter reverts. + * See `docs/plan-mobile-frontend-parity.md` Phase 0.1. + */ +const SEPOLIA: EvmDeployment = { + petCore: '0xD94B02fC6238AcE5c0Fd767bFf8f5A1FCD9B59DB', + gameLogic: '0x87E3E1e3EB22eC45fB99715BdF91911697997Be4', + gameConfig: '0xE16e0e982D390C4F826D00Fc0E771846a002F10B', +}; + +/** + * Base Sepolia (84532), deployed 2026-08-06 and verified on-chain the same day: + * PetCore answers name()="CryptoPets" / symbol()="PETS", GameLogic.petCore() and + * .gameConfig() point back at the other two, GameLogic.entropy() resolves to Pyth + * Entropy V2 (0x41c9e3…0d4c), and PetCore.authorizedCallers(GameLogic) is true, + * without which minting reverts. + * + * These are the **proxies**. The deployment also produced `PetCoreImpl` + * (0xf99e17…a21F) and `GameLogicImpl` (0x5fB2ec…00A3); those are implementation + * contracts with no storage of their own and must never appear here. + * + * totalPets() is 0, so mint before expecting the gallery to show anything. + */ +const BASE_SEPOLIA: EvmDeployment = { + petCore: '0x4B89BC0269523D8656e5B30E3A6Dda48fe9d2957', + gameLogic: '0x5ac524f54D5145167A43E7abA6D2EFc5D5e8E431', + gameConfig: '0xF35FB2696b3Adc2BF7dfd2eF81B4C177fF6Cd0d5', +}; + +const DEPLOYMENTS: Record = { + [sepolia.id]: SEPOLIA, + [baseSepolia.id]: BASE_SEPOLIA, +}; + +/** + * `.env` overrides apply to the target chain only. + * + * The variables are chain-agnostic (`PETCORE_ADDRESS`, not `SEPOLIA_PETCORE_ADDRESS`), + * so they can only mean one deployment. Letting them override every chain would + * point both at the same proxy, which is the bug this module is keyed to avoid. + * + * The trap this leaves: both chains are known here now, so a stale address in + * `.env` no longer fills a gap, it *overrides* a correct one. Changing + * `EVM_CHAIN_ID` while leaving the address variables set from the previous chain + * silently points the new target at the old chain's proxy. Leave them unset + * unless you are deliberately overriding. + */ +const envOverrides: EvmDeployment = { + petCore: (PETCORE_ADDRESS || undefined) as `0x${string}` | undefined, + gameLogic: (GAMELOGIC_ADDRESS || undefined) as `0x${string}` | undefined, + gameConfig: (GAMECONFIG_ADDRESS || undefined) as `0x${string}` | undefined, +}; + +export interface EvmContract { + address?: `0x${string}`; + abi: Abi; +} + +export interface EvmContracts { + petCore: EvmContract; + gameLogic: EvmContract; + gameConfig: EvmContract; +} + +export function resolveEvmDeployment(chainId: number): EvmDeployment { + const base = DEPLOYMENTS[chainId] ?? {}; + if (chainId !== TARGET_CHAIN_ID) return base; + return { + petCore: envOverrides.petCore ?? base.petCore, + gameLogic: envOverrides.gameLogic ?? base.gameLogic, + gameConfig: envOverrides.gameConfig ?? base.gameConfig, + }; +} + +/** + * True when a chain has the two contracts the app cannot work without. + * GameConfig is read-only fee/cooldown display, so its absence degrades rather + * than blocks. + */ +export function hasEvmDeployment(chainId: number): boolean { + const d = resolveEvmDeployment(chainId); + return Boolean(d.petCore && d.gameLogic); +} + +/** Contract refs for one chain. Addresses may be undefined; reads stay disabled then. */ +export function evmContractsFor(chainId: number): EvmContracts { + const d = resolveEvmDeployment(chainId); + return { + petCore: { address: d.petCore, abi: petCoreAbi.abi as Abi }, + gameLogic: { address: d.gameLogic, abi: gameLogicAbi.abi as Abi }, + gameConfig: { address: d.gameConfig, abi: gameConfigAbi.abi as Abi }, + }; +} + +/** The target chain's contracts, for callers with no wallet context. */ +export const evmContracts: EvmContracts = evmContractsFor(TARGET_CHAIN_ID); diff --git a/mobile/src/chains/ethereum/gameConfigAbi.json b/mobile/src/chains/ethereum/gameConfigAbi.json new file mode 100644 index 00000000..34cc74de --- /dev/null +++ b/mobile/src/chains/ethereum/gameConfigAbi.json @@ -0,0 +1,977 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "initialOwner", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "BaseMintFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "BloodlustBpsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "cooldown", + "type": "uint256" + } + ], + "name": "BreedCooldownBaseUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "BreedFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "CunningCritCapUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "FuryDmgMultUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "FuryHpThresholdUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "cap", + "type": "uint8" + } + ], + "name": "GenerationCapUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "LevelUpFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "cooldown", + "type": "uint256" + } + ], + "name": "MarriageCooldownUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint32", + "name": "level", + "type": "uint32" + } + ], + "name": "MaxLevelUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "cooldown", + "type": "uint256" + } + ], + "name": "NewbornCooldownUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "tier", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "size", + "type": "uint8" + } + ], + "name": "PoolSizeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "ttl", + "type": "uint256" + } + ], + "name": "ProposalTTLUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "SageMdefMultUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "ShellDefMultUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "StudFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "SwiftCritBonusUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "TankHpMultUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "cooldown", + "type": "uint256" + } + ], + "name": "TrainCooldownUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "TrainFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint32", + "name": "xp", + "type": "uint32" + } + ], + "name": "TrainXpUpdated", + "type": "event" + }, + { + "inputs": [], + "name": "baseMintFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "bloodlustBps", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "breedCooldownBase", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "breedFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "cunningCritCap", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "furyDmgMult", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "furyHpThreshold", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "generationCap", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "levelUpFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "marriageCooldown", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxLevel", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxNameLength", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "newbornCooldown", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "name": "poolSizes", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proposalTTL", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "sageMdefMult", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "setBaseMintFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "setBloodlustBps", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "cooldown", + "type": "uint256" + } + ], + "name": "setBreedCooldownBase", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "setBreedFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "setCunningCritCap", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "setFuryDmgMult", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "setFuryHpThreshold", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "cap", + "type": "uint8" + } + ], + "name": "setGenerationCap", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "setLevelUpFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "cooldown", + "type": "uint256" + } + ], + "name": "setMarriageCooldown", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint32", + "name": "level", + "type": "uint32" + } + ], + "name": "setMaxLevel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "cooldown", + "type": "uint256" + } + ], + "name": "setNewbornCooldown", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "tier", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "size", + "type": "uint8" + } + ], + "name": "setPoolSize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "ttl", + "type": "uint256" + } + ], + "name": "setProposalTTL", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "setSageMdefMult", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "setShellDefMult", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "setStudFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "setSwiftCritBonus", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "value", + "type": "uint16" + } + ], + "name": "setTankHpMult", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "cooldown", + "type": "uint256" + } + ], + "name": "setTrainCooldown", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "fee", + "type": "uint256" + } + ], + "name": "setTrainFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint32", + "name": "xp", + "type": "uint32" + } + ], + "name": "setTrainXp", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "shellDefMult", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "studFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "swiftCritBonus", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "tankHpMult", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "trainCooldown", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "trainFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "trainXp", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} diff --git a/mobile/src/chains/ethereum/gameLogicAbi.json b/mobile/src/chains/ethereum/gameLogicAbi.json new file mode 100644 index 00000000..a3db0342 --- /dev/null +++ b/mobile/src/chains/ethereum/gameLogicAbi.json @@ -0,0 +1,647 @@ +{ + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "previousAdmin", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "AdminChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "beacon", + "type": "address" + } + ], + "name": "BeaconUpgraded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "petId1", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "petId2", + "type": "uint256" + } + ], + "name": "BreedRandomnessRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "childId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "studFeePaidTo", + "type": "address" + } + ], + "name": "BreedSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "config", + "type": "address" + } + ], + "name": "GameConfigUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "MintRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "MintSettled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "xpGained", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "newXp", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "newLevel", + "type": "uint32" + } + ], + "name": "Trained", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "sequence", + "type": "uint64" + }, + { + "internalType": "address", + "name": "provider", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "randomNumber", + "type": "bytes32" + } + ], + "name": "_entropyCallback", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "cancelBreed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "cancelMint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "entropy", + "outputs": [ + { + "internalType": "contract IEntropyV2", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "gameConfig", + "outputs": [ + { + "internalType": "contract GameConfig", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "entropy_", + "type": "address" + }, + { + "internalType": "address", + "name": "petCore_", + "type": "address" + }, + { + "internalType": "address", + "name": "gameConfig_", + "type": "address" + }, + { + "internalType": "address", + "name": "initialOwner", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "pendingStudFees", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "petBreedRequestId", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "petCore", + "outputs": [ + { + "internalType": "contract PetCore", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proxiableUUID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId1", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "petId2", + "type": "uint256" + }, + { + "internalType": "string", + "name": "name_", + "type": "string" + } + ], + "name": "requestCreateFromDNA", + "outputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "name_", + "type": "string" + } + ], + "name": "requestMintStarter", + "outputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "gameConfig_", + "type": "address" + } + ], + "name": "setGameConfig", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "settleBreed", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "settleMint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + } + ], + "name": "train", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + } + ], + "name": "upgradeTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "withdrawStudFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} diff --git a/mobile/src/contracts/ethereumAbi.json b/mobile/src/chains/ethereum/petCoreAbi.json similarity index 60% rename from mobile/src/contracts/ethereumAbi.json rename to mobile/src/chains/ethereum/petCoreAbi.json index e39dc9f7..dc98a51c 100644 --- a/mobile/src/contracts/ethereumAbi.json +++ b/mobile/src/chains/ethereum/petCoreAbi.json @@ -1,199 +1,161 @@ { "abi": [ { - "inputs": [ - { - "internalType": "uint256", - "name": "vrfSubscriptionId", - "type": "uint256" - }, - { - "internalType": "bytes32", - "name": "vrfKeyHash", - "type": "bytes32" - }, - { - "internalType": "address", - "name": "vrfCoordinator", - "type": "address" - }, - { - "internalType": "bool", - "name": "vrfNativePayment", - "type": "bool" - } - ], + "inputs": [], "stateMutability": "nonpayable", "type": "constructor" }, { + "anonymous": false, "inputs": [ { + "indexed": false, "internalType": "address", - "name": "sender", + "name": "previousAdmin", "type": "address" }, { - "internalType": "uint256", - "name": "tokenId", - "type": "uint256" - }, - { + "indexed": false, "internalType": "address", - "name": "owner", + "name": "newAdmin", "type": "address" } ], - "name": "ERC721IncorrectOwner", - "type": "error" + "name": "AdminChanged", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "operator", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "approved", "type": "address" }, { + "indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256" } ], - "name": "ERC721InsufficientApproval", - "type": "error" + "name": "Approval", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "approver", + "name": "owner", "type": "address" - } - ], - "name": "ERC721InvalidApprover", - "type": "error" - }, - { - "inputs": [ + }, { + "indexed": true, "internalType": "address", "name": "operator", "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" } ], - "name": "ERC721InvalidOperator", - "type": "error" + "name": "ApprovalForAll", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "owner", + "name": "beacon", "type": "address" } ], - "name": "ERC721InvalidOwner", - "type": "error" + "name": "BeaconUpgraded", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "receiver", + "name": "caller", "type": "address" } ], - "name": "ERC721InvalidReceiver", - "type": "error" + "name": "CallerAuthorized", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "sender", + "name": "caller", "type": "address" } ], - "name": "ERC721InvalidSender", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "tokenId", - "type": "uint256" - } - ], - "name": "ERC721NonexistentToken", - "type": "error" + "name": "CallerRevoked", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": false, "internalType": "address", - "name": "have", - "type": "address" - }, - { - "internalType": "address", - "name": "want", + "name": "config", "type": "address" } ], - "name": "OnlyCoordinatorCanFulfill", - "type": "error" + "name": "GameConfigUpdated", + "type": "event" }, { + "anonymous": false, "inputs": [ { - "internalType": "address", - "name": "have", - "type": "address" - }, - { - "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "internalType": "address", - "name": "coordinator", - "type": "address" + "indexed": false, + "internalType": "uint8", + "name": "version", + "type": "uint8" } ], - "name": "OnlyOwnerOrCoordinator", - "type": "error" - }, - { - "inputs": [], - "name": "ZeroAddress", - "type": "error" + "name": "Initialized", + "type": "event" }, { "anonymous": false, "inputs": [ { "indexed": true, - "internalType": "address", - "name": "owner", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "approved", - "type": "address" + "internalType": "uint256", + "name": "petIdA", + "type": "uint256" }, { "indexed": true, "internalType": "uint256", - "name": "tokenId", + "name": "petIdB", "type": "uint256" } ], - "name": "Approval", + "name": "MarriageAccepted", "type": "event" }, { @@ -201,112 +163,125 @@ "inputs": [ { "indexed": true, - "internalType": "address", - "name": "owner", - "type": "address" + "internalType": "uint256", + "name": "petIdA", + "type": "uint256" }, { "indexed": true, - "internalType": "address", - "name": "operator", - "type": "address" + "internalType": "uint256", + "name": "petIdB", + "type": "uint256" }, { "indexed": false, - "internalType": "bool", - "name": "approved", - "type": "bool" + "internalType": "string", + "name": "reason", + "type": "string" } ], - "name": "ApprovalForAll", + "name": "MarriageDissolved", "type": "event" }, { "anonymous": false, "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "owner", - "type": "address" - }, { "indexed": true, "internalType": "uint256", - "name": "childId", + "name": "petIdA", "type": "uint256" }, { "indexed": true, "internalType": "uint256", - "name": "requestId", + "name": "petIdB", "type": "uint256" } ], - "name": "BreedFulfilled", + "name": "MarriageProposed", "type": "event" }, { "anonymous": false, "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "owner", - "type": "address" - }, { "indexed": true, "internalType": "uint256", - "name": "requestId", + "name": "petId", "type": "uint256" }, { "indexed": false, - "internalType": "uint256", - "name": "petId1", - "type": "uint256" + "internalType": "string", + "name": "name", + "type": "string" }, { "indexed": false, "internalType": "uint256", - "name": "petId2", + "name": "dna", "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "rarity", + "type": "uint8" } ], - "name": "BreedRandomnessRequested", + "name": "NewPet", "type": "event" }, { "anonymous": false, "inputs": [ { - "indexed": false, + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, "internalType": "address", - "name": "vrfCoordinator", + "name": "newOwner", "type": "address" } ], - "name": "CoordinatorSet", + "name": "OwnershipTransferred", "type": "event" }, { "anonymous": false, "inputs": [ { - "indexed": true, + "indexed": false, "internalType": "address", - "name": "from", + "name": "account", "type": "address" - }, + } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ { "indexed": true, - "internalType": "address", - "name": "to", - "type": "address" + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "newLevel", + "type": "uint32" } ], - "name": "OwnershipTransferRequested", + "name": "PetLevelUp", "type": "event" }, { @@ -314,25 +289,25 @@ "inputs": [ { "indexed": true, - "internalType": "address", - "name": "from", - "type": "address" + "internalType": "uint256", + "name": "petId", + "type": "uint256" }, { - "indexed": true, - "internalType": "address", - "name": "to", - "type": "address" + "indexed": false, + "internalType": "string", + "name": "newName", + "type": "string" } ], - "name": "OwnershipTransferred", + "name": "PetNameChanged", "type": "event" }, { "anonymous": false, "inputs": [ { - "indexed": false, + "indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256" @@ -378,9 +353,35 @@ "name": "Transfer", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "implementation", + "type": "address" + } + ], + "name": "Upgraded", + "type": "event" + }, { "inputs": [], - "name": "LEVEL_UP_FEE", + "name": "DNA_DIGITS", "outputs": [ { "internalType": "uint256", @@ -393,7 +394,7 @@ }, { "inputs": [], - "name": "MAX_NAME_LENGTH", + "name": "DNA_MODULUS", "outputs": [ { "internalType": "uint256", @@ -419,25 +420,31 @@ }, { "inputs": [], - "name": "acceptOwnership", - "outputs": [], - "stateMutability": "nonpayable", + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", "type": "function" }, { "inputs": [ { - "internalType": "address", - "name": "to", - "type": "address" + "internalType": "uint256", + "name": "petIdA", + "type": "uint256" }, { "internalType": "uint256", - "name": "tokenId", + "name": "petIdB", "type": "uint256" } ], - "name": "approve", + "name": "acceptMarriage", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -446,16 +453,16 @@ "inputs": [ { "internalType": "uint256", - "name": "_petId", + "name": "petId", "type": "uint256" }, { - "internalType": "uint256", - "name": "_targetId", - "type": "uint256" + "internalType": "uint32", + "name": "amount", + "type": "uint32" } ], - "name": "attack", + "name": "addXp", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -464,60 +471,66 @@ "inputs": [ { "internalType": "address", - "name": "owner", + "name": "to", "type": "address" - } - ], - "name": "balanceOf", - "outputs": [ + }, { "internalType": "uint256", - "name": "", + "name": "tokenId", "type": "uint256" } ], - "stateMutability": "view", + "name": "approve", + "outputs": [], + "stateMutability": "nonpayable", "type": "function" }, { "inputs": [ { - "internalType": "uint256", - "name": "_id1", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_id2", - "type": "uint256" + "internalType": "address", + "name": "caller", + "type": "address" } ], - "name": "battle", + "name": "authorizeCaller", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { - "inputs": [], - "name": "battleLogic", - "outputs": [ + "inputs": [ { - "internalType": "contract Battle", + "internalType": "address", "name": "", "type": "address" } ], + "name": "authorizedCallers", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], "stateMutability": "view", "type": "function" }, { - "inputs": [], - "name": "breeding", + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "balanceOf", "outputs": [ { - "internalType": "contract Breeding", + "internalType": "uint256", "name": "", - "type": "address" + "type": "uint256" } ], "stateMutability": "view", @@ -527,12 +540,25 @@ "inputs": [ { "internalType": "uint256", - "name": "_tokenId", + "name": "petIdA", + "type": "uint256" + } + ], + "name": "cancelProposal", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", "type": "uint256" }, { "internalType": "string", - "name": "_newName", + "name": "newName_", "type": "string" } ], @@ -541,19 +567,94 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petIdA", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "petIdB", + "type": "uint256" + } + ], + "name": "clearStaleMarriage", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { "internalType": "string", - "name": "_name", + "name": "name_", "type": "string" + }, + { + "internalType": "uint256", + "name": "dna", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "rarity", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "generation", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "parent1Id", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "parent2Id", + "type": "uint256" + } + ], + "name": "createPet", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" } ], - "name": "createRandom", + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + } + ], + "name": "divorce", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "gameConfig", + "outputs": [ + { + "internalType": "contract GameConfig", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -577,26 +678,50 @@ "inputs": [ { "internalType": "uint256", - "name": "_tokenId", + "name": "petId", "type": "uint256" } ], - "name": "getBattleStats", + "name": "getBreedInfo", "outputs": [ { - "internalType": "uint16", - "name": "", - "type": "uint16" + "internalType": "uint8", + "name": "generation", + "type": "uint8" }, { - "internalType": "uint16", - "name": "", - "type": "uint16" + "internalType": "uint8", + "name": "breedCount", + "type": "uint8" }, { "internalType": "uint256", - "name": "", + "name": "parent1Id", "type": "uint256" + }, + { + "internalType": "uint256", + "name": "parent2Id", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner_", + "type": "address" + } + ], + "name": "getByOwner", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" } ], "stateMutability": "view", @@ -606,11 +731,11 @@ "inputs": [ { "internalType": "uint256", - "name": "_tokenId", + "name": "petId", "type": "uint256" } ], - "name": "getById", + "name": "getPet", "outputs": [ { "components": [ @@ -635,24 +760,189 @@ "type": "uint32" }, { - "internalType": "uint16", - "name": "winCount", - "type": "uint16" + "internalType": "uint8", + "name": "rarity", + "type": "uint8" }, { - "internalType": "uint16", - "name": "lossCount", - "type": "uint16" + "internalType": "uint32", + "name": "xp", + "type": "uint32" }, { "internalType": "uint8", - "name": "rarity", + "name": "generation", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "breedCount", "type": "uint8" + }, + { + "internalType": "uint32", + "name": "breedReadyAt", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "trainReadyAt", + "type": "uint32" + }, + { + "internalType": "uint16", + "name": "speciesId", + "type": "uint16" + }, + { + "internalType": "uint256", + "name": "parent1Id", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "parent2Id", + "type": "uint256" } ], - "internalType": "struct Inventory.Pet", + "internalType": "struct PetCore.Pet", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + } + ], + "name": "getPetStats", + "outputs": [ + { + "internalType": "uint32", + "name": "level", + "type": "uint32" + }, + { + "internalType": "uint8", + "name": "rarity", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + } + ], + "name": "incrementBreedCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "incrementWalletMintCount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "gameConfig_", + "type": "address" + }, + { + "internalType": "address", + "name": "initialOwner", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + } + ], + "name": "isBreedReady", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petIdA", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "petIdB", + "type": "uint256" + } + ], + "name": "isMarriageValid", + "outputs": [ + { + "internalType": "bool", "name": "", - "type": "tuple" + "type": "bool" } ], "stateMutability": "view", @@ -661,17 +951,17 @@ { "inputs": [ { - "internalType": "address", - "name": "owner", - "type": "address" + "internalType": "uint256", + "name": "petId", + "type": "uint256" } ], - "name": "getByOwner", + "name": "isReady", "outputs": [ { - "internalType": "uint256[]", + "internalType": "bool", "name": "", - "type": "uint256[]" + "type": "bool" } ], "stateMutability": "view", @@ -681,57 +971,48 @@ "inputs": [ { "internalType": "uint256", - "name": "_tokenId", + "name": "petId", "type": "uint256" } ], - "name": "getStats", + "name": "isTrainReady", "outputs": [ { - "internalType": "uint32", - "name": "", - "type": "uint32" - }, - { - "internalType": "uint16", - "name": "", - "type": "uint16" - }, - { - "internalType": "uint16", - "name": "", - "type": "uint16" - }, - { - "internalType": "uint8", + "internalType": "bool", "name": "", - "type": "uint8" + "type": "bool" } ], "stateMutability": "view", "type": "function" }, { - "inputs": [], - "name": "getTotalCount", - "outputs": [ + "inputs": [ { "internalType": "uint256", - "name": "", + "name": "tokenId", "type": "uint256" } ], - "stateMutability": "view", + "name": "levelUp", + "outputs": [], + "stateMutability": "payable", "type": "function" }, { - "inputs": [], - "name": "inventory", + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "marriageCooldownUntil", "outputs": [ { - "internalType": "contract Inventory", + "internalType": "uint256", "name": "", - "type": "address" + "type": "uint256" } ], "stateMutability": "view", @@ -740,22 +1021,51 @@ { "inputs": [ { - "internalType": "address", - "name": "owner", - "type": "address" + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "marriageOf", + "outputs": [ + { + "internalType": "uint256", + "name": "spouseId", + "type": "uint256" }, { "internalType": "address", - "name": "operator", + "name": "ownerSnapshot", "type": "address" } ], - "name": "isApprovedForAll", - "outputs": [ + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ { - "internalType": "bool", + "internalType": "uint256", "name": "", - "type": "bool" + "type": "uint256" + } + ], + "name": "marriageProposal", + "outputs": [ + { + "internalType": "uint256", + "name": "petIdB", + "type": "uint256" + }, + { + "internalType": "address", + "name": "proposer", + "type": "address" + }, + { + "internalType": "uint256", + "name": "expiry", + "type": "uint256" } ], "stateMutability": "view", @@ -763,15 +1073,20 @@ }, { "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, { "internalType": "uint256", - "name": "_tokenId", + "name": "tokenId", "type": "uint256" } ], - "name": "levelUp", + "name": "mintTo", "outputs": [], - "stateMutability": "payable", + "stateMutability": "nonpayable", "type": "function" }, { @@ -820,19 +1135,20 @@ "type": "function" }, { - "inputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "name": "petBreedRequestId", + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", "outputs": [ { - "internalType": "uint256", + "internalType": "bool", "name": "", - "type": "uint256" + "type": "bool" } ], "stateMutability": "view", @@ -842,60 +1158,51 @@ "inputs": [ { "internalType": "uint256", - "name": "requestId", + "name": "petIdA", "type": "uint256" }, { - "internalType": "uint256[]", - "name": "randomWords", - "type": "uint256[]" + "internalType": "uint256", + "name": "petIdB", + "type": "uint256" } ], - "name": "rawFulfillRandomWords", + "name": "proposeMarriage", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { - "inputs": [ - { - "internalType": "uint256", - "name": "_petId1", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "_petId2", - "type": "uint256" - }, - { - "internalType": "string", - "name": "_name", - "type": "string" - } - ], - "name": "requestCreateFromDNA", + "inputs": [], + "name": "proxiableUUID", "outputs": [ { - "internalType": "uint256", - "name": "requestId", - "type": "uint256" + "internalType": "bytes32", + "name": "", + "type": "bytes32" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { "inputs": [], - "name": "s_vrfCoordinator", - "outputs": [ + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ { - "internalType": "contract IVRFCoordinatorV2Plus", - "name": "", + "internalType": "address", + "name": "caller", "type": "address" } ], - "stateMutability": "view", + "name": "revokeCaller", + "outputs": [], + "stateMutability": "nonpayable", "type": "function" }, { @@ -967,15 +1274,33 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "cooldownSeconds", + "type": "uint256" + } + ], + "name": "setCooldown", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { "internalType": "address", - "name": "_vrfCoordinator", + "name": "gameConfig_", "type": "address" } ], - "name": "setCoordinator", + "name": "setGameConfig", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -1031,6 +1356,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "totalPets", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -1058,7 +1396,7 @@ "inputs": [ { "internalType": "address", - "name": "to", + "name": "newOwner", "type": "address" } ], @@ -1067,16 +1405,91 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "cooldownSeconds", + "type": "uint256" + } + ], + "name": "triggerBreedCooldown", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "petId", + "type": "uint256" + } + ], + "name": "triggerTrainCooldown", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [], - "name": "utils", - "outputs": [ + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + } + ], + "name": "upgradeTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newImplementation", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "upgradeToAndCall", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ { - "internalType": "contract Utils", + "internalType": "address", "name": "", "type": "address" } ], + "name": "walletMintCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], "stateMutability": "view", "type": "function" }, @@ -1088,4 +1501,4 @@ "type": "function" } ] -} \ No newline at end of file +} diff --git a/mobile/src/components/AccountSheet.tsx b/mobile/src/components/AccountSheet.tsx new file mode 100644 index 00000000..feed6d5e --- /dev/null +++ b/mobile/src/components/AccountSheet.tsx @@ -0,0 +1,385 @@ +import React, { useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, + type LayoutChangeEvent, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useAppKit } from '@reown/appkit-react-native'; +import { useAccount } from 'wagmi'; +import { useAuth } from '@shared/core'; + +import NativeBalance from './NativeBalance'; +import { usePanelTransition } from '../hooks/usePanelTransition'; +import { neon, neonGlow } from '../theme/neon'; + +const truncate = (addr: string): string => `${addr.slice(0, 6)}...${addr.slice(-4)}`; + +/** Stand-in slide distance for the one frame before the sheet has been measured. */ +const CLOSED_FALLBACK = 800; + +/** + * The wallet surface for the tab shell, mirroring frontend's `account-dropdown`. + * + * It exists for one thing AppKit's own modal cannot do: backend sign-in. Auth is + * nonce → wallet signature → JWT, and every backend-served read (opponents, + * taunts, battle rooms, the battle mutation) needs that token. Before this, the + * header's only control opened AppKit's wallet modal, so a player who connected + * went straight into the tab shell with no way to sign in — `ConnectButton`'s + * auth actions only ever rendered on `LandingScreen`, which the navigator + * unregisters the moment a wallet connects. + * + * Wallet-level concerns stay with AppKit rather than being reimplemented: the + * Wallet button opens its modal. + * + * Navigation is no longer here. This sheet used to carry five rows to Allow Challenges, + * Marriage, Leaderboard, Messages and Inventory, which made the wallet control the only way + * to reach half the app — the address you are connected with and where you want to go are + * not the same question. They are `AppDrawer` now. + */ +export default function AccountSheet() { + const { open, disconnect } = useAppKit(); + const { address } = useAccount(); + const { isAuthenticated, signAndLogin, logout, isSigning, isVerifying, isNonceLoading } = + useAuth(); + const { isVisible, progress: entry, open: openSheet, close, reduceMotion } = + usePanelTransition(); + + /** Trigger press feedback. Kept apart from `entry` so a press never touches the sheet. */ + const pressScale = useRef(new Animated.Value(1)).current; + + const pressTrigger = (toValue: number) => { + if (reduceMotion) { + return; + } + Animated.spring(pressScale, { + toValue, + useNativeDriver: true, + stiffness: 400, + damping: 30, + mass: 0.5, + }).start(); + }; + + const insets = useSafeAreaInsets(); + + /** + * How far down the sheet sits when closed, which is its own height: anchored to the bottom + * edge, translating by that much puts it exactly off-screen. + * + * Measured rather than assumed, because the sheet's height depends on whether the player + * is signed in and whether an auth stage is showing. The fallback covers the frame before + * layout runs and is deliberately taller than the sheet can be, since starting too far + * down is invisible and starting too little shows a band of it at rest. + */ + const [sheetHeight, setSheetHeight] = useState(0); + const onSheetLayout = (event: LayoutChangeEvent) => + setSheetHeight(event.nativeEvent.layout.height); + + const isAuthPending = isNonceLoading || isSigning || isVerifying; + const authLabel = isNonceLoading + ? 'Getting nonce...' + : isSigning + ? 'Approve the signature in your wallet...' + : isVerifying + ? 'Verifying...' + : 'Sign message & login'; + + const rise = entry.interpolate({ + inputRange: [0, 1], + outputRange: [sheetHeight || CLOSED_FALLBACK, 0], + }); + + return ( + + + pressTrigger(0.96)} + onPressOut={() => pressTrigger(1)} + accessibilityRole="button" + accessibilityLabel="Account" + activeOpacity={0.85} + > + + {address ? truncate(address) : 'Connected'} + + + + + + + + {/* + * The backdrop fades with the panel rather than being painted on at once: + * at 88% black, appearing instantly is the whole screen blinking out. + */} + + + + {/* + * No opacity on the panel. It is anchored to the bottom edge and slides by + * its own height, so it is already off-screen when closed; fading as well + * would only make a half-open sheet translucent, which a bottom sheet is + * not. + */} + + {/* The grab handle is what says "this came up from the bottom edge and + goes back down there". */} + + + + Account + + × + + + + {address ? ( + <> + Address + {/* + * `selectable` gives the native long-press copy. + * RN core's `Clipboard` is deprecated and warns on + * every call, and its replacement is a native + * module — a rebuild and the pnpm/Metro install + * trap for one button. + */} + + {address} + + + ) : null} + + Balance + + + + {isAuthenticated ? ( + { + logout(); + close(); + }} + > + Logout + + ) : ( + signAndLogin()} + disabled={isAuthPending} + > + {isAuthPending ? ( + + + {authLabel} + + ) : ( + {authLabel} + )} + + )} + + { + close(); + open(); + }} + > + Wallet + + + { + close(); + disconnect(); + }} + > + Disconnect + + + + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + alignSelf: 'center', + }, + trigger: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: neon.bgCard, + borderRadius: 12, + minWidth: 120, + justifyContent: 'center', + borderWidth: 1, + borderColor: neon.magenta, + ...neonGlow(neon.magenta, 10, 0.35), + }, + triggerText: { + color: neon.magenta, + fontSize: 12, + fontWeight: '700', + }, + triggerArrow: { + fontSize: 9, + color: neon.magenta, + marginLeft: 6, + }, + modalRoot: { + flex: 1, + justifyContent: 'flex-end', + }, + backdrop: { + backgroundColor: 'rgba(5, 5, 13, 0.88)', + }, + sheet: { + zIndex: 2, + backgroundColor: neon.bgPanel, + // Top corners only: the bottom two are off the screen edge, and rounding them leaves + // two slivers of scrim in the corners of the display. + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingHorizontal: 20, + paddingTop: 12, + borderTopWidth: 1, + borderColor: neon.border, + ...neonGlow(neon.purple, 14, 0.35), + }, + grabber: { + alignSelf: 'center', + width: 40, + height: 4, + borderRadius: 2, + backgroundColor: neon.textDim, + marginBottom: 12, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, + title: { + fontSize: 18, + fontWeight: '800', + color: neon.text, + flex: 1, + letterSpacing: 0.3, + }, + closeBtn: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + closeBtnText: { + fontSize: 24, + color: neon.magenta, + lineHeight: Platform.OS === 'ios' ? 28 : 24, + }, + label: { + fontSize: 11, + fontWeight: '800', + color: neon.textDim, + letterSpacing: 1, + textTransform: 'uppercase', + marginTop: 12, + marginBottom: 4, + }, + address: { + fontSize: 13, + color: neon.textMuted, + fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace' }), + }, + actions: { + marginTop: 20, + }, + action: { + backgroundColor: neon.bgCard, + borderRadius: 12, + paddingVertical: 12, + alignItems: 'center', + justifyContent: 'center', + marginTop: 8, + borderWidth: 1, + borderColor: neon.cyan, + ...neonGlow(neon.cyan, 8, 0.35), + }, + actionInner: { + flexDirection: 'row', + alignItems: 'center', + }, + spinner: { + marginRight: 8, + }, + actionText: { + color: neon.cyan, + fontSize: 15, + fontWeight: '800', + }, + secondary: { + borderColor: neon.purple, + ...neonGlow(neon.purple, 6, 0.15), + }, + secondaryText: { + color: neon.purple, + fontSize: 15, + fontWeight: '700', + }, + danger: { + borderColor: neon.danger, + ...neonGlow(neon.danger, 8, 0.3), + }, + dangerText: { + color: neon.danger, + fontSize: 15, + fontWeight: '800', + }, + disabled: { + opacity: 0.55, + }, +}); diff --git a/mobile/src/components/AppDrawer.tsx b/mobile/src/components/AppDrawer.tsx new file mode 100644 index 00000000..00ce4ea0 --- /dev/null +++ b/mobile/src/components/AppDrawer.tsx @@ -0,0 +1,214 @@ +import React, { useMemo } from 'react'; +import { + Animated, + Modal, + PanResponder, + Pressable, + StyleSheet, + Text, + TouchableOpacity, + useWindowDimensions, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { shouldCloseFromDrag, useDrawer } from './DrawerHost'; +import { DRAWER_ITEMS, STACK_TITLES, type RootStackParamList } from '../navigation/routes'; +import { neon, neonGlow } from '../theme/neon'; + +/** + * The five account-level destinations, as a drawer rather than rows in the wallet sheet. + * + * They were already a group — the navigator had them as `FROM_ACCOUNT_SHEET` — so this moves + * an existing group to a surface that is about going somewhere, and leaves `AccountSheet` + * doing one thing. `Rename` and `Equip` stay out: both act on a pet you picked by tapping its + * card, and a menu cannot ask which one. + * + * Built on `Animated` rather than `@react-navigation/drawer`. That package needs + * `react-native-gesture-handler` and `react-native-reanimated`, both native modules on a bare + * RN 0.82 app, so adopting it means a Gradle rebuild, a `pod install`, a babel plugin that + * must be ordered last, and a reanimated release that actually targets 0.82 — this project + * has hit the 0.82 ceiling before. What that buys is edge-swipe-to-open, which is also the + * gesture most likely to fight the horizontal pagers in Gallery and Marriage. Five + * destinations reached from a button do not need it. The edge swipe arrived later anyway, + * as `DrawerHost`, which owns the open state so the button is not the only way in. + */ +export default function AppDrawer() { + const navigation = useNavigation>(); + const { isVisible, progress, open, close } = useDrawer(); + const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); + + // Never the full width: the strip of scrim left showing is what says the screen behind is + // still there and a tap outside will bring it back. + const panelWidth = Math.min(320, width * 0.82); + + /** + * Push, then close — in that order. + * + * Closing first shows the screen you came from through the closing drawer, because the + * push has not painted yet. Navigating first puts the destination behind the drawer + * before it starts to leave, so what is revealed is where you are going. The Modal is its + * own native window above the navigator, so the push is invisible until then. + */ + const go = (route: keyof RootStackParamList) => { + navigation.navigate(route as never); + close(); + }; + + /** + * Pushing the panel back to the left closes it, the mirror of the swipe that opened it. + * + * Claimed in the capture phase so it wins against the rows underneath. That costs nothing + * a tap needs: capture only fires once a touch has moved, and a tap has not moved. + */ + const dismiss = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponderCapture: shouldCloseFromDrag, + onPanResponderGrant: close, + }), + [close], + ); + + const slide = progress.interpolate({ inputRange: [0, 1], outputRange: [-panelWidth, 0] }); + + return ( + <> + + + + + + + + + + + + Menu + + × + + + + {DRAWER_ITEMS.map((route) => ( + go(route)} + activeOpacity={0.85} + > + {STACK_TITLES[route]} + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + trigger: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + /* + * Nudged down against the header title rather than centred on the row. + * + * The row centres on the full line box of 28px text, but the glyph reads against the + * title's cap height, which sits lower than that box. Centring by geometry therefore + * looks high. The `hitSlop` on the control absorbs the offset, so the tap target does + * not move with it. + */ + marginTop: 6, + }, + triggerGlyph: { + fontSize: 22, + color: neon.cyan, + textShadowColor: neon.cyan, + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 8, + }, + scrim: { + backgroundColor: 'rgba(5, 5, 13, 0.88)', + }, + panel: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + backgroundColor: neon.bgPanel, + borderRightWidth: 1, + borderRightColor: neon.border, + paddingHorizontal: 16, + ...neonGlow(neon.purple, 16, 0.35), + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 12, + }, + title: { + flex: 1, + fontSize: 18, + fontWeight: '800', + color: neon.text, + letterSpacing: 0.3, + }, + closeBtn: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + closeBtnText: { + fontSize: 24, + color: neon.magenta, + }, + row: { + backgroundColor: neon.bgCard, + borderRadius: 12, + paddingVertical: 14, + paddingHorizontal: 14, + marginTop: 8, + borderWidth: 1, + borderColor: neon.purple, + ...neonGlow(neon.purple, 6, 0.15), + }, + rowText: { + color: neon.purple, + fontSize: 15, + fontWeight: '700', + }, +}); diff --git a/mobile/src/components/AppHeader.tsx b/mobile/src/components/AppHeader.tsx new file mode 100644 index 00000000..7134fe48 --- /dev/null +++ b/mobile/src/components/AppHeader.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useAccount } from 'wagmi'; + +import AccountSheet from './AccountSheet'; +import AppDrawer from './AppDrawer'; +import EthereumNetworkSwitcher from './EthereumNetworkSwitcher'; +import NetworkGate from './NetworkGate'; +import { neon, neonGlow } from '../theme/neon'; + +/** + * Sits above the tab shell, carrying the wallet controls that frontend keeps in + * its sidebar. Rendered once around the navigator rather than per screen, so it + * does not remount on every tab change. + * + * `NetworkGate` lives here for that reason: it owns the session repair in + * `useEvmSessionChain`, which must run once for the whole shell rather than + * restarting on every tab change. It renders nothing while the network is fine. + */ +export default function AppHeader() { + const { isConnected } = useAccount(); + const insets = useSafeAreaInsets(); + + return ( + + {/* + * The spacer is the same width as the drawer's button, so the title stays + * centred on the screen rather than on what is left of the row. + */} + + + Do Not Stop + + + {isConnected ? ( + + + + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + header: { + backgroundColor: neon.bgPanel, + borderBottomWidth: 1, + borderBottomColor: neon.border, + paddingHorizontal: 16, + paddingBottom: 16, + ...neonGlow(neon.cyan, 8, 0.2), + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + }, + titleSpacer: { + width: 40, + }, + headerTitle: { + flex: 1, + fontSize: 28, + fontWeight: '800', + textAlign: 'center', + color: neon.text, + letterSpacing: 2, + textShadowColor: neon.cyan, + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 12, + }, + walletRow: { + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + flexWrap: 'wrap', + }, +}); diff --git a/mobile/src/components/ConnectButton.tsx b/mobile/src/components/ConnectButton.tsx index df0afe96..01aa1807 100644 --- a/mobile/src/components/ConnectButton.tsx +++ b/mobile/src/components/ConnectButton.tsx @@ -2,14 +2,17 @@ import React from 'react'; import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Platform } from 'react-native'; import { useAppKit } from '@reown/appkit-react-native'; import { useAccount } from 'wagmi'; -import { shortAddress, useAuth } from '@shared/core'; +import { useAuth } from '@shared/core'; import { neon, neonGlow } from '../theme/neon'; -interface ConnectButtonProps { - compact?: boolean; -} - -export default function ConnectButton({ compact = false }: ConnectButtonProps = {}) { +/** + * Pre-connect and post-connect wallet panel for `LandingScreen`. + * + * The compact variant is gone: inside the tab shell `AccountSheet` carries the + * same actions, and this one only ever renders on the landing screen, which the + * navigator unregisters once a wallet connects. + */ +export default function ConnectButton() { const { open, disconnect } = useAppKit(); const { address, isConnected, chainId } = useAccount(); const { @@ -24,7 +27,12 @@ export default function ConnectButton({ compact = false }: ConnectButtonProps = if (!isConnected) { return ( - open()}> + open()} + > Connect Wallet ); @@ -32,15 +40,15 @@ export default function ConnectButton({ compact = false }: ConnectButtonProps = const isLoading = isSigning || isVerifying || isNonceLoading; - if (compact) { - return ( - open()}> - - {address ? shortAddress(address) : 'Connected'} - - - ); - } + // if (compact) { + // return ( + // open()}> + // + // {address ? shortAddress(address) : 'Connected'} + // + // + // ); + // } return ( @@ -126,23 +134,6 @@ const styles = StyleSheet.create({ fontWeight: '800', letterSpacing: 1, }, - compactButton: { - paddingHorizontal: 12, - paddingVertical: 8, - backgroundColor: neon.bgCard, - borderRadius: 12, - minWidth: 120, - alignItems: 'center', - justifyContent: 'center', - borderWidth: 1, - borderColor: neon.magenta, - ...neonGlow(neon.magenta, 10, 0.35), - }, - compactButtonText: { - color: neon.magenta, - fontSize: 12, - fontWeight: '700', - }, container: { padding: 16, backgroundColor: neon.bgPanel, diff --git a/mobile/src/components/CreatePetModal.tsx b/mobile/src/components/CreatePetModal.tsx index cc17bf43..4a84e1e8 100644 --- a/mobile/src/components/CreatePetModal.tsx +++ b/mobile/src/components/CreatePetModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { ActivityIndicator, KeyboardAvoidingView, @@ -12,57 +12,59 @@ import { useWindowDimensions, View, } from 'react-native'; +import { parseContractError, type CreatePetArgs, type PetMutationResult } from '@shared/core'; import { neon, neonGlow } from '../theme/neon'; +/** + * A wallet refusal and a prompt that never arrived report identically (code 4001), and + * on a phone the second is common: MetaMask sitting on an update or onboarding screen + * never renders the request, then denies it. So the wording covers both rather than + * telling a player they cancelled something they were never shown. + */ +const REJECTED_HINT = + 'Cancelled in your wallet. If you never saw a prompt, open MetaMask, clear any update screen, and try again.'; + type Props = { visible: boolean; onClose: () => void; - isContractConfigured: boolean; - createRandomPet: (name: string) => void; - isWritePending: boolean; - writeError: Error | null | undefined; - isConfirming: boolean; - txHash: `0x${string}` | undefined; + createPet: PetMutationResult; }; -export default function CreatePetModal({ - visible, - onClose, - isContractConfigured, - createRandomPet, - isWritePending, - writeError, - isConfirming, - txHash, -}: Props) { +export default function CreatePetModal({ visible, onClose, createPet }: Props) { + const { mutate, isPending, error, hash, isAwaitingFulfillment, isSettling, reset } = createPet; const [name, setName] = useState(''); - const prevTxHash = useRef(undefined); + // Only the refusal case is rewritten. A revert reason or a gas failure is worth + // showing verbatim, because it says something specific about why this mint failed; + // a rejection dump says nothing the player can act on. + const isUserRejection = error ? parseContractError(error).isUserRejection : false; const { width } = useWindowDimensions(); const cardWidth = Math.min(400, width - 48); + // Opening the sheet clears the previous attempt. Keyed on `visible` alone: + // the adapter rebuilds its `lifecycle` object every render, so `reset` is a + // new identity each time, and depending on it would re-run this effect on + // every render, whose own `reset()` renders again. That loop is what + // "Maximum update depth exceeded" was. useEffect(() => { if (visible) { setName(''); + reset(); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- open-only reset }, [visible]); - useEffect(() => { - if (prevTxHash.current && !txHash) { - setName(''); - onClose(); - } - prevTxHash.current = txHash; - }, [txHash, onClose]); - - const busy = isWritePending || isConfirming; - const canSubmit = isContractConfigured && name.trim().length > 0 && !busy; + // EVM minting spans three waits: the request tx, Pyth Entropy revealing, then + // the settle tx. All of them mean "keep the sheet locked and spinning". + const busy = isPending || isAwaitingFulfillment === true || isSettling === true; + const canSubmit = name.trim().length > 0 && !busy; const handleSubmit = () => { const trimmed = name.trim(); if (!trimmed) { return; } - createRandomPet(trimmed); + // `mutate` captures its own errors into `error`, so it never rejects. + mutate({ name: trimmed }); }; return ( @@ -114,6 +116,8 @@ export default function CreatePetModal({ /> @@ -121,21 +125,29 @@ export default function CreatePetModal({ - {isWritePending ? 'Confirm in wallet…' : 'Confirming…'} + {isPending + ? 'Confirm in wallet…' + : isAwaitingFulfillment + ? 'Rolling traits…' + : 'Minting…'} ) : ( Create pet )} - {writeError ? ( + {error ? ( - {writeError instanceof Error ? writeError.message : String(writeError)} + {isUserRejection + ? REJECTED_HINT + : error instanceof Error + ? error.message + : String(error)} ) : null} - {txHash && !writeError ? ( + {hash && !error ? ( - {isConfirming ? 'Transaction submitted…' : 'Done — refreshing list…'} + {busy ? 'Transaction submitted…' : 'Done — refreshing list…'} ) : null} diff --git a/mobile/src/components/DrawerHost.tsx b/mobile/src/components/DrawerHost.tsx new file mode 100644 index 00000000..f6266876 --- /dev/null +++ b/mobile/src/components/DrawerHost.tsx @@ -0,0 +1,125 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { + PanResponder, + StyleSheet, + View, + type GestureResponderEvent, + type PanResponderGestureState, +} from 'react-native'; + +import { usePanelTransition, type PanelTransition } from '../hooks/usePanelTransition'; + +/** + * How far in from the left edge a drag has to start to count as reaching for the drawer. + * + * Narrow on purpose. Everything inside this strip is taken away from whatever is underneath, + * and what is underneath on two screens is a horizontal pager, so a generous edge would make + * the first swipe of a pet gallery open the menu instead. + */ +const EDGE_WIDTH = 24; + +/** + * How far a drag has to travel to count, in either direction. "A little", but more than a + * thumb resting on the bezel or the wobble in a tap. + */ +const SWIPE_THRESHOLD = 12; + +/** + * How much more horizontal than vertical the drag has to be. + * + * Without this, a vertical scroll that starts near the edge and wanders right by 12px opens + * the drawer mid-scroll. Every screen here scrolls vertically, so that is the common case, + * not the rare one. + */ +const HORIZONTAL_BIAS = 2; + +/** + * Whether a drag is a reach for the drawer. + * + * Exported and pure so it can be checked directly. Driving it through `PanResponder` in a + * test means synthesising a touch history for RN to derive a gesture state from, which tests + * RN rather than this rule. + */ +export const shouldOpenFromEdge = ( + event: GestureResponderEvent, + gesture: PanResponderGestureState, +): boolean => { + // Where the finger went down. `gesture.x0` is only meaningful once the responder has been + // granted, which is the thing being decided here. + const startX = event.nativeEvent.pageX - gesture.dx; + if (startX > EDGE_WIDTH) { + return false; + } + return gesture.dx > SWIPE_THRESHOLD && gesture.dx > Math.abs(gesture.dy) * HORIZONTAL_BIAS; +}; + +/** + * Whether a drag on the open drawer is a push to send it back. + * + * The mirror of `shouldOpenFromEdge`, and it has to be a separate responder rather than the + * same one reading the open state: the drawer is a `Modal`, which is its own native window, + * so the host's surface is behind it and never sees the touch. This one lives on the panel. + * + * No edge test, because the panel is the surface being pushed and the whole of it should + * answer. The direction and the horizontal bias still apply. + */ +export const shouldCloseFromDrag = ( + _event: GestureResponderEvent, + gesture: PanResponderGestureState, +): boolean => + gesture.dx < -SWIPE_THRESHOLD && -gesture.dx > Math.abs(gesture.dy) * HORIZONTAL_BIAS; + +const DrawerContext = createContext(null); + +/** + * The drawer's open state, for the two things that open it: the header button and the edge + * swipe. It lived inside `AppDrawer` while the button was the only way in. + */ +export const useDrawer = (): PanelTransition => { + const drawer = useContext(DrawerContext); + if (!drawer) { + throw new Error('useDrawer must be used inside DrawerHost'); + } + return drawer; +}; + +/** + * Owns the drawer's open state and the edge swipe that opens it, for everything it wraps. + * + * The gesture is claimed in the **capture** phase, which is the only way it can win against a + * child that also wants horizontal drags. `Carousel` is exactly that child, so the predicate + * has to be strict enough to be wrong rarely: the drag must start within `EDGE_WIDTH` of the + * left edge, travel `OPEN_THRESHOLD` to the right, and be `HORIZONTAL_BIAS` times more + * horizontal than vertical. Fail any one and the touch is left alone, so taps and scrolls + * that begin near the edge behave as they always did. + * + * Opening on grant rather than on release is deliberate: the drawer is meant to answer a + * small movement, and waiting for the finger to lift makes a swipe feel like a tap. + */ +export default function DrawerHost({ children }: { children: React.ReactNode }) { + const drawer = usePanelTransition(); + const { open } = drawer; + + const responder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponderCapture: shouldOpenFromEdge, + onPanResponderGrant: open, + }), + [open], + ); + + return ( + + + {children} + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, +}); diff --git a/mobile/src/components/EquippedBadges.tsx b/mobile/src/components/EquippedBadges.tsx new file mode 100644 index 00000000..3c595d4e --- /dev/null +++ b/mobile/src/components/EquippedBadges.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Image, StyleSheet, Text, View } from 'react-native'; +import { IMAGE_SERVICE_URL } from '@env'; +import { getRarityColor, itemArtUrl, type EquippedItem } from '@shared/core'; + +import { neon } from '../theme/neon'; + +type Props = { + /** Undefined until the batched read lands; empty for a bare pet. */ + equipped: readonly EquippedItem[] | undefined; + /** + * The **pet's** rarity, not each item's, matching frontend. + * + * So a pet's gear reads as one set belonging to that pet rather than three chips + * arguing with each other and with the card's own rarity stripe. The trade is real: a + * legendary sword and a common vest look alike here, and an item's own tier is only + * visible in the inventory, where it is what the screen is about. + */ + rarity: number; + size?: number; +}; + +/** + * A pet's gear, as small icons under its art. + * + * Gear changes what a pet does in a fight, and mobile had no way to see any of it short of + * opening the equip screen one pet at a time — `usePetEquipmentForPets` existed with no + * caller at all. On a gallery card it is the difference between "that pet is stronger" + * being visible and being a surprise mid-battle. + * + * Laid out in a row rather than pinned over the art, unlike frontend. There it is absolutely + * positioned inside the art's own container; here the card gives art a fixed 64px box beside + * the text, and overlaying 18px icons on that would cover the pet rather than sit beside it. + * + * Nothing renders for a bare pet. Most pets have no gear, and a placeholder on every card + * would cost more attention than the feature is worth. + */ +export default function EquippedBadges({ equipped, rarity, size = 18 }: Props) { + if (!equipped || equipped.length === 0) return null; + + // By slot, so icons do not reshuffle between renders or between cards. Sorted on the + // number itself rather than through a lookup, which would rank an unknown fourth slot + // ahead of the weapon instead of after the trinket. + const ordered = [...equipped].sort((a, b) => a.slot - b.slot); + const tint = getRarityColor(rarity); + + return ( + entry.item.name).join(', ')}`} + > + {ordered.map((entry) => { + const uri = itemArtUrl(entry.item.itemType, IMAGE_SERVICE_URL); + const box = { width: size, height: size, borderColor: tint }; + return ( + + {uri ? ( + + ) : ( + // No image service configured: the rarity-tinted square still + // says a slot is filled, which is the part that matters. + + {entry.item.name.slice(0, 1)} + + )} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + strip: { + flexDirection: 'row', + marginTop: 6, + }, + badge: { + borderWidth: 1, + borderRadius: 5, + backgroundColor: neon.bgPanel, + alignItems: 'center', + justifyContent: 'center', + marginRight: 4, + overflow: 'hidden', + }, + image: { + width: '100%', + height: '100%', + }, + initial: { + fontSize: 10, + fontWeight: '800', + }, +}); diff --git a/mobile/src/components/EthereumNetworkSwitcher.tsx b/mobile/src/components/EthereumNetworkSwitcher.tsx index 144dd95d..d1483878 100644 --- a/mobile/src/components/EthereumNetworkSwitcher.tsx +++ b/mobile/src/components/EthereumNetworkSwitcher.tsx @@ -5,41 +5,42 @@ import { Text, Pressable, StyleSheet, - Switch, useWindowDimensions, Platform, } from 'react-native'; import { useAccount, useSwitchChain } from 'wagmi'; -import { getEvmNetworkMeta, getEvmSwitcherChains, EVM_SWITCHER_CHAINS } from '../constants/ethereumNetworks'; -import { neon, neonGlow } from '../theme/neon'; +import { CHAINS, getChainConfig } from '../constants/ethereumNetworks'; +import { alpha, neon, neonGlow } from '../theme/neon'; /** - * Mirrors the web `EthereumNetworkSwitcher` (compact trigger + “Select Network” modal, - * testnet toggle, chain rows with active checkmark). + * Mirrors the web `EthereumNetworkSwitcher`: compact trigger, “Select Network” + * modal, chain rows with an active checkmark. + * + * Lists `CHAINS` — the chains with a deployment — so it can no longer put a + * player somewhere `isSupportedChain` rejects. That also retires the testnet + * toggle: every playable chain is a testnet, so switching it off emptied the + * list. */ export default function EthereumNetworkSwitcher() { - const { chain } = useAccount(); + // Keyed off `chainId` (the raw connected id, defined even on networks the app + // does not support) rather than `chain` (only set for configured chains). + // Keying off `chain` hid this control exactly when a player needed it to get + // back to a supported network — the same bug frontend fixed. + const { chainId, isConnected } = useAccount(); const { switchChain, isPending, error: switchError } = useSwitchChain(); const [isOpen, setIsOpen] = useState(false); - const [showTestnets, setShowTestnets] = useState(() => { - if (!chain) { - return false; - } - return EVM_SWITCHER_CHAINS.some((c) => c.chain.id === chain.id && c.isTestnet); - }); const { width } = useWindowDimensions(); const modalWidth = Math.min(400, width - 40); - if (!chain) { + if (!isConnected) { return null; } - const visibleChains = getEvmSwitcherChains(showTestnets); - const currentMeta = getEvmNetworkMeta(chain.id); + const currentMeta = chainId === undefined ? undefined : getChainConfig(chainId); - const handleNetworkSelect = (chainId: number) => { - switchChain({ chainId }); + const handleNetworkSelect = (targetChainId: number) => { + switchChain({ chainId: targetChainId }); setIsOpen(false); }; @@ -55,12 +56,14 @@ export default function EthereumNetworkSwitcher() { [styles.trigger, pressed && styles.triggerPressed, isPending && styles.triggerDisabled]} + accessibilityRole="button" + accessibilityLabel="Switch network" onPress={() => setIsOpen(true)} disabled={isPending} > - {isPending ? 'Switching...' : currentMeta?.name ?? 'Unknown'} + {isPending ? 'Switching...' : currentMeta?.name ?? 'Wrong network'} @@ -81,39 +84,18 @@ export default function EthereumNetworkSwitcher() { Select Network - - - - Testnets - - setIsOpen(false)} - hitSlop={8} - > - × - - + setIsOpen(false)} + hitSlop={8} + > + × + - {visibleChains.map(({ chain: ch, name, symbol, isTestnet }) => { - const active = chain.id === ch.id; + {CHAINS.map(({ chain: ch, name, symbol, isTestnet }) => { + const active = chainId === ch.id; return ( ; + /** Square edge in px. Tile size in the bag; larger where an item is the subject. */ + size?: number; +}; + +/** + * An item's art, degrading in two steps rather than one. + * + * Unlike a pet — which falls straight back to its emoji avatar — an item has a real + * intermediate. The service draws a deterministic SVG from the catalog entry alone, needing + * no model, no store and no credentials, so the painted PNG is tried first and anything that + * stops it arriving falls through to art that is merely plainer instead of to a hole. + * + * Only an unconfigured service renders nothing, which is the "art is optional" state the + * whole image service is built around: leave `IMAGE_SERVICE_URL` unset and the app keeps + * working, minus pictures. + * + * No retry timer, unlike `PetArt`. That one needs one because its only fallback is an emoji + * and a cold pet's art lands seconds later; here the second attempt is a different URL that + * is always ready, so there is nothing to wait for. + * + * The route shapes live in `@shared/core`, so both clients address items identically and + * only the environment read differs. + */ +export default function ItemArt({ item, size = 40 }: Props) { + const [stage, setStage] = useState<'painted' | 'drawn' | 'none'>('painted'); + + const painted = itemArtUrl(item.itemType, IMAGE_SERVICE_URL); + const drawn = itemFallbackArtUrl(item.itemType, IMAGE_SERVICE_URL); + + // No service configured: nothing is coming, so the caller is left exactly as it was + // before art existed rather than holding an empty box. + if (!painted) return null; + + const uri = stage === 'painted' ? painted : stage === 'drawn' ? drawn : null; + if (!uri) return null; + + return ( + + setStage(stage === 'painted' ? 'drawn' : 'none')} + /> + + ); +} + +const styles = StyleSheet.create({ + frame: { + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + image: { + width: '100%', + height: '100%', + }, +}); diff --git a/mobile/src/components/NativeBalance.tsx b/mobile/src/components/NativeBalance.tsx new file mode 100644 index 00000000..6dcc8669 --- /dev/null +++ b/mobile/src/components/NativeBalance.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { formatEther } from 'viem'; +import { useAccount, useBalance } from 'wagmi'; + +import { getNativeTokenSymbol } from '../constants/ethereumNetworks'; +import { neon } from '../theme/neon'; + +/** + * The connected wallet's native balance. + * + * EVM only. Frontend's takes a `type` prop and branches to a Solana + * `connection.getBalance` poll; mobile's Solana wiring is its own workstream in + * `docs/plan-mobile-frontend-parity.md`, and a prop with one valid value is + * configurability nothing asked for. It grows the branch when Solana lands. + */ +export default function NativeBalance() { + const { address, chainId } = useAccount(); + const { data, isLoading, error } = useBalance({ address }); + + if (!address) return null; + + if (isLoading) { + return ; + } + + if (error) { + // A wrong-network read fails here first, and NetworkGate already explains + // why. Saying so plainly beats rendering a zero that looks like the truth. + return Balance unavailable; + } + + if (!data) return null; + + return ( + + {parseFloat(formatEther(data.value)).toFixed(4)} + {getNativeTokenSymbol(chainId)} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'baseline', + }, + amount: { + fontSize: 20, + fontWeight: '800', + color: neon.text, + }, + symbol: { + fontSize: 13, + fontWeight: '700', + color: neon.cyan, + marginLeft: 6, + }, + muted: { + fontSize: 13, + color: neon.textMuted, + }, +}); diff --git a/mobile/src/components/NetworkGate.tsx b/mobile/src/components/NetworkGate.tsx new file mode 100644 index 00000000..cfce54c1 --- /dev/null +++ b/mobile/src/components/NetworkGate.tsx @@ -0,0 +1,229 @@ +import React, { useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useAppKit } from '@reown/appkit-react-native'; +import { useAccount, useSwitchChain } from 'wagmi'; + +import { + TARGET_CHAIN_ID, + getTargetChainName, + isSupportedChain, +} from '../constants/ethereumNetworks'; +import { useApprovedEvmChains } from '../hooks/useApprovedEvmChains'; +import { useEvmChainSync } from '../hooks/useEvmChainSync'; +import { useEvmSessionChain } from '../hooks/useEvmSessionChain'; +import { useNotifyError } from '../hooks/useNotifyError'; +import { alpha, neon, neonGlow } from '../theme/neon'; + +/** MetaMask's user-rejection code, per EIP-1193. */ +const USER_REJECTED = 4001; + + +function describeSwitchFailure( + err: unknown, + targetName: string, + targetAuthorized: boolean, +): string { + const code = (err as { code?: number } | null)?.code; + if (code === USER_REJECTED) { + return 'You dismissed the request in your wallet. Try again to keep playing.'; + } + const message = err instanceof Error ? err.message : String(err); + // Some wallets answer a request for a chain they never approved by ending the + // session outright rather than refusing the call. Observed with Rabby, which + // hides testnets by default and so has no Base Sepolia to add. + if (/disconnect/i.test(message)) { + return `Your wallet ended the session instead of adding ${targetName}. Enable ${targetName} in the wallet, then connect again.`; + } + if (!targetAuthorized) { + return `Your wallet would not add ${targetName} to this session. Enable ${targetName} in the wallet, then disconnect and reconnect.`; + } + return message || 'Could not switch networks. Change it manually in your wallet.'; +} + +/** + * Blocks play on the wrong EVM network and offers a one-click fix. Renders + * nothing unless an EVM wallet is connected, so Solana-only players never see it. + * + * Wider than frontend's gate, because WalletConnect fails in a way the browser's + * injected connector does not: the session freezes its approved chain set at + * handshake, so a wallet can be "on" a chain it never authorized. That case shows + * different copy, and `useEvmSessionChain` runs from here to repair it before the + * player ever reaches a signature prompt. + */ +export default function NetworkGate() { + const { isConnected, chainId } = useAccount(); + const { switchChainAsync } = useSwitchChain(); + const { open, disconnect } = useAppKit(); + const approvedChains = useApprovedEvmChains(); + const notifyError = useNotifyError(); + const [isBusy, setIsBusy] = useState(false); + const [error, setError] = useState(null); + + useEvmSessionChain(); + // Runs before the early return below, so it keeps working while the gate is hidden. + // The desync it repairs is invisible to this gate: `chainId` reads as a supported + // chain throughout, because that stale value is itself the bug. + useEvmChainSync(); + + // `null` means the approved set is unknown, so it cannot rule the target out. + const targetAuthorized = approvedChains === null || approvedChains.includes(TARGET_CHAIN_ID); + + if (!isConnected || (isSupportedChain(chainId) && targetAuthorized)) return null; + + const targetName = getTargetChainName(); + + const report = (err: unknown) => { + const copy = describeSwitchFailure(err, targetName, targetAuthorized); + setError(copy); + // A wallet that ends the session takes this gate down with it: the tab + // shell unmounts back to Landing, so the inline copy is gone before it can + // be read. The toast outlives that. + notifyError(copy, err, 'network-gate'); + }; + + const handleSwitch = async () => { + setIsBusy(true); + setError(null); + try { + await switchChainAsync({ chainId: TARGET_CHAIN_ID }); + } catch (err) { + report(err); + } finally { + setIsBusy(false); + } + }; + + /** + * Drops the session and opens the connect sheet, so the next handshake can + * propose the target chain. + * + * This is the reliable path when the wallet never approved the target: + * WalletConnect freezes a session's chain set at connect time, and only some + * wallets honour `wallet_addEthereumChain` against a live session. The ones + * that do not either refuse it or, worse, end the session. + */ + const handleReconnect = async () => { + setIsBusy(true); + setError(null); + try { + await disconnect(); + await open(); + } catch (err) { + report(err); + } finally { + setIsBusy(false); + } + }; + + // Which action leads depends on why the gate is up. On the wrong chain with + // the target approved, switching is a local provider call and always works. + // With the target unapproved, only a fresh handshake reliably widens the set, + // so reconnect leads and the add attempt stays available for the wallets that + // do honour it. + return ( + + + {targetAuthorized + ? `CryptoPets runs on ${targetName}` + : `Wallet did not approve ${targetName}`} + + + {error ?? + (targetAuthorized + ? "Your wallet is on a different network, so pets and battles can't load." + : `This session has no permission for ${targetName}, so signing will fail. Your wallet fixes that list when it connects, so turn on ${targetName} there, then reconnect.`)} + + { + (targetAuthorized ? handleSwitch() : handleReconnect()).catch(() => undefined); + }} + disabled={isBusy} + activeOpacity={0.85} + accessibilityRole="button" + accessibilityLabel={ + targetAuthorized ? `Switch to ${targetName}` : 'Reconnect wallet' + } + > + {isBusy ? ( + + ) : ( + + {targetAuthorized ? `Switch to ${targetName}` : 'Reconnect wallet'} + + )} + + {targetAuthorized ? null : ( + { + handleSwitch().catch(() => undefined); + }} + disabled={isBusy} + activeOpacity={0.85} + accessibilityRole="button" + accessibilityLabel={`Ask this wallet to add ${targetName}`} + > + + Ask this wallet to add {targetName} + + + )} + + ); +} + +const styles = StyleSheet.create({ + gate: { + marginTop: 12, + width: '100%', + backgroundColor: alpha(neon.warning, 0.1), + borderWidth: 1, + borderColor: alpha(neon.warning, 0.55), + borderRadius: 12, + padding: 12, + ...neonGlow(neon.warning, 8, 0.2), + }, + title: { + fontSize: 14, + fontWeight: '800', + color: neon.warningText, + marginBottom: 4, + }, + detail: { + fontSize: 13, + color: neon.textMuted, + lineHeight: 18, + marginBottom: 10, + }, + btn: { + alignSelf: 'flex-start', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + borderWidth: 1, + borderColor: neon.warning, + minWidth: 140, + alignItems: 'center', + }, + btnText: { + color: neon.warningText, + fontWeight: '800', + fontSize: 13, + }, + secondaryBtn: { + alignSelf: 'flex-start', + paddingHorizontal: 4, + paddingVertical: 8, + marginTop: 4, + }, + secondaryBtnText: { + color: neon.textMuted, + fontWeight: '700', + fontSize: 12, + textDecorationLine: 'underline', + }, + disabled: { + opacity: 0.55, + }, +}); diff --git a/mobile/src/components/PetCard.tsx b/mobile/src/components/PetCard.tsx new file mode 100644 index 00000000..27e89a89 --- /dev/null +++ b/mobile/src/components/PetCard.tsx @@ -0,0 +1,361 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { + getGeneration, + getLifePercent, + getPetClass, + getPetSkill, + getRarityColor, + getRarityName, + getXpNumbers, + getXpPercent, + type EquippedItem, + type Pet, +} from '@shared/core'; + +import EquippedBadges from './EquippedBadges'; +import PetArt from './PetArt'; +import type { PetCooldownStatus } from '../hooks/usePetCooldowns'; +import { statTiles, winPercent } from '../utils/petStats'; +import { neon, neonGlow } from '../theme/neon'; + +/** + * The five per-pet actions, together or not at all. + * + * One optional object rather than five optional handlers, because there is no such thing as + * a card with three of them: either it is the Gallery's card, which acts on the pet, or it is + * a read-only look at one, which `PetPreview` shows when a picker chip is held down. + */ +export type PetCardActions = { + onBattle: () => void; + onRename: () => void; + onDefend: () => void; + onEquip: () => void; + onSend: () => void; +}; + +type Props = { + pet: Pet; + /** Absent on a read-only card: nothing there is waiting on a cooldown to be usable. */ + status?: PetCooldownStatus; + /** Filled slots, or undefined for a pet wearing nothing. */ + equipped?: EquippedItem[]; + actions?: PetCardActions; +}; + +/** + * One pet, with its cooldowns and the per-pet actions that reach the stack routes. + * Rename and Defense live here rather than in the tab bar because both act on a + * chosen pet; see plan 3.1. + * + * Everything below the name comes from `@shared/core` helpers rather than being derived + * here, so a pet reads identically on both clients. That was the gap this card had: the + * app knew its art, stats, skill and class the whole time and drew none of them. + */ +export default function PetCard({ pet, status, equipped, actions }: Props) { + const rarityColor = getRarityColor(pet.rarity); + const skill = getPetSkill(pet.speciesId); + const xp = getXpNumbers(pet); + const hp = getLifePercent(pet); + + return ( + + {/* A rarity stripe across the top, as on web: the card's colour is the pet's. */} + + + + + + + {pet.name} + + + {getPetClass(pet.dna)} · Gen {pet.generation ?? getGeneration(pet.dna)} + + + ID #{pet.id} · Level {pet.level} + + + + + {getRarityName(pet.rarity)} + + + + + + + {skill ? ( + + {skill.name} + + {skill.description} + + + ) : null} + + + {statTiles(pet).map((tile) => ( + + {tile.label} + {tile.value} + + ))} + + + + XP + + + + + {xp.xpCurrent}/{xp.xpMax} + + + + + HP + + + + {hp}% + + + + {pet.winCount}W + {' / '} + {pet.lossCount}L + {winPercent(pet) != null ? ` · ${winPercent(pet)}% win rate` : ''} + + + {status?.onCooldown ? ( + + {status.battleOnCooldown && ( + Battle ready in {status.battleLabel} + )} + {status.breedOnCooldown && ( + Breed ready in {status.breedLabel} + )} + {status.trainOnCooldown && ( + Train ready in {status.trainLabel} + )} + + ) : null} + + {actions ? ( + + + Battle + + + Rename + + {/* + * "Allow" rather than "Defend": the screen this opens grants standing + * consent to be challenged, and "Defend" reads as an action taken during + * a fight. Shortened from the screen's own "Allow Challenges" only + * because five buttons share this row. + */} + + Allow + + + Equip + + + Send + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: neon.bgCard, + borderRadius: 14, + padding: 16, + marginBottom: 12, + borderWidth: 1, + borderColor: 'rgba(0, 245, 255, 0.22)', + width: '100%', + ...neonGlow(neon.cyan, 8, 0.2), + }, + rarityBar: { + height: 3, + borderRadius: 2, + marginBottom: 12, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + identity: { + flex: 1, + marginLeft: 12, + minWidth: 0, + }, + petName: { + fontSize: 20, + fontWeight: '800', + color: neon.text, + }, + petClass: { + fontSize: 12, + color: neon.purple, + marginTop: 2, + fontWeight: '700', + }, + skill: { + marginTop: 4, + marginBottom: 10, + borderLeftWidth: 2, + borderLeftColor: neon.purple, + paddingLeft: 10, + }, + skillName: { + fontSize: 13, + fontWeight: '800', + color: neon.purple, + }, + skillText: { + fontSize: 12, + color: neon.textMuted, + marginTop: 2, + lineHeight: 16, + }, + stats: { + flexDirection: 'row', + marginBottom: 10, + }, + stat: { + flex: 1, + alignItems: 'center', + backgroundColor: neon.bgPanel, + borderRadius: 10, + borderWidth: 1, + borderColor: neon.border, + paddingVertical: 8, + marginRight: 6, + }, + statLabel: { + fontSize: 10, + fontWeight: '800', + letterSpacing: 1, + color: neon.textDim, + }, + statValue: { + fontSize: 16, + fontWeight: '800', + color: neon.cyan, + marginTop: 2, + }, + barRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 6, + }, + barLabel: { + width: 26, + fontSize: 11, + fontWeight: '800', + color: neon.textDim, + }, + barTrack: { + flex: 1, + height: 6, + borderRadius: 3, + backgroundColor: neon.bgInput, + overflow: 'hidden', + marginHorizontal: 8, + }, + barFill: { + height: 6, + borderRadius: 3, + backgroundColor: neon.cyan, + }, + hpFill: { + backgroundColor: neon.success, + }, + barValue: { + minWidth: 62, + fontSize: 11, + color: neon.textMuted, + textAlign: 'right', + }, + wins: { + color: neon.success, + fontWeight: '800', + }, + losses: { + color: neon.danger, + fontWeight: '800', + }, + rarityBadge: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 4, + }, + rarityText: { + fontSize: 12, + fontWeight: '600', + }, + meta: { + fontSize: 14, + color: neon.textMuted, + marginTop: 4, + }, + cooldowns: { + marginTop: 8, + paddingTop: 8, + borderTopWidth: 1, + borderTopColor: 'rgba(255, 45, 166, 0.2)', + }, + cooldown: { + fontSize: 13, + color: neon.textDim, + marginTop: 2, + }, + actions: { + flexDirection: 'row', + marginTop: 12, + flexWrap: 'wrap', + }, + action: { + borderWidth: 1, + borderColor: neon.border, + backgroundColor: neon.bgPanel, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 8, + marginRight: 8, + marginTop: 4, + }, + battleAction: { + borderColor: neon.borderMagenta, + }, + actionDisabled: { + opacity: 0.4, + }, + actionText: { + fontSize: 13, + fontWeight: '700', + color: neon.cyan, + }, +}); diff --git a/mobile/src/components/PetDetailStrip.tsx b/mobile/src/components/PetDetailStrip.tsx new file mode 100644 index 00000000..9134c7e2 --- /dev/null +++ b/mobile/src/components/PetDetailStrip.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + getGeneration, + getLifePercent, + getPetClass, + getPetSkill, + getRarityColor, + getRarityName, + getXpNumbers, + type Pet, +} from '@shared/core'; + +import { statTiles, winPercent } from '../utils/petStats'; +import { neon } from '../theme/neon'; + +/** + * What you just picked, in three lines under the picker. + * + * The chips carry art, a name and a level, which is enough to tell two pets apart and not + * enough to decide between twenty. Everywhere outside the Gallery a pet is chosen from those + * chips and then acted on, so until now the screen never showed the numbers the choice + * actually turns on. + * + * Deliberately not `PetCard`. That is roughly 400px, and `BreedScreen` picks two parents, so + * the card twice over would push the name field and the action bar off a phone. The full card + * is still one long press away for a pet you are only considering. + * + * Every number comes from the same `@shared/core` helper the card and the web app read, so a + * pet cannot read one way here and another way one screen over. + */ +export default function PetDetailStrip({ pet }: { pet: Pet }) { + const xp = getXpNumbers(pet); + const skill = getPetSkill(pet.speciesId); + const rarityColor = getRarityColor(pet.rarity); + + return ( + + + + {pet.name} + + + {getRarityName(pet.rarity)} + + + + + {getPetClass(pet.dna)} · Gen {pet.generation ?? getGeneration(pet.dna)} + {skill ? ` · ${skill.name}` : ''} + + + + {statTiles(pet).map((tile) => ( + + {tile.label} + {tile.value} + + ))} + + + + XP {xp.xpCurrent}/{xp.xpMax} · HP {getLifePercent(pet)}% ·{' '} + {pet.winCount}W + {' / '} + {pet.lossCount}L + {winPercent(pet) != null ? ` · ${winPercent(pet)}% wins` : ''} + + + ); +} + +const styles = StyleSheet.create({ + strip: { + backgroundColor: neon.bgPanel, + borderWidth: 1, + borderColor: neon.border, + // The pet's rarity, as a stripe down the side. `PetCard` runs the same colour across + // its top, so the two read as the same pet rather than as two unrelated panels. + borderLeftWidth: 3, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + marginBottom: 16, + }, + head: { + flexDirection: 'row', + alignItems: 'baseline', + }, + name: { + flex: 1, + fontSize: 16, + fontWeight: '800', + color: neon.text, + }, + rarity: { + fontSize: 12, + fontWeight: '700', + marginLeft: 8, + }, + lineage: { + fontSize: 12, + fontWeight: '700', + color: neon.purple, + marginTop: 2, + }, + stats: { + flexDirection: 'row', + marginTop: 8, + }, + stat: { + flex: 1, + alignItems: 'center', + backgroundColor: neon.bgCard, + borderRadius: 8, + borderWidth: 1, + borderColor: neon.border, + paddingVertical: 4, + marginRight: 6, + }, + statLabel: { + fontSize: 10, + fontWeight: '800', + letterSpacing: 1, + color: neon.textDim, + }, + statValue: { + fontSize: 14, + fontWeight: '800', + color: neon.cyan, + }, + record: { + fontSize: 12, + color: neon.textMuted, + marginTop: 8, + }, + wins: { + color: neon.success, + fontWeight: '800', + }, + losses: { + color: neon.danger, + fontWeight: '800', + }, +}); diff --git a/mobile/src/components/PetList.tsx b/mobile/src/components/PetList.tsx index a8a4d91f..b70bd76b 100644 --- a/mobile/src/components/PetList.tsx +++ b/mobile/src/components/PetList.tsx @@ -7,54 +7,50 @@ import { Text, View, } from 'react-native'; -import type { Pet } from '@shared/core'; -import { neon, neonGlow } from '../theme/neon'; -import PetArt from './PetArt'; +import type { EquippedItem, Pet } from '@shared/core'; + +import PetCard from './PetCard'; +import Carousel from './ui/Carousel'; +import type { PetCooldownStatus } from '../hooks/usePetCooldowns'; +import { neon } from '../theme/neon'; type Props = { pets: Pet[]; - petIds: bigint[]; isLoading: boolean; - contractError: Error | null | undefined; - isContractConfigured: boolean; + error: Error | null; onRefresh: () => void; refreshing: boolean; - getRarityName: (rarity: number) => string; - getRarityColor: (rarity: number) => string; + statusFor: (pet: Pet) => PetCooldownStatus; + onBattle: (pet: Pet) => void; + onRename: (pet: Pet) => void; + onDefend: (pet: Pet) => void; + onEquip: (pet: Pet) => void; + equippedFor: (petId: string) => EquippedItem[] | undefined; + onSend: (pet: Pet) => void; }; export default function PetList({ pets, - petIds, isLoading, - contractError, - isContractConfigured, + error, onRefresh, refreshing, - getRarityName, - getRarityColor, + statusFor, + onBattle, + onRename, + onDefend, + onEquip, + equippedFor, + onSend, }: Props) { - if (!isContractConfigured) { - return ( - - Contract not configured - - Set CONTRACT_ADDRESS in your mobile `.env` to match the deployed CryptoPets address (same as - frontend `VITE_CONTRACT_ADDRESS`), then restart Metro. - - - ); - } - - if (contractError) { - const message = - contractError instanceof Error ? contractError.message : String(contractError); + if (error) { + const message = error instanceof Error ? error.message : String(error); return ( Could not load pets {message} - Check that your wallet network matches the contract (e.g. Hardhat Local for local deploy). + Check that your wallet is on the network the contracts are deployed to. ); @@ -90,51 +86,39 @@ export default function PetList({ ); } + /* + * One pet per page, swiped through, rather than a vertical stack. + * + * Pull-to-refresh goes with it: `RefreshControl` on a horizontal list is unsupported on + * Android and awkward on iOS. `GalleryScreen`'s Refresh button already does the same job, + * so what is lost is the gesture, not the ability. The empty state above keeps its pull, + * since that branch is still a vertical scroll and is where a reload is most wanted. + * + * The heading sits outside the pager. Inside, it would be one per page. + */ return ( - - } - > + Your pets - {pets.map((pet, index) => { - const id = petIds[index]; - const rarityColor = getRarityColor(pet.rarity); - return ( - - - {/* - * Addressed from petIds, not pet.id: these pets come - * straight off the EVM PetCore read, whose tuple carries - * no id or chain of its own. The id lives alongside in - * petIds, and this screen is EVM-only, so both are known - * here even though the pet object does not carry them. - */} - - {pet.name} - - - {getRarityName(pet.rarity)} - - - - {id !== undefined && ID #{id.toString()}} - Level {pet.level} - - W {pet.winCount} · L {pet.lossCount} - - - ); - })} - + pet.id} + itemLabel="Pet" + renderItem={(pet) => ( + onBattle(pet), + onRename: () => onRename(pet), + onDefend: () => onDefend(pet), + onEquip: () => onEquip(pet), + onSend: () => onSend(pet), + }} + /> + )} + /> + ); } @@ -162,44 +146,6 @@ const styles = StyleSheet.create({ textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 8, }, - card: { - backgroundColor: neon.bgCard, - borderRadius: 14, - padding: 16, - marginBottom: 12, - borderWidth: 1, - borderColor: 'rgba(0, 245, 255, 0.22)', - width: '100%', - ...neonGlow(neon.cyan, 8, 0.2), - }, - cardHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - petName: { - fontSize: 20, - fontWeight: '800', - color: neon.text, - flex: 1, - marginLeft: 10, - }, - rarityBadge: { - borderWidth: 1, - borderRadius: 8, - paddingHorizontal: 10, - paddingVertical: 4, - }, - rarityText: { - fontSize: 12, - fontWeight: '600', - }, - meta: { - fontSize: 14, - color: neon.textMuted, - marginTop: 4, - }, loadingText: { marginTop: 12, fontSize: 16, diff --git a/mobile/src/components/PetPicker.tsx b/mobile/src/components/PetPicker.tsx new file mode 100644 index 00000000..aa6baf11 --- /dev/null +++ b/mobile/src/components/PetPicker.tsx @@ -0,0 +1,163 @@ +import React, { useState } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import type { Pet, ReadyPet } from '@shared/core'; + +import PetArt from './PetArt'; +import PetDetailStrip from './PetDetailStrip'; +import PetPreview from './PetPreview'; +import { neon } from '../theme/neon'; + +type Props = { + pets: ReadyPet[]; + selectedId: string; + onSelect: (id: string) => void; + /** Shown when nothing is selectable, e.g. every pet is on cooldown. */ + emptyHint: string; + /** + * Whether the wallet holds any pets at all, before this screen's filter. + * Only the cooldown-filtered screens pass it: there an empty roster and a + * fully filtered one look identical, so `emptyHint` would tell a player + * with no pets that theirs are busy. Screens whose `emptyHint` already + * states a fact of their own ("No pets on this chain yet") omit it. + */ + hasAnyPets?: boolean; + disabled?: boolean; +}; + +const NO_PETS_HINT = 'No pets in this wallet yet. Mint one from the Gallery tab.'; + +/** + * Horizontal chips in place of frontend's `