Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
pull_request:
push:
branches: ['**']

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
checks:
name: 🔍 Checks
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout
uses: actions/checkout@v4

- name: 🥟 Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: 📦 Install dependencies
run: bun install --frozen-lockfile

- name: 🎨 Check formatting
run: bun run format:check

- name: 🧹 Lint
run: bun run lint

- name: 🔤 Typecheck
run: bun run build:types

- name: 🧪 Test
run: bun run test

- name: 🏗️ Build
run: bun run build:vite
23 changes: 15 additions & 8 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,42 @@ concurrency:

jobs:
build:
name: 🏗️ Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 📥 Checkout
uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
- name: 🥟 Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install
- name: 📦 Install dependencies
run: bun install --frozen-lockfile

- name: Test
- name: 🧪 Test
run: bun run test

- name: Build
- name: 🏗️ Build
run: bun run build

- uses: actions/configure-pages@v5
- name: ⚙️ Configure Pages
uses: actions/configure-pages@v5

- uses: actions/upload-pages-artifact@v3
- name: 📤 Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist

deploy:
name: 🚀 Deploy
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
- name: 🚀 Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Binary file added public/sprites/anim/baxcalibur-mega.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/darkrai-mega.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/fezandipiti.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/golisopod-mega.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/heatran-mega.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/iron-boulder.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/iron-crown.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/iron-jugulis.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/iron-leaves.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/iron-treads.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/magearna-mega.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/maushold-family-of-three.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/meowstic-male-mega.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/munkidori.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/ogerpon.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/okidogi.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/pecharunt.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/zeraora-mega.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/anim/zygarde-mega.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/trainers/aaron.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/trainers/alder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/trainers/barry.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/trainers/bertha.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/trainers/bianca.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/trainers/blaine.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/sprites/trainers/blue.png
Binary file added public/sprites/trainers/brawly.png
Binary file added public/sprites/trainers/brock.png
Binary file added public/sprites/trainers/bruno.png
Binary file added public/sprites/trainers/brycen.png
Binary file added public/sprites/trainers/bugsy.png
Binary file added public/sprites/trainers/burgh.png
Binary file added public/sprites/trainers/byron.png
Binary file added public/sprites/trainers/caitlin.png
Binary file added public/sprites/trainers/candice.png
Binary file added public/sprites/trainers/cheren.png
Binary file added public/sprites/trainers/chuck.png
Binary file added public/sprites/trainers/cilan.png
Binary file added public/sprites/trainers/clair.png
Binary file added public/sprites/trainers/clay.png
Binary file added public/sprites/trainers/crasherwake.png
Binary file added public/sprites/trainers/cynthia.png
Binary file added public/sprites/trainers/drayden.png
Binary file added public/sprites/trainers/elesa.png
Binary file added public/sprites/trainers/erika.png
Binary file added public/sprites/trainers/falkner.png
Binary file added public/sprites/trainers/fantina.png
Binary file added public/sprites/trainers/flannery.png
Binary file added public/sprites/trainers/flint.png
Binary file added public/sprites/trainers/gardenia.png
Binary file added public/sprites/trainers/giovanni.png
Binary file added public/sprites/trainers/grimsley.png
Binary file added public/sprites/trainers/jasmine.png
Binary file added public/sprites/trainers/karen.png
Binary file added public/sprites/trainers/koga.png
Binary file added public/sprites/trainers/lance.png
Binary file added public/sprites/trainers/lenora.png
Binary file added public/sprites/trainers/ltsurge.png
Binary file added public/sprites/trainers/lucian.png
Binary file added public/sprites/trainers/marshal.png
Binary file added public/sprites/trainers/maylene.png
Binary file added public/sprites/trainers/misty.png
Binary file added public/sprites/trainers/morty.png
Binary file added public/sprites/trainers/n.png
Binary file added public/sprites/trainers/norman.png
Binary file added public/sprites/trainers/pryce.png
Binary file added public/sprites/trainers/roark.png
Binary file added public/sprites/trainers/roxanne.png
Binary file added public/sprites/trainers/sabrina.png
Binary file added public/sprites/trainers/shauntal.png
Binary file added public/sprites/trainers/silver.png
Binary file added public/sprites/trainers/skyla.png
Binary file added public/sprites/trainers/steven.png
Binary file added public/sprites/trainers/tateliza.gif
Binary file added public/sprites/trainers/volkner.png
Binary file added public/sprites/trainers/wallace.png
Binary file added public/sprites/trainers/wattson.png
Binary file added public/sprites/trainers/whitney.png
Binary file added public/sprites/trainers/will.png
Binary file added public/sprites/trainers/winona.png
72 changes: 72 additions & 0 deletions src/__tests__/CompetitiveBuild.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CompetitiveBuild from '@/components/CompetitiveBuild';
import type { ResolvedBuild } from '@/competitive';
import type { GameId } from '@/trainers';

const GAMES: GameId[] = ['firered-leafgreen', 'scarlet-violet'];

const BUILD: ResolvedBuild = {
pokemonKey: 'Charizard',
tier: 'ou',
buildName: 'Mixed Attacker',
sourceGen: 3,
set: {
moves: ['Flamethrower', ['Fire Blast', 'Overheat']],
item: 'Choice Band',
nature: 'Naive',
evs: { atk: 252, spa: 4, spe: 252 },
},
};

function renderBuild(overrides: Partial<Parameters<typeof CompetitiveBuild>[0]> = {}) {
const onSelectGame = vi.fn();
render(
<CompetitiveBuild
build={BUILD}
loading={false}
error={null}
games={GAMES}
selectedGame={null}
onSelectGame={onSelectGame}
{...overrides}
/>,
);
return onSelectGame;
}

describe('CompetitiveBuild', () => {
it('renders the set with tier, build name, and gen-accurate source', () => {
renderBuild();
expect(screen.getByText('[OU]')).toBeInTheDocument();
expect(screen.getByText('Mixed Attacker')).toBeInTheDocument();
expect(screen.getByText(/smogon\.com\/dex\/rs/)).toBeInTheDocument(); // gen 3 → rs
});

it('offers LATEST plus every game the Pokémon appears in', () => {
renderBuild();
const select = screen.getByLabelText(/game for competitive build/i);
const options = [...select.querySelectorAll('option')].map((o) => o.textContent);
expect(options[0]).toMatch(/latest/i);
expect(options).toContain('FIRERED / LEAFGREEN');
expect(options).toContain('SCARLET / VIOLET');
});

it('reports a picked game and switching back to LATEST', async () => {
const onSelectGame = renderBuild();
const user = userEvent.setup();
const select = screen.getByLabelText(/game for competitive build/i);
await user.selectOptions(select, 'firered-leafgreen');
expect(onSelectGame).toHaveBeenCalledWith('firered-leafgreen');
});

it('shows a game-specific empty state when a pinned gen has no set', () => {
renderBuild({ build: null, selectedGame: 'firered-leafgreen' });
expect(
screen.getByText(/no Smogon set for this entry in FireRed \/ LeafGreen/),
).toBeInTheDocument();
// the selector stays available so the user can escape the empty state
expect(screen.getByLabelText(/game for competitive build/i)).toBeInTheDocument();
});
});
60 changes: 60 additions & 0 deletions src/__tests__/TrainerCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TrainerCard from '@/components/TrainerCard';
import type { Trainer } from '@/trainers';

const BROCK: Trainer = {
id: 'rb-brock',
name: 'Brock',
trainerClass: 'Gym Leader',
game: 'red-blue',
location: 'Pewter City Gym',
team: [
{ species: 'geodude', level: 12 },
{ species: 'onix', level: 14 },
],
};

describe('TrainerCard', () => {
it('renders name, class, game, location, and the roster', () => {
render(
<TrainerCard
trainer={BROCK}
onBack={() => {}}
onSelectPokemon={() => {}}
speciesIndex={[]}
/>,
);
expect(screen.getByText('BROCK')).toBeInTheDocument();
expect(screen.getByText('GYM LEADER')).toBeInTheDocument();
expect(screen.getByText('Red / Blue')).toBeInTheDocument();
expect(screen.getByText('Pewter City Gym')).toBeInTheDocument();
expect(screen.getByText('Lv 14')).toBeInTheDocument();
});

it('clicking a roster member reports its species slug', async () => {
const onSelectPokemon = vi.fn();
render(
<TrainerCard
trainer={BROCK}
onBack={() => {}}
onSelectPokemon={onSelectPokemon}
speciesIndex={[]}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /onix/i }));
expect(onSelectPokemon).toHaveBeenCalledWith('onix');
});

it('back button fires onBack', async () => {
const onBack = vi.fn();
render(
<TrainerCard trainer={BROCK} onBack={onBack} onSelectPokemon={() => {}} speciesIndex={[]} />,
);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /back/i }));
expect(onBack).toHaveBeenCalled();
});
});
85 changes: 85 additions & 0 deletions src/__tests__/TrainerGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TrainerGrid from '@/components/TrainerGrid';
import type { Trainer } from '@/trainers';

const TRAINERS: Trainer[] = [
{
id: 'rb-brock',
name: 'Brock',
trainerClass: 'Gym Leader',
game: 'red-blue',
team: [{ species: 'onix', level: 14 }],
},
{
id: 'frlg-misty',
name: 'Misty',
trainerClass: 'Gym Leader',
game: 'firered-leafgreen',
team: [{ species: 'starmie', level: 21 }],
},
{
id: 'gs-falkner',
name: 'Falkner',
trainerClass: 'Gym Leader',
game: 'gold-silver',
team: [{ species: 'pidgeotto', level: 9 }],
},
];

function regionHeadings(): string[] {
return screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent ?? '');
}

describe('TrainerGrid', () => {
beforeEach(() => {
window.localStorage.clear();
});

it('groups trainers under regions, expanded by default', () => {
render(<TrainerGrid trainers={TRAINERS} onSelect={() => {}} />);
const headings = regionHeadings();
expect(headings[0]).toContain('KANTO');
expect(headings[1]).toContain('JOHTO');
expect(screen.getByText('Brock')).toBeInTheDocument();
expect(screen.getByText('Falkner')).toBeInTheDocument();
});

it('orders games within a region by release date', () => {
render(<TrainerGrid trainers={TRAINERS} onSelect={() => {}} />);
const cards = screen.getAllByRole('button', { name: /gym leader/i });
const names = cards.map((c) => c.textContent);
// Red/Blue (1996) before FireRed/LeafGreen (2004) inside Kanto
expect(names.findIndex((n) => n?.includes('Brock'))).toBeLessThan(
names.findIndex((n) => n?.includes('Misty')),
);
});

it('collapse all hides the cards; a region toggle brings its own back', async () => {
render(<TrainerGrid trainers={TRAINERS} onSelect={() => {}} />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /collapse all/i }));
expect(screen.queryByText('Brock')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^[▼▶]KANTO$/ }));
expect(screen.getByText('Brock')).toBeInTheDocument();
expect(screen.queryByText('Falkner')).not.toBeInTheDocument();
});

it('an active filter auto-expands collapsed regions', async () => {
render(<TrainerGrid trainers={TRAINERS} onSelect={() => {}} />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /collapse all/i }));
await user.type(screen.getByLabelText(/search trainer name/i), 'falkner');
expect(screen.getByText('Falkner')).toBeInTheDocument();
expect(screen.queryByText('Brock')).not.toBeInTheDocument();
});

it('clicking a trainer card reports the trainer', async () => {
const onSelect = vi.fn();
render(<TrainerGrid trainers={TRAINERS} onSelect={onSelect} />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /brock/i }));
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'rb-brock' }));
});
});
50 changes: 50 additions & 0 deletions src/__tests__/useExpandedRegions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useExpandedRegions } from '@/hooks/useExpandedRegions';

const KEY = 'test.regions';

describe('useExpandedRegions', () => {
beforeEach(() => {
window.localStorage.clear();
});

it('starts all-collapsed with no default', () => {
const { result } = renderHook(() => useExpandedRegions(KEY));
expect(result.current.expanded.size).toBe(0);
});

it('honours defaultExpanded when nothing is stored', () => {
const { result } = renderHook(() => useExpandedRegions(KEY, ['Kanto', 'Johto']));
expect([...result.current.expanded]).toEqual(['Kanto', 'Johto']);
});

it('does not write to localStorage until the user toggles', () => {
renderHook(() => useExpandedRegions(KEY, ['Kanto']));
expect(window.localStorage.getItem(KEY)).toBeNull();
});

it('toggle persists and a stored value beats the default', () => {
const { result, unmount } = renderHook(() => useExpandedRegions(KEY));
act(() => result.current.toggle('Hoenn'));
expect(window.localStorage.getItem(KEY)).toBe('["Hoenn"]');
unmount();
const { result: again } = renderHook(() => useExpandedRegions(KEY, ['Kanto']));
expect([...again.current.expanded]).toEqual(['Hoenn']);
});

it('expandAll and collapseAll replace the whole set', () => {
const { result } = renderHook(() => useExpandedRegions(KEY));
act(() => result.current.expandAll(['Kanto', 'Johto']));
expect(result.current.expanded.size).toBe(2);
act(() => result.current.collapseAll());
expect(result.current.expanded.size).toBe(0);
expect(window.localStorage.getItem(KEY)).toBe('[]');
});

it('falls back to the default on a corrupted stored value', () => {
window.localStorage.setItem(KEY, 'not json');
const { result } = renderHook(() => useExpandedRegions(KEY, ['Kanto']));
expect([...result.current.expanded]).toEqual(['Kanto']);
});
});
2 changes: 1 addition & 1 deletion src/components/MoveList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function MoveList({ moves, versionGroup }: Props) {
: '· ';
return (
<li key={m.name}>
{prefix}
<span className="crt-move-prefix">{prefix}</span>
<Detail kind="move" name={m.name} label={pretty(m.name)} />
</li>
);
Expand Down
Loading
Loading